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

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

591 lines 18.5 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 return self::failure( 'psi', $url, $strategy, $message );
198 }
199
200 $row = self::parse_psi( $url, $strategy, $json );
201 self::record( $row );
202 return $row;
203 }
204
205 /* ------------------------------------------------------------------ */
206 /* GTmetrix */
207 /* ------------------------------------------------------------------ */
208
209 /**
210 * The Authorization header GTmetrix v2 expects.
211 *
212 * HTTP Basic with the API key as the username and an EMPTY password —
213 * the trailing colon is load-bearing, and omitting it authenticates as
214 * nobody with a 401 that reads like a bad key.
215 */
216 public static function gtmetrix_auth_header( string $api_key ): string {
217 return 'Basic ' . base64_encode( trim( $api_key ) . ':' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- HTTP Basic auth encoding, not obfuscation.
218 }
219
220 /**
221 * Body for "start a test". GTmetrix speaks JSON:API, so the URL is
222 * nested under data.attributes rather than posted flat.
223 *
224 * Pure — the shape is easy to get subtly wrong and impossible to
225 * notice, because a malformed body returns a generic 400.
226 */
227 public static function gtmetrix_start_body( string $url ): string {
228 return (string) wp_json_encode(
229 array(
230 'data' => array(
231 'type' => 'test',
232 'attributes' => array( 'url' => $url ),
233 ),
234 )
235 );
236 }
237
238 /**
239 * Read a GTmetrix test envelope into either a pending marker or a
240 * finished history row.
241 *
242 * The API reports progress through `data.attributes.state`
243 * (queued / started / completed / error). Anything that is not
244 * `completed` is still in flight — treating an unknown state as done
245 * would record a row with no score in it.
246 *
247 * @param array $envelope Decoded GTmetrix JSON.
248 * @return array<string,mixed>
249 */
250 public static function parse_gtmetrix( string $url, array $envelope ): array {
251 $data = isset( $envelope['data'] ) && is_array( $envelope['data'] ) ? $envelope['data'] : array();
252 $attributes = isset( $data['attributes'] ) && is_array( $data['attributes'] ) ? $data['attributes'] : array();
253 $state = isset( $attributes['state'] ) ? (string) $attributes['state'] : '';
254 $test_id = isset( $data['id'] ) ? (string) $data['id'] : '';
255
256 if ( 'error' === $state ) {
257 $row = self::failure(
258 'gtmetrix',
259 $url,
260 'desktop',
261 isset( $attributes['error'] ) && '' !== (string) $attributes['error']
262 ? (string) $attributes['error']
263 : __( 'GTmetrix reported the test failed.', 'xspeed' )
264 );
265 $row['state'] = 'error';
266 return $row;
267 }
268
269 if ( 'completed' !== $state ) {
270 return array(
271 'ok' => true,
272 'provider' => 'gtmetrix',
273 'state' => '' === $state ? 'queued' : $state,
274 'test_id' => $test_id,
275 'url' => $url,
276 'pending' => true,
277 );
278 }
279
280 // GTmetrix reports Performance/Structure as 0-1 and the vitals in
281 // milliseconds, with CLS unitless — the same units PSI uses, which
282 // is why both providers can share one history shape.
283 // GTmetrix has shipped this both ways: API 2.0 returns an integer
284 // 0-100, older/other shapes a 0-1 fraction. Scaling unconditionally
285 // turned a real 96 into 9600. Treat >1 as already-percent — a genuine
286 // fractional score above 1 does not exist.
287 $score = self::percent( $attributes['performance_score'] ?? null );
288
289 return array(
290 'ok' => true,
291 'provider' => 'gtmetrix',
292 'state' => 'completed',
293 'test_id' => $test_id,
294 'ts' => time(),
295 'url' => $url,
296 'strategy' => 'desktop',
297 'score' => $score,
298 'metrics' => array(
299 'lcp' => self::numeric( $attributes['largest_contentful_paint'] ?? null ),
300 'fcp' => self::numeric( $attributes['first_contentful_paint'] ?? null ),
301 'cls' => self::numeric( $attributes['cumulative_layout_shift'] ?? null ),
302 'tbt' => self::numeric( $attributes['total_blocking_time'] ?? null ),
303 'si' => self::numeric( $attributes['speed_index'] ?? null ),
304 'ttfb' => self::numeric( $attributes['time_to_first_byte'] ?? null ),
305 ),
306 'issues' => array(),
307 'error' => '',
308 );
309 }
310
311 /**
312 * Start a GTmetrix test. Returns the pending marker; the result
313 * arrives via poll_gtmetrix().
314 *
315 * @return array<string,mixed>|\WP_Error
316 */
317 public static function start_gtmetrix( string $url, string $api_key ) {
318 if ( '' === trim( $api_key ) ) {
319 return new \WP_Error(
320 'xspeed_score_no_key',
321 __( 'GTmetrix requires an API key — there is no anonymous mode. Add one in the Score settings.', 'xspeed' ),
322 array( 'status' => 400 )
323 );
324 }
325
326 $response = wp_remote_post(
327 self::GTMETRIX_ENDPOINT,
328 array(
329 'timeout' => 30,
330 'headers' => array(
331 'Authorization' => self::gtmetrix_auth_header( $api_key ),
332 'Content-Type' => 'application/vnd.api+json',
333 ),
334 'body' => self::gtmetrix_start_body( $url ),
335 )
336 );
337
338 if ( is_wp_error( $response ) ) {
339 return $response;
340 }
341
342 $code = (int) wp_remote_retrieve_response_code( $response );
343 $json = json_decode( (string) wp_remote_retrieve_body( $response ), true );
344
345 if ( $code >= 400 || ! is_array( $json ) ) {
346 return new \WP_Error(
347 'xspeed_score_gtmetrix_failed',
348 sprintf(
349 /* translators: %d: HTTP status code. */
350 __( 'GTmetrix returned HTTP %d. Check the API key.', 'xspeed' ),
351 $code
352 ),
353 array( 'status' => 502 )
354 );
355 }
356
357 $parsed = self::parse_gtmetrix( $url, $json );
358 if ( ! empty( $parsed['test_id'] ) ) {
359 update_option(
360 self::PENDING_OPTION,
361 array(
362 'test_id' => (string) $parsed['test_id'],
363 'url' => $url,
364 'started' => time(),
365 'provider' => 'gtmetrix',
366 ),
367 false
368 );
369 }
370 return $parsed;
371 }
372
373 /**
374 * Check the in-flight GTmetrix test, recording it if it finished.
375 *
376 * @return array<string,mixed>|\WP_Error
377 */
378 public static function poll_gtmetrix( string $api_key ) {
379 $pending = get_option( self::PENDING_OPTION, array() );
380 if ( ! is_array( $pending ) || empty( $pending['test_id'] ) ) {
381 return array(
382 'pending' => false,
383 'state' => 'idle',
384 );
385 }
386
387 $response = wp_remote_get(
388 self::GTMETRIX_ENDPOINT . '/' . rawurlencode( (string) $pending['test_id'] ),
389 array(
390 'timeout' => 30,
391 'headers' => array( 'Authorization' => self::gtmetrix_auth_header( $api_key ) ),
392 )
393 );
394
395 if ( is_wp_error( $response ) ) {
396 return $response;
397 }
398
399 $json = json_decode( (string) wp_remote_retrieve_body( $response ), true );
400 if ( ! is_array( $json ) ) {
401 return new \WP_Error(
402 'xspeed_score_gtmetrix_failed',
403 __( 'GTmetrix returned an unreadable response.', 'xspeed' ),
404 array( 'status' => 502 )
405 );
406 }
407
408 $parsed = self::parse_gtmetrix( (string) ( $pending['url'] ?? '' ), $json );
409
410 if ( empty( $parsed['pending'] ) ) {
411 // Terminal, either way — stop polling a test that has resolved.
412 delete_option( self::PENDING_OPTION );
413 if ( ! empty( $parsed['ok'] ) ) {
414 self::record( $parsed );
415 }
416 }
417
418 return $parsed;
419 }
420
421 /* ------------------------------------------------------------------ */
422 /* History */
423 /* ------------------------------------------------------------------ */
424
425 /**
426 * Append a run. Newest first, capped, autoload off — the history is
427 * only ever read in admin contexts.
428 *
429 * @param array<string,mixed> $row A parsed run.
430 */
431 public static function record( array $row ): void {
432 $history = self::history();
433 array_unshift( $history, $row );
434 if ( count( $history ) > self::MAX_HISTORY ) {
435 $history = array_slice( $history, 0, self::MAX_HISTORY );
436 }
437
438 if ( false === get_option( self::HISTORY_OPTION, false ) ) {
439 add_option( self::HISTORY_OPTION, $history, '', 'no' );
440 return;
441 }
442 update_option( self::HISTORY_OPTION, $history );
443 }
444
445 /**
446 * Stored runs, newest first.
447 *
448 * @return array<int,array<string,mixed>>
449 */
450 public static function history(): array {
451 $raw = get_option( self::HISTORY_OPTION, array() );
452 if ( ! is_array( $raw ) ) {
453 return array();
454 }
455 $out = array();
456 foreach ( $raw as $row ) {
457 if ( is_array( $row ) && isset( $row['ts'] ) ) {
458 $out[] = $row;
459 }
460 }
461 return $out;
462 }
463
464 /** Most recent successful run, or null. */
465 public static function latest(): ?array {
466 foreach ( self::history() as $row ) {
467 if ( ! empty( $row['ok'] ) ) {
468 return $row;
469 }
470 }
471 return null;
472 }
473
474 public static function clear(): void {
475 delete_option( self::HISTORY_OPTION );
476 delete_option( self::PENDING_OPTION );
477 }
478
479 /* ------------------------------------------------------------------ */
480 /* Core Web Vitals thresholds */
481 /* ------------------------------------------------------------------ */
482
483 /**
484 * Google's published Core Web Vitals thresholds, in the units the
485 * metrics arrive in (ms, except CLS which is unitless).
486 *
487 * @return array<string,array{good:float,poor:float}>
488 */
489 public static function thresholds(): array {
490 return array(
491 'lcp' => array(
492 'good' => 2500.0,
493 'poor' => 4000.0,
494 ),
495 'fcp' => array(
496 'good' => 1800.0,
497 'poor' => 3000.0,
498 ),
499 'cls' => array(
500 'good' => 0.1,
501 'poor' => 0.25,
502 ),
503 'tbt' => array(
504 'good' => 200.0,
505 'poor' => 600.0,
506 ),
507 'si' => array(
508 'good' => 3400.0,
509 'poor' => 5800.0,
510 ),
511 'ttfb' => array(
512 'good' => 800.0,
513 'poor' => 1800.0,
514 ),
515 );
516 }
517
518 /**
519 * Rate one metric good / needs-improvement / poor.
520 *
521 * Returns 'unknown' for a missing value rather than defaulting to
522 * 'poor' — a chip that says "poor" because we have no measurement is a
523 * false alarm someone will chase.
524 */
525 public static function rate( string $metric, ?float $value ): string {
526 $thresholds = self::thresholds();
527 if ( null === $value || ! isset( $thresholds[ $metric ] ) ) {
528 return 'unknown';
529 }
530 if ( $value <= $thresholds[ $metric ]['good'] ) {
531 return 'good';
532 }
533 if ( $value <= $thresholds[ $metric ]['poor'] ) {
534 return 'needs-improvement';
535 }
536 return 'poor';
537 }
538
539 /* ------------------------------------------------------------------ */
540 /* Helpers */
541 /* ------------------------------------------------------------------ */
542
543 /**
544 * A failed run, in the same shape as a successful one.
545 *
546 * Recorded like any other row so "we tried and it failed" is visible in
547 * the history rather than looking like nobody ever ran a test.
548 *
549 * @return array<string,mixed>
550 */
551 private static function failure( string $provider, string $url, string $strategy, string $error ): array {
552 $row = array(
553 'ok' => false,
554 'provider' => $provider,
555 'ts' => time(),
556 'url' => $url,
557 'strategy' => $strategy,
558 'score' => null,
559 'metrics' => array(),
560 'issues' => array(),
561 'error' => $error,
562 );
563 self::record( $row );
564 return $row;
565 }
566
567 /**
568 * @param mixed $value Raw metric value.
569 */
570 private static function numeric( $value ): ?float {
571 return is_numeric( $value ) ? (float) $value : null;
572 }
573
574 /**
575 * Normalise a performance score to 0-100 from either wire shape.
576 *
577 * Public so a test can pin both, because which one GTmetrix sends is the
578 * single assumption in this file we cannot verify without a live key.
579 *
580 * @param mixed $value Raw score, 0-1 or 0-100.
581 */
582 public static function percent( $value ): ?int {
583 if ( ! is_numeric( $value ) ) {
584 return null;
585 }
586 $number = (float) $value;
587 $scaled = $number > 1.0 ? $number : $number * 100.0;
588 return (int) max( 0, min( 100, round( $scaled ) ) );
589 }
590 }
591