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

550 lines 19.6 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 // Not-configured is a configuration state, not a failure: it is the
251 // default for a fresh install, it never resolves on its own, and the
252 // REST layer already reports it to the UI. Logging it wrote a line to
253 // every unconfigured site's error log on every scheduled run (#585).
254 if ($this->last_error_code === self::ERROR_NOT_CONFIGURED) {
255 return;
256 }
257
258 error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- the only record that a silent cron failure happened.
259 sprintf(
260 'ThinkRank [performance]: scheduled Core Web Vitals collection failed (%s) — %s',
261 $this->last_error_code !== '' ? $this->last_error_code : 'unknown',
262 $this->last_error !== '' ? $this->last_error : 'no reason reported'
263 )
264 );
265 }
266
267 /**
268 * Reason the last collection failed, for the REST layer to report.
269 *
270 * @since 1.31.0
271 * @return array{code: string, message: string} Empty strings when the last
272 * run did not fail.
273 */
274 public function get_last_error(): array {
275 return [
276 'code' => $this->last_error_code,
277 'message' => $this->last_error,
278 ];
279 }
280
281 /**
282 * Classify an exception from the PageSpeed call into a failure class.
283 *
284 * The distinctions matter to the caller: an unreachable site and an
285 * exhausted quota need different advice, and neither is a server fault.
286 *
287 * @since 1.31.0
288 * @param \Exception $e Exception thrown while collecting.
289 * @return void
290 */
291 private function record_error(\Exception $e): void {
292 $message = $e->getMessage();
293 $this->last_error = $message;
294
295 if ((int) $e->getCode() === Google_PageSpeed_Client::CODE_REMEMBERED_FAILURE) {
296 $this->last_error_code = self::ERROR_RECENT_FAILURE;
297 return;
298 }
299
300 // Lighthouse could not load the page: not public, DNS/TLS failure, or the
301 // server refused the fetch.
302 if (stripos($message, 'FAILED_DOCUMENT_REQUEST') !== false
303 || stripos($message, 'ERRORED_DOCUMENT_REQUEST') !== false
304 || stripos($message, 'DNS_FAILURE') !== false
305 || stripos($message, 'net::') !== false) {
306 $this->last_error_code = self::ERROR_URL_UNREACHABLE;
307 return;
308 }
309
310 // The base client throws with the HTTP status as the exception code.
311 if ((int) $e->getCode() === 429
312 || stripos($message, 'rate limit') !== false
313 || stripos($message, 'quota') !== false) {
314 $this->last_error_code = self::ERROR_RATE_LIMITED;
315 return;
316 }
317
318 $this->last_error_code = self::ERROR_API_FAILED;
319 }
320
321 /**
322 * Collect performance data for specific device type
323 *
324 * @param string $url URL to test
325 * @param string $device_type Device type (mobile/desktop)
326 * @return bool Success status
327 */
328 private function collect_device_performance_data(string $url, string $device_type, bool $force = false): bool {
329 try {
330 // Check if PageSpeed client is available
331 if (!$this->pagespeed_client) {
332 if ($this->last_error === '') {
333 $this->last_error = __('Connect Google or add a PageSpeed API key to collect Core Web Vitals.', 'thinkrank');
334 $this->last_error_code = self::ERROR_NOT_CONFIGURED;
335 }
336 return false;
337 }
338
339 // One snapshot provides both the Core Web Vitals and the performance
340 // score — previously this ran two full Lighthouse audits per device.
341 $snapshot = $this->pagespeed_client->get_pagespeed_snapshot($url, $device_type, $force);
342
343 // Prepare data for storage
344 $performance_data = $snapshot['core_web_vitals'];
345 $performance_data['performance_score'] = $snapshot['performance_score'];
346
347 // Store in database
348 $stored = $this->get_performance_manager()->store_historical_performance_data(
349 $performance_data,
350 'site',
351 null,
352 $device_type
353 );
354
355
356
357 if (!$stored) {
358 $this->last_error = sprintf(
359 /* translators: %s: device type (mobile or desktop). */
360 __('Measured %s successfully but could not store the result.', 'thinkrank'),
361 $device_type
362 );
363 $this->last_error_code = self::ERROR_STORAGE_FAILED;
364 }
365
366 return $stored;
367
368 } catch (\Exception $e) {
369 $this->record_error($e);
370 return false;
371 }
372 }
373
374 /**
375 * Manually trigger data collection (for testing or immediate collection)
376 *
377 * @return array Collection results
378 */
379 public function manual_collect(): array {
380 $results = [
381 'success' => false,
382 'message' => '',
383 'data_collected' => false,
384 'error_code' => '',
385 'errors' => []
386 ];
387
388 try {
389 // Manual refresh always re-measures (bypasses the 7-day gate).
390 $success = $this->collect_performance_data(true);
391
392 if ($success) {
393 $results['success'] = true;
394 $results['data_collected'] = true;
395 $results['message'] = __('Performance data collected successfully', 'thinkrank');
396 } else {
397 $error = $this->get_last_error();
398 $results['message'] = $error['message'] !== ''
399 ? $error['message']
400 : __('Failed to collect performance data', 'thinkrank');
401 $results['error_code'] = $error['code'];
402 $results['errors'][] = $results['message'];
403 }
404
405 } catch (\Exception $e) {
406 $this->record_error($e);
407 $error = $this->get_last_error();
408 $results['message'] = $error['message'] !== ''
409 ? $error['message']
410 : __('Error during data collection', 'thinkrank');
411 $results['error_code'] = $error['code'];
412 $results['errors'][] = $e->getMessage();
413 }
414
415 return $results;
416 }
417
418 /**
419 * Get collection schedule information
420 *
421 * @return array Schedule information
422 */
423 public function get_schedule_info(): array {
424 $next_scheduled = wp_next_scheduled(self::CRON_HOOK);
425
426 return [
427 'is_scheduled' => $next_scheduled !== false,
428 'next_run' => $next_scheduled ? gmdate('Y-m-d H:i:s', $next_scheduled) : null,
429 'next_run_human' => $next_scheduled ? human_time_diff($next_scheduled) : null,
430 'cron_hook' => self::CRON_HOOK,
431 'frequency' => 'daily'
432 ];
433 }
434
435 /**
436 * Clean up old performance data (data retention policy)
437 *
438 * @param int $days Number of days to keep (default: 365 days = 1 year)
439 * @return int Number of records deleted
440 */
441 public function cleanup_old_data(int $days = 365): int {
442 global $wpdb;
443
444 $table_name = $wpdb->prefix . 'thinkrank_seo_performance';
445 $cutoff_date = gmdate('Y-m-d H:i:s', strtotime("-{$days} days"));
446
447 $sql = sprintf(
448 "DELETE FROM %s WHERE measured_at < %%s",
449 $table_name
450 );
451
452 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Performance data cleanup requires direct database access
453 $deleted = $wpdb->query(
454 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
455 $wpdb->prepare($sql, $cutoff_date)
456 );
457
458 return $deleted !== false ? (int) $deleted : 0;
459 }
460
461 /**
462 * Reschedule data collection
463 *
464 * @param string $frequency Cron frequency (hourly, daily, weekly)
465 * @return bool Success status
466 */
467 public function reschedule_collection(string $frequency = 'daily'): bool {
468 try {
469 // Clear existing schedule
470 wp_clear_scheduled_hook(self::CRON_HOOK);
471
472 // Schedule new collection
473 $scheduled = wp_schedule_event(time(), $frequency, self::CRON_HOOK);
474
475 return $scheduled !== false;
476
477 } catch (\Exception $e) {
478 return false;
479 }
480 }
481
482 /**
483 * Clear scheduled data collection
484 *
485 * @return bool Success status
486 */
487 public function clear_schedule(): bool {
488 try {
489 wp_clear_scheduled_hook(self::CRON_HOOK);
490 return true;
491 } catch (\Exception $e) {
492 return false;
493 }
494 }
495
496 /**
497 * Get recent collection statistics
498 *
499 * @param int $days Number of days to check
500 * @return array Collection statistics
501 */
502 public function get_collection_stats(int $days = 7): array {
503 global $wpdb;
504
505 $table_name = $wpdb->prefix . 'thinkrank_seo_performance';
506 $start_date = gmdate('Y-m-d H:i:s', strtotime("-{$days} days"));
507
508 try {
509 $sql = sprintf("
510 SELECT
511 COUNT(*) as total_records,
512 COUNT(DISTINCT DATE(measured_at)) as days_with_data,
513 COUNT(DISTINCT device_type) as device_types,
514 MIN(measured_at) as first_measurement,
515 MAX(measured_at) as last_measurement
516 FROM %s
517 WHERE measured_at >= %%s
518 AND context_type = 'site'
519 AND measured_by = 'google_pagespeed'
520 ", $table_name);
521
522 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Performance statistics retrieval requires direct database access
523 $stats = $wpdb->get_row(
524 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
525 $wpdb->prepare($sql, $start_date),
526 ARRAY_A
527 );
528
529 return [
530 'total_records' => (int) ($stats['total_records'] ?? 0),
531 'days_with_data' => (int) ($stats['days_with_data'] ?? 0),
532 'device_types' => (int) ($stats['device_types'] ?? 0),
533 'first_measurement' => $stats['first_measurement'] ?? null,
534 'last_measurement' => $stats['last_measurement'] ?? null,
535 'collection_rate' => $stats['days_with_data'] ? round(($stats['days_with_data'] / $days) * 100, 1) : 0
536 ];
537
538 } catch (\Exception $e) {
539 return [
540 'total_records' => 0,
541 'days_with_data' => 0,
542 'device_types' => 0,
543 'first_measurement' => null,
544 'last_measurement' => null,
545 'collection_rate' => 0
546 ];
547 }
548 }
549 }
550