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

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