PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.6
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.1.6, at includes/class-score.php

616 lines 19.4 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 // The table is the store. It is created on activation and on
433 // admin_init, but record() can run from CLI on a site that has done
434 // neither yet, so make sure it exists before writing.
435 Score_Store::maybe_install();
436 Score_Store::insert( $row, isset( $row['source'] ) ? (string) $row['source'] : 'local' );
437
438 $history = self::history_option();
439 array_unshift( $history, $row );
440 if ( count( $history ) > self::MAX_HISTORY ) {
441 $history = array_slice( $history, 0, self::MAX_HISTORY );
442 }
443
444 if ( false === get_option( self::HISTORY_OPTION, false ) ) {
445 add_option( self::HISTORY_OPTION, $history, '', 'no' );
446 return;
447 }
448 update_option( self::HISTORY_OPTION, $history );
449 }
450
451 /**
452 * Stored runs, newest first.
453 *
454 * @return array<int,array<string,mixed>>
455 */
456 public static function history(): array {
457 Score_Store::maybe_install();
458 $rows = Score_Store::history( self::MAX_HISTORY );
459 if ( ! empty( $rows ) ) {
460 return $rows;
461 }
462 // Empty table on a site whose migration has not run yet — fall back
463 // so the panel never looks like it lost the user's history.
464 return self::history_option();
465 }
466
467 /**
468 * The legacy option-based history.
469 *
470 * Retained for the one-time migration in Score_Store and as a fallback,
471 * NOT as a second source of truth. Nothing else should call it.
472 *
473 * @return array<int,array<string,mixed>>
474 */
475 private static function history_option(): array {
476 $raw = get_option( self::HISTORY_OPTION, array() );
477 if ( ! is_array( $raw ) ) {
478 return array();
479 }
480 $out = array();
481 foreach ( $raw as $row ) {
482 if ( is_array( $row ) && isset( $row['ts'] ) ) {
483 $out[] = $row;
484 }
485 }
486 return $out;
487 }
488
489 /** Most recent successful run, or null. */
490 public static function latest(): ?array {
491 foreach ( self::history() as $row ) {
492 if ( ! empty( $row['ok'] ) ) {
493 return $row;
494 }
495 }
496 return null;
497 }
498
499 public static function clear(): void {
500 delete_option( self::HISTORY_OPTION );
501 delete_option( self::PENDING_OPTION );
502 }
503
504 /* ------------------------------------------------------------------ */
505 /* Core Web Vitals thresholds */
506 /* ------------------------------------------------------------------ */
507
508 /**
509 * Google's published Core Web Vitals thresholds, in the units the
510 * metrics arrive in (ms, except CLS which is unitless).
511 *
512 * @return array<string,array{good:float,poor:float}>
513 */
514 public static function thresholds(): array {
515 return array(
516 'lcp' => array(
517 'good' => 2500.0,
518 'poor' => 4000.0,
519 ),
520 'fcp' => array(
521 'good' => 1800.0,
522 'poor' => 3000.0,
523 ),
524 'cls' => array(
525 'good' => 0.1,
526 'poor' => 0.25,
527 ),
528 'tbt' => array(
529 'good' => 200.0,
530 'poor' => 600.0,
531 ),
532 'si' => array(
533 'good' => 3400.0,
534 'poor' => 5800.0,
535 ),
536 'ttfb' => array(
537 'good' => 800.0,
538 'poor' => 1800.0,
539 ),
540 );
541 }
542
543 /**
544 * Rate one metric good / needs-improvement / poor.
545 *
546 * Returns 'unknown' for a missing value rather than defaulting to
547 * 'poor' — a chip that says "poor" because we have no measurement is a
548 * false alarm someone will chase.
549 */
550 public static function rate( string $metric, ?float $value ): string {
551 $thresholds = self::thresholds();
552 if ( null === $value || ! isset( $thresholds[ $metric ] ) ) {
553 return 'unknown';
554 }
555 if ( $value <= $thresholds[ $metric ]['good'] ) {
556 return 'good';
557 }
558 if ( $value <= $thresholds[ $metric ]['poor'] ) {
559 return 'needs-improvement';
560 }
561 return 'poor';
562 }
563
564 /* ------------------------------------------------------------------ */
565 /* Helpers */
566 /* ------------------------------------------------------------------ */
567
568 /**
569 * A failed run, in the same shape as a successful one.
570 *
571 * Recorded like any other row so "we tried and it failed" is visible in
572 * the history rather than looking like nobody ever ran a test.
573 *
574 * @return array<string,mixed>
575 */
576 private static function failure( string $provider, string $url, string $strategy, string $error ): array {
577 $row = array(
578 'ok' => false,
579 'provider' => $provider,
580 'ts' => time(),
581 'url' => $url,
582 'strategy' => $strategy,
583 'score' => null,
584 'metrics' => array(),
585 'issues' => array(),
586 'error' => $error,
587 );
588 self::record( $row );
589 return $row;
590 }
591
592 /**
593 * @param mixed $value Raw metric value.
594 */
595 private static function numeric( $value ): ?float {
596 return is_numeric( $value ) ? (float) $value : null;
597 }
598
599 /**
600 * Normalise a performance score to 0-100 from either wire shape.
601 *
602 * Public so a test can pin both, because which one GTmetrix sends is the
603 * single assumption in this file we cannot verify without a live key.
604 *
605 * @param mixed $value Raw score, 0-1 or 0-100.
606 */
607 public static function percent( $value ): ?int {
608 if ( ! is_numeric( $value ) ) {
609 return null;
610 }
611 $number = (float) $value;
612 $scaled = $number > 1.0 ? $number : $number * 100.0;
613 return (int) max( 0, min( 100, round( $scaled ) ) );
614 }
615 }
616