PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.0 1.2.1 All 27 releases
xspeed / includes / class-recommendations.php

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

289 lines 11.5 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 * Evaluate all rules against a state snapshot. Pure — unit-tested.
28 *
29 * @param array $state See state() for the shape.
30 * @return array<int,array{id:string,priority:int,title:string,detail:string,action:array<string,mixed>}>
31 * Sorted most-important-first (ascending priority).
32 */
33 public static function evaluate( array $state ): array {
34 $out = array();
35
36 $cache_enabled = ! empty( $state['cache_enabled'] );
37 $hits = (int) ( $state['hits_24h'] ?? 0 );
38 $misses = (int) ( $state['misses_24h'] ?? 0 );
39 $traffic = $hits + $misses;
40 $ratio = (float) ( $state['hit_ratio'] ?? 0 );
41
42 // 0. Cache off entirely — nothing else matters until this is on.
43 if ( ! $cache_enabled ) {
44 $out[] = array(
45 'id' => 'cache_disabled',
46 'priority' => 5,
47 'title' => __( 'Enable page caching', 'xspeed' ),
48 'detail' => __( 'Caching is off, so every visit renders the full page. Turning it on is the single biggest speed win.', 'xspeed' ),
49 'action' => array_merge(
50 array( 'type' => 'link' ),
51 // Lands ON the enable toggle, not merely on the panel
52 // that contains it (issue #49).
53 // No `suggest` here: cache_enabled is deliberately outside
54 // the cache schema (it drives the drop-in install), so the
55 // Apply strip — which only renders on a schema row — could
56 // never appear for it. Offering a value nothing can apply
57 // is worse than offering none.
58 Deep_Link::action( __( 'Go to Cache settings', 'xspeed' ), 'cache', 'cache_enabled' )
59 ),
60 );
61 // Later rules assume a running cache; report just this one.
62 return $out;
63 }
64
65 // 1. Expiry shorter than the preloader interval (issue #31): pages
66 // expire before the next crawl re-warms them — the classic silent
67 // hit-ratio killer. One-click fix raises expiry to the interval.
68 $interval = Health::PRELOAD_INTERVALS[ (string) ( $state['preload_schedule'] ?? '' ) ] ?? null;
69 $expiry = (int) ( $state['cache_expiry'] ?? 0 );
70 if ( ! empty( $state['preloader_enabled'] ) && null !== $interval && $expiry > 0 && $expiry < $interval ) {
71 $out[] = array(
72 'id' => 'expiry_preload_mismatch',
73 'priority' => 10,
74 'title' => __( 'Raise Cache Expiry to match your preload schedule', 'xspeed' ),
75 'detail' => sprintf(
76 /* translators: 1: current expiry hours, 2: preload interval hours. */
77 __( 'Pages expire after %1$dh but the preloader only re-warms them every %2$dh — most visits hit a cold cache.', 'xspeed' ),
78 $expiry,
79 $interval
80 ),
81 'action' => array(
82 'type' => 'apply',
83 'module' => 'cache',
84 'values' => array( 'cache_expiry' => $interval ),
85 'label' => sprintf(
86 /* translators: %d: recommended expiry in hours. */
87 __( 'Raise expiry to %dh', 'xspeed' ),
88 $interval
89 ),
90 ),
91 );
92 }
93
94 // 2. A plugin is setting cookies on anonymous pages (issue #33):
95 // CDN edge caches BYPASS any response carrying Set-Cookie.
96 $cookies = isset( $state['poison_cookies'] ) && is_array( $state['poison_cookies'] ) ? $state['poison_cookies'] : array();
97 if ( ! empty( $cookies ) ) {
98 $first = $cookies[0];
99 $culprit = ! empty( $first['plugin'] ) ? (string) $first['plugin'] : __( 'A plugin', 'xspeed' );
100 $out[] = array(
101 'id' => 'set_cookie_poisoning',
102 'priority' => 15,
103 'title' => __( 'A plugin is blocking CDN edge caching', 'xspeed' ),
104 'detail' => sprintf(
105 /* translators: 1: plugin name, 2: cookie name. */
106 __( '%1$s sets the "%2$s" cookie on anonymous pages, which makes CDNs skip their edge cache for all HTML.', 'xspeed' ),
107 $culprit,
108 (string) ( $first['name'] ?? '' )
109 ),
110 'action' => array_merge(
111 array( 'type' => 'link' ),
112 // Health is a read-only panel — there is no control to
113 // focus, so this one carries the destination only.
114 Deep_Link::action( __( 'See details in Health', 'xspeed' ), 'health' )
115 ),
116 );
117 }
118
119 // 3. nginx detected but the server-level rewrite isn't serving hits.
120 if ( 'nginx' === ( $state['server'] ?? '' ) && false === ( $state['rewrite_active'] ?? null ) ) {
121 $out[] = array(
122 'id' => 'nginx_snippet_missing',
123 'priority' => 20,
124 'title' => __( 'Apply the nginx server snippet', 'xspeed' ),
125 'detail' => __( 'nginx can serve cache hits directly (~5-15ms TTFB, PHP bypassed) once the snippet is in your server block.', 'xspeed' ),
126 'action' => array_merge(
127 array( 'type' => 'link' ),
128 Deep_Link::action( __( 'Get the snippet', 'xspeed' ), 'cache', 'nginx_snippet' )
129 ),
130 );
131 }
132
133 // 4. Preloader off while the hit ratio is poor — with real traffic.
134 if ( empty( $state['preloader_enabled'] ) && $traffic >= 50 && $ratio < 0.5 ) {
135 $out[] = array(
136 'id' => 'preloader_off_low_ratio',
137 'priority' => 25,
138 'title' => __( 'Turn on the preloader', 'xspeed' ),
139 'detail' => sprintf(
140 /* translators: %d: hit ratio percent. */
141 __( 'Your hit ratio is %d%% — most visitors hit a cold cache. The preloader crawls your sitemap so pages are warm before anyone asks.', 'xspeed' ),
142 (int) round( $ratio * 100 )
143 ),
144 'action' => array(
145 'type' => 'apply',
146 'module' => 'preloader',
147 'values' => array( 'enabled' => true ),
148 'label' => __( 'Enable preloader', 'xspeed' ),
149 ),
150 );
151 }
152
153 // 5. Object cache configured but not actually persisting.
154 if ( ! empty( $state['objcache_enabled'] ) && empty( $state['objcache_persistent'] ) ) {
155 $out[] = array(
156 'id' => 'object_cache_degraded',
157 'priority' => 30,
158 'title' => __( 'Object cache is configured but not persisting', 'xspeed' ),
159 'detail' => __( 'The backend is not connected, so every request falls back to the database. Run the connection test to see why.', 'xspeed' ),
160 'action' => array_merge(
161 array( 'type' => 'link' ),
162 Deep_Link::action( __( 'Test the connection', 'xspeed' ), 'object-cache', 'connection_test' )
163 ),
164 );
165 }
166
167 // 6. Cloudflare enabled but missing credentials/zone — configured in
168 // name only; purges will silently do nothing.
169 if ( ! empty( $state['cloudflare_enabled'] ) && empty( $state['cloudflare_ready'] ) ) {
170 $out[] = array(
171 'id' => 'cloudflare_unverified',
172 'priority' => 35,
173 'title' => __( 'Finish connecting Cloudflare', 'xspeed' ),
174 'detail' => __( 'The Cloudflare module is on but has no verified credentials or zone, so edge purges cannot work.', 'xspeed' ),
175 'action' => array_merge(
176 array( 'type' => 'link' ),
177 Deep_Link::action( __( 'Open Cloudflare settings', 'xspeed' ), 'cloudflare', 'api_token' )
178 ),
179 );
180 }
181
182 usort(
183 $out,
184 static function ( $a, $b ) {
185 return $a['priority'] <=> $b['priority'];
186 }
187 );
188 return $out;
189 }
190
191 /**
192 * Gather the live state the rules read. Cached probe verdicts only —
193 * this runs on dashboard paints and must never block on HTTP.
194 *
195 * @return array<string,mixed>
196 */
197 public static function state(): array {
198 $opts = Settings::get();
199 $cache_opts = Settings_Manager::get( 'cache' );
200 $pre_opts = Settings_Manager::get( 'preloader' );
201 $cf_opts = Settings_Manager::get( 'cloudflare' );
202 $oc_opts = Settings_Manager::get( 'object-cache' );
203 $totals = Hit_Counter::totals_24h();
204 $probe = Cache::probe_static_rewrite( false );
205 $cookie = Cookie_Inspector::probe( false );
206
207 $oc_detect = Object_Cache::detect();
208 $oc_persistent = ! empty( $oc_detect['persistent'] ) || ( ! empty( $oc_detect['wp_cache_active'] ) && empty( $oc_detect['degraded'] ) );
209
210 return array(
211 'cache_enabled' => ! empty( $opts['cache_enabled'] ),
212 'cache_expiry' => (int) ( $cache_opts['cache_expiry'] ?? 0 ),
213 'preloader_enabled' => ! empty( $pre_opts['enabled'] ),
214 'preload_schedule' => (string) ( $pre_opts['schedule'] ?? 'manual' ),
215 'server' => Server::type(),
216 // null = probe pending/unknown (rule stays silent); false = probed inactive.
217 'rewrite_active' => ! empty( $probe['pending'] ) ? null : (bool) ( $probe['active'] ?? false ),
218 'poison_cookies' => ! empty( $cookie['checked'] ) ? $cookie['cookies'] : array(),
219 'hits_24h' => (int) $totals['hits'],
220 'misses_24h' => (int) $totals['misses'],
221 'hit_ratio' => (float) $totals['ratio'],
222 'objcache_enabled' => ! empty( $oc_opts['enabled'] ),
223 'objcache_persistent' => $oc_persistent,
224 'cloudflare_enabled' => ! empty( $cf_opts['enabled'] ),
225 'cloudflare_ready' => ! empty( $cf_opts['zone_id'] ) && ( ! empty( $cf_opts['api_token'] ) || ( ! empty( $cf_opts['api_key'] ) && ! empty( $cf_opts['email'] ) ) ),
226 );
227 }
228
229 /** Ranked recommendations for the live site. */
230 public static function all(): array {
231 return self::evaluate( self::state() );
232 }
233
234 /**
235 * One-click apply: re-evaluate, find the recommendation, and run its
236 * settings write through Settings_Manager (schema-validated + logged
237 * as a change annotation like every other write).
238 *
239 * @param string $id Recommendation id.
240 * @return array|\WP_Error The refreshed recommendation list on success.
241 */
242 public static function apply( string $id ) {
243 foreach ( self::all() as $rec ) {
244 if ( $rec['id'] !== $id ) {
245 continue;
246 }
247 $action = $rec['action'];
248 if ( 'apply' !== ( $action['type'] ?? '' ) ) {
249 return new \WP_Error(
250 'xspeed_rec_not_applicable',
251 __( 'This recommendation links to a settings screen; it has no one-click fix.', 'xspeed' ),
252 array( 'status' => 400 )
253 );
254 }
255 Settings_Manager::update( (string) $action['module'], (array) $action['values'] );
256 return array(
257 'applied' => $id,
258 'recommendations' => self::all(),
259 );
260 }
261 /**
262 * Last chance to resolve a recommendation id this engine doesn't own.
263 *
264 * Free and Pro each ship a recommendation engine with its OWN id
265 * namespace — Free uses underscores (`nginx_snippet_missing`), Pro
266 * uses hyphens (`cache-disabled`) — but only Free's ids reached this
267 * method, so EVERY Pro Apply button returned 404 and the failure was
268 * swallowed by the UI. This seam lets Pro claim its own ids rather
269 * than duplicating the endpoint. (#198)
270 *
271 * Return an array (the same shape this method returns on success) or
272 * a WP_Error to claim the id; return null to decline it.
273 *
274 * @param array|\WP_Error|null $handled Result from a previous filter, or null.
275 * @param string $id The recommendation id being applied.
276 */
277 $handled = apply_filters( 'xspeed_apply_recommendation', null, $id );
278 if ( is_array( $handled ) || is_wp_error( $handled ) ) {
279 return $handled;
280 }
281
282 return new \WP_Error(
283 'xspeed_rec_unknown',
284 __( 'Unknown or no-longer-applicable recommendation.', 'xspeed' ),
285 array( 'status' => 404 )
286 );
287 }
288 }
289