# xspeed/1.1.1/includes/class-health.php

xSpeed Cache: AI-Powered Performance Hub with MCP, Caching &amp; CDN, version 1.1.1. 416 lines.

- Page: https://pluginprobe.com/plugins/xspeed/1.1.1/code/includes/class-health.php
- Raw: https://pluginprobe.com/plugins/xspeed/1.1.1/raw/includes/class-health.php
- Modified: 2026-07-28T06:23:24+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/xspeed/1.1.1/code/includes/class-health.php#L10-L20`.

```php
<?php
/**
 * Health — shared diagnostic checks consumed by both Onboarding's
 * environment surface and the Health module's dashboard panel.
 *
 * Each check returns:
 *   [
 *     'id'     => 'wp_version',
 *     'tone'   => 'ok' | 'warn' | 'fail' | 'info',
 *     'label'  => 'WordPress 6.6',
 *     'detail' => 'Meets the 6.0+ minimum.',
 *   ]
 *
 * Pure reads — never writes to disk, never makes outbound HTTP calls.
 * Safe to call from any request including the loading dashboard.
 *
 * @package XSpeed
 */

declare(strict_types=1);

namespace XSpeed;

defined( 'ABSPATH' ) || exit;

final class Health {

	public const OK   = 'ok';
	public const WARN = 'warn';
	public const FAIL = 'fail';
	public const INFO = 'info';

	private const SERVER_LABELS = array(
		'apache'    => 'Apache',
		'litespeed' => 'LiteSpeed',
		'nginx'     => 'nginx',
		'iis'       => 'IIS',
		'unknown'   => 'Unknown',
	);

	/**
	 * Full check list used by the Health module's dashboard panel.
	 *
	 * @return array<int,array{id:string,tone:string,label:string,detail:string}>
	 */
	public static function checks(): array {
		global $wp_version;

		$server_type = Server::type();
		$gzip_mode   = Server::gzip_mode();
		$conflicts   = Server::conflicts();
		$cache_dir   = defined( 'XSPEED_CACHE_DIR' ) ? XSPEED_CACHE_DIR : ( WP_CONTENT_DIR . '/cache/xspeed' );

		$out = array();

		// WordPress version
		$wp_ok  = version_compare( (string) $wp_version, '6.0', '>=' );
		$out[]  = array(
			'id'     => 'wp_version',
			'tone'   => $wp_ok ? self::OK : self::FAIL,
			'label'  => sprintf( 'WordPress %s', (string) $wp_version ),
			'detail' => $wp_ok ? 'Meets the 6.0+ minimum.' : 'Upgrade to WordPress 6.0 or higher.',
		);

		// PHP version
		$php_ok      = version_compare( PHP_VERSION, '7.4', '>=' );
		$php_modern  = version_compare( PHP_VERSION, '8.1', '>=' );
		$out[]       = array(
			'id'     => 'php_version',
			'tone'   => $php_modern ? self::OK : ( $php_ok ? self::WARN : self::FAIL ),
			'label'  => sprintf( 'PHP %s', PHP_VERSION ),
			'detail' => $php_modern
				? 'Modern PHP — full speed.'
				: ( $php_ok
					? 'Works, but 8.1+ is recommended for best performance.'
					: 'Upgrade to PHP 7.4 or higher.' ),
		);

		// Server
		$out[] = array(
			'id'     => 'server',
			'tone'   => self::INFO,
			'label'  => sprintf( 'Server: %s', self::SERVER_LABELS[ $server_type ] ?? 'Unknown' ),
			'detail' => 'auto' === $gzip_mode
				? 'GZIP can be auto-configured via .htaccess.'
				: 'GZIP requires a manual server-config snippet (shown in the GZIP module).',
		);

		// Cache directory writable
		$dir_writable = wp_mkdir_p( $cache_dir ) && wp_is_writable( $cache_dir );
		$out[]        = array(
			'id'     => 'cache_dir',
			'tone'   => $dir_writable ? self::OK : self::FAIL,
			'label'  => 'Cache directory writable',
			'detail' => $dir_writable
				? $cache_dir
				: sprintf( 'Cannot write to %s. Adjust file permissions before enabling cache.', $cache_dir ),
		);

		// Drop-in installed (only when cache is enabled — otherwise N/A)
		$cache_enabled = (bool) Settings::get()['cache_enabled'];
		if ( $cache_enabled ) {
			$dropin_path  = WP_CONTENT_DIR . '/advanced-cache.php';
			$dropin_match = file_exists( $dropin_path ) && false !== strpos( (string) file_get_contents( $dropin_path ), 'xspeed' );
			$out[]        = array(
				'id'     => 'dropin',
				'tone'   => $dropin_match ? self::OK : self::FAIL,
				'label'  => 'advanced-cache.php drop-in',
				'detail' => $dropin_match
					? 'Installed and owned by xSpeed.'
					: 'Drop-in missing or owned by another plugin. Toggle Enable Cache off and on to reinstall.',
			);
		}

		// WP_CACHE constant
		$wp_cache_const = defined( 'WP_CACHE' ) && WP_CACHE;
		if ( $cache_enabled ) {
			$out[] = array(
				'id'     => 'wp_cache_constant',
				'tone'   => $wp_cache_const ? self::OK : self::WARN,
				'label'  => 'WP_CACHE constant',
				'detail' => $wp_cache_const
					? 'Defined and truthy in wp-config.php.'
					: 'Not set. Cache is configured but WordPress will not load the drop-in until WP_CACHE = true is added to wp-config.php.',
			);
		}

		// Static-rewrite probe. Active end-to-end check: writes a probe
		// file under the static-cache dir, fetches it over HTTP, and
		// confirms the web server (nginx OR Apache/LiteSpeed) served
		// the raw file. Result is throttled to a 5-minute transient
		// inside Cache::probe_static_rewrite so we never hit the
		// network per-paint.
		if ( $cache_enabled ) {
			$server_type = Server::type();
			// The live loopback probe only matters where a server-level static
			// rewrite is actually used (nginx snippet / Apache .htaccess).
			// LiteSpeed serves hits via the drop-in (see below), so skip the
			// probe there entirely — no needless self-request. Health is the
			// right place to pay for the probe when we DO run it (the admin
			// bootstrap reads cache-only so it never blocks); the 5-minute
			// transient still throttles repeat runs. (FBS-82142)
			$probe     = ( Server::LITESPEED === $server_type )
				? array( 'active' => false )
				: Cache::probe_static_rewrite( true );
			$is_active = (bool) ( $probe['active'] ?? false );

			$block_reason = Cache::static_rewrite_block_reason();
			$mobile_block = ( 'mobile_separate' === $block_reason )
				? ' Note: Separate Mobile Cache is on, which disables the device-blind static rewrite — if your site serves the same HTML to all devices, turn it off (Cache settings) for much faster cache hits.'
				: '';

			if ( Server::NGINX === $server_type ) {
				$out[] = array(
					'id'      => 'static_rewrite_nginx',
					'tone'    => $is_active ? self::OK : self::WARN,
					'label'   => 'Static-file rewrite (nginx server config)',
					'detail'  => $is_active
						? 'nginx is serving cache hits directly — PHP bypassed (~5-15ms TTFB).'
						: ( 'mobile_separate' === $block_reason
							? 'nginx detected, but the static rewrite is disabled because Separate Mobile Cache is on.' . $mobile_block
							: 'nginx detected but not yet routing to the cache. Paste the snippet below into your site\'s server { } block, then reload nginx.' ),
					// Always ship the snippet — even when active, so the
					// admin has it handy for re-pasting after a server
					// rebuild without having to find it elsewhere. Mirror
					// the SAME unified block the "Server config" panel
					// renders (cache + gzip + browser-cache directives),
					// not the cache-only snippet — otherwise Health and
					// the Cache panel disagree on what to paste.
					'snippet' => Cache::full_nginx_server_block(),
				);
			} elseif ( Server::LITESPEED === $server_type ) {
				// LiteSpeed intentionally does NOT use the .htaccess static
				// rewrite: OpenLiteSpeed's .htaccess engine ignores
				// mod_headers (so we can't stamp X-XSpeed-Cache: HIT) and has
				// no per-rule access_log (so a static hit can't be counted).
				// We route LiteSpeed hits through the PHP drop-in instead, so
				// every hit is both visible (X-XSpeed-Cache: HIT) and counted
				// in the hit-ratio — see Cache::static_rewrite_allowed(). This
				// is the healthy, expected state on LiteSpeed, not a fallback.
				$out[] = array(
					'id'     => 'static_rewrite_litespeed',
					'tone'   => self::OK,
					'label'  => 'Cache serving (LiteSpeed)',
					'detail' => 'Cache hits are served by xSpeed\'s drop-in and tagged X-XSpeed-Cache: HIT — so every hit is visible and counted in your hit-ratio. (LiteSpeed\'s .htaccess can\'t add that header or log static hits, so xSpeed serves them itself for accurate reporting.)',
				);
			} elseif ( Server::APACHE === $server_type ) {
				$installed = Cache::rewrite_installed();
				if ( $is_active ) {
					$tone   = self::OK;
					$detail = 'Block installed and serving cache hits directly — PHP bypassed.';
				} elseif ( 'mobile_separate' === $block_reason ) {
					$tone   = self::WARN;
					$detail = 'Static rewrite disabled because Separate Mobile Cache is on.' . $mobile_block;
				} elseif ( ! $installed ) {
					$tone   = self::WARN;
					$detail = 'Block missing from .htaccess. Toggle Enable Cache off and on to reinstall it.';
				} else {
					$tone   = self::WARN;
					$detail = sprintf( 'Block installed but probe failed (%s). Confirm the .htaccess block is at the TOP of the file, and that AllowOverride is enabled for your site so mod_rewrite reads it.', (string) ( $probe['reason'] ?? 'unknown' ) );
				}
				$out[] = array(
					'id'     => 'static_rewrite',
					'tone'   => $tone,
					'label'  => 'Static-file rewrite (.htaccess)',
					'detail' => $detail,
				);
			}
		}

		// Cache expiry vs preloader schedule (deterministic rule, issue #31):
		// pages that expire faster than the preloader re-warms them leave the
		// cache cold for most real traffic — the classic "24.8% hit ratio with
		// everything on" misconfiguration. Pure logic in
		// expiry_preload_check() so it's unit-testable.
		if ( $cache_enabled ) {
			$cache_opts = Settings_Manager::get( 'cache' );
			$pre_opts   = Settings_Manager::get( 'preloader' );
			$schedule   = (string) ( $pre_opts['schedule'] ?? 'manual' );
			$mismatch   = self::expiry_preload_check(
				(int) ( $cache_opts['cache_expiry'] ?? 24 ),
				$schedule,
				! empty( $pre_opts['enabled'] ),
				self::schedule_interval_hours( $schedule )
			);
			if ( null !== $mismatch ) {
				$out[] = $mismatch;
			}
		}

		// Permalinks
		$permalinks_ok = (bool) get_option( 'permalink_structure' );
		$out[]         = array(
			'id'     => 'permalinks',
			'tone'   => $permalinks_ok ? self::OK : self::WARN,
			'label'  => 'Permalinks',
			'detail' => $permalinks_ok
				? 'Pretty permalinks active.'
				: 'Set permalinks to anything other than "Plain" — page caching needs URL paths to key on.',
		);

		// Cache-poisoning Set-Cookie detection (issue #33): a plugin emitting
		// Set-Cookie on anonymous pageviews forces CDN/edge BYPASS for all
		// HTML (Cloudflare never caches a response carrying Set-Cookie). Probe
		// is transient-throttled inside Cookie_Inspector, same pattern as the
		// static-rewrite probe above — Health is the right place to pay for it.
		// Cached-only: Health runs inside the dashboard REST request and the
		// MCP get_health tool, so this must never block on an HTTP call.
		// A cold verdict schedules a background refresh and reports nothing
		// this paint. See Cookie_Inspector::probe_cached().
		$cookie_probe = Cookie_Inspector::probe_cached();
		if ( $cookie_probe['checked'] ) {
			$offenders = $cookie_probe['cookies'];
			if ( empty( $offenders ) ) {
				$out[] = array(
					'id'     => 'set_cookie_poisoning',
					'tone'   => self::OK,
					'label'  => 'No cache-poisoning cookies',
					'detail' => 'Anonymous pages are served without Set-Cookie, so CDN/edge caches can store them.',
				);
			} else {
				$named = array();
				foreach ( $offenders as $c ) {
					$named[] = null !== $c['plugin']
						? sprintf( '%s is setting %s', $c['plugin'], $c['name'] )
						: sprintf( 'an unidentified plugin is setting %s', $c['name'] );
				}
				$out[] = array(
					'id'     => 'set_cookie_poisoning',
					'tone'   => self::WARN,
					'label'  => 'Set-Cookie on cacheable pages',
					'detail' => sprintf(
						'%s — this prevents CDN edge caching (Cloudflare returns BYPASS for any response with Set-Cookie). Configure the plugin to set its cookie via JavaScript instead, or exclude it from anonymous pageviews.',
						implode( '; ', $named )
					),
				);
			}
		}

		// Conflicting plugins
		$out[] = array(
			'id'     => 'conflicts',
			'tone'   => empty( $conflicts ) ? self::OK : self::WARN,
			'label'  => 'Caching plugin conflicts',
			'detail' => empty( $conflicts )
				? 'No other caching plugins detected.'
				: sprintf( 'Active: %s. Deactivate before enabling xSpeed cache to avoid double-caching.', implode( ', ', $conflicts ) ),
		);

		return $out;
	}

	/**
	 * Hours between recurring preloader crawls, per schedule option.
	 * `twicedaily` is a WordPress core schedule — omitting it meant a site
	 * using it got no check at all, not even a pass.
	 */
	public const PRELOAD_INTERVALS = array(
		'hourly'     => 1,
		'twicedaily' => 12,
		'daily'      => 24,
		'weekly'     => 168,
	);

	/**
	 * Interval in hours for a cron schedule slug, or null when it isn't a
	 * recurring schedule (`manual`) or can't be resolved.
	 *
	 * Falls back to `wp_get_schedules()` so custom crons registered by a
	 * theme or another plugin are covered too, rather than silently
	 * skipping the check.
	 */
	public static function schedule_interval_hours( string $schedule ): ?int {
		if ( isset( self::PRELOAD_INTERVALS[ $schedule ] ) ) {
			return self::PRELOAD_INTERVALS[ $schedule ];
		}
		if ( '' === $schedule || 'manual' === $schedule || ! function_exists( 'wp_get_schedules' ) ) {
			return null;
		}
		$schedules = wp_get_schedules();
		if ( ! isset( $schedules[ $schedule ]['interval'] ) ) {
			return null;
		}
		$hours = (int) round( (int) $schedules[ $schedule ]['interval'] / HOUR_IN_SECONDS );
		return $hours > 0 ? $hours : 1;
	}

	/**
	 * Deterministic rule: warn when cache_expiry is shorter than the
	 * preloader's recurring interval (pages go cold between crawls).
	 *
	 * Pure — no WP calls — so it can be unit-tested directly.
	 *
	 * @param int      $expiry_hours      Cache expiry in hours.
	 * @param string   $schedule          Preloader schedule (manual|hourly|twicedaily|daily|weekly|custom).
	 * @param bool     $preloader_enabled Whether the preloader module is on.
	 * @param int|null $interval_hours    Pre-resolved interval, for schedules
	 *                                    outside PRELOAD_INTERVALS. Keeps this
	 *                                    function pure — the caller does the
	 *                                    wp_get_schedules() lookup.
	 * @return array{id:string,tone:string,label:string,detail:string}|null Check
	 *         row, or null when the rule doesn't apply (preloader off/manual).
	 */
	public static function expiry_preload_check( int $expiry_hours, string $schedule, bool $preloader_enabled, ?int $interval_hours = null ): ?array {
		if ( ! $preloader_enabled ) {
			return null;
		}
		$interval = $interval_hours ?? ( self::PRELOAD_INTERVALS[ $schedule ] ?? null );
		if ( null === $interval || $interval < 1 ) {
			return null;
		}
		if ( $expiry_hours < $interval ) {
			return array(
				'id'     => 'expiry_preload_mismatch',
				'tone'   => self::WARN,
				'label'  => 'Cache expiry shorter than the preload schedule',
				'detail' => sprintf(
					'Pages expire after %dh but the preloader only re-warms them every %dh (%s), so most visits hit a cold cache. Raise Cache Expiry to at least %dh (Cache settings), or preload more often (Preloader settings).',
					$expiry_hours,
					$interval,
					$schedule,
					$interval
				),
			);
		}
		return array(
			'id'     => 'expiry_preload_mismatch',
			'tone'   => self::OK,
			'label'  => 'Cache expiry covers the preload schedule',
			'detail' => sprintf( 'Expiry %dh ≥ preload interval %dh (%s) — preloaded pages stay warm between crawls.', $expiry_hours, $interval, $schedule ),
		);
	}

	/**
	 * Lightweight environment payload for the onboarding wizard. Keeps
	 * the legacy shape Onboarding::env_payload returned so the Welcome
	 * step's HealthRow rendering doesn't change.
	 */
	public static function env_payload(): array {
		global $wp_version;
		$cache_dir = defined( 'XSPEED_CACHE_DIR' ) ? XSPEED_CACHE_DIR : ( WP_CONTENT_DIR . '/cache/xspeed' );
		return array(
			'wp'            => array(
				'version' => (string) $wp_version,
				'ok'      => version_compare( (string) $wp_version, '6.0', '>=' ),
			),
			'php'           => array(
				'version' => PHP_VERSION,
				'ok'      => version_compare( PHP_VERSION, '7.4', '>=' ),
				'modern'  => version_compare( PHP_VERSION, '8.1', '>=' ),
			),
			'server'        => array(
				'type'      => Server::type(),
				'gzip_mode' => Server::gzip_mode(),
			),
			'cache_dir'     => array(
				'path'     => $cache_dir,
				'writable' => wp_mkdir_p( $cache_dir ) && wp_is_writable( $cache_dir ),
			),
			'wp_config'     => array(
				'writable' => self::wp_config_writable(),
			),
			'permalinks_ok' => (bool) get_option( 'permalink_structure' ),
			'conflicts'     => Server::conflicts(),
		);
	}

	private static function wp_config_writable(): bool {
		$path = ABSPATH . 'wp-config.php';
		if ( ! file_exists( $path ) ) {
			$path = dirname( ABSPATH ) . '/wp-config.php';
		}
		return file_exists( $path ) && wp_is_writable( $path );
	}
}

```
