PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.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 All 36 releases
xspeed / includes / class-score.php

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

654 lines 21.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Score — external performance scores on the dashboard (issue #47).
4 *
5 * Users judge a caching plugin by its GTmetrix or PageSpeed score whether
6 * or not the plugin shows one, so the number belongs next to the internal
7 * TTFB benchmark rather than one tab away. Both live on the same timeline:
8 * "expiry raised → TTFB fell → score climbed" is the story the dashboard
9 * exists to tell, and it can't be told from two different tools.
10 *
11 * Two providers, deliberately different in shape:
12 *
13 * psi Google PageSpeed Insights v5. Synchronous, and works with NO
14 * key (Google rate-limits anonymous callers). A key raises the
15 * quota; it is optional, not required.
16 * gtmetrix GTmetrix API v2. Asynchronous by design — you start a test,
17 * it queues, and you poll. Requires an API key; there is no
18 * anonymous mode.
19 *
20 * Outbound HTTP is opt-in and user-initiated: nothing here runs unless the
21 * module is enabled AND someone presses Test / runs the command. There is
22 * no schedule, no background call, no telemetry — see readme.txt "External
23 * services".
24 *
25 * Storage: the `xspeed_scores` table (Score_Store). The legacy
26 * `xspeed_score_history` option, capped and autoload off, is written only
27 * when the table refuses a row. The row shape
28 * matches what Pro's Pagespeed engine already returns (ok/status/url/
29 * strategy/score/metrics/issues) so a Pro run and a Free run are the same
30 * kind of row and the history stays one series.
31 *
32 * @package XSpeed
33 */
34
35 declare(strict_types=1);
36
37 namespace XSpeed;
38
39 defined( 'ABSPATH' ) || exit;
40
41 final class Score {
42
43 public const HISTORY_OPTION = 'xspeed_score_history';
44 public const PENDING_OPTION = 'xspeed_score_pending';
45
46 /** Runs kept. Enough for a trend, small enough to stay a single option. */
47 public const MAX_HISTORY = 30;
48
49 public const PSI_ENDPOINT = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed';
50 public const GTMETRIX_ENDPOINT = 'https://gtmetrix.com/api/2.0/tests';
51
52 /** Seconds allowed for the PSI call. Lighthouse runs are genuinely slow. */
53 public const PSI_TIMEOUT = 60;
54
55 /**
56 * How long a queued GTmetrix test may stay pending before we give up on
57 * it. Without a ceiling, a start that never resolves leaves a marker
58 * that polls a third-party API on every status check, forever.
59 */
60 public const PENDING_MAX_AGE = 1800;
61
62 /* ------------------------------------------------------------------ */
63 /* PageSpeed Insights */
64 /* ------------------------------------------------------------------ */
65
66 /**
67 * Build the PSI request URL.
68 *
69 * Pure, and separated from the call so the query — especially "no key
70 * means no key parameter", not `key=` — is testable without network.
71 */
72 public static function psi_url( string $url, string $strategy, string $api_key = '' ): string {
73 $query = array(
74 'url' => $url,
75 'strategy' => 'desktop' === strtolower( $strategy ) ? 'desktop' : 'mobile',
76 'category' => 'performance',
77 );
78 if ( '' !== trim( $api_key ) ) {
79 $query['key'] = trim( $api_key );
80 }
81 return self::PSI_ENDPOINT . '?' . http_build_query( $query );
82 }
83
84 /**
85 * Pull the score and Core Web Vitals out of a PSI envelope.
86 *
87 * Public so tests can drive it with canned fixtures — the parsing, not
88 * the HTTP, is where this breaks when Google reshuffles the response.
89 *
90 * @param array $envelope Decoded PSI JSON.
91 * @return array<string,mixed>
92 */
93 public static function parse_psi( string $url, string $strategy, array $envelope ): array {
94 $lh = isset( $envelope['lighthouseResult'] ) && is_array( $envelope['lighthouseResult'] ) ? $envelope['lighthouseResult'] : array();
95 $audits = isset( $lh['audits'] ) && is_array( $lh['audits'] ) ? $lh['audits'] : array();
96
97 $raw_score = isset( $lh['categories']['performance']['score'] ) ? $lh['categories']['performance']['score'] : null;
98 // PSI reports 0-1; a missing score is null, NOT zero — "we didn't get
99 // a score" and "your score is 0" are very different news.
100 $score = is_numeric( $raw_score ) ? (int) round( ( (float) $raw_score ) * 100 ) : null;
101
102 return array(
103 'ok' => true,
104 'provider' => 'psi',
105 'ts' => time(),
106 'url' => $url,
107 'strategy' => 'desktop' === strtolower( $strategy ) ? 'desktop' : 'mobile',
108 'score' => $score,
109 'metrics' => array(
110 'lcp' => self::audit_number( $audits, 'largest-contentful-paint' ),
111 'fcp' => self::audit_number( $audits, 'first-contentful-paint' ),
112 'cls' => self::audit_number( $audits, 'cumulative-layout-shift' ),
113 'tbt' => self::audit_number( $audits, 'total-blocking-time' ),
114 'si' => self::audit_number( $audits, 'speed-index' ),
115 'ttfb' => self::audit_number( $audits, 'server-response-time' ),
116 ),
117 'issues' => self::top_issues( $audits ),
118 'error' => '',
119 );
120 }
121
122 /**
123 * Numeric value of one Lighthouse audit, or null when absent.
124 *
125 * @param array $audits Lighthouse audits keyed by id.
126 */
127 private static function audit_number( array $audits, string $id ): ?float {
128 if ( ! isset( $audits[ $id ] ) || ! is_array( $audits[ $id ] ) ) {
129 return null;
130 }
131 $value = $audits[ $id ]['numericValue'] ?? null;
132 return is_numeric( $value ) ? (float) $value : null;
133 }
134
135 /**
136 * The opportunities worth showing: biggest measured savings first.
137 *
138 * Capped at 5 — a dashboard card is not an audit report, and a list
139 * nobody reads to the end is a list that buries its own first item.
140 *
141 * @param array $audits Lighthouse audits keyed by id.
142 * @return array<int,array<string,mixed>>
143 */
144 public static function top_issues( array $audits ): array {
145 $issues = array();
146 foreach ( $audits as $id => $audit ) {
147 if ( ! is_array( $audit ) ) {
148 continue;
149 }
150 $savings = isset( $audit['details']['overallSavingsMs'] ) && is_numeric( $audit['details']['overallSavingsMs'] )
151 ? (int) $audit['details']['overallSavingsMs']
152 : 0;
153 if ( $savings <= 0 ) {
154 continue;
155 }
156 $issues[] = array(
157 'id' => (string) $id,
158 'title' => isset( $audit['title'] ) ? (string) $audit['title'] : (string) $id,
159 'savings_ms' => $savings,
160 );
161 }
162
163 usort(
164 $issues,
165 static function ( array $a, array $b ): int {
166 return $b['savings_ms'] <=> $a['savings_ms'];
167 }
168 );
169
170 return array_slice( $issues, 0, 5 );
171 }
172
173 /**
174 * Run a PageSpeed Insights audit and record it.
175 *
176 * @return array<string,mixed> A history row (ok=false carries `error`).
177 */
178 public static function run_psi( string $url, string $strategy = 'mobile', string $api_key = '' ): array {
179 $response = wp_remote_get(
180 self::psi_url( $url, $strategy, $api_key ),
181 array( 'timeout' => self::PSI_TIMEOUT )
182 );
183
184 if ( is_wp_error( $response ) ) {
185 return self::failure( 'psi', $url, $strategy, $response->get_error_message() );
186 }
187
188 $code = (int) wp_remote_retrieve_response_code( $response );
189 $json = json_decode( (string) wp_remote_retrieve_body( $response ), true );
190
191 if ( $code >= 400 || ! is_array( $json ) ) {
192 $message = is_array( $json ) && isset( $json['error']['message'] )
193 ? (string) $json['error']['message']
194 : sprintf(
195 /* translators: %d: HTTP status code. */
196 __( 'PageSpeed Insights returned HTTP %d.', 'xspeed' ),
197 $code
198 );
199
200 // A keyless refusal is Google's shared anonymous pool running dry,
201 // not a fault on this site — and Google's own sentence (project
202 // numbers, quota metric names) reads like a broken plugin. Name
203 // the two remedies instead. (#426)
204 if ( '' === trim( $api_key ) && ( 429 === $code || preg_match( '/quota|rate limit/i', $message ) ) ) {
205 $message = __( 'Google\'s shared anonymous PageSpeed quota is exhausted right now — this is not a problem with your site. Add a free PageSpeed API key in the Speed Test settings, or connect this site to xSpeed Hub to run tests through it.', 'xspeed' );
206 }
207
208 return self::failure( 'psi', $url, $strategy, $message );
209 }
210
211 $row = self::parse_psi( $url, $strategy, $json );
212 self::record( $row );
213 return $row;
214 }
215
216 /* ------------------------------------------------------------------ */
217 /* GTmetrix */
218 /* ------------------------------------------------------------------ */
219
220 /**
221 * The Authorization header GTmetrix v2 expects.
222 *
223 * HTTP Basic with the API key as the username and an EMPTY password —
224 * the trailing colon is load-bearing, and omitting it authenticates as
225 * nobody with a 401 that reads like a bad key.
226 */
227 public static function gtmetrix_auth_header( string $api_key ): string {
228 return 'Basic ' . base64_encode( trim( $api_key ) . ':' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- HTTP Basic auth encoding, not obfuscation.
229 }
230
231 /**
232 * Body for "start a test". GTmetrix speaks JSON:API, so the URL is
233 * nested under data.attributes rather than posted flat.
234 *
235 * Pure — the shape is easy to get subtly wrong and impossible to
236 * notice, because a malformed body returns a generic 400.
237 */
238 public static function gtmetrix_start_body( string $url ): string {
239 return (string) wp_json_encode(
240 array(
241 'data' => array(
242 'type' => 'test',
243 'attributes' => array( 'url' => $url ),
244 ),
245 )
246 );
247 }
248
249 /**
250 * Read a GTmetrix test envelope into either a pending marker or a
251 * finished history row.
252 *
253 * The API reports progress through `data.attributes.state`
254 * (queued / started / completed / error). Anything that is not
255 * `completed` is still in flight — treating an unknown state as done
256 * would record a row with no score in it.
257 *
258 * @param array $envelope Decoded GTmetrix JSON.
259 * @return array<string,mixed>
260 */
261 public static function parse_gtmetrix( string $url, array $envelope ): array {
262 $data = isset( $envelope['data'] ) && is_array( $envelope['data'] ) ? $envelope['data'] : array();
263 $attributes = isset( $data['attributes'] ) && is_array( $data['attributes'] ) ? $data['attributes'] : array();
264 $state = isset( $attributes['state'] ) ? (string) $attributes['state'] : '';
265 $test_id = isset( $data['id'] ) ? (string) $data['id'] : '';
266
267 if ( 'error' === $state ) {
268 $row = self::failure(
269 'gtmetrix',
270 $url,
271 'desktop',
272 isset( $attributes['error'] ) && '' !== (string) $attributes['error']
273 ? (string) $attributes['error']
274 : __( 'GTmetrix reported the test failed.', 'xspeed' )
275 );
276 $row['state'] = 'error';
277 return $row;
278 }
279
280 if ( 'completed' !== $state ) {
281 return array(
282 'ok' => true,
283 'provider' => 'gtmetrix',
284 'state' => '' === $state ? 'queued' : $state,
285 'test_id' => $test_id,
286 'url' => $url,
287 'pending' => true,
288 );
289 }
290
291 // GTmetrix reports Performance/Structure as 0-1 and the vitals in
292 // milliseconds, with CLS unitless — the same units PSI uses, which
293 // is why both providers can share one history shape.
294 // GTmetrix has shipped this both ways: API 2.0 returns an integer
295 // 0-100, older/other shapes a 0-1 fraction. Scaling unconditionally
296 // turned a real 96 into 9600. Treat >1 as already-percent — a genuine
297 // fractional score above 1 does not exist.
298 $score = self::percent( $attributes['performance_score'] ?? null );
299
300 return array(
301 'ok' => true,
302 'provider' => 'gtmetrix',
303 'state' => 'completed',
304 'test_id' => $test_id,
305 'ts' => time(),
306 'url' => $url,
307 'strategy' => 'desktop',
308 'score' => $score,
309 'metrics' => array(
310 'lcp' => self::numeric( $attributes['largest_contentful_paint'] ?? null ),
311 'fcp' => self::numeric( $attributes['first_contentful_paint'] ?? null ),
312 'cls' => self::numeric( $attributes['cumulative_layout_shift'] ?? null ),
313 'tbt' => self::numeric( $attributes['total_blocking_time'] ?? null ),
314 'si' => self::numeric( $attributes['speed_index'] ?? null ),
315 'ttfb' => self::numeric( $attributes['time_to_first_byte'] ?? null ),
316 ),
317 'issues' => array(),
318 'error' => '',
319 );
320 }
321
322 /**
323 * Start a GTmetrix test. Returns the pending marker; the result
324 * arrives via poll_gtmetrix().
325 *
326 * @return array<string,mixed>|\WP_Error
327 */
328 public static function start_gtmetrix( string $url, string $api_key ) {
329 if ( '' === trim( $api_key ) ) {
330 return new \WP_Error(
331 'xspeed_score_no_key',
332 __( 'GTmetrix requires an API key — there is no anonymous mode. Add one in the Score settings.', 'xspeed' ),
333 array( 'status' => 400 )
334 );
335 }
336
337 $response = wp_remote_post(
338 self::GTMETRIX_ENDPOINT,
339 array(
340 'timeout' => 30,
341 'headers' => array(
342 'Authorization' => self::gtmetrix_auth_header( $api_key ),
343 'Content-Type' => 'application/vnd.api+json',
344 ),
345 'body' => self::gtmetrix_start_body( $url ),
346 )
347 );
348
349 if ( is_wp_error( $response ) ) {
350 return $response;
351 }
352
353 $code = (int) wp_remote_retrieve_response_code( $response );
354 $json = json_decode( (string) wp_remote_retrieve_body( $response ), true );
355
356 if ( $code >= 400 || ! is_array( $json ) ) {
357 return new \WP_Error(
358 'xspeed_score_gtmetrix_failed',
359 sprintf(
360 /* translators: %d: HTTP status code. */
361 __( 'GTmetrix returned HTTP %d. Check the API key.', 'xspeed' ),
362 $code
363 ),
364 array( 'status' => 502 )
365 );
366 }
367
368 $parsed = self::parse_gtmetrix( $url, $json );
369 if ( ! empty( $parsed['test_id'] ) ) {
370 update_option(
371 self::PENDING_OPTION,
372 array(
373 'test_id' => (string) $parsed['test_id'],
374 'url' => $url,
375 'started' => time(),
376 'provider' => 'gtmetrix',
377 ),
378 false
379 );
380 }
381 return $parsed;
382 }
383
384 /**
385 * Check the in-flight GTmetrix test, recording it if it finished.
386 *
387 * @return array<string,mixed>|\WP_Error
388 */
389 public static function poll_gtmetrix( string $api_key ) {
390 $pending = get_option( self::PENDING_OPTION, array() );
391 if ( ! is_array( $pending ) || empty( $pending['test_id'] ) ) {
392 return array(
393 'pending' => false,
394 'state' => 'idle',
395 );
396 }
397
398 $response = wp_remote_get(
399 self::GTMETRIX_ENDPOINT . '/' . rawurlencode( (string) $pending['test_id'] ),
400 array(
401 'timeout' => 30,
402 'headers' => array( 'Authorization' => self::gtmetrix_auth_header( $api_key ) ),
403 )
404 );
405
406 if ( is_wp_error( $response ) ) {
407 return $response;
408 }
409
410 $json = json_decode( (string) wp_remote_retrieve_body( $response ), true );
411 if ( ! is_array( $json ) ) {
412 return new \WP_Error(
413 'xspeed_score_gtmetrix_failed',
414 __( 'GTmetrix returned an unreadable response.', 'xspeed' ),
415 array( 'status' => 502 )
416 );
417 }
418
419 $parsed = self::parse_gtmetrix( (string) ( $pending['url'] ?? '' ), $json );
420
421 if ( empty( $parsed['pending'] ) ) {
422 // Terminal, either way — stop polling a test that has resolved.
423 delete_option( self::PENDING_OPTION );
424 if ( ! empty( $parsed['ok'] ) ) {
425 self::record( $parsed );
426 }
427 }
428
429 return $parsed;
430 }
431
432 /* ------------------------------------------------------------------ */
433 /* History */
434 /* ------------------------------------------------------------------ */
435
436 /**
437 * Append a run. Newest first, capped, autoload off — the history is
438 * only ever read in admin contexts.
439 *
440 * @param array<string,mixed> $row A parsed run.
441 */
442 public static function record( array $row ): void {
443 // The table is the store. It is created on activation and on
444 // admin_init, but record() can run from CLI on a site that has done
445 // neither yet, so make sure it exists before writing.
446 Score_Store::maybe_install();
447 $id = Score_Store::insert( $row, isset( $row['source'] ) ? (string) $row['source'] : 'local' );
448
449 // The option is only the fallback for a site where the table could
450 // not take the row. Writing it on every run as well kept a second,
451 // PHP-serialized copy that a latin1 options table can corrupt the
452 // same way it corrupted the scan result (#468).
453 if ( $id > 0 ) {
454 return;
455 }
456
457 $history = self::history_option();
458 array_unshift( $history, $row );
459 if ( count( $history ) > self::MAX_HISTORY ) {
460 $history = array_slice( $history, 0, self::MAX_HISTORY );
461 }
462
463 if ( false === get_option( self::HISTORY_OPTION, false ) ) {
464 add_option( self::HISTORY_OPTION, $history, '', 'no' );
465 return;
466 }
467 update_option( self::HISTORY_OPTION, $history );
468 }
469
470 /**
471 * Stored runs, newest first.
472 *
473 * @return array<int,array<string,mixed>>
474 */
475 public static function history(): array {
476 Score_Store::maybe_install();
477 $rows = Score_Store::history( self::MAX_HISTORY );
478 if ( ! empty( $rows ) ) {
479 return $rows;
480 }
481 // Empty table on a site whose migration has not run yet — fall back
482 // so the panel never looks like it lost the user's history.
483 return self::history_option();
484 }
485
486 /**
487 * The legacy option-based history.
488 *
489 * Retained for the one-time migration in Score_Store and as a fallback,
490 * NOT as a second source of truth. Nothing else should call it.
491 *
492 * @return array<int,array<string,mixed>>
493 */
494 private static function history_option(): array {
495 $raw = get_option( self::HISTORY_OPTION, array() );
496 if ( ! is_array( $raw ) ) {
497 return array();
498 }
499 $out = array();
500 foreach ( $raw as $row ) {
501 if ( is_array( $row ) && isset( $row['ts'] ) ) {
502 $out[] = $row;
503 }
504 }
505 return $out;
506 }
507
508 /**
509 * Most recent successful run, or null.
510 *
511 * Asks the store for the newest `ok` row rather than scanning the capped
512 * history window. Failed runs are recorded too, so a site whose audits
513 * keep failing — the unauthenticated PSI quota refuses often, and the key
514 * is optional — would push its last real score out of the window after
515 * MAX_HISTORY failures and then report no score at all (#306 review,
516 * issue 1). Reproduced: one genuine audit of 91, then 30 failures, and
517 * latest() returned null.
518 *
519 * The option fallback still scans, because that path has no query to make
520 * and is only reached when the table is unavailable.
521 */
522 public static function latest(): ?array {
523 Score_Store::maybe_install();
524 $row = Score_Store::latest_ok();
525 if ( is_array( $row ) ) {
526 return $row;
527 }
528
529 foreach ( self::history_option() as $row ) {
530 if ( ! empty( $row['ok'] ) ) {
531 return $row;
532 }
533 }
534 return null;
535 }
536
537 public static function clear(): void {
538 delete_option( self::HISTORY_OPTION );
539 delete_option( self::PENDING_OPTION );
540 }
541
542 /* ------------------------------------------------------------------ */
543 /* Core Web Vitals thresholds */
544 /* ------------------------------------------------------------------ */
545
546 /**
547 * Google's published Core Web Vitals thresholds, in the units the
548 * metrics arrive in (ms, except CLS which is unitless).
549 *
550 * @return array<string,array{good:float,poor:float}>
551 */
552 public static function thresholds(): array {
553 return array(
554 'lcp' => array(
555 'good' => 2500.0,
556 'poor' => 4000.0,
557 ),
558 'fcp' => array(
559 'good' => 1800.0,
560 'poor' => 3000.0,
561 ),
562 'cls' => array(
563 'good' => 0.1,
564 'poor' => 0.25,
565 ),
566 'tbt' => array(
567 'good' => 200.0,
568 'poor' => 600.0,
569 ),
570 'si' => array(
571 'good' => 3400.0,
572 'poor' => 5800.0,
573 ),
574 'ttfb' => array(
575 'good' => 800.0,
576 'poor' => 1800.0,
577 ),
578 );
579 }
580
581 /**
582 * Rate one metric good / needs-improvement / poor.
583 *
584 * Returns 'unknown' for a missing value rather than defaulting to
585 * 'poor' — a chip that says "poor" because we have no measurement is a
586 * false alarm someone will chase.
587 */
588 public static function rate( string $metric, ?float $value ): string {
589 $thresholds = self::thresholds();
590 if ( null === $value || ! isset( $thresholds[ $metric ] ) ) {
591 return 'unknown';
592 }
593 if ( $value <= $thresholds[ $metric ]['good'] ) {
594 return 'good';
595 }
596 if ( $value <= $thresholds[ $metric ]['poor'] ) {
597 return 'needs-improvement';
598 }
599 return 'poor';
600 }
601
602 /* ------------------------------------------------------------------ */
603 /* Helpers */
604 /* ------------------------------------------------------------------ */
605
606 /**
607 * A failed run, in the same shape as a successful one.
608 *
609 * Recorded like any other row so "we tried and it failed" is visible in
610 * the history rather than looking like nobody ever ran a test.
611 *
612 * @return array<string,mixed>
613 */
614 private static function failure( string $provider, string $url, string $strategy, string $error ): array {
615 $row = array(
616 'ok' => false,
617 'provider' => $provider,
618 'ts' => time(),
619 'url' => $url,
620 'strategy' => $strategy,
621 'score' => null,
622 'metrics' => array(),
623 'issues' => array(),
624 'error' => $error,
625 );
626 self::record( $row );
627 return $row;
628 }
629
630 /**
631 * @param mixed $value Raw metric value.
632 */
633 private static function numeric( $value ): ?float {
634 return is_numeric( $value ) ? (float) $value : null;
635 }
636
637 /**
638 * Normalise a performance score to 0-100 from either wire shape.
639 *
640 * Public so a test can pin both, because which one GTmetrix sends is the
641 * single assumption in this file we cannot verify without a live key.
642 *
643 * @param mixed $value Raw score, 0-1 or 0-100.
644 */
645 public static function percent( $value ): ?int {
646 if ( ! is_numeric( $value ) ) {
647 return null;
648 }
649 $number = (float) $value;
650 $scaled = $number > 1.0 ? $number : $number * 100.0;
651 return (int) max( 0, min( 100, round( $scaled ) ) );
652 }
653 }
654