PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 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 All 35 releases
xspeed / includes / modules / Health / HealthModule.php

HealthModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.1, at includes/modules/Health/HealthModule.php

386 lines 17.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Health module — read-only diagnostic surface for the dashboard.
4 *
5 * No settings_schema (this is a status panel, not a configuration
6 * surface). The React side renders a custom panel (HealthCard, declared
7 * via `ui_metadata.custom_panel`) instead of going through ModulePanel's
8 * schema-driven path.
9 *
10 * Data sources are all existing services:
11 * - Health::checks() — diagnostic rows
12 * - Cache::get_stats() — cached_pages / size / last_purge /
13 * hits_24h / misses_24h / hit_ratio
14 * - Hit_Counter::buckets() — 24 hourly buckets for the sparkline
15 * - Activity_Log::entries() — newest-first event log
16 *
17 * Tier: Free (per FEATURES.md "Cache Insights" — Cache Performance +
18 * Last 24h chart are Free; Recommendations + Frequently-missed-URLs
19 * stay Pro).
20 *
21 * @package XSpeed
22 */
23
24 declare(strict_types=1);
25
26 namespace XSpeed\Modules\Health;
27
28 defined( 'ABSPATH' ) || exit;
29
30 use XSpeed\Activity_Log;
31 use XSpeed\Cache;
32 use XSpeed\Health;
33 use XSpeed\Hit_Counter;
34 use XSpeed\Module;
35
36 final class HealthModule extends Module {
37
38 public const SLUG = 'health';
39 public const TIER = self::TIER_FREE;
40 public const VERSION = '1.0.0';
41
42 /**
43 * Surface the most important diagnostics in WordPress's built-in
44 * Site Health screen (Tools → Site Health → Status). Admins who
45 * never open the xSpeed dashboard still get a heads-up when
46 * static-rewrite is missing on Apache/LiteSpeed or when the nginx
47 * snippet hasn't been pasted yet — both lead to a 5-10× slowdown
48 * vs the optimal cache hit path.
49 */
50 public function boot(): void {
51 add_filter( 'site_status_tests', array( $this, 'register_site_status_tests' ) );
52
53 // Out-of-band refresh of the Set-Cookie probe. Health::checks()
54 // only ever reads the cached verdict, so the HTTP round-trip
55 // happens here instead of inside a request the user waits on.
56 add_action( \XSpeed\Cookie_Inspector::CRON_HOOK, array( $this, 'refresh_cookie_probe' ) );
57
58 // Same pattern for the edge-mode probe: the page it requests is
59 // answered here, and the two round-trips run from cron.
60 \XSpeed\Edge_Mode_Probe::boot();
61 add_action( \XSpeed\Edge_Mode_Probe::CRON_HOOK, array( $this, 'refresh_edge_mode_probe' ) );
62 }
63
64 /** Cron callback: ask the edge whether it obeys "do not store". */
65 public function refresh_edge_mode_probe(): void {
66 \XSpeed\Edge_Mode_Probe::run();
67 }
68
69 /** Cron callback: perform the real (blocking) probe off-request. */
70 public function refresh_cookie_probe(): void {
71 \XSpeed\Cookie_Inspector::probe( true );
72 }
73
74 public function register_site_status_tests( array $tests ): array {
75 $tests['direct']['xspeed_static_rewrite'] = array(
76 'label' => __( 'xSpeed static-rewrite cache', 'xspeed' ),
77 'test' => array( $this, 'site_status_static_rewrite' ),
78 );
79 return $tests;
80 }
81
82 /**
83 * Site Health test row. Reports green when the .htaccess block is
84 * present (Apache/LiteSpeed) or yellow with the nginx snippet
85 * embedded when nginx is detected. Skipped entirely when cache is
86 * disabled — no point telling the user to install a rewrite they
87 * haven't opted into.
88 */
89 /**
90 * Decide what the nginx static rewrite is actually doing.
91 *
92 * Extracted so the ordering is testable without a WordPress bootstrap,
93 * and so Site Health and the dashboard Health panel cannot drift apart
94 * again — the whole point of #480.
95 *
96 * Returns one of: 'active', 'mobile_separate', 'skipped_nonce',
97 * 'unverified', 'required'.
98 *
99 * @param array<string, mixed> $probe probe_static_rewrite() result.
100 * @param string $block_reason A known refusal, or ''.
101 */
102 public static function nginx_rewrite_verdict( array $probe, string $block_reason ): string {
103 $is_active = (bool) ( $probe['active'] ?? false );
104 $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
105
106 // A known refusal OUTRANKS the probe. probe_static_rewrite() writes
107 // its own file under the static-cache dir and fetches that, which
108 // succeeds whenever the server can serve a static file at all — even
109 // when no real page is on the static path. It also outranks
110 // "inconclusive", so a blocked rewrite whose probe merely failed to
111 // complete is reported as the refusal it is. (FBS-83145)
112 if ( '' !== $block_reason ) {
113 if ( 'mobile_separate' === $block_reason ) {
114 return 'mobile_separate';
115 }
116 if ( 'skipped_nonce' === $block_reason ) {
117 return 'skipped_nonce';
118 }
119 return 'required';
120 }
121
122 if ( $is_active ) {
123 return 'active';
124 }
125
126 // The probe never reached a verdict (blocked loopback, self-signed
127 // cert, timeout, a CDN/WAF answering instead of the origin). That is
128 // not evidence the config is wrong, and must not produce a
129 // "paste this snippet" banner. (FBS-84012, #480)
130 return $inconclusive ? 'unverified' : 'required';
131 }
132
133 public function site_status_static_rewrite(): array {
134 $result = array(
135 'label' => __( 'xSpeed static-rewrite cache is active', 'xspeed' ),
136 'status' => 'good',
137 'badge' => array(
138 'label' => __( 'Performance', 'xspeed' ),
139 'color' => 'blue',
140 ),
141 'description' => '<p>' . esc_html__( 'Cache hits bypass PHP for ~5-15ms TTFB.', 'xspeed' ) . '</p>',
142 'test' => 'xspeed_static_rewrite',
143 );
144
145 $cache_enabled = (bool) ( \XSpeed\Settings::get()['cache_enabled'] ?? false );
146 if ( ! $cache_enabled ) {
147 $result['label'] = __( 'xSpeed cache is disabled', 'xspeed' );
148 $result['status'] = 'recommended';
149 $result['description'] = '<p>' . esc_html__( 'Enable the page cache in the xSpeed dashboard to start serving cached HTML for non-logged-in visitors.', 'xspeed' ) . '</p>';
150 return $result;
151 }
152
153 $server_type = \XSpeed\Server::type();
154 if ( \XSpeed\Server::APACHE === $server_type || \XSpeed\Server::LITESPEED === $server_type ) {
155 // Only call the block "missing" when it is genuinely absent by
156 // accident. When static_rewrite_allowed() deliberately refused it,
157 // "toggle Enable Cache off and on" cannot reinstall anything —
158 // the same condition suppresses the write and auto_heal() strips
159 // the block again on the next admin load. Explain the real cause.
160 $block_reason = \XSpeed\Cache::static_rewrite_block_reason();
161 if ( 'litespeed_dropin' === $block_reason ) {
162 // The intended LiteSpeed default (#509) — 'good', not a nag:
163 // hits are visible and counted, and the faster path is a
164 // deliberate opt-in, not a missing config.
165 $result['label'] = __( 'xSpeed is serving cache hits through PHP (LiteSpeed)', 'xspeed' );
166 $result['status'] = 'good';
167 $result['description'] = '<p>' . esc_html__( 'Caching is working — hits are served by the xSpeed drop-in and tagged X-XSpeed-Cache: HIT (php), so every hit is visible and counted. LiteSpeed\'s .htaccess engine cannot tag or log statically served files, so this is the default. To serve hits straight from the web server with no PHP (at the cost of that tagging and counting), turn on LiteSpeed Static Fast Path in xSpeed\'s Cache settings.', 'xspeed' ) . '</p>';
168 } elseif ( 'no_mod_headers' === $block_reason ) {
169 $result['label'] = __( 'xSpeed is serving cache hits through PHP', 'xspeed' );
170 $result['status'] = 'recommended';
171 $result['description'] = '<p>' . esc_html__( 'Caching is working — hits are served by the xSpeed drop-in and tagged X-XSpeed-Cache: HIT (php). The faster .htaccess fast path is off because Apache\'s mod_headers module is not loaded, without which a static hit could not be tagged or counted. Enable mod_headers (a2enmod headers on Debian/Ubuntu, then restart Apache) to shave roughly 20-30ms off each cache hit.', 'xspeed' ) . '</p>';
172 } elseif ( 'mobile_separate' === $block_reason ) {
173 $result['label'] = __( 'xSpeed static rewrite is off (Separate Mobile Cache)', 'xspeed' );
174 $result['status'] = 'recommended';
175 $result['description'] = '<p>' . esc_html__( 'Separate Mobile Cache is on, so cache hits are served by the PHP drop-in to keep per-device HTML correct. Turn Separate Mobile Cache off if your site serves the same HTML to every device to regain the faster static path.', 'xspeed' ) . '</p>';
176 } elseif ( '' === $block_reason && ! \XSpeed\Cache::rewrite_installed() ) {
177 // Apache, or LiteSpeed with the Static Fast Path opt-in on
178 // (#509) — either way the block SHOULD be there and is not.
179 $result['label'] = __( 'xSpeed .htaccess rewrite block is missing', 'xspeed' );
180 $result['status'] = 'recommended';
181 $result['description'] = '<p>' . esc_html__( 'Without the static-rewrite block, cache hits go through the PHP drop-in (~85ms TTFB) instead of the web server (~5-15ms). Toggle Enable Cache off and on in xSpeed to reinstall the block.', 'xspeed' ) . '</p>';
182 }
183 return $result;
184 }
185
186 if ( \XSpeed\Server::NGINX === $server_type ) {
187 // Ask the same question the dashboard Health panel asks, the same
188 // way. This test used to return "config required" unconditionally,
189 // so every correctly-configured nginx site — every xCloud site,
190 // where the panel installs the block for you — was told to paste a
191 // snippet it already had, and re-running the check never cleared
192 // it. Worse, the dashboard said the opposite at the same moment.
193 // Reuse probe_static_rewrite() + the refusal reasons so the two
194 // surfaces cannot disagree. (#480)
195 $probe = \XSpeed\Cache::probe_static_rewrite( true );
196 $probe_reason = (string) ( $probe['reason'] ?? '' );
197
198 $block_reason = \XSpeed\Cache::static_rewrite_block_reason();
199 $skip = \XSpeed\Cache::last_static_skip();
200 if ( '' === $block_reason && ! empty( $skip['reason'] ) ) {
201 $block_reason = 'skipped_' . (string) $skip['reason'];
202 }
203
204 switch ( self::nginx_rewrite_verdict( $probe, $block_reason ) ) {
205 case 'active':
206 $result['label'] = __( 'xSpeed nginx static rewrite is active', 'xspeed' );
207 $result['status'] = 'good';
208 $result['description'] = '<p>' . esc_html__( 'nginx is serving cache hits directly — PHP is bypassed (~5-15ms TTFB). No action needed.', 'xspeed' ) . '</p>';
209 return $result;
210
211 case 'mobile_separate':
212 $result['label'] = __( 'xSpeed static rewrite is off (Separate Mobile Cache)', 'xspeed' );
213 $result['status'] = 'recommended';
214 $result['description'] = '<p>' . esc_html__( 'Separate Mobile Cache is on, so cache hits are served by the PHP drop-in to keep per-device HTML correct. Turn Separate Mobile Cache off if your site serves the same HTML to every device to regain the faster static path.', 'xspeed' ) . '</p>';
215 return $result;
216
217 case 'skipped_nonce':
218 $result['label'] = __( 'xSpeed is serving cache hits through PHP (pages contain nonces)', 'xspeed' );
219 $result['status'] = 'recommended';
220 $result['description'] = '<p>' . esc_html__( 'Your nginx config is correct, but pages are not reaching the static cache, so hits are served by PHP. They contain nonces, and a static file is served with no PHP — nothing could ever refresh them, so every anonymous form on the page would break once they expire. Keeping these pages on PHP is deliberate.', 'xspeed' ) . '</p>';
221 return $result;
222
223 case 'unverified':
224 // The probe never reached a verdict (blocked loopback,
225 // self-signed cert, timeout, a CDN/WAF answering instead of
226 // the origin). Not evidence the config is wrong, so don't
227 // say "required" and don't dump a snippet the user has
228 // probably already pasted. (FBS-84012, and why #480 was filed.)
229 $result['label'] = __( 'xSpeed could not verify the nginx static rewrite', 'xspeed' );
230 $result['status'] = 'recommended';
231 $result['description'] = '<p>' . esc_html(
232 sprintf(
233 /* translators: %s: the reason the probe could not complete. */
234 __( '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', 'xspeed' ),
235 $probe_reason
236 )
237 ) . '</p>';
238 return $result;
239 }
240
241 $snippet = \XSpeed\Cache::nginx_snippet();
242 $result['label'] = __( 'xSpeed nginx server config required', 'xspeed' );
243 $result['status'] = 'recommended';
244 $result['description'] = '<p>' . esc_html__( 'xSpeed can\'t write nginx config from PHP. Paste this snippet into your site\'s server { } block, then reload nginx so cache hits serve without booting PHP:', 'xspeed' ) . '</p>'
245 . '<pre style="white-space:pre;overflow-x:auto;background:#f6f7f7;border:1px solid #c3c4c7;border-radius:4px;padding:12px;font-size:12px;line-height:1.4;">'
246 . esc_html( (string) $snippet )
247 . '</pre>';
248 return $result;
249 }
250
251 // Unknown / IIS — no server-level rewrite path available; PHP
252 // drop-in is the best we can offer. Don't flag as broken.
253 $result['label'] = __( 'xSpeed PHP drop-in cache active', 'xspeed' );
254 $result['status'] = 'recommended';
255 $result['description'] = '<p>' . esc_html__( 'Static-rewrite caching needs Apache, LiteSpeed, or nginx. The PHP drop-in is still serving cache hits at ~85ms TTFB on this server.', 'xspeed' ) . '</p>';
256 return $result;
257 }
258
259 public function ui_metadata(): array {
260 return array(
261 'label' => __( 'Health', 'xspeed' ),
262 'icon' => 'HeartPulse',
263 'description' => __( 'Checks for problems and shows how often visitors get cached pages.', 'xspeed' ),
264 // Health is the single host page for all Insights (FBS-83633):
265 // a Recommendations action card + Cache / Visitors / PageSpeed
266 // tabs. HealthPanel renders the Free cache diagnostics (the old
267 // HealthCard) as the Cache tab and hosts the Pro insight panels
268 // as the other tabs via ProSlot.
269 'custom_panel' => 'HealthPanel',
270 'group' => 'insights',
271 );
272 }
273
274 // No settings — explicit empty so Module::rest_routes() doesn't
275 // auto-wire the schema-driven GET+POST.
276 public function settings_schema(): array {
277 return array();
278 }
279
280 public function rest_routes(): array {
281 return array(
282 array(
283 'path' => '/',
284 'methods' => 'GET',
285 'callback' => array( $this, 'rest_get_payload' ),
286 ),
287 );
288 }
289
290 public function cli_commands(): array {
291 return array(
292 array(
293 'name' => 'xspeed health',
294 'callback' => array( $this, 'cli_handler' ),
295 'shortdesc' => 'Print diagnostic checks + cache stats + recent activity.',
296 'ai_hint' => 'Full diagnostic sweep: what is misconfigured or degraded on this site right now, plus cache stats and recent activity. The best FIRST call for open-ended "why is my site slow" or "is anything wrong" questions.',
297 'synopsis' => array(),
298 ),
299 array(
300 'name' => 'xspeed recommend',
301 'callback' => array( $this, 'cli_recommend' ),
302 'shortdesc' => 'List ranked next-best-action recommendations, or apply one by id.',
303 'ai_hint' => 'The ranked list of what to do next to make this site faster, and the way to apply one. Use when asked "what should I improve" or "what\'s the biggest win" — each item is actionable and ordered by impact.',
304 'synopsis' => array(
305 array(
306 'type' => 'positional',
307 'name' => 'action',
308 'options' => array( 'list', 'apply' ),
309 'optional' => true,
310 ),
311 array(
312 'type' => 'positional',
313 'name' => 'id',
314 'optional' => true,
315 ),
316 ),
317 ),
318 );
319 }
320
321 /** CLI: `wp xspeed recommend [list|apply <id>]` — MCP-reachable via run_command. */
322 public function cli_recommend( array $args, array $assoc ): void {
323 $action = isset( $args[0] ) ? (string) $args[0] : 'list';
324
325 if ( 'apply' === $action ) {
326 $id = isset( $args[1] ) ? (string) $args[1] : '';
327 if ( '' === $id ) {
328 \WP_CLI::error( 'Usage: wp xspeed recommend apply <id>' );
329 return;
330 }
331 $result = \XSpeed\Recommendations::apply( $id );
332 if ( is_wp_error( $result ) ) {
333 \WP_CLI::error( $result->get_error_message() );
334 return;
335 }
336 \WP_CLI::success( sprintf( 'Applied "%s". %d recommendation(s) remain.', $id, count( $result['recommendations'] ) ) );
337 return;
338 }
339
340 $recs = \XSpeed\Recommendations::all();
341 if ( empty( $recs ) ) {
342 \WP_CLI::success( 'No recommendations — configuration looks healthy.' );
343 return;
344 }
345 foreach ( $recs as $i => $rec ) {
346 $fixable = 'apply' === ( $rec['action']['type'] ?? '' ) ? ' (one-click: wp xspeed recommend apply ' . $rec['id'] . ')' : '';
347 \WP_CLI::log( sprintf( '%d. [%s] %s — %s%s', $i + 1, $rec['id'], $rec['title'], $rec['detail'], $fixable ) );
348 }
349 }
350
351 /**
352 * Single endpoint that backs the dashboard panel. Refreshed lazily by
353 * the React side; cheap enough that aggregating into one response is
354 * the right call (the buckets array is at most 24 entries; activity
355 * is capped at 50).
356 */
357 public function rest_get_payload( \WP_REST_Request $request ) {
358 return rest_ensure_response( $this->payload() );
359 }
360
361 public function payload(): array {
362 return array(
363 'checks' => Health::checks(),
364 'stats' => Cache::get_stats(),
365 'buckets' => Hit_Counter::buckets(),
366 'activity' => Activity_Log::entries(),
367 );
368 }
369
370 public function cli_handler( array $args, array $assoc ): void {
371 $payload = $this->payload();
372 \WP_CLI::log( '== Checks ==' );
373 foreach ( $payload['checks'] as $c ) {
374 \WP_CLI::log( sprintf( '[%s] %s — %s', strtoupper( $c['tone'] ), $c['label'], $c['detail'] ) );
375 }
376 \WP_CLI::log( '' );
377 \WP_CLI::log( '== Stats (24h) ==' );
378 \WP_CLI::log( sprintf( 'Hits %d · Misses %d · Hit ratio %.2f%%', $payload['stats']['hits_24h'], $payload['stats']['misses_24h'], $payload['stats']['hit_ratio'] * 100 ) );
379 \WP_CLI::log( '' );
380 \WP_CLI::log( '== Recent activity ==' );
381 foreach ( array_slice( $payload['activity'], 0, 10 ) as $e ) {
382 \WP_CLI::log( sprintf( '%s [%s] %s', gmdate( 'Y-m-d H:i:s', $e['ts'] ), $e['severity'], $e['message'] ) );
383 }
384 }
385 }
386