PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
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.0, at includes/class-optimize-diagnosis.php

281 lines 10.7 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 return array(
59 'score' => self::latest_score(),
60 'agent_fixable' => self::agent_fixable( $settings ),
61 'human_fixable' => self::human_fixable(),
62 );
63 }
64
65 /**
66 * The most recent stored audit, reduced to the numbers that decide a score.
67 *
68 * Read from history rather than run fresh: an optimize run should not
69 * silently spend someone's PageSpeed allowance, and a score from this
70 * morning is enough to say what is wrong.
71 *
72 * @return array<string,mixed>|null
73 */
74 private static function latest_score(): ?array {
75 if ( ! class_exists( '\XSpeed\Score' ) ) {
76 return null;
77 }
78 $history = Score::history();
79 $latest = is_array( $history ) && ! empty( $history ) ? end( $history ) : null;
80 if ( ! is_array( $latest ) ) {
81 return null;
82 }
83
84 $metrics = array();
85 foreach ( array( 'lcp', 'cls', 'tbt' ) as $key ) {
86 $value = $latest[ $key ] ?? null;
87 if ( ! is_numeric( $value ) ) {
88 continue;
89 }
90 $metrics[ $key ] = array(
91 'value' => (float) $value,
92 'rating' => self::rate( $key, (float) $value ),
93 );
94 }
95
96 return array(
97 'score' => $latest['score'] ?? null,
98 'strategy' => $latest['strategy'] ?? null,
99 'ran_at' => $latest['ts'] ?? null,
100 'metrics' => $metrics,
101 );
102 }
103
104 /**
105 * Rate one metric against Google's published thresholds.
106 *
107 * Uses Score::thresholds() rather than a second copy of the numbers: a
108 * dashboard saying "needs improvement" while this says "poor" about the
109 * same measurement is the kind of contradiction that costs trust.
110 *
111 * @param string $key Metric key.
112 * @param float $value Measured value.
113 */
114 private static function rate( string $key, float $value ): string {
115 $thresholds = Score::thresholds();
116 if ( ! isset( $thresholds[ $key ] ) ) {
117 return 'unknown';
118 }
119 if ( $value <= $thresholds[ $key ]['good'] ) {
120 return 'good';
121 }
122 if ( $value <= $thresholds[ $key ]['poor'] ) {
123 return 'needs-improvement';
124 }
125 return 'poor';
126 }
127
128 /**
129 * Settings this tool could still turn on, with the risk of each.
130 *
131 * Only AGGRESSIVE steps appear here: safe and standard ones are applied
132 * automatically, so anything still off at those tiers has already been
133 * tried and skipped. What is left is exactly the set that needs a human to
134 * say yes.
135 *
136 * Every entry carries `risk` in plain language, naming the real failure
137 * mode rather than a generic warning. "This may affect your site" teaches
138 * nobody anything; "removing jQuery Migrate broke a page with
139 * `e.indexOf is not a function`" lets someone decide.
140 *
141 * @param array<string,array<string,mixed>> $settings Current settings.
142 * @return array<int,array<string,mixed>>
143 */
144 private static function agent_fixable( array $settings ): array {
145 $risks = self::risks();
146 $plan = Optimize_Plan::build( $settings, Optimize_Plan::TIER_AGGRESSIVE );
147
148 $out = array();
149 foreach ( $plan['steps'] as $step ) {
150 if ( Optimize_Plan::TIER_AGGRESSIVE !== $step['tier'] ) {
151 continue;
152 }
153 $out[] = array(
154 'id' => $step['id'],
155 'change' => $step['label'],
156 'risk' => $risks[ $step['id'] ] ?? __( 'May change how the page renders. It is verified after applying and undone if the page breaks.', 'xspeed' ),
157 );
158 }
159 return $out;
160 }
161
162 /**
163 * What each aggressive setting can actually break.
164 *
165 * Written from failures that have happened, not from imagination. A risk
166 * note the reader has no reason to believe is worse than none, because it
167 * trains them to click past the next one.
168 *
169 * @return array<string,string>
170 */
171 private static function risks(): array {
172 return array(
173 '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' ),
174 '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' ),
175 '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' ),
176 );
177 }
178
179 /**
180 * Problems no caching plugin can reach.
181 *
182 * Two sources: the site's own environment checks, and the last audit's
183 * measured opportunities. Both are things the user or their host has to
184 * act on — which is precisely why they must be reported rather than
185 * quietly dropped from a success message.
186 *
187 * @return array<int,array<string,mixed>>
188 */
189 private static function human_fixable(): array {
190 $out = array();
191
192 if ( class_exists( '\XSpeed\Health' ) ) {
193 foreach ( Health::checks() as $check ) {
194 $tone = (string) ( $check['tone'] ?? '' );
195 if ( 'warn' !== $tone && 'fail' !== $tone ) {
196 continue;
197 }
198 // Environment facts the plugin surfaces but cannot change: a
199 // PHP version, a server config snippet only the host can paste.
200 if ( ! in_array( (string) ( $check['id'] ?? '' ), array( 'php_version', 'server', 'static_rewrite_nginx' ), true ) ) {
201 continue;
202 }
203 $out[] = array(
204 'issue' => (string) ( $check['label'] ?? '' ),
205 'why' => (string) ( $check['detail'] ?? '' ),
206 'owner' => 'host',
207 );
208 }
209 }
210
211 // The audit's own measured savings. These are page CONTENT — an
212 // oversized image, a render-blocking third-party script, a DOM the
213 // theme builds — so they are named with their measured cost and left
214 // to the person who can change the page.
215 //
216 // With one exception. A poor CLS used to be listed here unconditionally
217 // with "images on another domain need the dimensions set by hand",
218 // which stopped being true once the dimension lookup learned to
219 // measure remote images during a cache warm. Telling someone to go and
220 // re-host their media for something the plugin now fixes is the same
221 // dishonesty as claiming a win we did not earn, pointed the other way:
222 // it sends them off to do unnecessary work.
223 $latest = self::latest_score();
224 if ( is_array( $latest ) && isset( $latest['metrics'] ) ) {
225 $dimensions_on = ! empty( Settings_Manager::get( 'lazy' )['add_missing_dimensions'] );
226 foreach ( $latest['metrics'] as $key => $metric ) {
227 if ( 'poor' !== $metric['rating'] ) {
228 continue;
229 }
230 if ( 'cls' === $key && $dimensions_on ) {
231 // Handled automatically — the next crawl resolves what is
232 // missing. Reporting it as the user's problem would be
233 // wrong; reporting it as nothing would hide that it needs
234 // a warm to take effect.
235 continue;
236 }
237 $out[] = array(
238 'issue' => self::metric_label( $key, $metric['value'] ),
239 'why' => self::metric_cause( $key ),
240 'owner' => 'content',
241 );
242 }
243 }
244
245 return $out;
246 }
247
248 /**
249 * @param string $key Metric key.
250 * @param float $value Measured value.
251 */
252 private static function metric_label( string $key, float $value ): string {
253 $names = array(
254 'lcp' => __( 'Largest Contentful Paint', 'xspeed' ),
255 'cls' => __( 'Cumulative Layout Shift', 'xspeed' ),
256 'tbt' => __( 'Total Blocking Time', 'xspeed' ),
257 );
258 $name = $names[ $key ] ?? strtoupper( $key );
259 $shown = 'cls' === $key ? number_format( $value, 3 ) : round( $value ) . 'ms';
260 return $name . ' is ' . $shown;
261 }
262
263 /**
264 * What actually causes a metric to be poor once caching is already right.
265 *
266 * @param string $key Metric key.
267 */
268 private static function metric_cause( string $key ): string {
269 switch ( $key ) {
270 case 'lcp':
271 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' );
272 case 'cls':
273 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' );
274 case 'tbt':
275 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' );
276 default:
277 return '';
278 }
279 }
280 }
281