PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.2
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-health.php

class-health.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.2, at includes/class-health.php

443 lines 17.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Health — shared diagnostic checks consumed by both Onboarding's
4 * environment surface and the Health module's dashboard panel.
5 *
6 * Each check returns:
7 * [
8 * 'id' => 'wp_version',
9 * 'tone' => 'ok' | 'warn' | 'fail' | 'info',
10 * 'label' => 'WordPress 6.6',
11 * 'detail' => 'Meets the 6.0+ minimum.',
12 * ]
13 *
14 * Pure reads — never writes to disk, never makes outbound HTTP calls.
15 * Safe to call from any request including the loading dashboard.
16 *
17 * @package XSpeed
18 */
19
20 declare(strict_types=1);
21
22 namespace XSpeed;
23
24 defined( 'ABSPATH' ) || exit;
25
26 final class Health {
27
28 public const OK = 'ok';
29 public const WARN = 'warn';
30 public const FAIL = 'fail';
31 public const INFO = 'info';
32
33 private const SERVER_LABELS = array(
34 'apache' => 'Apache',
35 'litespeed' => 'LiteSpeed',
36 'nginx' => 'nginx',
37 'iis' => 'IIS',
38 'unknown' => 'Unknown',
39 );
40
41 /**
42 * Full check list used by the Health module's dashboard panel.
43 *
44 * @return array<int,array{id:string,tone:string,label:string,detail:string}>
45 */
46 public static function checks(): array {
47 global $wp_version;
48
49 $server_type = Server::type();
50 $gzip_mode = Server::gzip_mode();
51 $conflicts = Server::conflicts();
52 $cache_dir = defined( 'XSPEED_CACHE_DIR' ) ? XSPEED_CACHE_DIR : ( WP_CONTENT_DIR . '/cache/xspeed' );
53
54 $out = array();
55
56 // WordPress version
57 $wp_ok = version_compare( (string) $wp_version, '6.0', '>=' );
58 $out[] = array(
59 'id' => 'wp_version',
60 'tone' => $wp_ok ? self::OK : self::FAIL,
61 'label' => sprintf( 'WordPress %s', (string) $wp_version ),
62 'detail' => $wp_ok ? 'Meets the 6.0+ minimum.' : 'Upgrade to WordPress 6.0 or higher.',
63 );
64
65 // PHP version
66 $php_ok = version_compare( PHP_VERSION, '7.4', '>=' );
67 $php_modern = version_compare( PHP_VERSION, '8.1', '>=' );
68 $out[] = array(
69 'id' => 'php_version',
70 'tone' => $php_modern ? self::OK : ( $php_ok ? self::WARN : self::FAIL ),
71 'label' => sprintf( 'PHP %s', PHP_VERSION ),
72 'detail' => $php_modern
73 ? 'Modern PHP — full speed.'
74 : ( $php_ok
75 ? 'Works, but 8.1+ is recommended for best performance.'
76 : 'Upgrade to PHP 7.4 or higher.' ),
77 );
78
79 // Server
80 $out[] = array(
81 'id' => 'server',
82 'tone' => self::INFO,
83 'label' => sprintf( 'Server: %s', self::SERVER_LABELS[ $server_type ] ?? 'Unknown' ),
84 'detail' => 'auto' === $gzip_mode
85 ? 'GZIP can be auto-configured via .htaccess.'
86 : 'GZIP requires a manual server-config snippet (shown in the GZIP module).',
87 );
88
89 // Cache directory writable
90 $dir_writable = wp_mkdir_p( $cache_dir ) && wp_is_writable( $cache_dir );
91 $out[] = array(
92 'id' => 'cache_dir',
93 'tone' => $dir_writable ? self::OK : self::FAIL,
94 'label' => 'Cache directory writable',
95 'detail' => $dir_writable
96 ? $cache_dir
97 : sprintf( 'Cannot write to %s. Adjust file permissions before enabling cache.', $cache_dir ),
98 );
99
100 // Drop-in installed (only when cache is enabled — otherwise N/A)
101 $cache_enabled = (bool) Settings::get()['cache_enabled'];
102 if ( $cache_enabled ) {
103 $dropin_path = WP_CONTENT_DIR . '/advanced-cache.php';
104 $dropin_match = file_exists( $dropin_path ) && false !== strpos( (string) file_get_contents( $dropin_path ), 'xspeed' );
105 $out[] = array(
106 'id' => 'dropin',
107 'tone' => $dropin_match ? self::OK : self::FAIL,
108 'label' => 'advanced-cache.php drop-in',
109 'detail' => $dropin_match
110 ? 'Installed and owned by xSpeed.'
111 : 'Drop-in missing or owned by another plugin. Toggle Enable Cache off and on to reinstall.',
112 );
113 }
114
115 // WP_CACHE constant
116 $wp_cache_const = defined( 'WP_CACHE' ) && WP_CACHE;
117 if ( $cache_enabled ) {
118 $out[] = array(
119 'id' => 'wp_cache_constant',
120 'tone' => $wp_cache_const ? self::OK : self::WARN,
121 'label' => 'WP_CACHE constant',
122 'detail' => $wp_cache_const
123 ? 'Defined and truthy in wp-config.php.'
124 : 'Not set. Cache is configured but WordPress will not load the drop-in until WP_CACHE = true is added to wp-config.php.',
125 );
126 }
127
128 // Static-rewrite probe. Active end-to-end check: writes a probe
129 // file under the static-cache dir, fetches it over HTTP, and
130 // confirms the web server (nginx OR Apache/LiteSpeed) served
131 // the raw file. Result is throttled to a 5-minute transient
132 // inside Cache::probe_static_rewrite so we never hit the
133 // network per-paint.
134 if ( $cache_enabled ) {
135 $server_type = Server::type();
136 // The live loopback probe only matters where a server-level static
137 // rewrite is actually used (nginx snippet / Apache .htaccess).
138 // LiteSpeed serves hits via the drop-in (see below), so skip the
139 // probe there entirely — no needless self-request. Health is the
140 // right place to pay for the probe when we DO run it (the admin
141 // bootstrap reads cache-only so it never blocks); the 5-minute
142 // transient still throttles repeat runs. (FBS-82142)
143 $probe = ( Server::LITESPEED === $server_type )
144 ? array( 'active' => false )
145 : Cache::probe_static_rewrite( true );
146 $is_active = (bool) ( $probe['active'] ?? false );
147 // An inconclusive probe (blocked loopback, TLS failure, timeout, a
148 // CDN/WAF answering instead of the origin) proves nothing about the
149 // rewrite. Reporting it as "not yet routing to the cache" told users
150 // with a correct nginx config that their config was broken, and
151 // pointed them at a snippet they had already pasted. (FBS-84012)
152 $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
153 $probe_reason = (string) ( $probe['reason'] ?? '' );
154
155 $block_reason = Cache::static_rewrite_block_reason();
156 $mobile_block = ( 'mobile_separate' === $block_reason )
157 ? ' 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.'
158 : '';
159
160 if ( Server::NGINX === $server_type ) {
161 if ( $is_active ) {
162 $nginx_detail = 'nginx is serving cache hits directly — PHP bypassed (~5-15ms TTFB).';
163 } elseif ( 'mobile_separate' === $block_reason ) {
164 $nginx_detail = 'nginx detected, but the static rewrite is disabled because Separate Mobile Cache is on.' . $mobile_block;
165 } elseif ( $inconclusive ) {
166 $nginx_detail = sprintf(
167 'Could not verify the static rewrite — the check itself did not complete, so this is not evidence that your config is wrong. If you have already pasted the snippet, it may well be working. Reason: %s',
168 $probe_reason
169 );
170 } else {
171 $nginx_detail = 'nginx detected but not yet routing to the cache. Paste the snippet below into your site\'s server { } block, then reload nginx.';
172 }
173
174 $out[] = array(
175 'id' => 'static_rewrite_nginx',
176 // Inconclusive is INFO, not WARN — we have no finding to warn about.
177 'tone' => $is_active ? self::OK : ( $inconclusive ? self::INFO : self::WARN ),
178 'label' => 'Static-file rewrite (nginx server config)',
179 'detail' => $nginx_detail,
180 // Always ship the snippet — even when active, so the
181 // admin has it handy for re-pasting after a server
182 // rebuild without having to find it elsewhere. Mirror
183 // the SAME unified block the "Server config" panel
184 // renders (cache + gzip + browser-cache directives),
185 // not the cache-only snippet — otherwise Health and
186 // the Cache panel disagree on what to paste.
187 'snippet' => Cache::full_nginx_server_block(),
188 );
189 } elseif ( Server::LITESPEED === $server_type ) {
190 // LiteSpeed intentionally does NOT use the .htaccess static
191 // rewrite: OpenLiteSpeed's .htaccess engine ignores
192 // mod_headers (so we can't stamp X-XSpeed-Cache: HIT) and has
193 // no per-rule access_log (so a static hit can't be counted).
194 // We route LiteSpeed hits through the PHP drop-in instead, so
195 // every hit is both visible (X-XSpeed-Cache: HIT) and counted
196 // in the hit-ratio — see Cache::static_rewrite_allowed(). This
197 // is the healthy, expected state on LiteSpeed, not a fallback.
198 $out[] = array(
199 'id' => 'static_rewrite_litespeed',
200 'tone' => self::OK,
201 'label' => 'Cache serving (LiteSpeed)',
202 '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.)',
203 );
204 } elseif ( Server::APACHE === $server_type ) {
205 $installed = Cache::rewrite_installed();
206 if ( $is_active ) {
207 $tone = self::OK;
208 $detail = 'Block installed and serving cache hits directly — PHP bypassed.';
209 } elseif ( 'mobile_separate' === $block_reason ) {
210 $tone = self::WARN;
211 $detail = 'Static rewrite disabled because Separate Mobile Cache is on.' . $mobile_block;
212 } elseif ( ! $installed ) {
213 $tone = self::WARN;
214 $detail = 'Block missing from .htaccess. Toggle Enable Cache off and on to reinstall it.';
215 } elseif ( $inconclusive ) {
216 // Same distinction as the nginx branch: the probe never
217 // reached a verdict, so telling the user to go re-check
218 // AllowOverride blames a config that may be perfectly
219 // fine. Report the failure honestly instead. (FBS-84012)
220 $tone = self::INFO;
221 $detail = sprintf(
222 'Block is installed, but the check could not complete (%s), so this is not evidence that anything is misconfigured. Re-run it with `wp xspeed cache recheck-rewrite` once the site can reach itself over HTTP.',
223 $probe_reason
224 );
225 } else {
226 $tone = self::WARN;
227 $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.', $probe_reason );
228 }
229 $out[] = array(
230 'id' => 'static_rewrite',
231 'tone' => $tone,
232 'label' => 'Static-file rewrite (.htaccess)',
233 'detail' => $detail,
234 );
235 }
236 }
237
238 // Cache expiry vs preloader schedule (deterministic rule, issue #31):
239 // pages that expire faster than the preloader re-warms them leave the
240 // cache cold for most real traffic — the classic "24.8% hit ratio with
241 // everything on" misconfiguration. Pure logic in
242 // expiry_preload_check() so it's unit-testable.
243 if ( $cache_enabled ) {
244 $cache_opts = Settings_Manager::get( 'cache' );
245 $pre_opts = Settings_Manager::get( 'preloader' );
246 $schedule = (string) ( $pre_opts['schedule'] ?? 'manual' );
247 $mismatch = self::expiry_preload_check(
248 (int) ( $cache_opts['cache_expiry'] ?? 24 ),
249 $schedule,
250 ! empty( $pre_opts['enabled'] ),
251 self::schedule_interval_hours( $schedule )
252 );
253 if ( null !== $mismatch ) {
254 $out[] = $mismatch;
255 }
256 }
257
258 // Permalinks
259 $permalinks_ok = (bool) get_option( 'permalink_structure' );
260 $out[] = array(
261 'id' => 'permalinks',
262 'tone' => $permalinks_ok ? self::OK : self::WARN,
263 'label' => 'Permalinks',
264 'detail' => $permalinks_ok
265 ? 'Pretty permalinks active.'
266 : 'Set permalinks to anything other than "Plain" — page caching needs URL paths to key on.',
267 );
268
269 // Cache-poisoning Set-Cookie detection (issue #33): a plugin emitting
270 // Set-Cookie on anonymous pageviews forces CDN/edge BYPASS for all
271 // HTML (Cloudflare never caches a response carrying Set-Cookie). Probe
272 // is transient-throttled inside Cookie_Inspector, same pattern as the
273 // static-rewrite probe above — Health is the right place to pay for it.
274 // Cached-only: Health runs inside the dashboard REST request and the
275 // MCP get_health tool, so this must never block on an HTTP call.
276 // A cold verdict schedules a background refresh and reports nothing
277 // this paint. See Cookie_Inspector::probe_cached().
278 $cookie_probe = Cookie_Inspector::probe_cached();
279 if ( $cookie_probe['checked'] ) {
280 $offenders = $cookie_probe['cookies'];
281 if ( empty( $offenders ) ) {
282 $out[] = array(
283 'id' => 'set_cookie_poisoning',
284 'tone' => self::OK,
285 'label' => 'No cache-poisoning cookies',
286 'detail' => 'Anonymous pages are served without Set-Cookie, so CDN/edge caches can store them.',
287 );
288 } else {
289 $named = array();
290 foreach ( $offenders as $c ) {
291 $named[] = null !== $c['plugin']
292 ? sprintf( '%s is setting %s', $c['plugin'], $c['name'] )
293 : sprintf( 'an unidentified plugin is setting %s', $c['name'] );
294 }
295 $out[] = array(
296 'id' => 'set_cookie_poisoning',
297 'tone' => self::WARN,
298 'label' => 'Set-Cookie on cacheable pages',
299 'detail' => sprintf(
300 '%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.',
301 implode( '; ', $named )
302 ),
303 );
304 }
305 }
306
307 // Conflicting plugins
308 $out[] = array(
309 'id' => 'conflicts',
310 'tone' => empty( $conflicts ) ? self::OK : self::WARN,
311 'label' => 'Caching plugin conflicts',
312 'detail' => empty( $conflicts )
313 ? 'No other caching plugins detected.'
314 : sprintf( 'Active: %s. Deactivate before enabling xSpeed cache to avoid double-caching.', implode( ', ', $conflicts ) ),
315 );
316
317 return $out;
318 }
319
320 /**
321 * Hours between recurring preloader crawls, per schedule option.
322 * `twicedaily` is a WordPress core schedule — omitting it meant a site
323 * using it got no check at all, not even a pass.
324 */
325 public const PRELOAD_INTERVALS = array(
326 'hourly' => 1,
327 'twicedaily' => 12,
328 'daily' => 24,
329 'weekly' => 168,
330 );
331
332 /**
333 * Interval in hours for a cron schedule slug, or null when it isn't a
334 * recurring schedule (`manual`) or can't be resolved.
335 *
336 * Falls back to `wp_get_schedules()` so custom crons registered by a
337 * theme or another plugin are covered too, rather than silently
338 * skipping the check.
339 */
340 public static function schedule_interval_hours( string $schedule ): ?int {
341 if ( isset( self::PRELOAD_INTERVALS[ $schedule ] ) ) {
342 return self::PRELOAD_INTERVALS[ $schedule ];
343 }
344 if ( '' === $schedule || 'manual' === $schedule || ! function_exists( 'wp_get_schedules' ) ) {
345 return null;
346 }
347 $schedules = wp_get_schedules();
348 if ( ! isset( $schedules[ $schedule ]['interval'] ) ) {
349 return null;
350 }
351 $hours = (int) round( (int) $schedules[ $schedule ]['interval'] / HOUR_IN_SECONDS );
352 return $hours > 0 ? $hours : 1;
353 }
354
355 /**
356 * Deterministic rule: warn when cache_expiry is shorter than the
357 * preloader's recurring interval (pages go cold between crawls).
358 *
359 * Pure — no WP calls — so it can be unit-tested directly.
360 *
361 * @param int $expiry_hours Cache expiry in hours.
362 * @param string $schedule Preloader schedule (manual|hourly|twicedaily|daily|weekly|custom).
363 * @param bool $preloader_enabled Whether the preloader module is on.
364 * @param int|null $interval_hours Pre-resolved interval, for schedules
365 * outside PRELOAD_INTERVALS. Keeps this
366 * function pure — the caller does the
367 * wp_get_schedules() lookup.
368 * @return array{id:string,tone:string,label:string,detail:string}|null Check
369 * row, or null when the rule doesn't apply (preloader off/manual).
370 */
371 public static function expiry_preload_check( int $expiry_hours, string $schedule, bool $preloader_enabled, ?int $interval_hours = null ): ?array {
372 if ( ! $preloader_enabled ) {
373 return null;
374 }
375 $interval = $interval_hours ?? ( self::PRELOAD_INTERVALS[ $schedule ] ?? null );
376 if ( null === $interval || $interval < 1 ) {
377 return null;
378 }
379 if ( $expiry_hours < $interval ) {
380 return array(
381 'id' => 'expiry_preload_mismatch',
382 'tone' => self::WARN,
383 'label' => 'Cache expiry shorter than the preload schedule',
384 'detail' => sprintf(
385 '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).',
386 $expiry_hours,
387 $interval,
388 $schedule,
389 $interval
390 ),
391 );
392 }
393 return array(
394 'id' => 'expiry_preload_mismatch',
395 'tone' => self::OK,
396 'label' => 'Cache expiry covers the preload schedule',
397 'detail' => sprintf( 'Expiry %dh ≥ preload interval %dh (%s) — preloaded pages stay warm between crawls.', $expiry_hours, $interval, $schedule ),
398 );
399 }
400
401 /**
402 * Lightweight environment payload for the onboarding wizard. Keeps
403 * the legacy shape Onboarding::env_payload returned so the Welcome
404 * step's HealthRow rendering doesn't change.
405 */
406 public static function env_payload(): array {
407 global $wp_version;
408 $cache_dir = defined( 'XSPEED_CACHE_DIR' ) ? XSPEED_CACHE_DIR : ( WP_CONTENT_DIR . '/cache/xspeed' );
409 return array(
410 'wp' => array(
411 'version' => (string) $wp_version,
412 'ok' => version_compare( (string) $wp_version, '6.0', '>=' ),
413 ),
414 'php' => array(
415 'version' => PHP_VERSION,
416 'ok' => version_compare( PHP_VERSION, '7.4', '>=' ),
417 'modern' => version_compare( PHP_VERSION, '8.1', '>=' ),
418 ),
419 'server' => array(
420 'type' => Server::type(),
421 'gzip_mode' => Server::gzip_mode(),
422 ),
423 'cache_dir' => array(
424 'path' => $cache_dir,
425 'writable' => wp_mkdir_p( $cache_dir ) && wp_is_writable( $cache_dir ),
426 ),
427 'wp_config' => array(
428 'writable' => self::wp_config_writable(),
429 ),
430 'permalinks_ok' => (bool) get_option( 'permalink_structure' ),
431 'conflicts' => Server::conflicts(),
432 );
433 }
434
435 private static function wp_config_writable(): bool {
436 $path = ABSPATH . 'wp-config.php';
437 if ( ! file_exists( $path ) ) {
438 $path = dirname( ABSPATH ) . '/wp-config.php';
439 }
440 return file_exists( $path ) && wp_is_writable( $path );
441 }
442 }
443