PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.32.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.32.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
thinkrank / includes / seo / class-performance-data-collector.php

class-performance-data-collector.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.32.0, at includes/seo/class-performance-data-collector.php

566 lines 19.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Performance Data Collector Class
4 *
5 * Automated system for collecting and storing performance data from Google PageSpeed Insights.
6 * Runs on WordPress cron to build historical performance tracking over time.
7 *
8 * @package ThinkRank\SEO
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\SEO;
15
16 // Prevent direct access.
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 use ThinkRank\Core\Settings;
22 use ThinkRank\Integrations\Google_PageSpeed_Client;
23
24 /**
25 * Performance Data Collector Class
26 *
27 * Handles automated collection and storage of performance data for historical tracking.
28 * Integrates with WordPress cron system for scheduled data collection.
29 *
30 * @since 1.0.0
31 */
32 class Performance_Data_Collector {
33
34 /**
35 * Performance Monitoring Manager instance (lazy — only built when a
36 * collection actually runs, not on every request)
37 *
38 * @var Performance_Monitoring_Manager|null
39 */
40 private ?Performance_Monitoring_Manager $performance_manager = null;
41
42 /**
43 * Google PageSpeed Client instance
44 *
45 * @var Google_PageSpeed_Client|null
46 */
47 private ?Google_PageSpeed_Client $pagespeed_client;
48
49 /**
50 * Cron hook name for data collection
51 */
52 private const CRON_HOOK = 'thinkrank_collect_performance_data';
53
54 /**
55 * Constructor
56 */
57 public function __construct() {
58 $this->pagespeed_client = null;
59
60 // Register cron hooks. Routed through the wrapper so a scheduled failure
61 // gets logged — WP-Cron throws the return value away.
62 add_action(self::CRON_HOOK, [$this, 'collect_performance_data_via_cron']);
63
64 // Schedule cron if not already scheduled
65 if (!wp_next_scheduled(self::CRON_HOOK)) {
66 wp_schedule_event(time(), 'daily', self::CRON_HOOK);
67 }
68 }
69
70 /**
71 * Get the Performance Monitoring Manager, constructing it on first use.
72 *
73 * The collector is instantiated on every request (bootstrap + REST), so the
74 * manager chain (Settings_Manager, SEO_Settings_Manager, …) must not be
75 * built until a collection actually needs it.
76 *
77 * @return Performance_Monitoring_Manager
78 */
79 private function get_performance_manager(): Performance_Monitoring_Manager {
80 if ($this->performance_manager === null) {
81 $this->performance_manager = new Performance_Monitoring_Manager();
82 }
83 return $this->performance_manager;
84 }
85
86 /**
87 * Initialize PageSpeed client with proper error handling
88 *
89 * @return void
90 */
91 private function initialize_pagespeed_client(): void {
92 // Ensure the Google PageSpeed Client class is loaded
93 if (!class_exists('ThinkRank\\Integrations\\Google_PageSpeed_Client')) {
94 $pagespeed_file = THINKRANK_PLUGIN_DIR . 'includes/integrations/class-google-pagespeed-client.php';
95 if (file_exists($pagespeed_file)) {
96 require_once $pagespeed_file;
97 }
98 }
99
100 // Ensure the base client is loaded
101 if (!class_exists('ThinkRank\\Integrations\\Google_API_Base_Client')) {
102 $base_client_file = THINKRANK_PLUGIN_DIR . 'includes/integrations/class-google-api-base-client.php';
103 if (file_exists($base_client_file)) {
104 require_once $base_client_file;
105 }
106 }
107
108 try {
109 // Either credential is enough. This used to require an OAuth token,
110 // which locked out sites configured with only a PageSpeed API key —
111 // the credential Google_PageSpeed_Client::for_site() actually
112 // *prefers*, since a dedicated key bills its own project quota. Those
113 // sites could never collect and got the same generic failure.
114 if (!$this->has_pagespeed_credentials()) {
115 $this->pagespeed_client = null;
116 $this->last_error = __('Connect Google or add a PageSpeed API key to collect Core Web Vitals.', 'thinkrank');
117 $this->last_error_code = self::ERROR_NOT_CONFIGURED;
118 return;
119 }
120
121 $this->pagespeed_client = Google_PageSpeed_Client::for_site();
122 } catch (\Exception $e) {
123 $this->pagespeed_client = null;
124 $this->last_error = $e->getMessage();
125 $this->last_error_code = self::ERROR_NOT_CONFIGURED;
126 }
127 }
128
129 /**
130 * Whether this site has a credential the PageSpeed API will accept.
131 *
132 * @return bool
133 */
134 private function has_pagespeed_credentials(): bool {
135 if ($this->get_google_pagespeed_api_key() !== '') {
136 return true;
137 }
138
139 return $this->get_google_access_token() !== '';
140 }
141
142 /**
143 * Get the site-owned PageSpeed API key from settings.
144 *
145 * @return string API key, or an empty string when not configured.
146 */
147 private function get_google_pagespeed_api_key(): string {
148 $api_key = (new Settings())->get('google_pagespeed_api_key', '');
149 return is_string($api_key) ? trim($api_key) : '';
150 }
151
152 /**
153 * Get Google OAuth access token from settings
154 *
155 * @return string Access token or empty string if not configured
156 */
157 private function get_google_access_token(): string {
158 // OAuth tokens are encrypted at rest; Settings::get() decrypts them.
159 // Reading the raw option yields ciphertext that PageSpeed rejects with a 401.
160 $access_token = (new Settings())->get('google_access_token', '');
161 return is_string($access_token) ? $access_token : '';
162 }
163
164 /**
165 * Option storing the timestamp of the last successful collection,
166 * used by the 7-day auto-refresh gate.
167 */
168 private const LAST_COLLECTED_OPTION = 'thinkrank_cwv_last_collected';
169
170 /**
171 * How long a successful measurement satisfies automatic collections.
172 * Lighthouse lab data is effectively static week-to-week (RankMath uses
173 * the same 7-day gate), and staying frugal keeps every install inside
174 * the shared PageSpeed quota.
175 */
176 private const AUTO_REFRESH_GAP = 7 * DAY_IN_SECONDS;
177
178 /**
179 * Failure classes a collection can end in. Every one of these used to
180 * collapse into a bare `false` and then into the literal string
181 * "Data collection failed", which told the user nothing and made the REST
182 * route answer 500 for conditions that are not server faults.
183 */
184 public const ERROR_NOT_CONFIGURED = 'not_configured';
185 public const ERROR_URL_UNREACHABLE = 'url_unreachable';
186 public const ERROR_RATE_LIMITED = 'rate_limited';
187 public const ERROR_RECENT_FAILURE = 'recent_failure';
188 public const ERROR_STORAGE_FAILED = 'storage_failed';
189 public const ERROR_API_FAILED = 'api_failed';
190
191 /**
192 * Human-readable reason the last collection failed.
193 *
194 * @var string
195 */
196 private string $last_error = '';
197
198 /**
199 * Machine-readable class of the last failure — one of the ERROR_* constants.
200 *
201 * @var string
202 */
203 private string $last_error_code = '';
204
205 /**
206 * Collect performance data for the site
207 *
208 * @param bool $force Bypass the 7-day auto-refresh gate (manual refresh).
209 * @return bool Success status
210 */
211 public function collect_performance_data(bool $force = false): bool {
212 $this->last_error = '';
213 $this->last_error_code = '';
214
215 try {
216 // Auto-collections (cron / background) re-measure at most every
217 // 7 days; only an explicit user refresh forces a new audit.
218 if (!$force) {
219 $last = (int) get_option(self::LAST_COLLECTED_OPTION, 0);
220 if ($last && (time() - $last) < self::AUTO_REFRESH_GAP) {
221 return true;
222 }
223 }
224
225 // Build the client here rather than in the constructor: the collector is
226 // instantiated on ordinary requests too, and the token must be read (and
227 // refreshed) at collection time to avoid using a stale one.
228 $this->initialize_pagespeed_client();
229
230 $home_url = home_url();
231
232 // Test both mobile and desktop
233 $devices = ['mobile', 'desktop'];
234 $success = true;
235
236 foreach ($devices as $device) {
237 $device_success = $this->collect_device_performance_data($home_url, $device, $force);
238 if (!$device_success) {
239 $success = false;
240 }
241 }
242
243 if ($success) {
244 update_option(self::LAST_COLLECTED_OPTION, time(), false);
245 }
246
247 // Run data cleanup (keep 1 year of data)
248 $this->cleanup_old_data(365);
249
250 return $success;
251
252 } catch (\Exception $e) {
253 $this->record_error($e);
254 return false;
255 }
256 }
257
258 /**
259 * Cron entry point.
260 *
261 * WP-Cron discards a callback's return value, so a hook that returns false is
262 * still reported as having run successfully — this collection could fail on
263 * every scheduled pass with the only evidence being an empty table. Log the
264 * reason instead.
265 *
266 * @since 1.31.0
267 * @return void
268 */
269 public function collect_performance_data_via_cron(): void {
270 if ($this->collect_performance_data()) {
271 return;
272 }
273
274 error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- the only record that a silent cron failure happened.
275 sprintf(
276 'ThinkRank [performance]: scheduled Core Web Vitals collection failed (%s) — %s',
277 $this->last_error_code !== '' ? $this->last_error_code : 'unknown',
278 $this->last_error !== '' ? $this->last_error : 'no reason reported'
279 )
280 );
281 }
282
283 /**
284 * Reason the last collection failed, for the REST layer to report.
285 *
286 * @since 1.31.0
287 * @return array{code: string, message: string} Empty strings when the last
288 * run did not fail.
289 */
290 public function get_last_error(): array {
291 return [
292 'code' => $this->last_error_code,
293 'message' => $this->last_error,
294 ];
295 }
296
297 /**
298 * Classify an exception from the PageSpeed call into a failure class.
299 *
300 * The distinctions matter to the caller: an unreachable site and an
301 * exhausted quota need different advice, and neither is a server fault.
302 *
303 * @since 1.31.0
304 * @param \Exception $e Exception thrown while collecting.
305 * @return void
306 */
307 private function record_error(\Exception $e): void {
308 $message = $e->getMessage();
309 $this->last_error = $message;
310
311 if ((int) $e->getCode() === Google_PageSpeed_Client::CODE_REMEMBERED_FAILURE) {
312 $this->last_error_code = self::ERROR_RECENT_FAILURE;
313 return;
314 }
315
316 // Lighthouse could not load the page: not public, DNS/TLS failure, or the
317 // server refused the fetch.
318 if (stripos($message, 'FAILED_DOCUMENT_REQUEST') !== false
319 || stripos($message, 'ERRORED_DOCUMENT_REQUEST') !== false
320 || stripos($message, 'DNS_FAILURE') !== false
321 || stripos($message, 'net::') !== false) {
322 $this->last_error_code = self::ERROR_URL_UNREACHABLE;
323 return;
324 }
325
326 // The base client throws with the HTTP status as the exception code.
327 if ((int) $e->getCode() === 429
328 || stripos($message, 'rate limit') !== false
329 || stripos($message, 'quota') !== false) {
330 $this->last_error_code = self::ERROR_RATE_LIMITED;
331 return;
332 }
333
334 $this->last_error_code = self::ERROR_API_FAILED;
335 }
336
337 /**
338 * Collect performance data for specific device type
339 *
340 * @param string $url URL to test
341 * @param string $device_type Device type (mobile/desktop)
342 * @return bool Success status
343 */
344 private function collect_device_performance_data(string $url, string $device_type, bool $force = false): bool {
345 try {
346 // Check if PageSpeed client is available
347 if (!$this->pagespeed_client) {
348 if ($this->last_error === '') {
349 $this->last_error = __('Connect Google or add a PageSpeed API key to collect Core Web Vitals.', 'thinkrank');
350 $this->last_error_code = self::ERROR_NOT_CONFIGURED;
351 }
352 return false;
353 }
354
355 // One snapshot provides both the Core Web Vitals and the performance
356 // score — previously this ran two full Lighthouse audits per device.
357 $snapshot = $this->pagespeed_client->get_pagespeed_snapshot($url, $device_type, $force);
358
359 // Prepare data for storage
360 $performance_data = $snapshot['core_web_vitals'];
361 $performance_data['performance_score'] = $snapshot['performance_score'];
362
363 // Store in database
364 $stored = $this->get_performance_manager()->store_historical_performance_data(
365 $performance_data,
366 'site',
367 null,
368 $device_type
369 );
370
371
372
373 if (!$stored) {
374 $this->last_error = sprintf(
375 /* translators: %s: device type (mobile or desktop). */
376 __('Measured %s successfully but could not store the result.', 'thinkrank'),
377 $device_type
378 );
379 $this->last_error_code = self::ERROR_STORAGE_FAILED;
380 }
381
382 return $stored;
383
384 } catch (\Exception $e) {
385 $this->record_error($e);
386 return false;
387 }
388 }
389
390 /**
391 * Manually trigger data collection (for testing or immediate collection)
392 *
393 * @return array Collection results
394 */
395 public function manual_collect(): array {
396 $results = [
397 'success' => false,
398 'message' => '',
399 'data_collected' => false,
400 'error_code' => '',
401 'errors' => []
402 ];
403
404 try {
405 // Manual refresh always re-measures (bypasses the 7-day gate).
406 $success = $this->collect_performance_data(true);
407
408 if ($success) {
409 $results['success'] = true;
410 $results['data_collected'] = true;
411 $results['message'] = __('Performance data collected successfully', 'thinkrank');
412 } else {
413 $error = $this->get_last_error();
414 $results['message'] = $error['message'] !== ''
415 ? $error['message']
416 : __('Failed to collect performance data', 'thinkrank');
417 $results['error_code'] = $error['code'];
418 $results['errors'][] = $results['message'];
419 }
420
421 } catch (\Exception $e) {
422 $this->record_error($e);
423 $error = $this->get_last_error();
424 $results['message'] = $error['message'] !== ''
425 ? $error['message']
426 : __('Error during data collection', 'thinkrank');
427 $results['error_code'] = $error['code'];
428 $results['errors'][] = $e->getMessage();
429 }
430
431 return $results;
432 }
433
434 /**
435 * Get collection schedule information
436 *
437 * @return array Schedule information
438 */
439 public function get_schedule_info(): array {
440 $next_scheduled = wp_next_scheduled(self::CRON_HOOK);
441
442 return [
443 'is_scheduled' => $next_scheduled !== false,
444 'next_run' => $next_scheduled ? gmdate('Y-m-d H:i:s', $next_scheduled) : null,
445 'next_run_human' => $next_scheduled ? human_time_diff($next_scheduled) : null,
446 'cron_hook' => self::CRON_HOOK,
447 'frequency' => 'daily'
448 ];
449 }
450
451 /**
452 * Clean up old performance data (data retention policy)
453 *
454 * @param int $days Number of days to keep (default: 365 days = 1 year)
455 * @return int Number of records deleted
456 */
457 public function cleanup_old_data(int $days = 365): int {
458 global $wpdb;
459
460 $table_name = $wpdb->prefix . 'thinkrank_seo_performance';
461 $cutoff_date = gmdate('Y-m-d H:i:s', strtotime("-{$days} days"));
462
463 $sql = sprintf(
464 "DELETE FROM %s WHERE measured_at < %%s",
465 $table_name
466 );
467
468 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Performance data cleanup requires direct database access
469 $deleted = $wpdb->query(
470 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
471 $wpdb->prepare($sql, $cutoff_date)
472 );
473
474 return $deleted !== false ? (int) $deleted : 0;
475 }
476
477 /**
478 * Reschedule data collection
479 *
480 * @param string $frequency Cron frequency (hourly, daily, weekly)
481 * @return bool Success status
482 */
483 public function reschedule_collection(string $frequency = 'daily'): bool {
484 try {
485 // Clear existing schedule
486 wp_clear_scheduled_hook(self::CRON_HOOK);
487
488 // Schedule new collection
489 $scheduled = wp_schedule_event(time(), $frequency, self::CRON_HOOK);
490
491 return $scheduled !== false;
492
493 } catch (\Exception $e) {
494 return false;
495 }
496 }
497
498 /**
499 * Clear scheduled data collection
500 *
501 * @return bool Success status
502 */
503 public function clear_schedule(): bool {
504 try {
505 wp_clear_scheduled_hook(self::CRON_HOOK);
506 return true;
507 } catch (\Exception $e) {
508 return false;
509 }
510 }
511
512 /**
513 * Get recent collection statistics
514 *
515 * @param int $days Number of days to check
516 * @return array Collection statistics
517 */
518 public function get_collection_stats(int $days = 7): array {
519 global $wpdb;
520
521 $table_name = $wpdb->prefix . 'thinkrank_seo_performance';
522 $start_date = gmdate('Y-m-d H:i:s', strtotime("-{$days} days"));
523
524 try {
525 $sql = sprintf("
526 SELECT
527 COUNT(*) as total_records,
528 COUNT(DISTINCT DATE(measured_at)) as days_with_data,
529 COUNT(DISTINCT device_type) as device_types,
530 MIN(measured_at) as first_measurement,
531 MAX(measured_at) as last_measurement
532 FROM %s
533 WHERE measured_at >= %%s
534 AND context_type = 'site'
535 AND measured_by = 'google_pagespeed'
536 ", $table_name);
537
538 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Performance statistics retrieval requires direct database access
539 $stats = $wpdb->get_row(
540 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
541 $wpdb->prepare($sql, $start_date),
542 ARRAY_A
543 );
544
545 return [
546 'total_records' => (int) ($stats['total_records'] ?? 0),
547 'days_with_data' => (int) ($stats['days_with_data'] ?? 0),
548 'device_types' => (int) ($stats['device_types'] ?? 0),
549 'first_measurement' => $stats['first_measurement'] ?? null,
550 'last_measurement' => $stats['last_measurement'] ?? null,
551 'collection_rate' => $stats['days_with_data'] ? round(($stats['days_with_data'] / $days) * 100, 1) : 0
552 ];
553
554 } catch (\Exception $e) {
555 return [
556 'total_records' => 0,
557 'days_with_data' => 0,
558 'device_types' => 0,
559 'first_measurement' => null,
560 'last_measurement' => null,
561 'collection_rate' => 0
562 ];
563 }
564 }
565 }
566