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

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

264 lines 9.9 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(
50 'type' => 'link',
51 'hash' => '#cache',
52 'label' => __( 'Go to Cache settings', 'xspeed' ),
53 ),
54 );
55 // Later rules assume a running cache; report just this one.
56 return $out;
57 }
58
59 // 1. Expiry shorter than the preloader interval (issue #31): pages
60 // expire before the next crawl re-warms them — the classic silent
61 // hit-ratio killer. One-click fix raises expiry to the interval.
62 $interval = Health::PRELOAD_INTERVALS[ (string) ( $state['preload_schedule'] ?? '' ) ] ?? null;
63 $expiry = (int) ( $state['cache_expiry'] ?? 0 );
64 if ( ! empty( $state['preloader_enabled'] ) && null !== $interval && $expiry > 0 && $expiry < $interval ) {
65 $out[] = array(
66 'id' => 'expiry_preload_mismatch',
67 'priority' => 10,
68 'title' => __( 'Raise Cache Expiry to match your preload schedule', 'xspeed' ),
69 'detail' => sprintf(
70 /* translators: 1: current expiry hours, 2: preload interval hours. */
71 __( 'Pages expire after %1$dh but the preloader only re-warms them every %2$dh — most visits hit a cold cache.', 'xspeed' ),
72 $expiry,
73 $interval
74 ),
75 'action' => array(
76 'type' => 'apply',
77 'module' => 'cache',
78 'values' => array( 'cache_expiry' => $interval ),
79 'label' => sprintf(
80 /* translators: %d: recommended expiry in hours. */
81 __( 'Raise expiry to %dh', 'xspeed' ),
82 $interval
83 ),
84 ),
85 );
86 }
87
88 // 2. A plugin is setting cookies on anonymous pages (issue #33):
89 // CDN edge caches BYPASS any response carrying Set-Cookie.
90 $cookies = isset( $state['poison_cookies'] ) && is_array( $state['poison_cookies'] ) ? $state['poison_cookies'] : array();
91 if ( ! empty( $cookies ) ) {
92 $first = $cookies[0];
93 $culprit = ! empty( $first['plugin'] ) ? (string) $first['plugin'] : __( 'A plugin', 'xspeed' );
94 $out[] = array(
95 'id' => 'set_cookie_poisoning',
96 'priority' => 15,
97 'title' => __( 'A plugin is blocking CDN edge caching', 'xspeed' ),
98 'detail' => sprintf(
99 /* translators: 1: plugin name, 2: cookie name. */
100 __( '%1$s sets the "%2$s" cookie on anonymous pages, which makes CDNs skip their edge cache for all HTML.', 'xspeed' ),
101 $culprit,
102 (string) ( $first['name'] ?? '' )
103 ),
104 'action' => array(
105 'type' => 'link',
106 'hash' => '#health',
107 'label' => __( 'See details in Health', 'xspeed' ),
108 ),
109 );
110 }
111
112 // 3. nginx detected but the server-level rewrite isn't serving hits.
113 if ( 'nginx' === ( $state['server'] ?? '' ) && false === ( $state['rewrite_active'] ?? null ) ) {
114 $out[] = array(
115 'id' => 'nginx_snippet_missing',
116 'priority' => 20,
117 'title' => __( 'Apply the nginx server snippet', 'xspeed' ),
118 'detail' => __( 'nginx can serve cache hits directly (~5-15ms TTFB, PHP bypassed) once the snippet is in your server block.', 'xspeed' ),
119 'action' => array(
120 'type' => 'link',
121 'hash' => '#cache',
122 'label' => __( 'Get the snippet', 'xspeed' ),
123 ),
124 );
125 }
126
127 // 4. Preloader off while the hit ratio is poor — with real traffic.
128 if ( empty( $state['preloader_enabled'] ) && $traffic >= 50 && $ratio < 0.5 ) {
129 $out[] = array(
130 'id' => 'preloader_off_low_ratio',
131 'priority' => 25,
132 'title' => __( 'Turn on the preloader', 'xspeed' ),
133 'detail' => sprintf(
134 /* translators: %d: hit ratio percent. */
135 __( 'Your hit ratio is %d%% — most visitors hit a cold cache. The preloader crawls your sitemap so pages are warm before anyone asks.', 'xspeed' ),
136 (int) round( $ratio * 100 )
137 ),
138 'action' => array(
139 'type' => 'apply',
140 'module' => 'preloader',
141 'values' => array( 'enabled' => true ),
142 'label' => __( 'Enable preloader', 'xspeed' ),
143 ),
144 );
145 }
146
147 // 5. Object cache configured but not actually persisting.
148 if ( ! empty( $state['objcache_enabled'] ) && empty( $state['objcache_persistent'] ) ) {
149 $out[] = array(
150 'id' => 'object_cache_degraded',
151 'priority' => 30,
152 'title' => __( 'Object cache is configured but not persisting', 'xspeed' ),
153 'detail' => __( 'The backend is not connected, so every request falls back to the database. Run the connection test to see why.', 'xspeed' ),
154 'action' => array(
155 'type' => 'link',
156 'hash' => '#object-cache',
157 'label' => __( 'Test the connection', 'xspeed' ),
158 ),
159 );
160 }
161
162 // 6. Cloudflare enabled but missing credentials/zone — configured in
163 // name only; purges will silently do nothing.
164 if ( ! empty( $state['cloudflare_enabled'] ) && empty( $state['cloudflare_ready'] ) ) {
165 $out[] = array(
166 'id' => 'cloudflare_unverified',
167 'priority' => 35,
168 'title' => __( 'Finish connecting Cloudflare', 'xspeed' ),
169 'detail' => __( 'The Cloudflare module is on but has no verified credentials or zone, so edge purges cannot work.', 'xspeed' ),
170 'action' => array(
171 'type' => 'link',
172 'hash' => '#cloudflare',
173 'label' => __( 'Open Cloudflare settings', 'xspeed' ),
174 ),
175 );
176 }
177
178 usort(
179 $out,
180 static function ( $a, $b ) {
181 return $a['priority'] <=> $b['priority'];
182 }
183 );
184 return $out;
185 }
186
187 /**
188 * Gather the live state the rules read. Cached probe verdicts only —
189 * this runs on dashboard paints and must never block on HTTP.
190 *
191 * @return array<string,mixed>
192 */
193 public static function state(): array {
194 $opts = Settings::get();
195 $cache_opts = Settings_Manager::get( 'cache' );
196 $pre_opts = Settings_Manager::get( 'preloader' );
197 $cf_opts = Settings_Manager::get( 'cloudflare' );
198 $oc_opts = Settings_Manager::get( 'object-cache' );
199 $totals = Hit_Counter::totals_24h();
200 $probe = Cache::probe_static_rewrite( false );
201 $cookie = Cookie_Inspector::probe( false );
202
203 $oc_detect = Object_Cache::detect();
204 $oc_persistent = ! empty( $oc_detect['persistent'] ) || ( ! empty( $oc_detect['wp_cache_active'] ) && empty( $oc_detect['degraded'] ) );
205
206 return array(
207 'cache_enabled' => ! empty( $opts['cache_enabled'] ),
208 'cache_expiry' => (int) ( $cache_opts['cache_expiry'] ?? 0 ),
209 'preloader_enabled' => ! empty( $pre_opts['enabled'] ),
210 'preload_schedule' => (string) ( $pre_opts['schedule'] ?? 'manual' ),
211 'server' => Server::type(),
212 // null = probe pending/unknown (rule stays silent); false = probed inactive.
213 'rewrite_active' => ! empty( $probe['pending'] ) ? null : (bool) ( $probe['active'] ?? false ),
214 'poison_cookies' => ! empty( $cookie['checked'] ) ? $cookie['cookies'] : array(),
215 'hits_24h' => (int) $totals['hits'],
216 'misses_24h' => (int) $totals['misses'],
217 'hit_ratio' => (float) $totals['ratio'],
218 'objcache_enabled' => ! empty( $oc_opts['enabled'] ),
219 'objcache_persistent' => $oc_persistent,
220 'cloudflare_enabled' => ! empty( $cf_opts['enabled'] ),
221 'cloudflare_ready' => ! empty( $cf_opts['zone_id'] ) && ( ! empty( $cf_opts['api_token'] ) || ( ! empty( $cf_opts['api_key'] ) && ! empty( $cf_opts['email'] ) ) ),
222 );
223 }
224
225 /** Ranked recommendations for the live site. */
226 public static function all(): array {
227 return self::evaluate( self::state() );
228 }
229
230 /**
231 * One-click apply: re-evaluate, find the recommendation, and run its
232 * settings write through Settings_Manager (schema-validated + logged
233 * as a change annotation like every other write).
234 *
235 * @param string $id Recommendation id.
236 * @return array|\WP_Error The refreshed recommendation list on success.
237 */
238 public static function apply( string $id ) {
239 foreach ( self::all() as $rec ) {
240 if ( $rec['id'] !== $id ) {
241 continue;
242 }
243 $action = $rec['action'];
244 if ( 'apply' !== ( $action['type'] ?? '' ) ) {
245 return new \WP_Error(
246 'xspeed_rec_not_applicable',
247 __( 'This recommendation links to a settings screen; it has no one-click fix.', 'xspeed' ),
248 array( 'status' => 400 )
249 );
250 }
251 Settings_Manager::update( (string) $action['module'], (array) $action['values'] );
252 return array(
253 'applied' => $id,
254 'recommendations' => self::all(),
255 );
256 }
257 return new \WP_Error(
258 'xspeed_rec_unknown',
259 __( 'Unknown or no-longer-applicable recommendation.', 'xspeed' ),
260 array( 'status' => 404 )
261 );
262 }
263 }
264