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

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

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