PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/api/class-performance-endpoint.php +240 -64 1.0.02.7.0 View file →
@@ -13,8 +13,9 @@
13 13 namespace ThinkRank\API;
14 14
15 15 use ThinkRank\SEO\Performance_Monitoring_Manager;
16 16 use ThinkRank\SEO\Performance_Data_Collector;
17 +use ThinkRank\SEO\Analytics_Manager;
17 18 use ThinkRank\API\Traits\API_Cache;
18 19 use WP_REST_Controller;
19 20 use WP_REST_Server;
20 21 use WP_REST_Request;
@@ -20,8 +21,13 @@
20 21 use WP_REST_Request;
21 22 use WP_REST_Response;
22 23 use WP_Error;
23 24
25 +// Prevent direct access
26 +if (!defined('ABSPATH')) {
27 + exit;
28 +}
29 +
24 30 // Load API Cache trait
25 31 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-api-cache.php';
26 32
27 33 /**
@@ -52,29 +58,30 @@
52 58 /**
53 59 * Performance Monitoring Manager instance
54 60 *
55 61 * @since 1.0.0
56 - * @var Performance_Monitoring_Manager
62 + * @var Performance_Monitoring_Manager|null
57 63 */
58 - private Performance_Monitoring_Manager $performance_manager;
64 + private ?Performance_Monitoring_Manager $performance_manager = null;
59 65
60 66 /**
61 - * Performance Data Collector instance
67 + * Performance Data Collector instance (lazy)
62 68 *
63 69 * @since 1.0.0
64 - * @var Performance_Data_Collector
70 + * @var Performance_Data_Collector|null
65 71 */
66 - private Performance_Data_Collector $data_collector;
72 + private ?Performance_Data_Collector $data_collector = null;
67 73
68 74 /**
69 75 * Constructor
70 76 *
77 + * Endpoint objects are constructed on every REST request (any namespace),
78 + * so the manager chain is built lazily — only when one of this endpoint's
79 + * routes actually executes.
80 + *
71 81 * @since 1.0.0
72 82 */
73 83 public function __construct() {
74 - $this->performance_manager = new Performance_Monitoring_Manager();
75 - $this->data_collector = new Performance_Data_Collector();
76 -
77 84 // Configure caching for performance endpoints
78 85 $this->set_cache_prefix('thinkrank_performance_');
79 86 $this->set_cache_duration(300); // 5 minutes for performance data
80 87 }
@@ -79,8 +86,32 @@
79 86 $this->set_cache_duration(300); // 5 minutes for performance data
80 87 }
81 88
82 89 /**
90 + * Get the Performance Monitoring Manager (lazy)
91 + *
92 + * @return Performance_Monitoring_Manager
93 + */
94 + private function get_performance_manager(): Performance_Monitoring_Manager {
95 + if ($this->performance_manager === null) {
96 + $this->performance_manager = new Performance_Monitoring_Manager();
97 + }
98 + return $this->performance_manager;
99 + }
100 +
101 + /**
102 + * Get the Performance Data Collector (lazy)
103 + *
104 + * @return Performance_Data_Collector
105 + */
106 + private function get_data_collector(): Performance_Data_Collector {
107 + if ($this->data_collector === null) {
108 + $this->data_collector = new Performance_Data_Collector();
109 + }
110 + return $this->data_collector;
111 + }
112 +
113 + /**
83 114 * Register REST API routes
84 115 *
85 116 * @since 1.0.0
86 117 */
@@ -92,9 +123,17 @@
92 123 [
93 124 [
94 125 'methods' => WP_REST_Server::READABLE,
95 126 'callback' => [$this, 'get_performance_data'],
96 - 'permission_callback' => [$this, 'check_read_permissions']
127 + 'permission_callback' => [$this, 'check_read_permissions'],
128 + 'args' => [
129 + 'device_type' => [
130 + 'default' => 'mobile',
131 + 'type' => 'string',
132 + 'enum' => ['mobile', 'desktop'],
133 + 'sanitize_callback' => 'sanitize_key'
134 + ]
135 + ]
97 136 ]
98 137 ]
99 138 );
100 139
@@ -134,9 +173,17 @@
134 173 [
135 174 [
136 175 'methods' => WP_REST_Server::READABLE,
137 176 'callback' => [$this, 'get_opportunities'],
138 - 'permission_callback' => [$this, 'check_read_permissions']
177 + 'permission_callback' => [$this, 'check_read_permissions'],
178 + 'args' => [
179 + 'device_type' => [
180 + 'default' => 'mobile',
181 + 'type' => 'string',
182 + 'enum' => ['mobile', 'desktop'],
183 + 'sanitize_callback' => 'sanitize_key'
184 + ]
185 + ]
139 186 ]
140 187 ]
141 188 );
142 189
@@ -147,9 +194,17 @@
147 194 [
148 195 [
149 196 'methods' => WP_REST_Server::READABLE,
150 197 'callback' => [$this, 'get_diagnostics'],
151 - 'permission_callback' => [$this, 'check_read_permissions']
198 + 'permission_callback' => [$this, 'check_read_permissions'],
199 + 'args' => [
200 + 'device_type' => [
201 + 'default' => 'mobile',
202 + 'type' => 'string',
203 + 'enum' => ['mobile', 'desktop'],
204 + 'sanitize_callback' => 'sanitize_key'
205 + ]
206 + ]
152 207 ]
153 208 ]
154 209 );
155 210
@@ -178,43 +233,44 @@
178 233 * @return WP_REST_Response|WP_Error Response object or error
179 234 */
180 235 public function get_performance_data(WP_REST_Request $request) {
181 236 try {
182 - // Use cached response wrapper for performance
183 - $response_data = $this->cached_response(
184 - 'performance_data',
185 - function() {
186 - // Get Core Web Vitals
187 - $core_web_vitals = $this->performance_manager->get_core_web_vitals();
237 + // Get device type from request
238 + $device_type = $request->get_param('device_type') ?? 'mobile';
188 239
189 - // Get performance score
190 - $performance_score = $this->performance_manager->get_performance_score();
240 + $cache_params = ['device_type' => $device_type];
241 + // Core Web Vitals are site-wide, not user-specific — a per-user
242 + // cache key would duplicate the entry (and the cold-path work)
243 + // for every admin user.
244 + $user_id = null;
245 + $cache_enabled = $this->is_caching_enabled();
191 246
192 - // Get performance grade
193 - $performance_grade = $this->performance_manager->get_performance_grade($performance_score);
247 + // Serve a fresh cached response when available.
248 + if ($cache_enabled) {
249 + $cached = $this->get_cached_response('performance_data', $cache_params, $user_id);
250 + if ($cached !== null) {
251 + return new WP_REST_Response(
252 + array_merge($cached['data'], [
253 + 'cached' => true,
254 + 'cached_at' => $cached['cached_at'],
255 + ]),
256 + 200
257 + );
258 + }
259 + }
194 260
195 - // Get SEO performance correlation
196 - $seo_correlation = $this->performance_manager->get_seo_performance_correlation();
261 + // Serve from the existing cache / collected DB data first; only the
262 + // background collector performs a live PageSpeed audit. A cold cache
263 + // no longer blocks the request on a 10-40s inline Lighthouse run.
264 + $response_data = $this->get_performance_manager()->get_performance_snapshot($device_type);
197 265
198 - return [
199 - 'success' => true,
200 - 'data' => [
201 - 'core_web_vitals' => $core_web_vitals,
202 - 'performance_score' => $performance_score,
203 - 'performance_grade' => $performance_grade,
204 - 'seo_correlation' => $seo_correlation,
205 - 'last_updated' => current_time('mysql'),
206 - 'status' => 'success'
207 - ],
208 - 'message' => __('Performance data retrieved successfully', 'thinkrank')
209 - ];
210 - },
211 - [], // No specific parameters for cache key
212 - null, // Use default cache duration
213 - get_current_user_id()
214 - );
266 + // Don't pin a transient "collecting" state in the response cache — the
267 + // background collection must be re-checked on the next request.
268 + if ($cache_enabled && ($response_data['data']['status'] ?? '') !== 'collecting') {
269 + $this->set_cached_response('performance_data', $response_data, $cache_params, null, $user_id);
270 + }
215 271
216 - return new WP_REST_Response($response_data, 200);
272 + return new WP_REST_Response(array_merge($response_data, ['cached' => false]), 200);
217 273
218 274 } catch (\Exception $e) {
219 275 return new WP_Error(
220 276 'performance_data_failed',
@@ -237,9 +293,9 @@
237 293 * @return WP_REST_Response|WP_Error Response object or error
238 294 */
239 295 public function get_recommendations(WP_REST_Request $request) {
240 296 try {
241 - $recommendations = $this->performance_manager->get_performance_recommendations();
297 + $recommendations = $this->get_performance_manager()->get_performance_recommendations();
242 298
243 299 return new WP_REST_Response([
244 300 'success' => true,
245 301 'data' => $recommendations,
@@ -267,9 +323,9 @@
267 323 try {
268 324 $days = $request->get_param('days') ?? 30;
269 325 $metric = $request->get_param('metric') ?? 'all';
270 326
271 - $historical_data = $this->performance_manager->get_historical_data($days, $metric);
327 + $historical_data = $this->get_performance_manager()->get_historical_data($days, $metric);
272 328
273 329 return new WP_REST_Response([
274 330 'success' => true,
275 331 'data' => $historical_data,
@@ -294,16 +350,21 @@
294 350 * @return WP_REST_Response|WP_Error Response object or error
295 351 */
296 352 public function get_opportunities(WP_REST_Request $request) {
297 353 try {
298 - $opportunities = $this->performance_manager->get_performance_opportunities();
354 + // Get device type from request
355 + $device_type = $request->get_param('device_type') ?? 'mobile';
299 356
300 - return new WP_REST_Response([
301 - 'success' => true,
302 - 'data' => $opportunities,
303 - 'message' => __('Performance opportunities retrieved successfully', 'thinkrank')
304 - ], 200);
357 + $manager = $this->get_performance_manager();
358 + $opportunities = $manager->get_performance_opportunities('', $device_type);
305 359
360 + return $this->pagespeed_list_response(
361 + $manager,
362 + $opportunities,
363 + $device_type,
364 + __('Performance opportunities retrieved successfully', 'thinkrank')
365 + );
366 +
306 367 } catch (\Exception $e) {
307 368 return new WP_Error(
308 369 'opportunities_failed',
309 370 'Failed to retrieve performance opportunities: ' . $e->getMessage(),
@@ -321,16 +382,21 @@
321 382 * @return WP_REST_Response|WP_Error Response object or error
322 383 */
323 384 public function get_diagnostics(WP_REST_Request $request) {
324 385 try {
325 - $diagnostics = $this->performance_manager->get_performance_diagnostics();
386 + // Get device type from request
387 + $device_type = $request->get_param('device_type') ?? 'mobile';
326 388
327 - return new WP_REST_Response([
328 - 'success' => true,
329 - 'data' => $diagnostics,
330 - 'message' => __('Performance diagnostics retrieved successfully', 'thinkrank')
331 - ], 200);
389 + $manager = $this->get_performance_manager();
390 + $diagnostics = $manager->get_performance_diagnostics('', $device_type);
332 391
392 + return $this->pagespeed_list_response(
393 + $manager,
394 + $diagnostics,
395 + $device_type,
396 + __('Performance diagnostics retrieved successfully', 'thinkrank')
397 + );
398 +
333 399 } catch (\Exception $e) {
334 400 return new WP_Error(
335 401 'diagnostics_failed',
336 402 'Failed to retrieve performance diagnostics: ' . $e->getMessage(),
@@ -348,16 +414,32 @@
348 414 * @return WP_REST_Response|WP_Error Response object or error
349 415 */
350 416 public function collect_performance_data(WP_REST_Request $request) {
351 417 try {
352 - $results = $this->data_collector->manual_collect();
418 + $results = $this->get_data_collector()->manual_collect();
353 419
354 - return new WP_REST_Response([
355 - 'success' => $results['success'],
356 - 'data' => $results,
357 - 'message' => $results['message']
358 - ], $results['success'] ? 200 : 500);
420 + if (!empty($results['success'])) {
421 + return new WP_REST_Response([
422 + 'success' => true,
423 + 'data' => $results,
424 + 'message' => $results['message']
425 + ], 200);
426 + }
359 427
428 + // A failure here is almost never a server fault: the site is not
429 + // connected, Google cannot reach the URL, or the quota is spent. This
430 + // used to answer 500 for all of them, with a hardcoded message that
431 + // dropped the real reason, so the user could neither tell what was
432 + // wrong nor that it was their configuration rather than a bug.
433 + // Return a WP_Error like every other failure in this file, so clients
434 + // get the normal code/message envelope instead of a 200-shaped body
435 + // carrying a 500.
436 + return new WP_Error(
437 + $this->collection_error_code((string) ($results['error_code'] ?? '')),
438 + $results['message'],
439 + $this->collection_error_data((string) ($results['error_code'] ?? ''))
440 + );
441 +
360 442 } catch (\Exception $e) {
361 443 return new WP_Error(
362 444 'data_collection_failed',
363 445 'Failed to collect performance data: ' . $e->getMessage(),
@@ -365,11 +447,65 @@
365 447 );
366 448 }
367 449 }
368 450
451 + /**
452 + * REST error code for a collection failure class.
453 + *
454 + * @since 1.31.0
455 + * @param string $error_code One of Performance_Data_Collector::ERROR_*.
456 + * @return string
457 + */
458 + private function collection_error_code(string $error_code): string {
459 + $codes = [
460 + Performance_Data_Collector::ERROR_NOT_CONFIGURED => 'pagespeed_not_configured',
461 + Performance_Data_Collector::ERROR_URL_UNREACHABLE => 'site_not_reachable',
462 + Performance_Data_Collector::ERROR_RATE_LIMITED => 'pagespeed_rate_limited',
463 + Performance_Data_Collector::ERROR_RECENT_FAILURE => 'pagespeed_recently_failed',
464 + Performance_Data_Collector::ERROR_STORAGE_FAILED => 'performance_storage_failed',
465 + ];
369 466
467 + return $codes[$error_code] ?? 'data_collection_failed';
468 + }
370 469
371 470 /**
471 + * HTTP status (and Retry-After, where it applies) for a failure class.
472 + *
473 + * @since 1.31.0
474 + * @param string $error_code One of Performance_Data_Collector::ERROR_*.
475 + * @return array Error data for WP_Error.
476 + */
477 + private function collection_error_data(string $error_code): array {
478 + switch ($error_code) {
479 + case Performance_Data_Collector::ERROR_NOT_CONFIGURED:
480 + // Client-side condition: no credential to call PageSpeed with.
481 + return ['status' => 400];
482 +
483 + case Performance_Data_Collector::ERROR_URL_UNREACHABLE:
484 + // The request was well-formed and authorised; the site simply
485 + // cannot be fetched by Google.
486 + return ['status' => 422];
487 +
488 + case Performance_Data_Collector::ERROR_RATE_LIMITED:
489 + return ['status' => 429];
490 +
491 + case Performance_Data_Collector::ERROR_RECENT_FAILURE:
492 + // Nothing was attempted — a recent failure is still remembered.
493 + return ['status' => 503, 'retry_after' => 300];
494 +
495 + case Performance_Data_Collector::ERROR_STORAGE_FAILED:
496 + // Measured fine but the write failed: genuinely our side.
497 + return ['status' => 500];
498 +
499 + default:
500 + // An upstream API error we could not classify.
501 + return ['status' => 502];
502 + }
503 + }
504 +
505 +
506 +
507 + /**
372 508 * Check read permissions
373 509 *
374 510 * @since 1.0.0
375 511 *
@@ -375,9 +511,11 @@
375 511 *
376 512 * @return bool True if user can read
377 513 */
378 514 public function check_read_permissions(): bool {
379 - return current_user_can('read');
515 + // Performance data + settings are not subscriber-visible — require the
516 + // same Performance management capability as the write routes.
517 + return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_performance');
380 518 }
381 519
382 520 /**
383 521 * Check manage permissions
@@ -386,9 +524,9 @@
386 524 *
387 525 * @return bool True if user can manage options
388 526 */
389 527 public function check_manage_permissions(): bool {
390 - return current_user_can('manage_options');
528 + return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_performance');
391 529 }
392 530
393 531 /**
394 532 * Get arguments for historical data endpoint
@@ -396,8 +534,46 @@
396 534 * @since 1.0.0
397 535 *
398 536 * @return array Arguments array
399 537 */
538 + /**
539 + * Wrap a PageSpeed-backed list, reporting whether it was actually fetched.
540 + *
541 + * An empty list used to come back as `success: true` /
542 + * "retrieved successfully" whether the site was clean, the request had
543 + * failed, or nothing had been attempted for want of a credential — so no
544 + * API or MCP consumer could tell the three apart, and the admin UI told
545 + * everyone to connect Google (#519). The list itself keeps its shape.
546 + *
547 + * @since 2.1.1
548 + *
549 + * @param Performance_Monitoring_Manager $manager Manager that produced the list.
550 + * @param array $data The list.
551 + * @param string $device_type Device the list is for.
552 + * @param string $success_message Message for a completed request.
553 + * @return WP_REST_Response
554 + */
555 + private function pagespeed_list_response(Performance_Monitoring_Manager $manager, array $data, string $device_type, string $success_message): WP_REST_Response {
556 + $error = $manager->get_last_error();
557 +
558 + if ('' !== $error['code']) {
559 + return new WP_REST_Response([
560 + 'success' => false,
561 + 'data' => $data,
562 + 'device_type' => $device_type,
563 + 'error_code' => $error['code'],
564 + 'message' => $error['message'],
565 + ], 200);
566 + }
567 +
568 + return new WP_REST_Response([
569 + 'success' => true,
570 + 'data' => $data,
571 + 'device_type' => $device_type,
572 + 'message' => $success_message,
573 + ], 200);
574 + }
575 +
400 576 private function get_historical_data_args(): array {
401 577 return [
402 578 'days' => [
403 579 'required' => false,
@@ -410,9 +586,9 @@
410 586 'metric' => [
411 587 'required' => false,
412 588 'type' => 'string',
413 589 'default' => 'all',
414 - 'enum' => ['all', 'lcp', 'fid', 'cls', 'inp', 'score'],
590 + 'enum' => ['all', 'lcp', 'cls', 'inp', 'score'],
415 591 'description' => 'Specific metric to retrieve'
416 592 ]
417 593 ];
418 594 }