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 +205 -65 1.1.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 */
@@ -205,45 +236,42 @@
205 236 try {
206 237 // Get device type from request
207 238 $device_type = $request->get_param('device_type') ?? 'mobile';
208 239
209 - // Use cached response wrapper for performance - include device type in cache key
210 - $response_data = $this->cached_response(
211 - 'performance_data',
212 - function() use ($device_type) {
213 - // Get Core Web Vitals with device type
214 - $core_web_vitals = $this->performance_manager->get_core_web_vitals('', false, $device_type);
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();
215 246
216 - // Get performance score based on the device-specific Core Web Vitals
217 - $performance_score = $this->performance_manager->get_performance_score($core_web_vitals);
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 + }
218 260
219 - // Get performance grade based on the device-specific performance score
220 - $performance_grade = $this->performance_manager->get_performance_grade($performance_score);
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);
221 265
222 - // Get SEO performance correlation based on device-specific data
223 - $seo_correlation = $this->performance_manager->get_seo_performance_correlation($core_web_vitals, $performance_score);
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 + }
224 271
225 - return [
226 - 'success' => true,
227 - 'data' => [
228 - 'core_web_vitals' => $core_web_vitals,
229 - 'performance_score' => $performance_score,
230 - 'performance_grade' => $performance_grade,
231 - 'seo_correlation' => $seo_correlation,
232 - 'device_type' => $device_type,
233 - 'last_updated' => current_time('mysql'),
234 - 'status' => 'success'
235 - ],
236 - 'message' => __('Performance data retrieved successfully', 'thinkrank')
237 - ];
238 - },
239 - ['device_type' => $device_type], // Include device type in cache key
240 - null, // Use default cache duration
241 - get_current_user_id()
242 - );
272 + return new WP_REST_Response(array_merge($response_data, ['cached' => false]), 200);
243 273
244 - return new WP_REST_Response($response_data, 200);
245 -
246 274 } catch (\Exception $e) {
247 275 return new WP_Error(
248 276 'performance_data_failed',
249 277 'Failed to retrieve performance data: ' . $e->getMessage(),
@@ -265,9 +293,9 @@
265 293 * @return WP_REST_Response|WP_Error Response object or error
266 294 */
267 295 public function get_recommendations(WP_REST_Request $request) {
268 296 try {
269 - $recommendations = $this->performance_manager->get_performance_recommendations();
297 + $recommendations = $this->get_performance_manager()->get_performance_recommendations();
270 298
271 299 return new WP_REST_Response([
272 300 'success' => true,
273 301 'data' => $recommendations,
@@ -295,9 +323,9 @@
295 323 try {
296 324 $days = $request->get_param('days') ?? 30;
297 325 $metric = $request->get_param('metric') ?? 'all';
298 326
299 - $historical_data = $this->performance_manager->get_historical_data($days, $metric);
327 + $historical_data = $this->get_performance_manager()->get_historical_data($days, $metric);
300 328
301 329 return new WP_REST_Response([
302 330 'success' => true,
303 331 'data' => $historical_data,
@@ -325,16 +353,17 @@
325 353 try {
326 354 // Get device type from request
327 355 $device_type = $request->get_param('device_type') ?? 'mobile';
328 356
329 - $opportunities = $this->performance_manager->get_performance_opportunities('', $device_type);
357 + $manager = $this->get_performance_manager();
358 + $opportunities = $manager->get_performance_opportunities('', $device_type);
330 359
331 - return new WP_REST_Response([
332 - 'success' => true,
333 - 'data' => $opportunities,
334 - 'device_type' => $device_type,
335 - 'message' => __('Performance opportunities retrieved successfully', 'thinkrank')
336 - ], 200);
360 + return $this->pagespeed_list_response(
361 + $manager,
362 + $opportunities,
363 + $device_type,
364 + __('Performance opportunities retrieved successfully', 'thinkrank')
365 + );
337 366
338 367 } catch (\Exception $e) {
339 368 return new WP_Error(
340 369 'opportunities_failed',
@@ -356,16 +385,17 @@
356 385 try {
357 386 // Get device type from request
358 387 $device_type = $request->get_param('device_type') ?? 'mobile';
359 388
360 - $diagnostics = $this->performance_manager->get_performance_diagnostics('', $device_type);
389 + $manager = $this->get_performance_manager();
390 + $diagnostics = $manager->get_performance_diagnostics('', $device_type);
361 391
362 - return new WP_REST_Response([
363 - 'success' => true,
364 - 'data' => $diagnostics,
365 - 'device_type' => $device_type,
366 - 'message' => __('Performance diagnostics retrieved successfully', 'thinkrank')
367 - ], 200);
392 + return $this->pagespeed_list_response(
393 + $manager,
394 + $diagnostics,
395 + $device_type,
396 + __('Performance diagnostics retrieved successfully', 'thinkrank')
397 + );
368 398
369 399 } catch (\Exception $e) {
370 400 return new WP_Error(
371 401 'diagnostics_failed',
@@ -384,16 +414,32 @@
384 414 * @return WP_REST_Response|WP_Error Response object or error
385 415 */
386 416 public function collect_performance_data(WP_REST_Request $request) {
387 417 try {
388 - $results = $this->data_collector->manual_collect();
418 + $results = $this->get_data_collector()->manual_collect();
389 419
390 - return new WP_REST_Response([
391 - 'success' => $results['success'],
392 - 'data' => $results,
393 - 'message' => $results['message']
394 - ], $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 + }
395 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 +
396 442 } catch (\Exception $e) {
397 443 return new WP_Error(
398 444 'data_collection_failed',
399 445 'Failed to collect performance data: ' . $e->getMessage(),
@@ -401,11 +447,65 @@
401 447 );
402 448 }
403 449 }
404 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 + ];
405 466
467 + return $codes[$error_code] ?? 'data_collection_failed';
468 + }
406 469
407 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 + /**
408 508 * Check read permissions
409 509 *
410 510 * @since 1.0.0
411 511 *
@@ -411,9 +511,11 @@
411 511 *
412 512 * @return bool True if user can read
413 513 */
414 514 public function check_read_permissions(): bool {
415 - 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');
416 518 }
417 519
418 520 /**
419 521 * Check manage permissions
@@ -422,9 +524,9 @@
422 524 *
423 525 * @return bool True if user can manage options
424 526 */
425 527 public function check_manage_permissions(): bool {
426 - return current_user_can('manage_options');
528 + return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_performance');
427 529 }
428 530
429 531 /**
430 532 * Get arguments for historical data endpoint
@@ -432,8 +534,46 @@
432 534 * @since 1.0.0
433 535 *
434 536 * @return array Arguments array
435 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 +
436 576 private function get_historical_data_args(): array {
437 577 return [
438 578 'days' => [
439 579 'required' => false,
@@ -446,9 +586,9 @@
446 586 'metric' => [
447 587 'required' => false,
448 588 'type' => 'string',
449 589 'default' => 'all',
450 - 'enum' => ['all', 'lcp', 'fid', 'cls', 'inp', 'score'],
590 + 'enum' => ['all', 'lcp', 'cls', 'inp', 'score'],
451 591 'description' => 'Specific metric to retrieve'
452 592 ]
453 593 ];
454 594 }