PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
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 / class-recommendations.php

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

457 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 * Recommendations — the "Next best action" engine (issue #48).
4 *
5 * Deterministic rules only (zero AI cost): each rule inspects plugin
6 * state and, when it applies, emits ONE ranked recommendation with either
7 * a one-click server-side fix (`apply` action → routed through
8 * Settings_Manager::update, so it validates against the schema AND lands
9 * in the change log like any other write) or a deep-link (`link` action →
10 * the dashboard's #hash router).
11 *
12 * evaluate() is pure — state in, ranked recommendations out — so every
13 * rule is unit-testable without WordPress. state() gathers the live
14 * inputs, reading ONLY cached probe verdicts (never paying for an HTTP
15 * probe on a dashboard paint).
16 *
17 * @package XSpeed
18 */
19
20 namespace XSpeed;
21
22 defined( 'ABSPATH' ) || exit;
23
24 final class Recommendations {
25
26 /**
27 * Bounds on what `xspeed_recommendations` may add.
28 *
29 * The list renders in the Overview card and the cache page's next-best
30 * action, and the REST route returns it as is. The caps are well above any
31 * honest use and keep a buggy contributor from flooding either screen.
32 */
33 private const MAX_CONTRIBUTED = 5;
34 private const MAX_TITLE_LEN = 120;
35 private const MAX_DETAIL_LEN = 400;
36 private const MAX_LABEL_LEN = 40;
37 private const MAX_TAG_LEN = 24;
38 private const FILTER = 'xspeed_recommendations';
39
40 /**
41 * Evaluate all rules against a state snapshot. Pure — unit-tested.
42 *
43 * @param array $state See state() for the shape.
44 * @return array<int,array{id:string,priority:int,title:string,detail:string,action:array<string,mixed>}>
45 * Sorted most-important-first (ascending priority).
46 */
47 public static function evaluate( array $state ): array {
48 $out = array();
49
50 $cache_enabled = ! empty( $state['cache_enabled'] );
51 $hits = (int) ( $state['hits_24h'] ?? 0 );
52 $misses = (int) ( $state['misses_24h'] ?? 0 );
53 $traffic = $hits + $misses;
54 $ratio = (float) ( $state['hit_ratio'] ?? 0 );
55
56 // 0. Cache off entirely — nothing else matters until this is on.
57 if ( ! $cache_enabled ) {
58 $out[] = array(
59 'id' => 'cache_disabled',
60 'priority' => 5,
61 'title' => __( 'Enable page caching', 'xspeed' ),
62 'detail' => __( 'Caching is off, so every visit renders the full page. Turning it on is the single biggest speed win.', 'xspeed' ),
63 'action' => array_merge(
64 array( 'type' => 'link' ),
65 // Lands ON the enable toggle, not merely on the panel
66 // that contains it (issue #49).
67 // No `suggest` here: cache_enabled is deliberately outside
68 // the cache schema (it drives the drop-in install), so the
69 // Apply strip — which only renders on a schema row — could
70 // never appear for it. Offering a value nothing can apply
71 // is worse than offering none.
72 Deep_Link::action( __( 'Go to Cache settings', 'xspeed' ), 'cache', 'cache_enabled' )
73 ),
74 );
75 // Later rules assume a running cache; report just this one.
76 return $out;
77 }
78
79 // 1. Expiry shorter than the preloader interval (issue #31): pages
80 // expire before the next crawl re-warms them — the classic silent
81 // hit-ratio killer. One-click fix raises expiry to the interval.
82 $interval = Health::PRELOAD_INTERVALS[ (string) ( $state['preload_schedule'] ?? '' ) ] ?? null;
83 $expiry = (int) ( $state['cache_expiry'] ?? 0 );
84 if ( ! empty( $state['preloader_enabled'] ) && null !== $interval && $expiry > 0 && $expiry < $interval ) {
85 $out[] = array(
86 'id' => 'expiry_preload_mismatch',
87 'priority' => 10,
88 'title' => __( 'Raise Cache Expiry to match your preload schedule', 'xspeed' ),
89 'detail' => sprintf(
90 /* translators: 1: current expiry hours, 2: preload interval hours. */
91 __( 'Pages expire after %1$dh but the preloader only re-warms them every %2$dh — most visits hit a cold cache.', 'xspeed' ),
92 $expiry,
93 $interval
94 ),
95 'action' => array(
96 'type' => 'apply',
97 'module' => 'cache',
98 'values' => array( 'cache_expiry' => $interval ),
99 'label' => sprintf(
100 /* translators: %d: recommended expiry in hours. */
101 __( 'Raise expiry to %dh', 'xspeed' ),
102 $interval
103 ),
104 ),
105 );
106 }
107
108 // 2. A plugin is setting cookies on anonymous pages (issue #33):
109 // CDN edge caches BYPASS any response carrying Set-Cookie.
110 $cookies = isset( $state['poison_cookies'] ) && is_array( $state['poison_cookies'] ) ? $state['poison_cookies'] : array();
111 if ( ! empty( $cookies ) ) {
112 $first = $cookies[0];
113 $culprit = ! empty( $first['plugin'] ) ? (string) $first['plugin'] : __( 'A plugin', 'xspeed' );
114 $out[] = array(
115 'id' => 'set_cookie_poisoning',
116 'priority' => 15,
117 'title' => __( 'A plugin is blocking CDN edge caching', 'xspeed' ),
118 'detail' => sprintf(
119 /* translators: 1: plugin name, 2: cookie name. */
120 __( '%1$s sets the "%2$s" cookie on anonymous pages, which makes CDNs skip their edge cache for all HTML.', 'xspeed' ),
121 $culprit,
122 (string) ( $first['name'] ?? '' )
123 ),
124 'action' => array_merge(
125 array( 'type' => 'link' ),
126 // Health is a read-only panel — there is no control to
127 // focus, so this one carries the destination only.
128 Deep_Link::action( __( 'See details in Health', 'xspeed' ), 'health' )
129 ),
130 );
131 }
132
133 // 3. nginx detected but the server-level rewrite isn't serving hits.
134 if ( 'nginx' === ( $state['server'] ?? '' ) && false === ( $state['rewrite_active'] ?? null ) ) {
135 $out[] = array(
136 'id' => 'nginx_snippet_missing',
137 'priority' => 20,
138 'title' => __( 'Apply the nginx server snippet', 'xspeed' ),
139 'detail' => __( 'nginx can serve cache hits directly (~5-15ms TTFB, PHP bypassed) once the snippet is in your server block.', 'xspeed' ),
140 'action' => array_merge(
141 array( 'type' => 'link' ),
142 Deep_Link::action( __( 'Get the snippet', 'xspeed' ), 'cache', 'nginx_snippet' )
143 ),
144 );
145 }
146
147 // 4. Preloader off while the hit ratio is poor — with real traffic.
148 if ( empty( $state['preloader_enabled'] ) && $traffic >= 50 && $ratio < 0.5 ) {
149 $out[] = array(
150 'id' => 'preloader_off_low_ratio',
151 'priority' => 25,
152 'title' => __( 'Turn on the preloader', 'xspeed' ),
153 'detail' => sprintf(
154 /* translators: %d: hit ratio percent. */
155 __( 'Your hit ratio is %d%% — most visitors hit a cold cache. The preloader crawls your sitemap so pages are warm before anyone asks.', 'xspeed' ),
156 (int) round( $ratio * 100 )
157 ),
158 'action' => array(
159 'type' => 'apply',
160 'module' => 'preloader',
161 'values' => array( 'enabled' => true ),
162 'label' => __( 'Enable preloader', 'xspeed' ),
163 ),
164 );
165 }
166
167 // 5. Object cache configured but not actually persisting.
168 if ( ! empty( $state['objcache_enabled'] ) && empty( $state['objcache_persistent'] ) ) {
169 $out[] = array(
170 'id' => 'object_cache_degraded',
171 'priority' => 30,
172 'title' => __( 'Object cache is configured but not persisting', 'xspeed' ),
173 'detail' => __( 'The backend is not connected, so every request falls back to the database. Run the connection test to see why.', 'xspeed' ),
174 'action' => array_merge(
175 array( 'type' => 'link' ),
176 Deep_Link::action( __( 'Test the connection', 'xspeed' ), 'object-cache', 'connection_test' )
177 ),
178 );
179 }
180
181 // 6. Cloudflare enabled but missing credentials/zone — configured in
182 // name only; purges will silently do nothing.
183 if ( ! empty( $state['cloudflare_enabled'] ) && empty( $state['cloudflare_ready'] ) ) {
184 $out[] = array(
185 'id' => 'cloudflare_unverified',
186 'priority' => 35,
187 'title' => __( 'Finish connecting Cloudflare', 'xspeed' ),
188 'detail' => __( 'The Cloudflare module is on but has no verified credentials or zone, so edge purges cannot work.', 'xspeed' ),
189 'action' => array_merge(
190 array( 'type' => 'link' ),
191 Deep_Link::action( __( 'Open Cloudflare settings', 'xspeed' ), 'cloudflare', 'api_token' )
192 ),
193 );
194 }
195
196 usort(
197 $out,
198 static function ( $a, $b ) {
199 return $a['priority'] <=> $b['priority'];
200 }
201 );
202 return $out;
203 }
204
205 /**
206 * Gather the live state the rules read. Cached probe verdicts only —
207 * this runs on dashboard paints and must never block on HTTP.
208 *
209 * @return array<string,mixed>
210 */
211 public static function state(): array {
212 $opts = Settings::get();
213 $cache_opts = Settings_Manager::get( 'cache' );
214 $pre_opts = Settings_Manager::get( 'preloader' );
215 $cf_opts = Settings_Manager::get( 'cloudflare' );
216 $oc_opts = Settings_Manager::get( 'object-cache' );
217 $totals = Hit_Counter::totals_24h();
218 $probe = Cache::probe_static_rewrite( false );
219 $cookie = Cookie_Inspector::probe( false );
220
221 $oc_detect = Object_Cache::detect();
222 $oc_persistent = ! empty( $oc_detect['persistent'] ) || ( ! empty( $oc_detect['wp_cache_active'] ) && empty( $oc_detect['degraded'] ) );
223
224 return array(
225 'cache_enabled' => ! empty( $opts['cache_enabled'] ),
226 'cache_expiry' => (int) ( $cache_opts['cache_expiry'] ?? 0 ),
227 'preloader_enabled' => ! empty( $pre_opts['enabled'] ),
228 'preload_schedule' => (string) ( $pre_opts['schedule'] ?? 'manual' ),
229 'server' => Server::type(),
230 // null = probe pending/unknown (rule stays silent); false = probed inactive.
231 'rewrite_active' => ! empty( $probe['pending'] ) ? null : (bool) ( $probe['active'] ?? false ),
232 'poison_cookies' => ! empty( $cookie['checked'] ) ? $cookie['cookies'] : array(),
233 'hits_24h' => (int) $totals['hits'],
234 'misses_24h' => (int) $totals['misses'],
235 'hit_ratio' => (float) $totals['ratio'],
236 'objcache_enabled' => ! empty( $oc_opts['enabled'] ),
237 'objcache_persistent' => $oc_persistent,
238 'cloudflare_enabled' => ! empty( $cf_opts['enabled'] ),
239 'cloudflare_ready' => ! empty( $cf_opts['zone_id'] ) && ( ! empty( $cf_opts['api_token'] ) || ( ! empty( $cf_opts['api_key'] ) && ! empty( $cf_opts['email'] ) ) ),
240 );
241 }
242
243 /** Ranked recommendations for the live site. */
244 public static function all(): array {
245 return self::evaluate( self::state() );
246 }
247
248 /**
249 * The live list with add-on entries folded in, for the Overview card.
250 *
251 * Kept apart from all() on purpose. all() feeds the cache page's next best
252 * action, `wp xspeed recommend` and apply(), which answer "what should I
253 * fix on this site"; an add-on's entry can be an offer, and an offer must
254 * not take the slot of the site's real next fix or read as one in the CLI.
255 */
256 public static function all_with_contributed(): array {
257 $state = self::state();
258 return self::with_contributed( self::evaluate( $state ), $state );
259 }
260
261 /**
262 * Add the entries other plugins contribute, then re-rank.
263 *
264 * Contributors may only add. The filter starts from an empty array and
265 * gets the native list as read-only context, so nothing it returns can
266 * remove or rewrite a native entry, and an id a native rule already uses
267 * is dropped rather than replacing it. Only `link` actions are accepted:
268 * an `apply` action would POST to an endpoint that cannot resolve an id it
269 * does not own (that is what `xspeed_apply_recommendation` is for).
270 *
271 * @param array<int,array<string,mixed>> $native Ranked native entries.
272 * @param array<string,mixed> $state What the rules read, as context.
273 * @return array<int,array<string,mixed>> Native and contributed, ranked.
274 */
275 public static function with_contributed( array $native, array $state ): array {
276 /**
277 * Filter: xspeed_recommendations
278 *
279 * Extra recommendations for the Overview card. Starts empty; append
280 * entries and return the array.
281 *
282 * Each entry: id (string, required, unique), title (string, required),
283 * detail (string, required, one or two sentences, no markup), priority
284 * (int 0-100, lower ranks first, default 50), tag (string, optional, a
285 * short label such as "Add-on" shown beside the title) and action
286 * (required: `type` 'link', `label`, `module` slug, optional `subtab`
287 * and `focus`). Anything else is dropped.
288 *
289 * @param array $contributed Contributed entries (empty on entry).
290 * @param array $native The native entries, as context.
291 * @param array $state What the native rules read, as context.
292 */
293 $depth = isset( $GLOBALS['wp_current_filter'] ) && is_array( $GLOBALS['wp_current_filter'] )
294 ? count( $GLOBALS['wp_current_filter'] )
295 : 0;
296 try {
297 $raw = apply_filters( self::FILTER, array(), $native, $state );
298 } catch ( \Throwable $e ) {
299 // A contributor that throws costs the round its contributions,
300 // not the card. apply_filters() leaves our name on the
301 // current-filter stack when a callback throws, plus any filter
302 // the contributor ran itself, so cut back to where it was.
303 if ( isset( $GLOBALS['wp_current_filter'] ) && is_array( $GLOBALS['wp_current_filter'] ) ) {
304 array_splice( $GLOBALS['wp_current_filter'], $depth );
305 }
306 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
307 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
308 error_log( '[xspeed] xspeed_recommendations contributor threw: ' . $e->getMessage() );
309 }
310 return $native;
311 }
312 if ( ! is_array( $raw ) || empty( $raw ) ) {
313 return $native;
314 }
315
316 $taken = array();
317 foreach ( $native as $rec ) {
318 $taken[ (string) ( $rec['id'] ?? '' ) ] = true;
319 }
320
321 $out = $native;
322 $added = 0;
323 foreach ( $raw as $row ) {
324 if ( $added >= self::MAX_CONTRIBUTED ) {
325 break;
326 }
327 $entry = self::normalise_contributed( $row );
328 if ( null === $entry || isset( $taken[ $entry['id'] ] ) ) {
329 continue;
330 }
331 $taken[ $entry['id'] ] = true;
332 $out[] = $entry;
333 ++$added;
334 }
335
336 // Ties keep their position, so a contribution never jumps a native
337 // entry it ties with. PHP 7.4's sort is not stable, hence the index.
338 $order = array_flip( array_keys( $out ) );
339 uksort(
340 $out,
341 static function ( $a, $b ) use ( $out, $order ) {
342 return array( $out[ $a ]['priority'], $order[ $a ] ) <=> array( $out[ $b ]['priority'], $order[ $b ] );
343 }
344 );
345 return array_values( $out );
346 }
347
348 /**
349 * One contributed entry in the native shape, or null when it is unusable.
350 *
351 * @param mixed $row What the contributor returned.
352 * @return array<string,mixed>|null
353 */
354 private static function normalise_contributed( $row ): ?array {
355 if ( ! is_array( $row ) ) {
356 return null;
357 }
358 $text = static function ( $value, int $max ): string {
359 if ( ! is_string( $value ) ) {
360 return '';
361 }
362 $value = trim( wp_strip_all_tags( $value ) );
363 return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, $max ) : substr( $value, 0, $max );
364 };
365
366 $id = is_string( $row['id'] ?? null ) ? sanitize_key( $row['id'] ) : '';
367 $title = $text( $row['title'] ?? null, self::MAX_TITLE_LEN );
368 $detail = $text( $row['detail'] ?? null, self::MAX_DETAIL_LEN );
369 $action = is_array( $row['action'] ?? null ) ? $row['action'] : array();
370 $label = $text( $action['label'] ?? null, self::MAX_LABEL_LEN );
371 $module = is_string( $action['module'] ?? null ) ? sanitize_key( $action['module'] ) : '';
372 if ( '' === $id || '' === $title || '' === $detail || 'link' !== ( $action['type'] ?? '' ) || '' === $label || '' === $module ) {
373 return null;
374 }
375
376 $link = array(
377 'type' => 'link',
378 'label' => $label,
379 'module' => $module,
380 );
381 foreach ( array( 'subtab', 'focus' ) as $key ) {
382 if ( is_string( $action[ $key ] ?? null ) && '' !== $action[ $key ] ) {
383 $link[ $key ] = $text( $action[ $key ], 80 );
384 }
385 }
386
387 $entry = array(
388 'id' => $id,
389 // (int) 'high' is 0, which would rank the entry above every native fix.
390 'priority' => is_numeric( $row['priority'] ?? null ) ? max( 0, min( 100, (int) $row['priority'] ) ) : 50,
391 'title' => $title,
392 'detail' => $detail,
393 'action' => $link,
394 );
395 $tag = $text( $row['tag'] ?? null, self::MAX_TAG_LEN );
396 if ( '' !== $tag ) {
397 $entry['tag'] = $tag;
398 }
399 return $entry;
400 }
401
402 /**
403 * One-click apply: re-evaluate, find the recommendation, and run its
404 * settings write through Settings_Manager (schema-validated + logged
405 * as a change annotation like every other write).
406 *
407 * @param string $id Recommendation id.
408 * @return array|\WP_Error The refreshed recommendation list on success.
409 */
410 public static function apply( string $id ) {
411 foreach ( self::all() as $rec ) {
412 if ( $rec['id'] !== $id ) {
413 continue;
414 }
415 $action = $rec['action'];
416 if ( 'apply' !== ( $action['type'] ?? '' ) ) {
417 return new \WP_Error(
418 'xspeed_rec_not_applicable',
419 __( 'This recommendation links to a settings screen; it has no one-click fix.', 'xspeed' ),
420 array( 'status' => 400 )
421 );
422 }
423 Settings_Manager::update( (string) $action['module'], (array) $action['values'] );
424 return array(
425 'applied' => $id,
426 'recommendations' => self::all(),
427 );
428 }
429 /**
430 * Last chance to resolve a recommendation id this engine doesn't own.
431 *
432 * Free and Pro each ship a recommendation engine with its OWN id
433 * namespace — Free uses underscores (`nginx_snippet_missing`), Pro
434 * uses hyphens (`cache-disabled`) — but only Free's ids reached this
435 * method, so EVERY Pro Apply button returned 404 and the failure was
436 * swallowed by the UI. This seam lets Pro claim its own ids rather
437 * than duplicating the endpoint. (#198)
438 *
439 * Return an array (the same shape this method returns on success) or
440 * a WP_Error to claim the id; return null to decline it.
441 *
442 * @param array|\WP_Error|null $handled Result from a previous filter, or null.
443 * @param string $id The recommendation id being applied.
444 */
445 $handled = apply_filters( 'xspeed_apply_recommendation', null, $id );
446 if ( is_array( $handled ) || is_wp_error( $handled ) ) {
447 return $handled;
448 }
449
450 return new \WP_Error(
451 'xspeed_rec_unknown',
452 __( 'Unknown or no-longer-applicable recommendation.', 'xspeed' ),
453 array( 'status' => 404 )
454 );
455 }
456 }
457