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.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 / modules / Score / ScoreModule.php

ScoreModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.2.0, at includes/modules/Score/ScoreModule.php

612 lines 19.6 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 (PageSpeed Insights / GTmetrix)
4 * on the dashboard (issue #47).
5 *
6 * Free tier. Users judge a caching plugin by its PSI or GTmetrix score
7 * whether or not the plugin shows one, so the number belongs next to the
8 * internal TTFB benchmark instead of one browser tab away.
9 *
10 * The module owns settings, REST and CLI; the measuring lives in
11 * \XSpeed\Score. Runs are stored in the shape Pro's Pagespeed engine
12 * already returns, so a Pro audit and a Free audit are the same kind of
13 * row and the history stays a single series — Pro augments this rather
14 * than starting a second one.
15 *
16 * Outbound HTTP is opt-in: nothing here calls out unless the module is
17 * enabled AND a person presses Test (or runs the command). No schedule,
18 * no background call. Disclosed in readme.txt "External services".
19 *
20 * @package XSpeed
21 */
22
23 declare(strict_types=1);
24
25 namespace XSpeed\Modules\Score;
26
27 defined( 'ABSPATH' ) || exit;
28
29 use XSpeed\Module;
30 use XSpeed\Modules\Mcp\Mcp_Hub;
31 use XSpeed\Score;
32 use XSpeed\Settings_Manager;
33
34 final class ScoreModule extends Module {
35
36 public const SLUG = 'score';
37 public const TIER = self::TIER_FREE;
38 public const VERSION = '1.1.0';
39
40 public function ui_metadata(): array {
41 return array(
42 'label' => 'Speed Test',
43 'icon' => 'Gauge',
44 // Provider-neutral: the panel runs whichever provider the site has
45 // configured (PageSpeed Insights by default, no API key needed).
46 // The Hub-run test has its own copy and is gated behind
47 // hub_speed_test_enabled(), so this line must not promise it.
48 'description' => 'Run a PageSpeed Insights or GTmetrix audit from the dashboard and keep the history next to your TTFB benchmark.',
49 'custom_panel' => 'ScorePanel',
50 );
51 }
52
53 public function settings_schema(): array {
54 return array(
55 'enabled' => array(
56 'type' => 'bool',
57 'default' => false,
58 'label' => 'Enable external scores',
59 // Off by default and stated plainly: this is the only part
60 // of the plugin that talks to a third party on your behalf.
61 'description' => 'Lets you run a PageSpeed Insights or GTmetrix audit from this dashboard. Nothing is sent anywhere until you press Test.',
62 ),
63 'provider' => array(
64 'type' => 'enum',
65 'default' => 'psi',
66 'options' => array( 'psi', 'gtmetrix' ),
67 'option_labels' => array(
68 'psi' => 'PageSpeed Insights',
69 'gtmetrix' => 'GTmetrix',
70 ),
71 'label' => 'Provider',
72 'description' => 'PageSpeed Insights works without an API key. GTmetrix requires one.',
73 'dependsOn' => array( 'field' => 'enabled' ),
74 ),
75 'psi_api_key' => array(
76 'type' => 'secret',
77 'default' => '',
78 'label' => 'PageSpeed API key (optional)',
79 'description' => 'Only needed if you hit Google\'s anonymous rate limit. Free from cloud.google.com.',
80 // Rendered as a trailing "Check the documentation" link —
81 // descriptions themselves are plain text (#111).
82 'doc_url' => 'https://xspeedcache.com/docs/pagespeed-insights-integration/',
83 'dependsOn' => array(
84 'field' => 'provider',
85 'value' => 'psi',
86 ),
87 ),
88 'gtmetrix_api_key' => array(
89 'type' => 'secret',
90 'default' => '',
91 'label' => 'GTmetrix API key',
92 'description' => 'Required — GTmetrix has no anonymous mode. Found in your GTmetrix account settings.',
93 'dependsOn' => array(
94 'field' => 'provider',
95 'value' => 'gtmetrix',
96 ),
97 ),
98 'test_url' => array(
99 'type' => 'url',
100 'default' => '',
101 'label' => 'URL to test',
102 'description' => 'Leave empty to test your home page.',
103 'dependsOn' => array( 'field' => 'enabled' ),
104 ),
105 'default_strategy' => array(
106 'type' => 'enum',
107 'default' => 'mobile',
108 'options' => array( 'mobile', 'desktop' ),
109 'label' => 'Strategy',
110 'description' => 'PageSpeed Insights only. Mobile is what Google ranks on.',
111 'dependsOn' => array(
112 'field' => 'provider',
113 'value' => 'psi',
114 ),
115 ),
116 );
117 }
118
119 /**
120 * Encrypt the pre-1.1.0 plaintext API keys on upgrade — psi_api_key /
121 * gtmetrix_api_key became `secret`-typed fields (encrypted at rest).
122 * Idempotent. (#115)
123 */
124 public function migrations(): array {
125 return array(
126 '1.1.0' => static function ( array $opts ): array {
127 foreach ( array( 'psi_api_key', 'gtmetrix_api_key' ) as $key ) {
128 if ( isset( $opts[ $key ] ) && is_string( $opts[ $key ] ) && '' !== $opts[ $key ] ) {
129 $opts[ $key ] = \XSpeed\Settings_Manager::encrypt_for_storage( $opts[ $key ] );
130 }
131 }
132 return $opts;
133 },
134 );
135 }
136
137 public function rest_routes(): array {
138 return array_merge(
139 parent::rest_routes(),
140 array(
141 array(
142 'path' => '/run',
143 'methods' => 'POST',
144 'callback' => array( $this, 'rest_run' ),
145 'feature' => self::SLUG,
146 ),
147 array(
148 'path' => '/status',
149 'methods' => 'GET',
150 'callback' => array( $this, 'rest_status' ),
151 ),
152 array(
153 'path' => '/history',
154 'methods' => 'GET',
155 'callback' => array( $this, 'rest_history' ),
156 ),
157 /*
158 * Hub-powered GTmetrix. Deliberately NOT gated on the
159 * `enabled` setting the way /run and /status are.
160 *
161 * That gate exists because /run makes an outbound call on the
162 * SITE's behalf with the SITE owner's key — it is the promise
163 * in readme.txt that nothing is sent to a third party until
164 * you say so. This path is different: the site talks only to
165 * the Hub it has already been deliberately connected to, and
166 * the Hub owns the GTmetrix account. Requiring the toggle as
167 * well would keep the five-step funnel this feature exists to
168 * remove.
169 */
170 array(
171 'path' => '/hub-test',
172 'methods' => 'POST',
173 'callback' => array( $this, 'rest_hub_test' ),
174 ),
175 array(
176 'path' => '/hub-status',
177 'methods' => 'GET',
178 'callback' => array( $this, 'rest_hub_status' ),
179 ),
180 )
181 );
182 }
183
184 /**
185 * Start a Hub-run GTmetrix test.
186 *
187 * Returns the Hub's payload on success. On failure the WP_Error code is
188 * the Hub's own stable code (site_not_verified, gtmetrix_quota_exceeded,
189 * …) so the UI can respond specifically, and the HTTP status is carried
190 * through rather than flattened to 500.
191 *
192 * @return \WP_REST_Response|\WP_Error
193 */
194 public function rest_hub_test() {
195 if ( ! self::hub_speed_test_enabled() ) {
196 return new \WP_Error(
197 'xspeed_hub_speed_test_disabled',
198 __( 'Hub-run speed tests are not enabled on this site.', 'xspeed' ),
199 array( 'status' => 403 )
200 );
201 }
202
203 $result = Mcp_Hub::gtmetrix_test();
204 return $this->hub_result( $result );
205 }
206
207 /**
208 * Is the Hub-run speed test available on this site?
209 *
210 * Off by default: the feature is built and merged, but the hosted runner
211 * behind it is not being announced yet, and a button that offers a test we
212 * are not ready to serve is worse than no button. The panel already has a
213 * complete story without it — PageSpeed Insights runs with no API key, and
214 * a site with its own provider key is unaffected either way.
215 *
216 * `rest_hub_status()` reports `feature_disabled`, which is deliberately NOT
217 * in the UI's CONNECTABLE_REASONS allowlist, so both surfaces (the Overview
218 * card and the panel's primary action) fall back to the PageSpeed flow
219 * rather than offering a Connect prompt that leads nowhere.
220 *
221 * Flip with `add_filter( 'xspeed_hub_speed_test_enabled', '__return_true' );`
222 * — one line, no code change, for when the runner is announced.
223 */
224 public static function hub_speed_test_enabled(): bool {
225 /**
226 * Whether the Hub-run speed test is offered in the dashboard.
227 *
228 * @param bool $enabled Default false.
229 */
230 return (bool) apply_filters( 'xspeed_hub_speed_test_enabled', false );
231 }
232
233 /**
234 * Recent Hub-run tests + the remaining monthly allowance.
235 *
236 * @return \WP_REST_Response|\WP_Error
237 */
238 public function rest_hub_status() {
239 if ( ! self::hub_speed_test_enabled() ) {
240 return rest_ensure_response(
241 array(
242 'available' => false,
243 'reason' => 'feature_disabled',
244 'message' => __( 'Hub-run speed tests are not enabled on this site.', 'xspeed' ),
245 'local' => false,
246 )
247 );
248 }
249
250 $result = Mcp_Hub::gtmetrix_runs();
251 if ( is_wp_error( $result ) ) {
252 // A status read must never look like a hard failure — the panel
253 // still has a history to draw. Report "unavailable" and let the UI
254 // hide the Hub affordance rather than showing an error banner.
255 return rest_ensure_response(
256 array(
257 'available' => false,
258 'reason' => $result->get_error_code(),
259 'message' => $result->get_error_message(),
260 'local' => Mcp_Hub::is_local_site(),
261 )
262 );
263 }
264
265 $result['available'] = true;
266 $result['local'] = Mcp_Hub::is_local_site();
267 return rest_ensure_response( $result );
268 }
269
270 /**
271 * Turn an Mcp_Hub result into a REST response, preserving the status code.
272 *
273 * @param array<string,mixed>|\WP_Error $result Hub call result.
274 * @return \WP_REST_Response|\WP_Error
275 */
276 private function hub_result( $result ) {
277 if ( ! is_wp_error( $result ) ) {
278 return rest_ensure_response( $result );
279 }
280
281 $data = $result->get_error_data();
282 $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 502;
283 // Anything below 400 would be nonsense on an error path.
284 if ( $status < 400 ) {
285 $status = 502;
286 }
287 $data = is_array( $data ) ? $data : array();
288 $data['status'] = $status;
289 $data['local'] = Mcp_Hub::is_local_site();
290
291 return new \WP_Error( $result->get_error_code(), $result->get_error_message(), $data );
292 }
293
294 /**
295 * Start (or, for PSI, complete) an audit.
296 *
297 * POST, never GET: this spends someone else's rate limit and takes up
298 * to a minute. A GET would be prefetched by a browser.
299 */
300 public function rest_run( \WP_REST_Request $request ) {
301 $opts = Settings_Manager::get( self::SLUG );
302
303 if ( empty( $opts['enabled'] ) ) {
304 return new \WP_Error(
305 'xspeed_score_disabled',
306 __( 'External scores are turned off. Enable them first — this is the only feature that contacts a third party.', 'xspeed' ),
307 array( 'status' => 409 )
308 );
309 }
310
311 $url = $this->resolve_url( (string) $request->get_param( 'url' ), $opts );
312 if ( '' === $url ) {
313 return new \WP_Error(
314 'xspeed_score_no_url',
315 __( 'No URL to test.', 'xspeed' ),
316 array( 'status' => 400 )
317 );
318 }
319
320 $provider = (string) ( $request->get_param( 'provider' ) ?: $opts['provider'] );
321
322 if ( 'gtmetrix' === $provider ) {
323 $started = Score::start_gtmetrix( $url, (string) $opts['gtmetrix_api_key'] );
324 return is_wp_error( $started ) ? $started : rest_ensure_response( $started );
325 }
326
327 $strategy = (string) ( $request->get_param( 'strategy' ) ?: $opts['default_strategy'] );
328 return rest_ensure_response( Score::run_psi( $url, $strategy, (string) $opts['psi_api_key'] ) );
329 }
330
331 /**
332 * Poll an in-flight GTmetrix test.
333 *
334 * GET because it is a read of state we already started — the browser
335 * calls it every few seconds while a test is queued.
336 */
337 public function rest_status() {
338 $opts = Settings_Manager::get( self::SLUG );
339 $pending = get_option( Score::PENDING_OPTION, array() );
340
341 // Same opt-in gate as rest_run(). Without it, `status` — which is
342 // also the CLI's DEFAULT action — polled GTmetrix with the feature
343 // switched off and no API key, which falsified readme.txt's promise
344 // that nothing is sent while it is off.
345 if ( ! $this->may_poll( $opts, $pending ) ) {
346 return rest_ensure_response(
347 array(
348 'pending' => false,
349 'state' => 'idle',
350 'latest' => Score::latest(),
351 )
352 );
353 }
354
355 if ( ! is_array( $pending ) || empty( $pending['test_id'] ) ) {
356 return rest_ensure_response(
357 array(
358 'pending' => false,
359 'state' => 'idle',
360 'latest' => Score::latest(),
361 )
362 );
363 }
364
365 $polled = Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
366 if ( is_wp_error( $polled ) ) {
367 return $polled;
368 }
369
370 return rest_ensure_response(
371 array_merge(
372 $polled,
373 array( 'latest' => Score::latest() )
374 )
375 );
376 }
377
378 public function rest_history() {
379 return rest_ensure_response(
380 array(
381 'runs' => Score::history(),
382 'latest' => Score::latest(),
383 'thresholds' => Score::thresholds(),
384 )
385 );
386 }
387
388 /**
389 * May we contact GTmetrix to poll the in-flight test?
390 *
391 * Three conditions, all necessary: the feature is on, an API key exists
392 * (there is no anonymous GTmetrix), and the pending marker is real and
393 * not stale. A marker with no expiry turned one failed start into a
394 * permanent poll loop against a third party.
395 *
396 * @param array<string,mixed> $opts Module settings.
397 * @param mixed $pending The stored pending marker.
398 */
399 private function may_poll( array $opts, $pending ): bool {
400 if ( empty( $opts['enabled'] ) || '' === trim( (string) $opts['gtmetrix_api_key'] ) ) {
401 return false;
402 }
403 if ( ! is_array( $pending ) || empty( $pending['test_id'] ) ) {
404 return false;
405 }
406 // A GTmetrix test that hasn't resolved within the window is not going
407 // to; drop the marker rather than poll it forever.
408 $started = isset( $pending['started'] ) ? (int) $pending['started'] : 0;
409 if ( $started > 0 && ( time() - $started ) > Score::PENDING_MAX_AGE ) {
410 delete_option( Score::PENDING_OPTION );
411 return false;
412 }
413 return true;
414 }
415
416 /**
417 * Fall back to the home page when no URL is configured — testing "my
418 * site" is what almost everyone means.
419 *
420 * @param array<string,mixed> $opts Module settings.
421 */
422 private function resolve_url( string $requested, array $opts ): string {
423 foreach ( array( $requested, (string) ( $opts['test_url'] ?? '' ) ) as $candidate ) {
424 $candidate = trim( $candidate );
425 if ( '' !== $candidate ) {
426 return $candidate;
427 }
428 }
429 return function_exists( 'home_url' ) ? (string) home_url( '/' ) : '';
430 }
431
432 public function cli_commands(): array {
433 return array(
434 array(
435 'name' => 'xspeed score',
436 'callback' => array( $this, 'cli_handler' ),
437 'shortdesc' => 'External performance scores: `run` a PageSpeed Insights / GTmetrix audit (use --target=<url>, not --url, which WP-CLI reserves), `status` for an in-flight GTmetrix test, `history` for past runs.',
438 'synopsis' => array(
439 array(
440 'type' => 'positional',
441 'name' => 'action',
442 'options' => array( 'status', 'run', 'history', 'hub-test' ),
443 'optional' => true,
444 ),
445 array(
446 'type' => 'assoc',
447 // NOT `--url`: that is a WP-CLI *global* parameter, so
448 // the value never reaches this handler and the flag is
449 // silently ignored.
450 'name' => 'target',
451 'description' => 'URL to audit. Defaults to the configured URL, then the home page.',
452 'optional' => true,
453 ),
454 array(
455 'type' => 'assoc',
456 'name' => 'strategy',
457 'description' => 'mobile (default) or desktop. PageSpeed Insights only.',
458 'optional' => true,
459 ),
460 array(
461 'type' => 'assoc',
462 'name' => 'provider',
463 'description' => 'psi (default) or gtmetrix.',
464 'optional' => true,
465 ),
466 ),
467 ),
468 );
469 }
470
471 public function cli_handler( array $args, array $assoc ): void {
472 $action = isset( $args[0] ) ? (string) $args[0] : 'status';
473 $opts = Settings_Manager::get( self::SLUG );
474
475 /*
476 * Hub-powered GTmetrix. Handled before the `enabled` gate below: this
477 * path does not use the site's own key or settings at all — it asks
478 * the Hub the site is already connected to, and the Hub owns the
479 * GTmetrix account.
480 */
481 if ( 'hub-test' === $action ) {
482 if ( ! self::hub_speed_test_enabled() ) {
483 \WP_CLI::error( 'Hub-run speed tests are not enabled on this site.' );
484 return;
485 }
486 $result = Mcp_Hub::gtmetrix_test();
487 if ( is_wp_error( $result ) ) {
488 \WP_CLI::error( $result->get_error_message() );
489 return;
490 }
491 $quota = isset( $result['quota'] ) && is_array( $result['quota'] ) ? $result['quota'] : array();
492 \WP_CLI::success(
493 sprintf(
494 'Test started. %s left this month.',
495 isset( $quota['remaining'] ) ? (string) (int) $quota['remaining'] : '?'
496 )
497 );
498 \WP_CLI::log( 'Results appear in the dashboard when the test finishes (about a minute).' );
499 return;
500 }
501
502 if ( 'history' === $action ) {
503 $runs = Score::history();
504 if ( empty( $runs ) ) {
505 \WP_CLI::log( 'No runs recorded yet.' );
506 return;
507 }
508 foreach ( $runs as $run ) {
509 \WP_CLI::log(
510 sprintf(
511 '%s %-9s %-8s %s',
512 gmdate( 'Y-m-d H:i', (int) $run['ts'] ),
513 (string) ( $run['provider'] ?? '' ),
514 empty( $run['ok'] ) ? 'FAILED' : ( null === ( $run['score'] ?? null ) ? 'no score' : $run['score'] . '/100' ),
515 empty( $run['ok'] ) ? (string) ( $run['error'] ?? '' ) : (string) ( $run['url'] ?? '' )
516 )
517 );
518 }
519 return;
520 }
521
522 if ( 'run' === $action ) {
523 if ( empty( $opts['enabled'] ) ) {
524 \WP_CLI::error( 'External scores are turned off. Enable the score module first — this is the only feature that contacts a third party.' );
525 return;
526 }
527
528 $url = $this->resolve_url( isset( $assoc['target'] ) ? (string) $assoc['target'] : '', $opts );
529 $provider = isset( $assoc['provider'] ) ? (string) $assoc['provider'] : (string) $opts['provider'];
530
531 if ( 'gtmetrix' === $provider ) {
532 $started = Score::start_gtmetrix( $url, (string) $opts['gtmetrix_api_key'] );
533 if ( is_wp_error( $started ) ) {
534 \WP_CLI::error( $started->get_error_message() );
535 return;
536 }
537 \WP_CLI::success( sprintf( 'GTmetrix test queued (id %s). Poll with: wp xspeed score status', (string) ( $started['test_id'] ?? '?' ) ) );
538 return;
539 }
540
541 $strategy = isset( $assoc['strategy'] ) ? (string) $assoc['strategy'] : (string) $opts['default_strategy'];
542 $run = Score::run_psi( $url, $strategy, (string) $opts['psi_api_key'] );
543
544 if ( empty( $run['ok'] ) ) {
545 \WP_CLI::error( (string) $run['error'] );
546 return;
547 }
548 \WP_CLI::success(
549 sprintf(
550 '%s (%s): %s',
551 $url,
552 $strategy,
553 null === $run['score'] ? 'no score returned' : $run['score'] . '/100'
554 )
555 );
556 $this->print_metrics( is_array( $run['metrics'] ) ? $run['metrics'] : array() );
557 return;
558 }
559
560 // status
561 $pending = get_option( Score::PENDING_OPTION, array() );
562 if ( $this->may_poll( $opts, $pending ) ) {
563 $polled = Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
564 if ( is_wp_error( $polled ) ) {
565 \WP_CLI::error( $polled->get_error_message() );
566 return;
567 }
568 if ( ! empty( $polled['pending'] ) ) {
569 \WP_CLI::log( sprintf( 'GTmetrix test %s is %s.', (string) $pending['test_id'], (string) ( $polled['state'] ?? 'running' ) ) );
570 return;
571 }
572 }
573
574 \WP_CLI::log( 'enabled ' . ( empty( $opts['enabled'] ) ? 'no' : 'yes' ) );
575 \WP_CLI::log( 'provider ' . (string) $opts['provider'] );
576
577 $latest = Score::latest();
578 if ( null === $latest ) {
579 \WP_CLI::log( 'No successful run yet. Run one with: wp xspeed score run' );
580 return;
581 }
582 \WP_CLI::log(
583 sprintf(
584 'latest %s — %s (%s)',
585 null === $latest['score'] ? 'no score' : $latest['score'] . '/100',
586 gmdate( 'Y-m-d H:i', (int) $latest['ts'] ),
587 (string) $latest['provider']
588 )
589 );
590 $this->print_metrics( is_array( $latest['metrics'] ) ? $latest['metrics'] : array() );
591 }
592
593 /**
594 * @param array<string,mixed> $metrics Metric name → value.
595 */
596 private function print_metrics( array $metrics ): void {
597 foreach ( $metrics as $name => $value ) {
598 if ( null === $value ) {
599 continue;
600 }
601 \WP_CLI::log(
602 sprintf(
603 ' %-5s %-10s %s',
604 strtoupper( (string) $name ),
605 'cls' === $name ? (string) round( (float) $value, 3 ) : (int) $value . 'ms',
606 Score::rate( (string) $name, (float) $value )
607 )
608 );
609 }
610 }
611 }
612