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

404 lines 13.7 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
61 add_action(self::CRON_HOOK, [$this, 'collect_performance_data']);
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 // The PageSpeed API itself is public (API key / keyless — see
109 // Google_PageSpeed_Client::for_site()), but a Google connection
110 // still gates the feature: only collect for connected sites.
111 $access_token = $this->get_google_access_token();
112
113 if (empty($access_token)) {
114 $this->pagespeed_client = null;
115 return;
116 }
117
118 $this->pagespeed_client = Google_PageSpeed_Client::for_site();
119 } catch (\Exception $e) {
120 $this->pagespeed_client = null;
121 }
122 }
123
124 /**
125 * Get Google OAuth access token from settings
126 *
127 * @return string Access token or empty string if not configured
128 */
129 private function get_google_access_token(): string {
130 // OAuth tokens are encrypted at rest; Settings::get() decrypts them.
131 // Reading the raw option yields ciphertext that PageSpeed rejects with a 401.
132 $access_token = (new Settings())->get('google_access_token', '');
133 return is_string($access_token) ? $access_token : '';
134 }
135
136 /**
137 * Option storing the timestamp of the last successful collection,
138 * used by the 7-day auto-refresh gate.
139 */
140 private const LAST_COLLECTED_OPTION = 'thinkrank_cwv_last_collected';
141
142 /**
143 * How long a successful measurement satisfies automatic collections.
144 * Lighthouse lab data is effectively static week-to-week (RankMath uses
145 * the same 7-day gate), and staying frugal keeps every install inside
146 * the shared PageSpeed quota.
147 */
148 private const AUTO_REFRESH_GAP = 7 * DAY_IN_SECONDS;
149
150 /**
151 * Collect performance data for the site
152 *
153 * @param bool $force Bypass the 7-day auto-refresh gate (manual refresh).
154 * @return bool Success status
155 */
156 public function collect_performance_data(bool $force = false): bool {
157 try {
158 // Auto-collections (cron / background) re-measure at most every
159 // 7 days; only an explicit user refresh forces a new audit.
160 if (!$force) {
161 $last = (int) get_option(self::LAST_COLLECTED_OPTION, 0);
162 if ($last && (time() - $last) < self::AUTO_REFRESH_GAP) {
163 return true;
164 }
165 }
166
167 // Build the client here rather than in the constructor: the collector is
168 // instantiated on ordinary requests too, and the token must be read (and
169 // refreshed) at collection time to avoid using a stale one.
170 $this->initialize_pagespeed_client();
171
172 $home_url = home_url();
173
174 // Test both mobile and desktop
175 $devices = ['mobile', 'desktop'];
176 $success = true;
177
178 foreach ($devices as $device) {
179 $device_success = $this->collect_device_performance_data($home_url, $device);
180 if (!$device_success) {
181 $success = false;
182 }
183 }
184
185 if ($success) {
186 update_option(self::LAST_COLLECTED_OPTION, time(), false);
187 }
188
189 // Run data cleanup (keep 1 year of data)
190 $this->cleanup_old_data(365);
191
192 return $success;
193
194 } catch (\Exception $e) {
195 return false;
196 }
197 }
198
199 /**
200 * Collect performance data for specific device type
201 *
202 * @param string $url URL to test
203 * @param string $device_type Device type (mobile/desktop)
204 * @return bool Success status
205 */
206 private function collect_device_performance_data(string $url, string $device_type): bool {
207 try {
208 // Check if PageSpeed client is available
209 if (!$this->pagespeed_client) {
210 return false;
211 }
212
213 // One snapshot provides both the Core Web Vitals and the performance
214 // score — previously this ran two full Lighthouse audits per device.
215 $snapshot = $this->pagespeed_client->get_pagespeed_snapshot($url, $device_type);
216
217 // Prepare data for storage
218 $performance_data = $snapshot['core_web_vitals'];
219 $performance_data['performance_score'] = $snapshot['performance_score'];
220
221 // Store in database
222 $stored = $this->get_performance_manager()->store_historical_performance_data(
223 $performance_data,
224 'site',
225 null,
226 $device_type
227 );
228
229
230
231 return $stored;
232
233 } catch (\Exception $e) {
234 return false;
235 }
236 }
237
238 /**
239 * Manually trigger data collection (for testing or immediate collection)
240 *
241 * @return array Collection results
242 */
243 public function manual_collect(): array {
244 $results = [
245 'success' => false,
246 'message' => '',
247 'data_collected' => false,
248 'errors' => []
249 ];
250
251 try {
252 // Manual refresh always re-measures (bypasses the 7-day gate).
253 $success = $this->collect_performance_data(true);
254
255 if ($success) {
256 $results['success'] = true;
257 $results['data_collected'] = true;
258 $results['message'] = __('Performance data collected successfully', 'thinkrank');
259 } else {
260 $results['message'] = __('Failed to collect performance data', 'thinkrank');
261 $results['errors'][] = 'Data collection failed';
262 }
263
264 } catch (\Exception $e) {
265 $results['message'] = __('Error during data collection', 'thinkrank');
266 $results['errors'][] = $e->getMessage();
267 }
268
269 return $results;
270 }
271
272 /**
273 * Get collection schedule information
274 *
275 * @return array Schedule information
276 */
277 public function get_schedule_info(): array {
278 $next_scheduled = wp_next_scheduled(self::CRON_HOOK);
279
280 return [
281 'is_scheduled' => $next_scheduled !== false,
282 'next_run' => $next_scheduled ? gmdate('Y-m-d H:i:s', $next_scheduled) : null,
283 'next_run_human' => $next_scheduled ? human_time_diff($next_scheduled) : null,
284 'cron_hook' => self::CRON_HOOK,
285 'frequency' => 'daily'
286 ];
287 }
288
289 /**
290 * Clean up old performance data (data retention policy)
291 *
292 * @param int $days Number of days to keep (default: 365 days = 1 year)
293 * @return int Number of records deleted
294 */
295 public function cleanup_old_data(int $days = 365): int {
296 global $wpdb;
297
298 $table_name = $wpdb->prefix . 'thinkrank_seo_performance';
299 $cutoff_date = gmdate('Y-m-d H:i:s', strtotime("-{$days} days"));
300
301 $sql = sprintf(
302 "DELETE FROM %s WHERE measured_at < %%s",
303 $table_name
304 );
305
306 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Performance data cleanup requires direct database access
307 $deleted = $wpdb->query(
308 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
309 $wpdb->prepare($sql, $cutoff_date)
310 );
311
312 return $deleted !== false ? (int) $deleted : 0;
313 }
314
315 /**
316 * Reschedule data collection
317 *
318 * @param string $frequency Cron frequency (hourly, daily, weekly)
319 * @return bool Success status
320 */
321 public function reschedule_collection(string $frequency = 'daily'): bool {
322 try {
323 // Clear existing schedule
324 wp_clear_scheduled_hook(self::CRON_HOOK);
325
326 // Schedule new collection
327 $scheduled = wp_schedule_event(time(), $frequency, self::CRON_HOOK);
328
329 return $scheduled !== false;
330
331 } catch (\Exception $e) {
332 return false;
333 }
334 }
335
336 /**
337 * Clear scheduled data collection
338 *
339 * @return bool Success status
340 */
341 public function clear_schedule(): bool {
342 try {
343 wp_clear_scheduled_hook(self::CRON_HOOK);
344 return true;
345 } catch (\Exception $e) {
346 return false;
347 }
348 }
349
350 /**
351 * Get recent collection statistics
352 *
353 * @param int $days Number of days to check
354 * @return array Collection statistics
355 */
356 public function get_collection_stats(int $days = 7): array {
357 global $wpdb;
358
359 $table_name = $wpdb->prefix . 'thinkrank_seo_performance';
360 $start_date = gmdate('Y-m-d H:i:s', strtotime("-{$days} days"));
361
362 try {
363 $sql = sprintf("
364 SELECT
365 COUNT(*) as total_records,
366 COUNT(DISTINCT DATE(measured_at)) as days_with_data,
367 COUNT(DISTINCT device_type) as device_types,
368 MIN(measured_at) as first_measurement,
369 MAX(measured_at) as last_measurement
370 FROM %s
371 WHERE measured_at >= %%s
372 AND context_type = 'site'
373 AND measured_by = 'google_pagespeed'
374 ", $table_name);
375
376 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Performance statistics retrieval requires direct database access
377 $stats = $wpdb->get_row(
378 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
379 $wpdb->prepare($sql, $start_date),
380 ARRAY_A
381 );
382
383 return [
384 'total_records' => (int) ($stats['total_records'] ?? 0),
385 'days_with_data' => (int) ($stats['days_with_data'] ?? 0),
386 'device_types' => (int) ($stats['device_types'] ?? 0),
387 'first_measurement' => $stats['first_measurement'] ?? null,
388 'last_measurement' => $stats['last_measurement'] ?? null,
389 'collection_rate' => $stats['days_with_data'] ? round(($stats['days_with_data'] / $days) * 100, 1) : 0
390 ];
391
392 } catch (\Exception $e) {
393 return [
394 'total_records' => 0,
395 'days_with_data' => 0,
396 'device_types' => 0,
397 'first_measurement' => null,
398 'last_measurement' => null,
399 'collection_rate' => 0
400 ];
401 }
402 }
403 }
404