PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.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 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-optimize-diagnosis.php

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

440 lines 16.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Optimize diagnosis — what is wrong, and who can actually fix it.
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 /**
13 * Turn a site's own measurements into an answer to three questions:
14 * what is slow, what could this tool still do about it, and what can only a
15 * person do.
16 *
17 * The autopilot without this is a dead end. It applies what it can, says
18 * "everything is already on", and stops — leaving a user looking at a score of
19 * 50 with no idea whether that is as good as it gets, what the remaining
20 * problems even are, or whether anything else is worth trying. The tool knows
21 * all three and was throwing the knowledge away.
22 *
23 * The split that matters is **agent-fixable vs human-fixable**:
24 *
25 * - `agent` — a setting this tool can flip, given permission. It belongs in an
26 * offer: "I can try this, here is the risk."
27 * - `human` — content and infrastructure. A 945KB autoplaying video, 2,000
28 * DOM elements from a page builder, an old PHP version. No caching plugin
29 * reaches these, and pretending otherwise is how a user ends up believing
30 * their site is fast when it is not.
31 *
32 * The boundary moves as the plugin learns to do more, and this file has to
33 * move with it. Images hotlinked from another domain sat under `human` until
34 * the dimension lookup learned to measure them during a cache warm; leaving
35 * them there would have kept telling people to re-host media the plugin now
36 * handles. An entry that has quietly become false is as harmful as a win we
37 * never earned — it just wastes the user's time instead of their trust.
38 *
39 * Keeping them apart is the whole point. An assistant handed one flat list of
40 * problems will offer to fix all of them; handed this, it can say "I can try
41 * these two, the other four need you or your host."
42 *
43 * @since 1.2.0
44 */
45 final class Optimize_Diagnosis {
46
47 /**
48 * The remaining problems, split by who can act on them.
49 *
50 * @param array<string,array<string,mixed>> $settings Current settings, as Optimize_Plan reads them.
51 * @return array{
52 * score:array<string,mixed>|null,
53 * agent_fixable:array<int,array<string,mixed>>,
54 * human_fixable:array<int,array<string,mixed>>
55 * }
56 */
57 public static function build( array $settings ): array {
58 $opportunities = self::routed_opportunities( $settings );
59
60 return array(
61 'score' => self::latest_score(),
62 'agent_fixable' => array_merge( self::agent_fixable( $settings ), $opportunities['agent'] ),
63 'human_fixable' => array_merge( self::human_fixable(), $opportunities['human'] ),
64 );
65 }
66
67 /**
68 * The last audit's measured opportunities, routed by who can act.
69 *
70 * The audit already names the problems ("Reduce unused CSS — 280ms
71 * available") and the split rule of this class applies to them exactly
72 * as it does to settings:
73 *
74 * - a module of ours addresses it and is OFF → agent_fixable, as an
75 * offer with the measured saving attached.
76 * - a module of ours addresses it and is ON → suppressed. The module
77 * is already working on it, and reporting it as the user's problem
78 * is the same dishonesty as the CLS entry this class already
79 * exempts.
80 * - nothing of ours reaches it (or the module isn't installed) →
81 * human_fixable with its measured cost, which is what makes the
82 * entry actionable: "Reduce unused CSS — 280ms" beats "TBT is
83 * 734ms".
84 *
85 * Reads history only — same rule as latest_score(): a diagnosis never
86 * spends the owner's audit quota.
87 *
88 * @param array<string,array<string,mixed>> $settings Current settings, keyed by module slug.
89 * @return array{agent:array<int,array<string,mixed>>,human:array<int,array<string,mixed>>}
90 */
91 private static function routed_opportunities( array $settings ): array {
92 $out = array(
93 'agent' => array(),
94 'human' => array(),
95 );
96
97 if ( ! class_exists( '\XSpeed\Score' ) ) {
98 return $out;
99 }
100 $latest = Score::latest();
101 if ( ! is_array( $latest ) ) {
102 return $out;
103 }
104
105 // Local runs store `issues` (parse_psi); Hub-synced rows store
106 // `opportunities` (Score_Store). Same facts, two field names.
107 $raw = array();
108 if ( isset( $latest['issues'] ) && is_array( $latest['issues'] ) ) {
109 $raw = $latest['issues'];
110 } elseif ( isset( $latest['opportunities'] ) && is_array( $latest['opportunities'] ) ) {
111 $raw = $latest['opportunities'];
112 }
113
114 $map = self::opportunity_map();
115 $available = Module_Registry::available();
116
117 foreach ( $raw as $opportunity ) {
118 if ( ! is_array( $opportunity ) ) {
119 continue;
120 }
121 $id = isset( $opportunity['id'] ) ? (string) $opportunity['id'] : '';
122 $title = isset( $opportunity['title'] ) ? (string) $opportunity['title'] : $id;
123 if ( '' === $id ) {
124 continue;
125 }
126 $savings = isset( $opportunity['savings_ms'] ) && is_numeric( $opportunity['savings_ms'] )
127 ? (int) $opportunity['savings_ms']
128 : null;
129 $cost = null !== $savings
130 ? sprintf(
131 /* translators: %d: measured saving in milliseconds. */
132 __( '%dms available', 'xspeed' ),
133 $savings
134 )
135 : '';
136
137 $route = $map[ $id ] ?? null;
138 if ( null !== $route && isset( $available[ $route['module'] ] ) ) {
139 $module_settings = $settings[ $route['module'] ] ?? Settings_Manager::get( $route['module'] );
140 if ( ! empty( $module_settings[ $route['setting'] ] ) ) {
141 // Already handled — the next crawl/render resolves it.
142 // Reporting it as the user's problem would be wrong.
143 continue;
144 }
145 $out['agent'][] = array(
146 'id' => $id,
147 'change' => $title,
148 'risk' => '' !== $cost
149 ? sprintf(
150 /* translators: 1: measured saving, 2: setting name. */
151 __( 'The last audit measured %1$s here; the %2$s setting addresses it and is currently off.', 'xspeed' ),
152 $cost,
153 $route['setting']
154 )
155 : sprintf(
156 /* translators: %s: setting name. */
157 __( 'The last audit flagged this; the %s setting addresses it and is currently off.', 'xspeed' ),
158 $route['setting']
159 ),
160 );
161 continue;
162 }
163
164 $out['human'][] = array(
165 'issue' => '' !== $cost ? $title . '' . $cost : $title,
166 'why' => __( 'Measured by the last audit. This is page content or a third-party asset — no caching setting reaches it.', 'xspeed' ),
167 'owner' => 'content',
168 );
169 }
170
171 return $out;
172 }
173
174 /**
175 * Lighthouse opportunity id → the module setting that addresses it.
176 *
177 * Only mappings that are actually true belong here — an id routed to a
178 * setting that doesn't move it sends the user (or an agent) to flip a
179 * switch and trust the tool less when nothing changes. When in doubt,
180 * leave the id unmapped and let it report as content with its cost.
181 *
182 * @return array<string,array{module:string,setting:string}>
183 */
184 private static function opportunity_map(): array {
185 return array(
186 'offscreen-images' => array(
187 'module' => 'lazy',
188 'setting' => 'lazy_images',
189 ),
190 'unminified-css' => array(
191 'module' => 'minify',
192 'setting' => 'minify_css',
193 ),
194 'unminified-javascript' => array(
195 'module' => 'minify',
196 'setting' => 'minify_js',
197 ),
198 'render-blocking-resources' => array(
199 'module' => 'minify',
200 'setting' => 'async_css',
201 ),
202 'uses-text-compression' => array(
203 'module' => 'gzip',
204 'setting' => 'gzip_enabled',
205 ),
206 'uses-long-cache-ttl' => array(
207 'module' => 'browser-cache',
208 'setting' => 'enabled',
209 ),
210 // Registered by Pro when installed; absent → routes to content,
211 // which is the honest answer on a Free-only site.
212 'unused-css-rules' => array(
213 'module' => 'unused-css',
214 'setting' => 'enabled',
215 ),
216 );
217 }
218
219 /**
220 * The most recent stored audit, reduced to the numbers that decide a score.
221 *
222 * Read from history rather than run fresh: an optimize run should not
223 * silently spend someone's PageSpeed allowance, and a score from this
224 * morning is enough to say what is wrong.
225 *
226 * @return array<string,mixed>|null
227 */
228 private static function latest_score(): ?array {
229 if ( ! class_exists( '\XSpeed\Score' ) ) {
230 return null;
231 }
232 // Score::latest() is the newest run with ok === true. History is
233 // newest-first, so end() walked to the OLDEST retained run — and a
234 // failed audit has score null, which must never be presented as the
235 // current score.
236 $latest = Score::latest();
237 if ( ! is_array( $latest ) ) {
238 return null;
239 }
240
241 $bag = isset( $latest['metrics'] ) && is_array( $latest['metrics'] ) ? $latest['metrics'] : array();
242
243 $metrics = array();
244 foreach ( array( 'lcp', 'cls', 'tbt' ) as $key ) {
245 $value = $bag[ $key ] ?? null;
246 if ( ! is_numeric( $value ) ) {
247 continue;
248 }
249 $metrics[ $key ] = array(
250 'value' => (float) $value,
251 'rating' => self::rate( $key, (float) $value ),
252 );
253 }
254
255 return array(
256 'score' => $latest['score'] ?? null,
257 'strategy' => $latest['strategy'] ?? null,
258 'ran_at' => $latest['ts'] ?? null,
259 'metrics' => $metrics,
260 );
261 }
262
263 /**
264 * Rate one metric against Google's published thresholds.
265 *
266 * Uses Score::thresholds() rather than a second copy of the numbers: a
267 * dashboard saying "needs improvement" while this says "poor" about the
268 * same measurement is the kind of contradiction that costs trust.
269 *
270 * @param string $key Metric key.
271 * @param float $value Measured value.
272 */
273 private static function rate( string $key, float $value ): string {
274 $thresholds = Score::thresholds();
275 if ( ! isset( $thresholds[ $key ] ) ) {
276 return 'unknown';
277 }
278 if ( $value <= $thresholds[ $key ]['good'] ) {
279 return 'good';
280 }
281 if ( $value <= $thresholds[ $key ]['poor'] ) {
282 return 'needs-improvement';
283 }
284 return 'poor';
285 }
286
287 /**
288 * Settings this tool could still turn on, with the risk of each.
289 *
290 * Only AGGRESSIVE steps appear here: safe and standard ones are applied
291 * automatically, so anything still off at those tiers has already been
292 * tried and skipped. What is left is exactly the set that needs a human to
293 * say yes.
294 *
295 * Every entry carries `risk` in plain language, naming the real failure
296 * mode rather than a generic warning. "This may affect your site" teaches
297 * nobody anything; "removing jQuery Migrate broke a page with
298 * `e.indexOf is not a function`" lets someone decide.
299 *
300 * @param array<string,array<string,mixed>> $settings Current settings.
301 * @return array<int,array<string,mixed>>
302 */
303 private static function agent_fixable( array $settings ): array {
304 $risks = self::risks();
305 $plan = Optimize_Plan::build( $settings, Optimize_Plan::TIER_AGGRESSIVE );
306
307 $out = array();
308 foreach ( $plan['steps'] as $step ) {
309 if ( Optimize_Plan::TIER_AGGRESSIVE !== $step['tier'] ) {
310 continue;
311 }
312 $out[] = array(
313 'id' => $step['id'],
314 'change' => $step['label'],
315 'risk' => $risks[ $step['id'] ] ?? __( 'May change how the page renders. It is verified after applying and undone if the page breaks.', 'xspeed' ),
316 );
317 }
318 return $out;
319 }
320
321 /**
322 * What each aggressive setting can actually break.
323 *
324 * Written from failures that have happened, not from imagination. A risk
325 * note the reader has no reason to believe is worse than none, because it
326 * trains them to click past the next one.
327 *
328 * @return array<string,string>
329 */
330 private static function risks(): array {
331 return array(
332 'delay_js_off' => __( 'Scripts do not run until the visitor interacts. Sliders, counters and anything that animates on load may sit still until first touch, and a script that expects to run immediately can misbehave.', 'xspeed' ),
333 'async_css_off' => __( 'Stylesheets load without blocking the first paint, so a theme with no critical CSS can flash unstyled for a moment. It also moves styling to after first paint, which can INCREASE layout shift on a page that already shifts.', 'xspeed' ),
334 'jquery_migrate_on' => __( 'Older themes and plugins still depend on it. Removing it broke a real page with "jQuery.Deferred exception: e.indexOf is not a function" — an error the page-level check cannot see, because the HTML arrives intact and only the browser notices.', 'xspeed' ),
335 );
336 }
337
338 /**
339 * Problems no caching plugin can reach.
340 *
341 * Two sources: the site's own environment checks, and the last audit's
342 * measured opportunities. Both are things the user or their host has to
343 * act on — which is precisely why they must be reported rather than
344 * quietly dropped from a success message.
345 *
346 * @return array<int,array<string,mixed>>
347 */
348 private static function human_fixable(): array {
349 $out = array();
350
351 if ( class_exists( '\XSpeed\Health' ) ) {
352 foreach ( Health::checks() as $check ) {
353 $tone = (string) ( $check['tone'] ?? '' );
354 if ( 'warn' !== $tone && 'fail' !== $tone ) {
355 continue;
356 }
357 // Environment facts the plugin surfaces but cannot change: a
358 // PHP version, a server config snippet only the host can paste.
359 if ( ! in_array( (string) ( $check['id'] ?? '' ), array( 'php_version', 'server', 'static_rewrite_nginx' ), true ) ) {
360 continue;
361 }
362 $out[] = array(
363 'issue' => (string) ( $check['label'] ?? '' ),
364 'why' => (string) ( $check['detail'] ?? '' ),
365 'owner' => 'host',
366 );
367 }
368 }
369
370 // The audit's own measured savings. These are page CONTENT — an
371 // oversized image, a render-blocking third-party script, a DOM the
372 // theme builds — so they are named with their measured cost and left
373 // to the person who can change the page.
374 //
375 // With one exception. A poor CLS used to be listed here unconditionally
376 // with "images on another domain need the dimensions set by hand",
377 // which stopped being true once the dimension lookup learned to
378 // measure remote images during a cache warm. Telling someone to go and
379 // re-host their media for something the plugin now fixes is the same
380 // dishonesty as claiming a win we did not earn, pointed the other way:
381 // it sends them off to do unnecessary work.
382 $latest = self::latest_score();
383 if ( is_array( $latest ) && isset( $latest['metrics'] ) ) {
384 $dimensions_on = ! empty( Settings_Manager::get( 'lazy' )['add_missing_dimensions'] );
385 foreach ( $latest['metrics'] as $key => $metric ) {
386 if ( 'poor' !== $metric['rating'] ) {
387 continue;
388 }
389 if ( 'cls' === $key && $dimensions_on ) {
390 // Handled automatically — the next crawl resolves what is
391 // missing. Reporting it as the user's problem would be
392 // wrong; reporting it as nothing would hide that it needs
393 // a warm to take effect.
394 continue;
395 }
396 $out[] = array(
397 'issue' => self::metric_label( $key, $metric['value'] ),
398 'why' => self::metric_cause( $key ),
399 'owner' => 'content',
400 );
401 }
402 }
403
404 return $out;
405 }
406
407 /**
408 * @param string $key Metric key.
409 * @param float $value Measured value.
410 */
411 private static function metric_label( string $key, float $value ): string {
412 $names = array(
413 'lcp' => __( 'Largest Contentful Paint', 'xspeed' ),
414 'cls' => __( 'Cumulative Layout Shift', 'xspeed' ),
415 'tbt' => __( 'Total Blocking Time', 'xspeed' ),
416 );
417 $name = $names[ $key ] ?? strtoupper( $key );
418 $shown = 'cls' === $key ? number_format( $value, 3 ) : round( $value ) . 'ms';
419 return $name . ' is ' . $shown;
420 }
421
422 /**
423 * What actually causes a metric to be poor once caching is already right.
424 *
425 * @param string $key Metric key.
426 */
427 private static function metric_cause( string $key ): string {
428 switch ( $key ) {
429 case 'lcp':
430 return __( 'The biggest thing on screen takes too long to appear. Usually a large hero image or video, or a font that blocks text. Caching does not shrink it — the file itself has to get smaller or load sooner.', 'xspeed' );
431 case 'cls':
432 return __( 'The page moves while it loads. Almost always images without width and height, or content injected above what is already visible. xSpeed fills in missing dimensions — including for images hosted on other domains, which it measures during a cache warm — so what is usually left here is content that appears above existing content: an embed, a banner, or a script that inserts markup after first paint.', 'xspeed' );
433 case 'tbt':
434 return __( 'Scripts hold the main thread so the page cannot respond. Fewer or smaller scripts is the only real fix; delaying them (aggressive mode) moves the work rather than removing it.', 'xspeed' );
435 default:
436 return '';
437 }
438 }
439 }
440