PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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 1.2.0 All 28 releases
xspeed / includes / class-optimize-diagnosis.php

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

632 lines 24.9 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 $score = null ): array {
58 $opportunities = self::routed_opportunities( $settings );
59
60 return array(
61 // A caller that already measured passes its result in rather than
62 // letting this re-read history — that is what makes a post-run
63 // score describe the site the run just changed. (#306)
64 'score' => null === $score ? self::latest_score() : $score,
65 'agent_fixable' => array_merge( self::agent_fixable( $settings ), $opportunities['agent'] ),
66 'human_fixable' => array_merge( self::human_fixable(), $opportunities['human'] ),
67 );
68 }
69
70 /**
71 * The last audit's measured opportunities, routed by who can act.
72 *
73 * The audit already names the problems ("Reduce unused CSS — 280ms
74 * available") and the split rule of this class applies to them exactly
75 * as it does to settings:
76 *
77 * - a module of ours addresses it and is OFF → agent_fixable, as an
78 * offer with the measured saving attached.
79 * - a module of ours addresses it and is ON → suppressed. The module
80 * is already working on it, and reporting it as the user's problem
81 * is the same dishonesty as the CLS entry this class already
82 * exempts.
83 * - nothing of ours reaches it (or the module isn't installed) →
84 * human_fixable with its measured cost, which is what makes the
85 * entry actionable: "Reduce unused CSS — 280ms" beats "TBT is
86 * 734ms".
87 *
88 * Reads history only — same rule as latest_score(): a diagnosis never
89 * spends the owner's audit quota.
90 *
91 * @param array<string,array<string,mixed>> $settings Current settings, keyed by module slug.
92 * @return array{agent:array<int,array<string,mixed>>,human:array<int,array<string,mixed>>}
93 */
94 private static function routed_opportunities( array $settings ): array {
95 $out = array(
96 'agent' => array(),
97 'human' => array(),
98 );
99
100 if ( ! class_exists( '\XSpeed\Score' ) ) {
101 return $out;
102 }
103 $latest = Score::latest();
104 if ( ! is_array( $latest ) ) {
105 return $out;
106 }
107
108 // Local runs store `issues` (parse_psi); Hub-synced rows store
109 // `opportunities` (Score_Store). Same facts, two field names.
110 $raw = array();
111 if ( isset( $latest['issues'] ) && is_array( $latest['issues'] ) ) {
112 $raw = $latest['issues'];
113 } elseif ( isset( $latest['opportunities'] ) && is_array( $latest['opportunities'] ) ) {
114 $raw = $latest['opportunities'];
115 }
116
117 $map = self::opportunity_map();
118 $available = Module_Registry::available();
119
120 foreach ( $raw as $opportunity ) {
121 if ( ! is_array( $opportunity ) ) {
122 continue;
123 }
124 $id = isset( $opportunity['id'] ) ? (string) $opportunity['id'] : '';
125 $title = isset( $opportunity['title'] ) ? (string) $opportunity['title'] : $id;
126 if ( '' === $id ) {
127 continue;
128 }
129 $savings = isset( $opportunity['savings_ms'] ) && is_numeric( $opportunity['savings_ms'] )
130 ? (int) $opportunity['savings_ms']
131 : null;
132 $cost = null !== $savings
133 ? sprintf(
134 /* translators: %d: measured saving in milliseconds. */
135 __( '%dms available', 'xspeed' ),
136 $savings
137 )
138 : '';
139
140 $route = $map[ $id ] ?? null;
141 if ( null !== $route && isset( $available[ $route['module'] ] ) ) {
142 $module_settings = $settings[ $route['module'] ] ?? Settings_Manager::get( $route['module'] );
143 if ( ! empty( $module_settings[ $route['setting'] ] ) ) {
144 // Already handled — the next crawl/render resolves it.
145 // Reporting it as the user's problem would be wrong.
146 continue;
147 }
148 $out['agent'][] = array(
149 'id' => $id,
150 'change' => $title,
151 'risk' => '' !== $cost
152 ? sprintf(
153 /* translators: 1: measured saving, 2: setting name. */
154 __( 'The last audit measured %1$s here; the %2$s setting addresses it and is currently off.', 'xspeed' ),
155 $cost,
156 $route['setting']
157 )
158 : sprintf(
159 /* translators: %s: setting name. */
160 __( 'The last audit flagged this; the %s setting addresses it and is currently off.', 'xspeed' ),
161 $route['setting']
162 ),
163 );
164 continue;
165 }
166
167 $out['human'][] = array(
168 'issue' => '' !== $cost ? $title . '' . $cost : $title,
169 'why' => __( 'Measured by the last audit. This is page content or a third-party asset — no caching setting reaches it.', 'xspeed' ),
170 'owner' => 'content',
171 );
172 }
173
174 return $out;
175 }
176
177 /**
178 * Lighthouse opportunity id → the module setting that addresses it.
179 *
180 * Only mappings that are actually true belong here — an id routed to a
181 * setting that doesn't move it sends the user (or an agent) to flip a
182 * switch and trust the tool less when nothing changes. When in doubt,
183 * leave the id unmapped and let it report as content with its cost.
184 *
185 * @return array<string,array{module:string,setting:string}>
186 */
187 private static function opportunity_map(): array {
188 return array(
189 'offscreen-images' => array(
190 'module' => 'lazy',
191 'setting' => 'lazy_images',
192 ),
193 'unminified-css' => array(
194 'module' => 'minify',
195 'setting' => 'minify_css',
196 ),
197 'unminified-javascript' => array(
198 'module' => 'minify',
199 'setting' => 'minify_js',
200 ),
201 'render-blocking-resources' => array(
202 'module' => 'minify',
203 'setting' => 'async_css',
204 ),
205 'uses-text-compression' => array(
206 'module' => 'gzip',
207 'setting' => 'gzip_enabled',
208 ),
209 'uses-long-cache-ttl' => array(
210 'module' => 'browser-cache',
211 'setting' => 'enabled',
212 ),
213 // Registered by Pro when installed; absent → routes to content,
214 // which is the honest answer on a Free-only site.
215 'unused-css-rules' => array(
216 'module' => 'unused-css',
217 'setting' => 'enabled',
218 ),
219 );
220 }
221
222 /**
223 * How old a stored score may be before it is worth spending a measurement.
224 *
225 * Six hours. Short enough that a score quoted after an optimize run
226 * reflects the site as it is now, long enough that the repeated calls an
227 * assistant makes while working on one site reuse a single measurement
228 * rather than one each.
229 */
230 public const STALE_AFTER = 6 * HOUR_IN_SECONDS;
231
232 /**
233 * Shortest gap between two measurements this class will start.
234 *
235 * The cooldown, not the staleness window, is what bounds spend. An agent
236 * that calls optimize_site in a loop would otherwise start a PSI run per
237 * call and drain the account's quota in a minute — the exact cost the
238 * original "read from history" comment was written to avoid. Staleness
239 * decides whether a fresh number is WANTED; this decides whether one is
240 * ALLOWED.
241 */
242 public const MEASURE_COOLDOWN = 15 * MINUTE_IN_SECONDS;
243
244 /** Transient holding the timestamp of the last measurement we started. */
245 private const COOLDOWN_KEY = 'xspeed_optimize_measured_at';
246
247 /**
248 * Measure now, if a measurement is both wanted and affordable.
249 *
250 * Returns the same shape as `latest_score()` so callers can treat a fresh
251 * and a stored score identically — the difference is visible in
252 * `age_seconds`, never in the structure.
253 *
254 * A failure here is deliberately NOT fatal. A missing API key, a quota
255 * refusal or a timeout means we fall back to the stored score, which is
256 * the behaviour this method replaced; a run that would have succeeded must
257 * not start failing because the score service is down.
258 *
259 * @param bool $force Skip the staleness check AND the cooldown — an
260 * explicit "measure now" from
261 * `--measure-score=always` or a multi-round
262 * tuner. Requires `score.enabled`: nothing
263 * bypasses that.
264 * @param bool $assume_stale Skip only the staleness check, keeping the
265 * cooldown. For the post-apply path, where
266 * changes just landed so the stored score is
267 * known to describe a site that no longer
268 * exists — but the caller did not ask to spend
269 * a measurement. Passing $force there billed
270 * every applying run against the quota the
271 * cooldown exists to protect. (#306 QA issue 1)
272 * @return array<string,mixed>|null
273 */
274 public static function measure_fresh( bool $force = false, bool $assume_stale = false ): ?array {
275 $stored = self::latest_score();
276
277 if ( ! $force && ! $assume_stale && is_array( $stored ) && empty( $stored['stale'] ) ) {
278 return $stored;
279 }
280
281 if ( ! class_exists( '\XSpeed\Score' ) ) {
282 return $stored;
283 }
284
285 $opts = Settings_Manager::get( 'score' );
286
287 // External scores are OFF unless the site owner turned them on, and
288 // that is the only thing standing between this plugin and a third
289 // party. readme.txt promises "if the feature is left off — which is
290 // the default — no request is ever made", and the dashboard repeats
291 // it on screen.
292 //
293 // This call site read `psi_api_key`, `test_url` and `default_strategy`
294 // out of the Score settings and then never looked at `enabled`, so an
295 // optimize run sent the site's address to Google on a site configured
296 // never to contact anyone — including sites set up for GTmetrix. Every
297 // other PSI caller checks it (ScoreModule::rest_run,
298 // ScoreModule's CLI). (QA #306, issue 1)
299 if ( empty( $opts['enabled'] ) ) {
300 return $stored;
301 }
302
303 // Measure with the provider the OWNER chose, or not at all.
304 //
305 // This method only knows how to run PSI, which answers synchronously.
306 // GTmetrix does not: start_gtmetrix() returns a pending marker and the
307 // result arrives later via poll_gtmetrix(), so there is no score to
308 // hand back inside one run. Ignoring the setting meant a site
309 // configured for GTmetrix — with no Google key at all — still had its
310 // address sent to Google, and that PSI result then became the headline
311 // score on the dashboard, displacing the GTmetrix history the owner
312 // set up. Every other part of the plugin honours `provider`; this call
313 // site never read it. (#306 QA issue 2)
314 //
315 // Falling back to the stored score is the same degradation this method
316 // already applies to a refused or failed measurement: the run still
317 // reports a number, with its age attached, and says nothing it cannot
318 // support.
319 // Skip only on an AFFIRMATIVE non-PSI choice. `??` alone catches null
320 // but not an empty string, `false`, or a case variant — and a blank
321 // `provider` in the option row (a hand-edited row, a partial
322 // migration, an older schema) would then disable post-run measurement
323 // permanently, with no diagnostic. Unset or empty means "the default",
324 // and the default is PSI.
325 $provider = strtolower( trim( (string) ( $opts['provider'] ?? '' ) ) );
326 if ( '' !== $provider && 'psi' !== $provider ) {
327 return $stored;
328 }
329
330 // The cooldown stops two runs racing into a paid measurement, but it
331 // must not silence an EXPLICIT request. `$force` is what
332 // `--measure-score=always` and a multi-round tuner send, and applying
333 // the full 15 minutes there meant round 2 reused round 1's number
334 // while the summary said "measured just now" — so a tuner planned its
335 // next round from before-the-first-round data and concluded its own
336 // changes had achieved nothing. (QA #306, issue 2)
337 //
338 // The quota protection is not traded away, because `$force` is not
339 // something a caller drifts into: it comes only from
340 // `--measure-score=always` — a person or a tuner saying "measure now"
341 // on purpose. The default path (`auto`) still waits the full cooldown,
342 // which is what an agent looping on optimize_site actually hits.
343 if ( ! $force && get_transient( self::COOLDOWN_KEY ) ) {
344 return $stored;
345 }
346
347 $api_key = (string) ( $opts['psi_api_key'] ?? '' );
348
349 /**
350 * Filter whether an optimize run may start a fresh measurement.
351 *
352 * Without a key PSI still answers, but on a shared unauthenticated
353 * quota that a busy host can exhaust for everyone on the IP. Sites
354 * that would rather never spend a measurement here can return false.
355 *
356 * @since 1.2.0
357 *
358 * @param bool $allowed Whether to measure.
359 * @param string $api_key The configured PSI key, empty if none.
360 */
361 if ( ! apply_filters( 'xspeed_optimize_may_measure', true, $api_key ) ) {
362 return $stored;
363 }
364
365 // Set the cooldown BEFORE the request, not after. PSI takes up to a
366 // minute; two calls arriving inside that window would both see no
367 // transient and both spend a measurement.
368 set_transient( self::COOLDOWN_KEY, time(), self::MEASURE_COOLDOWN );
369
370 // Measure what the site is configured to measure. Both this and the
371 // Score module's own runs write to the SAME history, so hardcoding
372 // the home page on mobile filed a result the dashboard then showed
373 // as the site's latest score — silently replacing the desktop or
374 // custom-URL audit the owner had set up (#306 review, issue 3).
375 $test_url = trim( (string) ( $opts['test_url'] ?? '' ) );
376 if ( '' === $test_url ) {
377 $test_url = (string) home_url( '/' );
378 }
379
380 $strategy = (string) ( $opts['default_strategy'] ?? 'mobile' );
381 if ( ! in_array( $strategy, array( 'mobile', 'desktop' ), true ) ) {
382 $strategy = 'mobile';
383 }
384
385 $row = Score::run_psi( $test_url, $strategy, $api_key );
386
387 if ( empty( $row['ok'] ) ) {
388 return $stored;
389 }
390
391 return self::latest_score();
392 }
393
394 /**
395 * The most recent stored audit, reduced to the numbers that decide a score.
396 *
397 * Reads history only — it never measures. That was once the whole policy,
398 * on the reasoning that an optimize run should not silently spend someone's
399 * PageSpeed allowance and a score from this morning is enough to say what
400 * is wrong. The first half still holds and is why `measure_fresh()` is
401 * cooldown-bound rather than unconditional. The second half did not: on a
402 * site whose last audit was two weeks old, a run applied changes and then
403 * quoted the old number as its result, which reads as a claim the run had
404 * produced it.
405 *
406 * So this stayed the cheap path, `measure_fresh()` became the honest one,
407 * and `age_seconds` / `stale` here let every caller tell them apart.
408 *
409 * @return array<string,mixed>|null
410 */
411 public static function latest_score(): ?array {
412 if ( ! class_exists( '\XSpeed\Score' ) ) {
413 return null;
414 }
415 // Score::latest() is the newest run with ok === true. History is
416 // newest-first, so end() walked to the OLDEST retained run — and a
417 // failed audit has score null, which must never be presented as the
418 // current score.
419 $latest = Score::latest();
420 if ( ! is_array( $latest ) ) {
421 return null;
422 }
423
424 $bag = isset( $latest['metrics'] ) && is_array( $latest['metrics'] ) ? $latest['metrics'] : array();
425
426 $metrics = array();
427 foreach ( array( 'lcp', 'cls', 'tbt' ) as $key ) {
428 $value = $bag[ $key ] ?? null;
429 if ( ! is_numeric( $value ) ) {
430 continue;
431 }
432 $metrics[ $key ] = array(
433 'value' => (float) $value,
434 'rating' => self::rate( $key, (float) $value ),
435 );
436 }
437
438 // Age travels WITH the score, always. A number quoted without it is how
439 // a two-week-old 77 gets relayed as the result of a run that just
440 // finished — the reading is not wrong so much as unanswerable, because
441 // nothing in the payload said when it was true.
442 $ran_at = isset( $latest['ts'] ) && is_numeric( $latest['ts'] ) ? (int) $latest['ts'] : null;
443 $age = null === $ran_at ? null : max( 0, time() - $ran_at );
444
445 return array(
446 'score' => $latest['score'] ?? null,
447 'strategy' => $latest['strategy'] ?? null,
448 'ran_at' => $ran_at,
449 'age_seconds' => $age,
450 'stale' => null === $age || $age > self::STALE_AFTER,
451 'metrics' => $metrics,
452 );
453 }
454
455 /**
456 * Rate one metric against Google's published thresholds.
457 *
458 * Uses Score::thresholds() rather than a second copy of the numbers: a
459 * dashboard saying "needs improvement" while this says "poor" about the
460 * same measurement is the kind of contradiction that costs trust.
461 *
462 * @param string $key Metric key.
463 * @param float $value Measured value.
464 */
465 private static function rate( string $key, float $value ): string {
466 $thresholds = Score::thresholds();
467 if ( ! isset( $thresholds[ $key ] ) ) {
468 return 'unknown';
469 }
470 if ( $value <= $thresholds[ $key ]['good'] ) {
471 return 'good';
472 }
473 if ( $value <= $thresholds[ $key ]['poor'] ) {
474 return 'needs-improvement';
475 }
476 return 'poor';
477 }
478
479 /**
480 * Settings this tool could still turn on, with the risk of each.
481 *
482 * Only AGGRESSIVE steps appear here: safe and standard ones are applied
483 * automatically, so anything still off at those tiers has already been
484 * tried and skipped. What is left is exactly the set that needs a human to
485 * say yes.
486 *
487 * Every entry carries `risk` in plain language, naming the real failure
488 * mode rather than a generic warning. "This may affect your site" teaches
489 * nobody anything; "removing jQuery Migrate broke a page with
490 * `e.indexOf is not a function`" lets someone decide.
491 *
492 * @param array<string,array<string,mixed>> $settings Current settings.
493 * @return array<int,array<string,mixed>>
494 */
495 private static function agent_fixable( array $settings ): array {
496 $risks = self::risks();
497 $plan = Optimize_Plan::build( $settings, Optimize_Plan::TIER_AGGRESSIVE );
498
499 $out = array();
500 foreach ( $plan['steps'] as $step ) {
501 if ( Optimize_Plan::TIER_AGGRESSIVE !== $step['tier'] ) {
502 continue;
503 }
504 $out[] = array(
505 'id' => $step['id'],
506 'change' => $step['label'],
507 'risk' => $risks[ $step['id'] ] ?? __( 'May change how the page renders. It is verified after applying and undone if the page breaks.', 'xspeed' ),
508 );
509 }
510 return $out;
511 }
512
513 /**
514 * What each aggressive setting can actually break.
515 *
516 * Written from failures that have happened, not from imagination. A risk
517 * note the reader has no reason to believe is worse than none, because it
518 * trains them to click past the next one.
519 *
520 * @return array<string,string>
521 */
522 private static function risks(): array {
523 return array(
524 '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' ),
525 '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' ),
526 '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' ),
527 );
528 }
529
530 /**
531 * Problems no caching plugin can reach.
532 *
533 * Two sources: the site's own environment checks, and the last audit's
534 * measured opportunities. Both are things the user or their host has to
535 * act on — which is precisely why they must be reported rather than
536 * quietly dropped from a success message.
537 *
538 * @return array<int,array<string,mixed>>
539 */
540 private static function human_fixable(): array {
541 $out = array();
542
543 if ( class_exists( '\XSpeed\Health' ) ) {
544 foreach ( Health::checks() as $check ) {
545 $tone = (string) ( $check['tone'] ?? '' );
546 if ( 'warn' !== $tone && 'fail' !== $tone ) {
547 continue;
548 }
549 // Environment facts the plugin surfaces but cannot change: a
550 // PHP version, a server config snippet only the host can paste.
551 if ( ! in_array( (string) ( $check['id'] ?? '' ), array( 'php_version', 'server', 'static_rewrite_nginx' ), true ) ) {
552 continue;
553 }
554 $out[] = array(
555 'issue' => (string) ( $check['label'] ?? '' ),
556 'why' => (string) ( $check['detail'] ?? '' ),
557 'owner' => 'host',
558 );
559 }
560 }
561
562 // The audit's own measured savings. These are page CONTENT — an
563 // oversized image, a render-blocking third-party script, a DOM the
564 // theme builds — so they are named with their measured cost and left
565 // to the person who can change the page.
566 //
567 // With one exception. A poor CLS used to be listed here unconditionally
568 // with "images on another domain need the dimensions set by hand",
569 // which stopped being true once the dimension lookup learned to
570 // measure remote images during a cache warm. Telling someone to go and
571 // re-host their media for something the plugin now fixes is the same
572 // dishonesty as claiming a win we did not earn, pointed the other way:
573 // it sends them off to do unnecessary work.
574 $latest = self::latest_score();
575 if ( is_array( $latest ) && isset( $latest['metrics'] ) ) {
576 $dimensions_on = ! empty( Settings_Manager::get( 'lazy' )['add_missing_dimensions'] );
577 foreach ( $latest['metrics'] as $key => $metric ) {
578 if ( 'poor' !== $metric['rating'] ) {
579 continue;
580 }
581 if ( 'cls' === $key && $dimensions_on ) {
582 // Handled automatically — the next crawl resolves what is
583 // missing. Reporting it as the user's problem would be
584 // wrong; reporting it as nothing would hide that it needs
585 // a warm to take effect.
586 continue;
587 }
588 $out[] = array(
589 'issue' => self::metric_label( $key, $metric['value'] ),
590 'why' => self::metric_cause( $key ),
591 'owner' => 'content',
592 );
593 }
594 }
595
596 return $out;
597 }
598
599 /**
600 * @param string $key Metric key.
601 * @param float $value Measured value.
602 */
603 private static function metric_label( string $key, float $value ): string {
604 $names = array(
605 'lcp' => __( 'Largest Contentful Paint', 'xspeed' ),
606 'cls' => __( 'Cumulative Layout Shift', 'xspeed' ),
607 'tbt' => __( 'Total Blocking Time', 'xspeed' ),
608 );
609 $name = $names[ $key ] ?? strtoupper( $key );
610 $shown = 'cls' === $key ? number_format( $value, 3 ) : round( $value ) . 'ms';
611 return $name . ' is ' . $shown;
612 }
613
614 /**
615 * What actually causes a metric to be poor once caching is already right.
616 *
617 * @param string $key Metric key.
618 */
619 private static function metric_cause( string $key ): string {
620 switch ( $key ) {
621 case 'lcp':
622 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' );
623 case 'cls':
624 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' );
625 case 'tbt':
626 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' );
627 default:
628 return '';
629 }
630 }
631 }
632