| @@ -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 | } |