PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.3
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 / modules / Score / ScoreModule.php

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

426 lines 13.2 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\Score;
31 use XSpeed\Settings_Manager;
32
33 final class ScoreModule extends Module {
34
35 public const SLUG = 'score';
36 public const TIER = self::TIER_FREE;
37 public const VERSION = '1.0.0';
38
39 public function ui_metadata(): array {
40 return array(
41 'label' => 'External Score',
42 'icon' => 'Gauge',
43 'description' => 'Run a PageSpeed Insights or GTmetrix audit from the dashboard and keep the history next to your TTFB benchmark.',
44 'custom_panel' => 'ScorePanel',
45 );
46 }
47
48 public function settings_schema(): array {
49 return array(
50 'enabled' => array(
51 'type' => 'bool',
52 'default' => false,
53 'label' => 'Enable external scores',
54 // Off by default and stated plainly: this is the only part
55 // of the plugin that talks to a third party on your behalf.
56 'description' => 'Lets you run a PageSpeed Insights or GTmetrix audit from this dashboard. Nothing is sent anywhere until you press Test.',
57 ),
58 'provider' => array(
59 'type' => 'enum',
60 'default' => 'psi',
61 'options' => array( 'psi', 'gtmetrix' ),
62 'option_labels' => array(
63 'psi' => 'PageSpeed Insights',
64 'gtmetrix' => 'GTmetrix',
65 ),
66 'label' => 'Provider',
67 'description' => 'PageSpeed Insights works without an API key. GTmetrix requires one.',
68 'dependsOn' => array( 'field' => 'enabled' ),
69 ),
70 'psi_api_key' => array(
71 'type' => 'string',
72 'default' => '',
73 'label' => 'PageSpeed API key (optional)',
74 'description' => 'Only needed if you hit Google\'s anonymous rate limit. Free from cloud.google.com.',
75 'dependsOn' => array(
76 'field' => 'provider',
77 'value' => 'psi',
78 ),
79 ),
80 'gtmetrix_api_key' => array(
81 'type' => 'string',
82 'default' => '',
83 'label' => 'GTmetrix API key',
84 'description' => 'Required — GTmetrix has no anonymous mode. Found in your GTmetrix account settings.',
85 'dependsOn' => array(
86 'field' => 'provider',
87 'value' => 'gtmetrix',
88 ),
89 ),
90 'test_url' => array(
91 'type' => 'url',
92 'default' => '',
93 'label' => 'URL to test',
94 'description' => 'Leave empty to test your home page.',
95 'dependsOn' => array( 'field' => 'enabled' ),
96 ),
97 'default_strategy' => array(
98 'type' => 'enum',
99 'default' => 'mobile',
100 'options' => array( 'mobile', 'desktop' ),
101 'label' => 'Strategy',
102 'description' => 'PageSpeed Insights only. Mobile is what Google ranks on.',
103 'dependsOn' => array(
104 'field' => 'provider',
105 'value' => 'psi',
106 ),
107 ),
108 );
109 }
110
111 public function rest_routes(): array {
112 return array_merge(
113 parent::rest_routes(),
114 array(
115 array(
116 'path' => '/run',
117 'methods' => 'POST',
118 'callback' => array( $this, 'rest_run' ),
119 'feature' => self::SLUG,
120 ),
121 array(
122 'path' => '/status',
123 'methods' => 'GET',
124 'callback' => array( $this, 'rest_status' ),
125 ),
126 array(
127 'path' => '/history',
128 'methods' => 'GET',
129 'callback' => array( $this, 'rest_history' ),
130 ),
131 )
132 );
133 }
134
135 /**
136 * Start (or, for PSI, complete) an audit.
137 *
138 * POST, never GET: this spends someone else's rate limit and takes up
139 * to a minute. A GET would be prefetched by a browser.
140 */
141 public function rest_run( \WP_REST_Request $request ) {
142 $opts = Settings_Manager::get( self::SLUG );
143
144 if ( empty( $opts['enabled'] ) ) {
145 return new \WP_Error(
146 'xspeed_score_disabled',
147 __( 'External scores are turned off. Enable them first — this is the only feature that contacts a third party.', 'xspeed' ),
148 array( 'status' => 409 )
149 );
150 }
151
152 $url = $this->resolve_url( (string) $request->get_param( 'url' ), $opts );
153 if ( '' === $url ) {
154 return new \WP_Error(
155 'xspeed_score_no_url',
156 __( 'No URL to test.', 'xspeed' ),
157 array( 'status' => 400 )
158 );
159 }
160
161 $provider = (string) ( $request->get_param( 'provider' ) ?: $opts['provider'] );
162
163 if ( 'gtmetrix' === $provider ) {
164 $started = Score::start_gtmetrix( $url, (string) $opts['gtmetrix_api_key'] );
165 return is_wp_error( $started ) ? $started : rest_ensure_response( $started );
166 }
167
168 $strategy = (string) ( $request->get_param( 'strategy' ) ?: $opts['default_strategy'] );
169 return rest_ensure_response( Score::run_psi( $url, $strategy, (string) $opts['psi_api_key'] ) );
170 }
171
172 /**
173 * Poll an in-flight GTmetrix test.
174 *
175 * GET because it is a read of state we already started — the browser
176 * calls it every few seconds while a test is queued.
177 */
178 public function rest_status() {
179 $opts = Settings_Manager::get( self::SLUG );
180 $pending = get_option( Score::PENDING_OPTION, array() );
181
182 // Same opt-in gate as rest_run(). Without it, `status` — which is
183 // also the CLI's DEFAULT action — polled GTmetrix with the feature
184 // switched off and no API key, which falsified readme.txt's promise
185 // that nothing is sent while it is off.
186 if ( ! $this->may_poll( $opts, $pending ) ) {
187 return rest_ensure_response(
188 array(
189 'pending' => false,
190 'state' => 'idle',
191 'latest' => Score::latest(),
192 )
193 );
194 }
195
196 if ( ! is_array( $pending ) || empty( $pending['test_id'] ) ) {
197 return rest_ensure_response(
198 array(
199 'pending' => false,
200 'state' => 'idle',
201 'latest' => Score::latest(),
202 )
203 );
204 }
205
206 $polled = Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
207 if ( is_wp_error( $polled ) ) {
208 return $polled;
209 }
210
211 return rest_ensure_response(
212 array_merge(
213 $polled,
214 array( 'latest' => Score::latest() )
215 )
216 );
217 }
218
219 public function rest_history() {
220 return rest_ensure_response(
221 array(
222 'runs' => Score::history(),
223 'latest' => Score::latest(),
224 'thresholds' => Score::thresholds(),
225 )
226 );
227 }
228
229 /**
230 * May we contact GTmetrix to poll the in-flight test?
231 *
232 * Three conditions, all necessary: the feature is on, an API key exists
233 * (there is no anonymous GTmetrix), and the pending marker is real and
234 * not stale. A marker with no expiry turned one failed start into a
235 * permanent poll loop against a third party.
236 *
237 * @param array<string,mixed> $opts Module settings.
238 * @param mixed $pending The stored pending marker.
239 */
240 private function may_poll( array $opts, $pending ): bool {
241 if ( empty( $opts['enabled'] ) || '' === trim( (string) $opts['gtmetrix_api_key'] ) ) {
242 return false;
243 }
244 if ( ! is_array( $pending ) || empty( $pending['test_id'] ) ) {
245 return false;
246 }
247 // A GTmetrix test that hasn't resolved within the window is not going
248 // to; drop the marker rather than poll it forever.
249 $started = isset( $pending['started'] ) ? (int) $pending['started'] : 0;
250 if ( $started > 0 && ( time() - $started ) > Score::PENDING_MAX_AGE ) {
251 delete_option( Score::PENDING_OPTION );
252 return false;
253 }
254 return true;
255 }
256
257 /**
258 * Fall back to the home page when no URL is configured — testing "my
259 * site" is what almost everyone means.
260 *
261 * @param array<string,mixed> $opts Module settings.
262 */
263 private function resolve_url( string $requested, array $opts ): string {
264 foreach ( array( $requested, (string) ( $opts['test_url'] ?? '' ) ) as $candidate ) {
265 $candidate = trim( $candidate );
266 if ( '' !== $candidate ) {
267 return $candidate;
268 }
269 }
270 return function_exists( 'home_url' ) ? (string) home_url( '/' ) : '';
271 }
272
273 public function cli_commands(): array {
274 return array(
275 array(
276 'name' => 'xspeed score',
277 'callback' => array( $this, 'cli_handler' ),
278 '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.',
279 'synopsis' => array(
280 array(
281 'type' => 'positional',
282 'name' => 'action',
283 'options' => array( 'status', 'run', 'history' ),
284 'optional' => true,
285 ),
286 array(
287 'type' => 'assoc',
288 // NOT `--url`: that is a WP-CLI *global* parameter, so
289 // the value never reaches this handler and the flag is
290 // silently ignored.
291 'name' => 'target',
292 'description' => 'URL to audit. Defaults to the configured URL, then the home page.',
293 'optional' => true,
294 ),
295 array(
296 'type' => 'assoc',
297 'name' => 'strategy',
298 'description' => 'mobile (default) or desktop. PageSpeed Insights only.',
299 'optional' => true,
300 ),
301 array(
302 'type' => 'assoc',
303 'name' => 'provider',
304 'description' => 'psi (default) or gtmetrix.',
305 'optional' => true,
306 ),
307 ),
308 ),
309 );
310 }
311
312 public function cli_handler( array $args, array $assoc ): void {
313 $action = isset( $args[0] ) ? (string) $args[0] : 'status';
314 $opts = Settings_Manager::get( self::SLUG );
315
316 if ( 'history' === $action ) {
317 $runs = Score::history();
318 if ( empty( $runs ) ) {
319 \WP_CLI::log( 'No runs recorded yet.' );
320 return;
321 }
322 foreach ( $runs as $run ) {
323 \WP_CLI::log(
324 sprintf(
325 '%s %-9s %-8s %s',
326 gmdate( 'Y-m-d H:i', (int) $run['ts'] ),
327 (string) ( $run['provider'] ?? '' ),
328 empty( $run['ok'] ) ? 'FAILED' : ( null === ( $run['score'] ?? null ) ? 'no score' : $run['score'] . '/100' ),
329 empty( $run['ok'] ) ? (string) ( $run['error'] ?? '' ) : (string) ( $run['url'] ?? '' )
330 )
331 );
332 }
333 return;
334 }
335
336 if ( 'run' === $action ) {
337 if ( empty( $opts['enabled'] ) ) {
338 \WP_CLI::error( 'External scores are turned off. Enable the score module first — this is the only feature that contacts a third party.' );
339 return;
340 }
341
342 $url = $this->resolve_url( isset( $assoc['target'] ) ? (string) $assoc['target'] : '', $opts );
343 $provider = isset( $assoc['provider'] ) ? (string) $assoc['provider'] : (string) $opts['provider'];
344
345 if ( 'gtmetrix' === $provider ) {
346 $started = Score::start_gtmetrix( $url, (string) $opts['gtmetrix_api_key'] );
347 if ( is_wp_error( $started ) ) {
348 \WP_CLI::error( $started->get_error_message() );
349 return;
350 }
351 \WP_CLI::success( sprintf( 'GTmetrix test queued (id %s). Poll with: wp xspeed score status', (string) ( $started['test_id'] ?? '?' ) ) );
352 return;
353 }
354
355 $strategy = isset( $assoc['strategy'] ) ? (string) $assoc['strategy'] : (string) $opts['default_strategy'];
356 $run = Score::run_psi( $url, $strategy, (string) $opts['psi_api_key'] );
357
358 if ( empty( $run['ok'] ) ) {
359 \WP_CLI::error( (string) $run['error'] );
360 return;
361 }
362 \WP_CLI::success(
363 sprintf(
364 '%s (%s): %s',
365 $url,
366 $strategy,
367 null === $run['score'] ? 'no score returned' : $run['score'] . '/100'
368 )
369 );
370 $this->print_metrics( is_array( $run['metrics'] ) ? $run['metrics'] : array() );
371 return;
372 }
373
374 // status
375 $pending = get_option( Score::PENDING_OPTION, array() );
376 if ( $this->may_poll( $opts, $pending ) ) {
377 $polled = Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
378 if ( is_wp_error( $polled ) ) {
379 \WP_CLI::error( $polled->get_error_message() );
380 return;
381 }
382 if ( ! empty( $polled['pending'] ) ) {
383 \WP_CLI::log( sprintf( 'GTmetrix test %s is %s.', (string) $pending['test_id'], (string) ( $polled['state'] ?? 'running' ) ) );
384 return;
385 }
386 }
387
388 \WP_CLI::log( 'enabled ' . ( empty( $opts['enabled'] ) ? 'no' : 'yes' ) );
389 \WP_CLI::log( 'provider ' . (string) $opts['provider'] );
390
391 $latest = Score::latest();
392 if ( null === $latest ) {
393 \WP_CLI::log( 'No successful run yet. Run one with: wp xspeed score run' );
394 return;
395 }
396 \WP_CLI::log(
397 sprintf(
398 'latest %s — %s (%s)',
399 null === $latest['score'] ? 'no score' : $latest['score'] . '/100',
400 gmdate( 'Y-m-d H:i', (int) $latest['ts'] ),
401 (string) $latest['provider']
402 )
403 );
404 $this->print_metrics( is_array( $latest['metrics'] ) ? $latest['metrics'] : array() );
405 }
406
407 /**
408 * @param array<string,mixed> $metrics Metric name → value.
409 */
410 private function print_metrics( array $metrics ): void {
411 foreach ( $metrics as $name => $value ) {
412 if ( null === $value ) {
413 continue;
414 }
415 \WP_CLI::log(
416 sprintf(
417 ' %-5s %-10s %s',
418 strtoupper( (string) $name ),
419 'cls' === $name ? (string) round( (float) $value, 3 ) : (int) $value . 'ms',
420 Score::rate( (string) $name, (float) $value )
421 )
422 );
423 }
424 }
425 }
426