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 +204 -74 1.10.02.7.0 View file →
@@ -21,8 +21,13 @@
21 21 use WP_REST_Request;
22 22 use WP_REST_Response;
23 23 use WP_Error;
24 24
25 +// Prevent direct access
26 +if (!defined('ABSPATH')) {
27 + exit;
28 +}
29 +
25 30 // Load API Cache trait
26 31 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-api-cache.php';
27 32
28 33 /**
@@ -53,29 +58,30 @@
53 58 /**
54 59 * Performance Monitoring Manager instance
55 60 *
56 61 * @since 1.0.0
57 - * @var Performance_Monitoring_Manager
62 + * @var Performance_Monitoring_Manager|null
58 63 */
59 - private Performance_Monitoring_Manager $performance_manager;
64 + private ?Performance_Monitoring_Manager $performance_manager = null;
60 65
61 66 /**
62 - * Performance Data Collector instance
67 + * Performance Data Collector instance (lazy)
63 68 *
64 69 * @since 1.0.0
65 - * @var Performance_Data_Collector
70 + * @var Performance_Data_Collector|null
66 71 */
67 - private Performance_Data_Collector $data_collector;
72 + private ?Performance_Data_Collector $data_collector = null;
68 73
69 74 /**
70 75 * Constructor
71 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 + *
72 81 * @since 1.0.0
73 82 */
74 83 public function __construct() {
75 - $this->performance_manager = new Performance_Monitoring_Manager();
76 - $this->data_collector = new Performance_Data_Collector();
77 -
78 84 // Configure caching for performance endpoints
79 85 $this->set_cache_prefix('thinkrank_performance_');
80 86 $this->set_cache_duration(300); // 5 minutes for performance data
81 87 }
@@ -80,8 +86,32 @@
80 86 $this->set_cache_duration(300); // 5 minutes for performance data
81 87 }
82 88
83 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 + /**
84 114 * Register REST API routes
85 115 *
86 116 * @since 1.0.0
87 117 */
@@ -206,48 +236,42 @@
206 236 try {
207 237 // Get device type from request
208 238 $device_type = $request->get_param('device_type') ?? 'mobile';
209 239
210 - // Ensure Google OAuth token is fresh before making PageSpeed API calls
211 - Analytics_Manager::ensure_fresh_token();
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();
212 246
213 - // Use cached response wrapper for performance - include device type in cache key
214 - $response_data = $this->cached_response(
215 - 'performance_data',
216 - function() use ($device_type) {
217 - // Get Core Web Vitals with device type
218 - $core_web_vitals = $this->performance_manager->get_core_web_vitals('', false, $device_type);
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 + }
219 260
220 - // Get performance score based on the device-specific Core Web Vitals
221 - $performance_score = $this->performance_manager->get_performance_score($core_web_vitals);
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);
222 265
223 - // Get performance grade based on the device-specific performance score
224 - $performance_grade = $this->performance_manager->get_performance_grade($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 + }
225 271
226 - // Get SEO performance correlation based on device-specific data
227 - $seo_correlation = $this->performance_manager->get_seo_performance_correlation($core_web_vitals, $performance_score);
272 + return new WP_REST_Response(array_merge($response_data, ['cached' => false]), 200);
228 273
229 - return [
230 - 'success' => true,
231 - 'data' => [
232 - 'core_web_vitals' => $core_web_vitals,
233 - 'performance_score' => $performance_score,
234 - 'performance_grade' => $performance_grade,
235 - 'seo_correlation' => $seo_correlation,
236 - 'device_type' => $device_type,
237 - 'last_updated' => current_time('mysql'),
238 - 'status' => 'success'
239 - ],
240 - 'message' => __('Performance data retrieved successfully', 'thinkrank')
241 - ];
242 - },
243 - ['device_type' => $device_type], // Include device type in cache key
244 - null, // Use default cache duration
245 - get_current_user_id()
246 - );
247 -
248 - return new WP_REST_Response($response_data, 200);
249 -
250 274 } catch (\Exception $e) {
251 275 return new WP_Error(
252 276 'performance_data_failed',
253 277 'Failed to retrieve performance data: ' . $e->getMessage(),
@@ -269,9 +293,9 @@
269 293 * @return WP_REST_Response|WP_Error Response object or error
270 294 */
271 295 public function get_recommendations(WP_REST_Request $request) {
272 296 try {
273 - $recommendations = $this->performance_manager->get_performance_recommendations();
297 + $recommendations = $this->get_performance_manager()->get_performance_recommendations();
274 298
275 299 return new WP_REST_Response([
276 300 'success' => true,
277 301 'data' => $recommendations,
@@ -299,9 +323,9 @@
299 323 try {
300 324 $days = $request->get_param('days') ?? 30;
301 325 $metric = $request->get_param('metric') ?? 'all';
302 326
303 - $historical_data = $this->performance_manager->get_historical_data($days, $metric);
327 + $historical_data = $this->get_performance_manager()->get_historical_data($days, $metric);
304 328
305 329 return new WP_REST_Response([
306 330 'success' => true,
307 331 'data' => $historical_data,
@@ -329,20 +353,18 @@
329 353 try {
330 354 // Get device type from request
331 355 $device_type = $request->get_param('device_type') ?? 'mobile';
332 356
333 - // Ensure Google OAuth token is fresh before making PageSpeed API calls
334 - Analytics_Manager::ensure_fresh_token();
357 + $manager = $this->get_performance_manager();
358 + $opportunities = $manager->get_performance_opportunities('', $device_type);
335 359
336 - $opportunities = $this->performance_manager->get_performance_opportunities('', $device_type);
360 + return $this->pagespeed_list_response(
361 + $manager,
362 + $opportunities,
363 + $device_type,
364 + __('Performance opportunities retrieved successfully', 'thinkrank')
365 + );
337 366
338 - return new WP_REST_Response([
339 - 'success' => true,
340 - 'data' => $opportunities,
341 - 'device_type' => $device_type,
342 - 'message' => __('Performance opportunities retrieved successfully', 'thinkrank')
343 - ], 200);
344 -
345 367 } catch (\Exception $e) {
346 368 return new WP_Error(
347 369 'opportunities_failed',
348 370 'Failed to retrieve performance opportunities: ' . $e->getMessage(),
@@ -363,20 +385,18 @@
363 385 try {
364 386 // Get device type from request
365 387 $device_type = $request->get_param('device_type') ?? 'mobile';
366 388
367 - // Ensure Google OAuth token is fresh before making PageSpeed API calls
368 - Analytics_Manager::ensure_fresh_token();
389 + $manager = $this->get_performance_manager();
390 + $diagnostics = $manager->get_performance_diagnostics('', $device_type);
369 391
370 - $diagnostics = $this->performance_manager->get_performance_diagnostics('', $device_type);
392 + return $this->pagespeed_list_response(
393 + $manager,
394 + $diagnostics,
395 + $device_type,
396 + __('Performance diagnostics retrieved successfully', 'thinkrank')
397 + );
371 398
372 - return new WP_REST_Response([
373 - 'success' => true,
374 - 'data' => $diagnostics,
375 - 'device_type' => $device_type,
376 - 'message' => __('Performance diagnostics retrieved successfully', 'thinkrank')
377 - ], 200);
378 -
379 399 } catch (\Exception $e) {
380 400 return new WP_Error(
381 401 'diagnostics_failed',
382 402 'Failed to retrieve performance diagnostics: ' . $e->getMessage(),
@@ -394,16 +414,32 @@
394 414 * @return WP_REST_Response|WP_Error Response object or error
395 415 */
396 416 public function collect_performance_data(WP_REST_Request $request) {
397 417 try {
398 - $results = $this->data_collector->manual_collect();
418 + $results = $this->get_data_collector()->manual_collect();
399 419
400 - return new WP_REST_Response([
401 - 'success' => $results['success'],
402 - 'data' => $results,
403 - 'message' => $results['message']
404 - ], $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 + }
405 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 +
406 442 } catch (\Exception $e) {
407 443 return new WP_Error(
408 444 'data_collection_failed',
409 445 'Failed to collect performance data: ' . $e->getMessage(),
@@ -411,11 +447,65 @@
411 447 );
412 448 }
413 449 }
414 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 + ];
415 466
467 + return $codes[$error_code] ?? 'data_collection_failed';
468 + }
416 469
417 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 + /**
418 508 * Check read permissions
419 509 *
420 510 * @since 1.0.0
421 511 *
@@ -421,9 +511,11 @@
421 511 *
422 512 * @return bool True if user can read
423 513 */
424 514 public function check_read_permissions(): bool {
425 - 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');
426 518 }
427 519
428 520 /**
429 521 * Check manage permissions
@@ -432,9 +524,9 @@
432 524 *
433 525 * @return bool True if user can manage options
434 526 */
435 527 public function check_manage_permissions(): bool {
436 - return current_user_can('manage_options');
528 + return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_performance');
437 529 }
438 530
439 531 /**
440 532 * Get arguments for historical data endpoint
@@ -442,8 +534,46 @@
442 534 * @since 1.0.0
443 535 *
444 536 * @return array Arguments array
445 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 +
446 576 private function get_historical_data_args(): array {
447 577 return [
448 578 'days' => [
449 579 'required' => false,
@@ -456,9 +586,9 @@
456 586 'metric' => [
457 587 'required' => false,
458 588 'type' => 'string',
459 589 'default' => 'all',
460 - 'enum' => ['all', 'lcp', 'fid', 'cls', 'inp', 'score'],
590 + 'enum' => ['all', 'lcp', 'cls', 'inp', 'score'],
461 591 'description' => 'Specific metric to retrieve'
462 592 ]
463 593 ];
464 594 }