PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.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 All 50 releases
← All changes | includes/seo/class-performance-data-collector.php +170 -24 1.28.0 → 2.9.0 View file →
@@ -17,9 +17,8 @@
17 17 if ( ! defined( 'ABSPATH' ) ) {
18 18 exit;
19 19 }
20 20
21 -use ThinkRank\Core\Settings;
22 21 use ThinkRank\Integrations\Google_PageSpeed_Client;
23 22
24 23 /**
25 24 * Performance Data Collector Class
@@ -56,10 +55,11 @@
56 55 */
57 56 public function __construct() {
58 57 $this->pagespeed_client = null;
59 58
60 - // Register cron hooks
61 - add_action(self::CRON_HOOK, [$this, 'collect_performance_data']);
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 62
63 63 // Schedule cron if not already scheduled
64 64 if (!wp_next_scheduled(self::CRON_HOOK)) {
65 65 wp_schedule_event(time(), 'daily', self::CRON_HOOK);
@@ -104,15 +104,17 @@
104 104 }
105 105 }
106 106
107 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)) {
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 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;
115 117 return;
116 118 }
117 119
118 120 $this->pagespeed_client = Google_PageSpeed_Client::for_site();
@@ -117,21 +119,23 @@
117 119
118 120 $this->pagespeed_client = Google_PageSpeed_Client::for_site();
119 121 } catch (\Exception $e) {
120 122 $this->pagespeed_client = null;
123 + $this->last_error = $e->getMessage();
124 + $this->last_error_code = self::ERROR_NOT_CONFIGURED;
121 125 }
122 126 }
123 127
124 128 /**
125 - * Get Google OAuth access token from settings
129 + * Whether this site has a credential the PageSpeed API will accept.
126 130 *
127 - * @return string Access token or empty string if not configured
131 + * @return bool
128 132 */
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 : '';
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();
134 138 }
135 139
136 140 /**
137 141 * Option storing the timestamp of the last successful collection,
@@ -147,8 +151,35 @@
147 151 */
148 152 private const AUTO_REFRESH_GAP = 7 * DAY_IN_SECONDS;
149 153
150 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 + /**
151 182 * Collect performance data for the site
152 183 *
153 184 * @param bool $force Bypass the 7-day auto-refresh gate (manual refresh).
154 185 * @return bool Success status
@@ -153,8 +184,11 @@
153 184 * @param bool $force Bypass the 7-day auto-refresh gate (manual refresh).
154 185 * @return bool Success status
155 186 */
156 187 public function collect_performance_data(bool $force = false): bool {
188 + $this->last_error = '';
189 + $this->last_error_code = '';
190 +
157 191 try {
158 192 // Auto-collections (cron / background) re-measure at most every
159 193 // 7 days; only an explicit user refresh forces a new audit.
160 194 if (!$force) {
@@ -175,9 +209,9 @@
175 209 $devices = ['mobile', 'desktop'];
176 210 $success = true;
177 211
178 212 foreach ($devices as $device) {
179 - $device_success = $this->collect_device_performance_data($home_url, $device);
213 + $device_success = $this->collect_device_performance_data($home_url, $device, $force);
180 214 if (!$device_success) {
181 215 $success = false;
182 216 }
183 217 }
@@ -191,13 +225,101 @@
191 225
192 226 return $success;
193 227
194 228 } catch (\Exception $e) {
229 + $this->record_error($e);
195 230 return false;
196 231 }
197 232 }
198 233
199 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 + /**
200 322 * Collect performance data for specific device type
201 323 *
202 324 * @param string $url URL to test
203 325 * @param string $device_type Device type (mobile/desktop)
@@ -202,18 +324,22 @@
202 324 * @param string $url URL to test
203 325 * @param string $device_type Device type (mobile/desktop)
204 326 * @return bool Success status
205 327 */
206 - private function collect_device_performance_data(string $url, string $device_type): bool {
328 + private function collect_device_performance_data(string $url, string $device_type, bool $force = false): bool {
207 329 try {
208 330 // Check if PageSpeed client is available
209 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 + }
210 336 return false;
211 337 }
212 338
213 339 // One snapshot provides both the Core Web Vitals and the performance
214 340 // score — previously this ran two full Lighthouse audits per device.
215 - $snapshot = $this->pagespeed_client->get_pagespeed_snapshot($url, $device_type);
341 + $snapshot = $this->pagespeed_client->get_pagespeed_snapshot($url, $device_type, $force);
216 342
217 343 // Prepare data for storage
218 344 $performance_data = $snapshot['core_web_vitals'];
219 345 $performance_data['performance_score'] = $snapshot['performance_score'];
@@ -227,11 +353,21 @@
227 353 );
228 354
229 355
230 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 +
231 366 return $stored;
232 -
367 +
233 368 } catch (\Exception $e) {
369 + $this->record_error($e);
234 370 return false;
235 371 }
236 372 }
237 373
@@ -244,8 +380,9 @@
244 380 $results = [
245 381 'success' => false,
246 382 'message' => '',
247 383 'data_collected' => false,
384 + 'error_code' => '',
248 385 'errors' => []
249 386 ];
250 387
251 388 try {
@@ -256,14 +393,23 @@
256 393 $results['success'] = true;
257 394 $results['data_collected'] = true;
258 395 $results['message'] = __('Performance data collected successfully', 'thinkrank');
259 396 } else {
260 - $results['message'] = __('Failed to collect performance data', 'thinkrank');
261 - $results['errors'][] = 'Data collection failed';
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'];
262 403 }
263 -
404 +
264 405 } catch (\Exception $e) {
265 - $results['message'] = __('Error during data collection', 'thinkrank');
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'];
266 412 $results['errors'][] = $e->getMessage();
267 413 }
268 414
269 415 return $results;