PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.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 All 57 releases
thinkrank / includes / seo / class-analytics-manager.php

class-analytics-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.14.2, at includes/seo/class-analytics-manager.php

1,226 lines 44.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Analytics Manager Class
5 *
6 * Coordinates Google API integrations for SEO analytics data collection,
7 * processing, and AI-powered insights generation. Manages Google Analytics,
8 * Search Console, and PageSpeed data with intelligent caching and rate limiting.
9 *
10 * @package ThinkRank
11 * @subpackage SEO
12 * @since 1.0.0
13 */
14
15 declare(strict_types=1);
16
17 namespace ThinkRank\SEO;
18
19 use ThinkRank\Core\Settings_Manager;
20 use ThinkRank\Integrations\Google_Analytics_Client;
21 use ThinkRank\Integrations\Google_Search_Console_Client;
22 use ThinkRank\Integrations\Google_PageSpeed_Client;
23 use ThinkRank\Integrations\Google_Search_Analytics_Client;
24 use ThinkRank\Integrations\Google_OAuth_Proxy;
25
26 // Prevent direct access
27 if (!defined('ABSPATH')) {
28 exit;
29 }
30
31 /**
32 * Analytics Manager Class
33 *
34 * Single Responsibility: Coordinate Google API data collection and processing
35 * Following ThinkRank manager patterns from AI_Manager and Performance_Monitoring_Manager
36 *
37 * @since 1.0.0
38 */
39 class Analytics_Manager {
40
41 /**
42 * Settings Manager instance
43 *
44 * @var Settings_Manager
45 */
46 private Settings_Manager $settings_manager;
47
48 /**
49 * Google Analytics client
50 *
51 * @var Google_Analytics_Client|null
52 */
53 private ?Google_Analytics_Client $analytics_client = null;
54
55 /**
56 * Google Search Console client
57 *
58 * @var Google_Search_Console_Client|null
59 */
60 private ?Google_Search_Console_Client $search_console_client = null;
61
62 /**
63 * Google Search Analytics client
64 *
65 * @var Google_Search_Analytics_Client|null
66 */
67 private ?Google_Search_Analytics_Client $search_analytics_client = null;
68
69 /**
70 * Google PageSpeed client
71 *
72 * @var Google_PageSpeed_Client|null
73 */
74 private ?Google_PageSpeed_Client $pagespeed_client = null;
75
76 /**
77 * Cache duration in seconds
78 *
79 * @var int
80 */
81 private int $cache_duration;
82
83 /**
84 * Static flag to prevent multiple token refreshes in the same request
85 *
86 * @var bool
87 */
88 private static bool $token_refreshed_this_request = false;
89
90 /**
91 * Single-flight lock for the OAuth refresh exchange.
92 *
93 * @var string
94 */
95 private const REFRESH_LOCK = 'thinkrank_token_refresh_lock';
96
97 /**
98 * How long a held refresh lock stays valid. Longer than the request
99 * timeout below, so a request that dies mid-exchange still frees it.
100 *
101 * @var int
102 */
103 private const REFRESH_LOCK_TTL = 60;
104
105 /**
106 * Set after a failed exchange; suppresses retries until it expires.
107 *
108 * @var string
109 */
110 private const REFRESH_BACKOFF = 'thinkrank_token_refresh_backoff';
111
112 /**
113 * How long to stay quiet after a failed exchange.
114 *
115 * @var int
116 */
117 private const REFRESH_BACKOFF_TTL = 300;
118
119 /**
120 * Timeout for the refresh exchange. A healthy proxy answers in ~1s; the
121 * old 30s meant one outage held a request open for half a minute.
122 *
123 * @var int
124 */
125 private const REFRESH_TIMEOUT = 10;
126
127 /**
128 * Transient prefix for the cached dashboard payload, suffixed with the
129 * date range ("30d"). Carries the plugin prefix so uninstall's
130 * `_transient_thinkrank_%` sweep removes it; the payload holds up to
131 * 1,000 of the site's search queries (#918).
132 *
133 * @since 2.14.2
134 * @var string
135 */
136 public const DASHBOARD_CACHE_PREFIX = 'thinkrank_analytics_dashboard_v5_';
137
138 /**
139 * Transient prefix for the cached SEO opportunities payload, suffixed
140 * with the date range. Prefixed for the same reason as the dashboard's.
141 *
142 * @since 2.14.2
143 * @var string
144 */
145 public const OPPORTUNITIES_CACHE_PREFIX = 'thinkrank_seo_opportunities_';
146
147 /**
148 * Every date range the dashboard cache is written under: the three the
149 * UI offers plus the doubled previous-period ranges used for trends.
150 *
151 * @since 2.14.2
152 * @var string[]
153 */
154 public const CACHED_DASHBOARD_RANGES = ['7d', '30d', '90d', '14d', '60d', '180d'];
155
156 /**
157 * Every date range the opportunities cache is written under.
158 *
159 * @since 2.14.2
160 * @var string[]
161 */
162 public const CACHED_OPPORTUNITY_RANGES = ['7d', '30d', '90d'];
163
164 /**
165 * Constructor
166 *
167 * @param Settings_Manager|null $settings_manager Settings manager instance
168 */
169 public function __construct(?Settings_Manager $settings_manager = null) {
170 $this->settings_manager = $settings_manager ?? new Settings_Manager();
171 // Pro: daily refresh (86400s), Free: 3-day refresh (259200s)
172 $this->cache_duration = defined('THINKRANK_PRO_VERSION') ? 86400 : 259200;
173 }
174
175 /**
176 * Initialize Analytics Manager
177 * Following ThinkRank init patterns
178 *
179 * @return void
180 */
181 public function init(): void {
182 // Register custom cron interval (45 minutes)
183 add_filter('cron_schedules', [$this, 'add_cron_intervals']);
184
185 // Initialize Google API clients — but only in the contexts that can use
186 // them. See maybe_initialize_clients().
187 add_action('init', [$this, 'maybe_initialize_clients']);
188
189 // Initialize token refresh scheduling
190 add_action('init', [$this, 'init_token_refresh']);
191
192 // Cron hook for token refresh
193 add_action('thinkrank_google_token_refresh', [$this, 'refresh_access_token_cron']);
194
195 // Schedule cache cleanup
196 add_action('thinkrank_daily_cleanup', [$this, 'cleanup_cache']);
197
198 // Cleanup cron on plugin deactivation
199 register_deactivation_hook(THINKRANK_PLUGIN_FILE, [__CLASS__, 'deactivation_cleanup']);
200 }
201
202 /**
203 * Add custom cron intervals
204 *
205 * @param array $schedules Existing cron schedules
206 * @return array Modified cron schedules
207 */
208 public function add_cron_intervals(array $schedules): array {
209 // Only translate once `init` has run: wp_get_schedules() can be reached
210 // before then (wp_schedule_event() at plugin boot does), and translating
211 // that early trips the _load_textdomain_just_in_time notice on WP 6.7+.
212 $schedules['thinkrank_45min'] = [
213 'interval' => 2700, // 45 minutes in seconds
214 'display' => did_action('init')
215 ? __('Every 45 Minutes', 'thinkrank')
216 : 'Every 45 Minutes'
217 ];
218 return $schedules;
219 }
220
221 /**
222 * Clean up cron events on plugin deactivation
223 *
224 * @return void
225 */
226 public static function deactivation_cleanup(): void {
227 $timestamp = wp_next_scheduled('thinkrank_google_token_refresh');
228 if ($timestamp) {
229 wp_unschedule_event($timestamp, 'thinkrank_google_token_refresh');
230 }
231 }
232
233 /**
234 * Get the initialized Search Console client
235 *
236 * @return Google_Search_Console_Client|null
237 */
238 public function get_search_console_client(): ?Google_Search_Console_Client {
239 if (!$this->search_console_client) {
240 $this->initialize_clients();
241 }
242 return $this->search_console_client;
243 }
244
245 /**
246 * Get the configured Search Console property URL
247 *
248 * @return string
249 */
250 public function get_property_url(): string {
251 return $this->get_setting('search_console_property', get_site_url());
252 }
253
254 /**
255 * Initialize the Google clients on `init`, in the contexts that use them.
256 *
257 * initialize_clients() refreshes the OAuth token, which is a blocking
258 * outbound POST to the OAuth proxy. Hooked unconditionally it ran on every
259 * anonymous front-end request, so a proxy outage became a site-wide TTFB
260 * collapse — with each visitor waiting for the network call, and none of
261 * them able to use a Google client anyway. No front-end code path reads
262 * one: every consumer is a REST endpoint, a cron callback or WP-CLI, and
263 * each either calls initialize_clients() itself or goes through
264 * get_search_console_client(), which initializes lazily (#383).
265 *
266 * @since 2.0.1
267 * @return void
268 */
269 public function maybe_initialize_clients(): void {
270 $wanted = is_admin()
271 || wp_doing_cron()
272 || (defined('REST_REQUEST') && REST_REQUEST)
273 || (defined('WP_CLI') && WP_CLI);
274
275 /**
276 * Filter whether the Google API clients are initialized for this request.
277 *
278 * @since 2.0.1
279 *
280 * @param bool $wanted Whether to initialize the clients.
281 */
282 if (!apply_filters('thinkrank_initialize_google_clients', $wanted)) {
283 return;
284 }
285
286 $this->initialize_clients();
287 }
288
289 /**
290 * Initialize Google API clients
291 * Following AI_Manager client initialization pattern
292 *
293 * @return void
294 */
295 public function initialize_clients(): void {
296 try {
297 // Refresh token if needed (non-forced, checks expiration)
298 $this->refresh_access_token();
299
300 // Initialize Search Console client
301 $gsc_api_key = $this->get_setting('google_search_console_api_key');
302 $access_token = $this->get_setting('google_access_token');
303
304 $timeout = (int) $this->get_setting('api_timeout', 30);
305 $this->search_console_client = new Google_Search_Console_Client(
306 $gsc_api_key ?: '',
307 $timeout,
308 !empty($access_token) ? $access_token : null
309 );
310
311 // Initialize Search Analytics client
312 $this->search_analytics_client = new Google_Search_Analytics_Client(
313 $gsc_api_key ?: '',
314 $timeout,
315 !empty($access_token) ? $access_token : null
316 );
317
318 // Initialize PageSpeed client. PSI is a public API — it uses the
319 // site's own API key (or keyless per-IP quota), never the shared
320 // OAuth token, which would bill every install's Lighthouse runs
321 // to one exhausted Google Cloud project (429 for everyone).
322 // Shorter timeout here: the dashboard CWV card fetches in-request
323 // on a cold cache and must not stall the whole dashboard payload.
324 $this->pagespeed_client = Google_PageSpeed_Client::for_site(25);
325
326 // Initialize Google Analytics (GA4) client when a property has
327 // been selected. The GA settings UI stores the property in the
328 // Admin API's "properties/XXXXXXXX" form, which is exactly what
329 // the Data API endpoints expect.
330 $ga_property = (string) $this->get_setting('seo_analytics_google_analytics_property_id');
331 if (!empty($access_token) && $ga_property !== '') {
332 if (strpos($ga_property, 'properties/') !== 0) {
333 $ga_property = 'properties/' . $ga_property;
334 }
335 $this->analytics_client = new Google_Analytics_Client('', $ga_property, $timeout, $access_token);
336 }
337 } catch (\Exception $e) {
338 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
339 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
340 error_log('ThinkRank Analytics Init Error: ' . $e->getMessage());
341 }
342 }
343 }
344
345 /**
346 * Initialize token refresh scheduling
347 * Also migrates old absolute-timestamp expires_in values to relative seconds
348 *
349 * @return void
350 */
351 public function init_token_refresh(): void {
352 $access_token = $this->get_setting('google_access_token');
353 $refresh_token = $this->get_setting('google_refresh_token');
354
355 if (empty($access_token) || empty($refresh_token)) {
356 return;
357 }
358
359 // Migrate old expires_in values stored as absolute timestamps
360 $this->maybe_migrate_expires_in();
361
362 // Schedule recurring hourly cron for token refresh
363 $this->schedule_token_refresh();
364 }
365
366 /**
367 * Migrate old expires_in values from absolute timestamps to relative seconds
368 *
369 * Old callback.php stored expires_in as time() + token->expires_in (e.g., 1771330205).
370 * New behavior stores raw seconds from Google (e.g., 3599).
371 *
372 * @return void
373 */
374 private function maybe_migrate_expires_in(): void {
375 $expires_in = (int) $this->get_setting('google_token_expires_in');
376 $created = (int) $this->get_setting('google_token_created');
377
378 // Google tokens expire in 3600 seconds max. If stored value is > 86400,
379 // it's almost certainly the old absolute timestamp format.
380 if ($expires_in > 86400 && $created > 0) {
381 $relative = $expires_in - $created;
382 if ($relative > 0 && $relative <= 7200) {
383 // Valid relative value, save the corrected value
384 $this->settings_manager->update_settings([
385 'google_token_expires_in' => $relative
386 ], 'integrations');
387 } else {
388 // Can't reliably compute, default to standard 3600
389 $this->settings_manager->update_settings([
390 'google_token_expires_in' => 3600
391 ], 'integrations');
392 }
393 $this->merged_settings = null;
394 }
395 }
396
397 /**
398 * Schedule recurring cron for token refresh (every 45 minutes)
399 *
400 * Uses WP recurring cron instead of single events for reliability.
401 * The cron callback checks expiration and only refreshes when needed.
402 * Using 45-minute interval ensures the cron always fires before
403 * Google's ~60-minute token expiry window.
404 *
405 * @return void
406 */
407 public function schedule_token_refresh(): void {
408 $next = wp_next_scheduled('thinkrank_google_token_refresh');
409
410 // If already scheduled with the old 'hourly' interval, reschedule with 45min
411 if ($next) {
412 // Check if it's using the old interval by looking at the schedule
413 $crons = _get_cron_array();
414 foreach ($crons as $timestamp => $cron_hooks) {
415 if (isset($cron_hooks['thinkrank_google_token_refresh'])) {
416 foreach ($cron_hooks['thinkrank_google_token_refresh'] as $hash => $args) {
417 if (($args['schedule'] ?? '') === 'hourly') {
418 // Remove old hourly schedule and re-add with 45min
419 wp_unschedule_event($timestamp, 'thinkrank_google_token_refresh');
420 $next = false; // Will be rescheduled below
421 }
422 }
423 break;
424 }
425 }
426 }
427
428 if (!$next) {
429 wp_schedule_event(time(), 'thinkrank_45min', 'thinkrank_google_token_refresh');
430 }
431 }
432
433 /**
434 * Cron callback for token refresh
435 * Called every 45 minutes; only refreshes if token is expired or expiring soon.
436 *
437 * @return void
438 */
439 public function refresh_access_token_cron(): void {
440 $this->refresh_access_token();
441 }
442
443 /**
444 * Ensure the Google access token is fresh before making API calls.
445 *
446 * This is a static convenience method that can be called from any endpoint
447 * (including the Pro plugin) before making Google API requests.
448 * Uses a per-request flag to avoid redundant refreshes when multiple
449 * endpoints are called in the same HTTP request.
450 *
451 * @since 1.6.0
452 * @return void
453 */
454 public static function ensure_fresh_token(): void {
455 // Only refresh once per HTTP request to avoid parallel race conditions
456 if (self::$token_refreshed_this_request) {
457 return;
458 }
459
460 $manager = new self();
461 $manager->refresh_access_token();
462 self::$token_refreshed_this_request = true;
463 }
464
465 /**
466 * Refresh OAuth access token if expired or expiring soon
467 *
468 * @since 1.5.0
469 * @param bool $force Force refresh even if not expired
470 * @return void
471 */
472 public function refresh_access_token(bool $force = false): void {
473 $refresh_token = $this->get_setting('google_refresh_token');
474
475 // If no refresh token, we can't refresh
476 if (empty($refresh_token)) {
477 return;
478 }
479
480 $expires_in = (int) $this->get_setting('google_token_expires_in');
481 $created = (int) $this->get_setting('google_token_created');
482 $current_time = time();
483
484 // Calculate absolute expiration time (created + relative seconds)
485 $expiration_time = $created + $expires_in;
486
487 // Refresh if forced, expired, or expiring within 5 minutes (300 seconds)
488 if (!$force && $current_time < ($expiration_time - 300)) {
489 return;
490 }
491
492 // A failed exchange leaves google_token_created untouched, so the
493 // expiry condition above stays true and the next request tries again.
494 // Without a backoff a proxy outage means one blocking network call per
495 // request, forever. A forced refresh — the user reconnecting — is a
496 // deliberate act and skips the wait (#383).
497 if (!$force && get_transient(self::REFRESH_BACKOFF)) {
498 return;
499 }
500
501 // One exchange at a time. Concurrent callers past the expiry threshold
502 // would otherwise all refresh at once and invalidate each other's
503 // in-flight grants; the losers fall through with the current token and
504 // pick up the new one on their next read.
505 if (!$force && !$this->acquire_refresh_lock()) {
506 return;
507 }
508
509 try {
510 // The proxy owns the Google app credentials; we only ever hand it
511 // the refresh token and let it perform the exchange.
512 $response = wp_remote_post(Google_OAuth_Proxy::get_proxy_url(), [
513 'headers' => [
514 'Content-Type' => 'application/json',
515 'Accept' => 'application/json',
516 ],
517 'body' => wp_json_encode([
518 'action' => 'refresh',
519 'refresh_token' => $refresh_token,
520 'site' => home_url(),
521 ]),
522 'timeout' => self::REFRESH_TIMEOUT
523 ]);
524
525 if (is_wp_error($response)) {
526 $this->back_off_refresh();
527 return;
528 }
529
530 $body = wp_remote_retrieve_body($response);
531 $data = json_decode($body, true);
532
533 if (empty($data['access_token'])) {
534 // invalid_grant is terminal: the user revoked access in their
535 // Google account, or the refresh token was superseded by a
536 // newer grant. Retrying can never succeed, so stop pretending
537 // the site is connected — otherwise the UI shows "Connected"
538 // while every API call 401s.
539 if (($data['error'] ?? '') === 'invalid_grant') {
540 Google_OAuth_Proxy::mark_revoked();
541 return;
542 }
543
544 // Any other failure (network blip, proxy 502) is transient;
545 // leave the credentials alone and let the next run retry —
546 // after the backoff, not on the very next request.
547 $this->back_off_refresh();
548 return;
549 }
550
551 // Update settings with new token data
552 $this->settings_manager->update_settings([
553 'google_access_token' => $data['access_token'],
554 'google_token_created' => $current_time,
555 'google_token_expires_in' => (int) ($data['expires_in'] ?? 3600)
556 ], 'integrations');
557
558 // Also update refresh token if a new one was returned
559 if (!empty($data['refresh_token'])) {
560 $this->settings_manager->update_settings([
561 'google_refresh_token' => $data['refresh_token']
562 ], 'integrations');
563 }
564
565 // A success clears any backoff a previous failure left behind.
566 delete_transient(self::REFRESH_BACKOFF);
567
568 // Drop the memoized settings merge so subsequent reads (e.g.
569 // re-initializing clients) see the fresh token.
570 $this->merged_settings = null;
571 } finally {
572 $this->release_refresh_lock();
573 }
574 }
575
576 /**
577 * Take the single-flight lock for the refresh exchange.
578 *
579 * @since 2.0.1
580 * @return bool True when this request holds the lock.
581 */
582 private function acquire_refresh_lock(): bool {
583 // With a persistent object cache, add is atomic — memcached and Redis
584 // both fail an ADD on an existing key — so exactly one caller wins.
585 if (wp_using_ext_object_cache()) {
586 return (bool) wp_cache_add(self::REFRESH_LOCK, time(), 'thinkrank', self::REFRESH_LOCK_TTL);
587 }
588
589 // Without one, the options table is the shared store, and the unique
590 // index on option_name gives add_option() the same all-or-nothing
591 // result. set_transient() would not: it is an update, so every
592 // concurrent caller would "win".
593 if (add_option(self::REFRESH_LOCK, time(), '', 'no')) {
594 return true;
595 }
596
597 // Reclaim a lock whose holder died before releasing it.
598 $held = (int) get_option(self::REFRESH_LOCK);
599
600 if ($held > 0 && (time() - $held) > self::REFRESH_LOCK_TTL) {
601 delete_option(self::REFRESH_LOCK);
602
603 return (bool) add_option(self::REFRESH_LOCK, time(), '', 'no');
604 }
605
606 return false;
607 }
608
609 /**
610 * Release the single-flight lock.
611 *
612 * @since 2.0.1
613 * @return void
614 */
615 private function release_refresh_lock(): void {
616 if (wp_using_ext_object_cache()) {
617 wp_cache_delete(self::REFRESH_LOCK, 'thinkrank');
618
619 return;
620 }
621
622 delete_option(self::REFRESH_LOCK);
623 }
624
625 /**
626 * Stop retrying the exchange for a while after a failure.
627 *
628 * @since 2.0.1
629 * @return void
630 */
631 private function back_off_refresh(): void {
632 set_transient(self::REFRESH_BACKOFF, time(), self::REFRESH_BACKOFF_TTL);
633 }
634
635 /**
636 * Test all Google API connections
637 * Following ThinkRank test_connection patterns
638 *
639 * @return array Connection test results
640 */
641 public function test_connections(): array {
642 // Every entry carries a `message`, configured or not: the result is
643 // handed to the Integrations screen as JSON, and a key that exists on
644 // some services and not others reads as `undefined` there.
645 $results = [
646 'google_analytics' => ['status' => 'not_configured', 'message' => ''],
647 'search_console' => ['status' => 'not_configured', 'message' => ''],
648 'pagespeed' => ['status' => 'not_configured', 'message' => '']
649 ];
650
651 // One shape for all three. They were three copies of the same block
652 // differing only in the client, which is how the same unguarded read
653 // came to exist in triplicate (#852).
654 $clients = [
655 'google_analytics' => $this->analytics_client,
656 'search_console' => $this->search_console_client,
657 'pagespeed' => $this->pagespeed_client,
658 ];
659
660 foreach ($clients as $service => $client) {
661 if (!$client) {
662 continue;
663 }
664
665 $results[$service] = $this->describe_connection_test($client);
666 }
667
668 return $results;
669 }
670
671 /**
672 * Run one client's connection test and report it in a fixed shape.
673 *
674 * The clients answer success with `message` and failure with `error`, and
675 * this read `$test_result['message']` unconditionally — so a failed test
676 * raised "Undefined array key" and handed the admin an error with
677 * `message => null`. The reason the client had in hand was the one thing
678 * the screen needed. The clients now set `message` on both branches; the
679 * fallbacks here cover a client that does not, including one that answers
680 * with neither key.
681 *
682 * @since 2.12.0
683 *
684 * @param object $client A client exposing test_connection(): array.
685 * @return array{status:string, message:string, details?:array}
686 */
687 private function describe_connection_test($client): array {
688 try {
689 $test_result = $client->test_connection();
690
691 if (!is_array($test_result)) {
692 return ['status' => 'error', 'message' => ''];
693 }
694
695 return [
696 'status' => !empty($test_result['success']) ? 'connected' : 'error',
697 'message' => (string) ($test_result['message'] ?? $test_result['error'] ?? ''),
698 'details' => $test_result,
699 ];
700 } catch (\Exception $e) {
701 return [
702 'status' => 'error',
703 'message' => $e->getMessage(),
704 ];
705 }
706 }
707
708 /**
709 * Get analytics dashboard data
710 * Combines data from all Google APIs with caching
711 *
712 * @param string $date_range Date range for data
713 * @return array Dashboard data
714 *
715 * @throws \Exception On failure.
716 */
717 public function get_dashboard_data(string $date_range = '30d'): array {
718 $cache_key = self::DASHBOARD_CACHE_PREFIX . $date_range;
719 $cached_data = get_transient($cache_key);
720
721 if ($cached_data !== false) {
722 // Core Web Vitals are cached separately with a much shorter
723 // lifetime than the GSC data (and failures are never cached), so
724 // a transient PageSpeed failure can't blank the CWV card for the
725 // dashboard cache's full 1-3 day TTL.
726 $cached_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
727 return $cached_data;
728 }
729
730 $dashboard_data = [
731 'traffic' => [],
732 'search_performance' => [],
733 'core_web_vitals' => [],
734 'last_updated' => current_time('mysql'),
735 'date_range' => $date_range
736 ];
737
738 $retry_count = 0;
739 $max_retries = 1;
740
741 while ($retry_count <= $max_retries) {
742 try {
743 // Ensure clients are initialized (lazy load) before any of
744 // them are used — this also builds the GA4 client when a
745 // property is configured.
746 if (!$this->search_console_client || !$this->search_analytics_client) {
747 $this->initialize_clients();
748 }
749
750 // Get Google Analytics traffic data. GA is optional — an
751 // isolated failure (misconfigured property, missing scope)
752 // must not abort the Search Console portion of the dashboard.
753 // 401s are re-thrown so the token-refresh retry below runs.
754 if ($this->analytics_client) {
755 try {
756 $dashboard_data['traffic'] = $this->analytics_client->get_traffic_data($date_range);
757 } catch (\Exception $ga_error) {
758 if ($ga_error->getCode() === 401) {
759 throw $ga_error;
760 }
761 $dashboard_data['traffic'] = [];
762 $dashboard_data['traffic_error'] = $ga_error->getMessage();
763 }
764 }
765
766 // Get Search Console data
767 if ($this->search_console_client) {
768 $site_url = $this->get_setting('search_console_property', get_site_url());
769 // Get totals
770 $totals = $this->search_console_client->get_search_totals($site_url, $date_range);
771
772 // Get performance data (keywords) using new client
773 if ($this->search_analytics_client) {
774 // GSC data has a 2-day delay; use D-2 as end_date to match the GSC dashboard.
775 $days = (int) str_replace('d', '', $date_range);
776 $end_date = gmdate('Y-m-d', strtotime('-2 days'));
777 $start_date = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($end_date)));
778
779 $search_performance = $this->search_analytics_client->get_search_analytics_data(
780 $site_url,
781 $start_date,
782 $end_date,
783 ['query'],
784 1000
785 );
786 } else {
787 // Fallback to old client if new one fails init (shouldn't happen if they use same creds)
788 $search_performance = $this->search_console_client->get_search_performance($site_url, $date_range, ['query'], 1000);
789 }
790
791 // Position distribution over every query with an
792 // impression, not over the 1,000-row list above (#913).
793 $position_distribution = $this->search_analytics_client
794 ? $this->count_position_distribution($site_url, $start_date, $end_date, $search_performance['rows'] ?? [])
795 : self::bucket_positions(
796 $search_performance['rows'] ?? [],
797 count($search_performance['rows'] ?? []) < 1000
798 );
799
800 $dashboard_data['search_performance'] = array_merge($search_performance, [
801 'totals' => $totals,
802 'position_distribution' => $position_distribution
803 ]);
804 } // Closing Search Console block
805
806 // If successful, break loop
807 break;
808 } catch (\Exception $e) {
809 // Check for 401 error
810 if ($e->getCode() === 401 && $retry_count < $max_retries) {
811 $this->refresh_access_token(true); // Force refresh
812
813 // Re-initialize clients with new token
814 $this->initialize_clients();
815
816 $retry_count++;
817 continue;
818 }
819 $dashboard_data['error'] = $e->getMessage();
820 break;
821 }
822 }
823
824 // Add last updated timestamp
825 $dashboard_data['last_updated'] = current_time('mysql');
826
827 // Cache the results — but never cache an error payload, otherwise a
828 // transient failure (e.g. a Google API 401) would be served from the
829 // cache for the full TTL even after the underlying issue is fixed.
830 // Core Web Vitals are deliberately NOT part of this cache (see below).
831 if (empty($dashboard_data['error'])) {
832 set_transient($cache_key, $dashboard_data, $this->cache_duration);
833 }
834
835 // Merge Core Web Vitals from their own short-lived cache after the
836 // long-lived GSC payload has been stored.
837 $dashboard_data['core_web_vitals'] = $this->get_dashboard_core_web_vitals();
838
839 return $dashboard_data;
840 }
841
842 /**
843 * Rows per page when counting the position distribution. The most the
844 * Search Analytics API returns in one request.
845 *
846 * @since 2.15.0
847 */
848 private const POSITION_PAGE_SIZE = 25000;
849
850 /**
851 * Pages read before the count stops: 200,000 queries. A property past
852 * that is counted over its top 200,000 by clicks and flagged incomplete.
853 *
854 * @since 2.15.0
855 */
856 private const POSITION_MAX_PAGES = 8;
857
858 /**
859 * Count the period's queries into position buckets across the whole
860 * property (#913).
861 *
862 * The dashboard's query list is capped at 1,000 rows ordered by clicks,
863 * so counting it told any larger site it had exactly 1,000 queries and
864 * dropped the long tail, which is where positions 51-100 live. When that
865 * list came back short it already holds every query and is counted as
866 * is, with no extra request. Otherwise the property is paged with
867 * `startRow` at POSITION_PAGE_SIZE rows until a short page, counting as
868 * rows arrive rather than keeping them.
869 *
870 * A page that fails (other than a 401, which is re-thrown so the token
871 * refresh runs) leaves the count at what was read so far, flagged
872 * `complete: false`, rather than failing the whole dashboard.
873 *
874 * @since 2.15.0
875 *
876 * @param string $site_url Search Console property.
877 * @param string $start_date Window start (Y-m-d).
878 * @param string $end_date Window end (Y-m-d).
879 * @param array $first_rows The capped query list already fetched.
880 * @return array{top_3:int,4_10:int,10_50:int,51_100:int,over_100:int,complete:bool}
881 * @throws \Exception On a 401, so get_dashboard_data() can refresh the token.
882 */
883 private function count_position_distribution(string $site_url, string $start_date, string $end_date, array $first_rows): array {
884 if (count($first_rows) < 1000) {
885 return self::bucket_positions($first_rows, true);
886 }
887
888 $distribution = self::bucket_positions([], true);
889 $start_row = 0;
890
891 for ($page = 0; $page < self::POSITION_MAX_PAGES; $page++) {
892 try {
893 $rows = $this->search_analytics_client->get_search_analytics_data(
894 $site_url,
895 $start_date,
896 $end_date,
897 ['query'],
898 self::POSITION_PAGE_SIZE,
899 $start_row
900 )['rows'] ?? [];
901 } catch (\Exception $e) {
902 if ($e->getCode() === 401) {
903 throw $e;
904 }
905 // Nothing read yet: the capped list is the best there is.
906 $partial = $start_row === 0 ? self::bucket_positions($first_rows, false) : $distribution;
907 $partial['complete'] = false;
908 return $partial;
909 }
910
911 $page_counts = self::bucket_positions($rows, true);
912 foreach (['top_3', '4_10', '10_50', '51_100', 'over_100'] as $bucket) {
913 $distribution[$bucket] += $page_counts[$bucket];
914 }
915
916 if (count($rows) < self::POSITION_PAGE_SIZE) {
917 return $distribution;
918 }
919 $start_row += self::POSITION_PAGE_SIZE;
920 }
921
922 $distribution['complete'] = false;
923 return $distribution;
924 }
925
926 /**
927 * Bucket Search Console rows by average position.
928 *
929 * `10_50` is the historical key for positions 11-50. Rows past 100 are
930 * counted in `over_100`: they still had impressions.
931 *
932 * @since 2.15.0
933 *
934 * @param array $rows Search Console rows.
935 * @param bool $complete Whether $rows is every query in the window.
936 * @return array{top_3:int,4_10:int,10_50:int,51_100:int,over_100:int,complete:bool}
937 */
938 private static function bucket_positions(array $rows, bool $complete): array {
939 $distribution = [
940 'top_3' => 0,
941 '4_10' => 0,
942 '10_50' => 0,
943 '51_100' => 0,
944 'over_100' => 0,
945 'complete' => $complete,
946 ];
947
948 foreach ($rows as $row) {
949 $position = (float) ($row['position'] ?? 0);
950 if ($position <= 3) {
951 $distribution['top_3']++;
952 } elseif ($position <= 10) {
953 $distribution['4_10']++;
954 } elseif ($position <= 50) {
955 $distribution['10_50']++;
956 } elseif ($position <= 100) {
957 $distribution['51_100']++;
958 } else {
959 $distribution['over_100']++;
960 }
961 }
962
963 return $distribution;
964 }
965
966 /**
967 * Get Core Web Vitals for the analytics dashboard, cached independently
968 * of the dashboard payload.
969 *
970 * Successful results are cached for 1 hour; failures are never cached
971 * here (the PageSpeed client itself remembers failures for a few minutes
972 * to avoid re-blocking requests on a broken URL), so CWV recovers as soon
973 * as PageSpeed does instead of staying empty for the dashboard cache's
974 * 1-3 day TTL.
975 *
976 * @return array Core Web Vitals data, or an error payload
977 */
978 private function get_dashboard_core_web_vitals(): array {
979 $cached = get_transient('thinkrank_dashboard_cwv');
980 if (is_array($cached)) {
981 return $cached;
982 }
983
984 if (!$this->pagespeed_client) {
985 $this->initialize_clients();
986 }
987
988 if (!$this->pagespeed_client) {
989 return [];
990 }
991
992 try {
993 $core_web_vitals = $this->pagespeed_client->get_core_web_vitals(get_site_url());
994 set_transient('thinkrank_dashboard_cwv', $core_web_vitals, HOUR_IN_SECONDS);
995 return $core_web_vitals;
996 } catch (\Exception $psi_error) {
997 return [
998 'error' => $psi_error->getMessage(),
999 'note' => 'PageSpeed data unavailable. This is expected on localhost or non-public URLs.'
1000 ];
1001 }
1002 }
1003
1004 /**
1005 * Get SEO opportunities using Search Console data
1006 *
1007 * @param string $date_range Date range for analysis
1008 * @return array SEO opportunities
1009 */
1010 public function get_seo_opportunities(string $date_range = '30d'): array {
1011 $cache_key = self::OPPORTUNITIES_CACHE_PREFIX . $date_range;
1012 $cached_data = get_transient($cache_key);
1013
1014 if ($cached_data !== false) {
1015 return $cached_data;
1016 }
1017
1018 $opportunities = [
1019 'keyword_opportunities' => [],
1020 'page_opportunities' => [],
1021 'device_insights' => [],
1022 'last_updated' => current_time('mysql')
1023 ];
1024
1025 $retry_count = 0;
1026 $max_retries = 1;
1027
1028 while ($retry_count <= $max_retries) {
1029 try {
1030 if ($this->search_console_client) {
1031 $site_url = $this->get_setting('search_console_property', get_site_url());
1032
1033 // Get keyword opportunities
1034 $opportunities['keyword_opportunities'] = $this->search_console_client->get_keyword_opportunities($site_url, $date_range);
1035
1036 // Get device performance insights
1037 $opportunities['device_insights'] = $this->search_console_client->get_device_performance($site_url, $date_range);
1038
1039 // Get search appearance data
1040 $opportunities['search_appearance'] = $this->search_console_client->get_search_appearance($site_url, $date_range);
1041 }
1042
1043 // If successful, break loop
1044 break;
1045 } catch (\Exception $e) {
1046 // Check for 401 error
1047 if ($e->getCode() === 401 && $retry_count < $max_retries) {
1048 $this->refresh_access_token(true); // Force refresh
1049
1050 // Re-initialize clients with new token
1051 $this->initialize_clients();
1052
1053 $retry_count++;
1054 continue;
1055 }
1056
1057 $opportunities['error'] = $e->getMessage();
1058 break;
1059 }
1060 }
1061
1062 // Cache the results — but never cache an error payload (see
1063 // get_dashboard_data() for rationale).
1064 if (empty($opportunities['error'])) {
1065 set_transient($cache_key, $opportunities, $this->cache_duration);
1066 }
1067
1068 return $opportunities;
1069 }
1070
1071 /**
1072 * Memoized merge of the two settings categories this manager reads from.
1073 * Rebuilt when settings are updated through update_settings() below.
1074 *
1075 * @var array|null
1076 */
1077 private ?array $merged_settings = null;
1078
1079 private function get_setting(string $key, $fallback = '') {
1080 if ($this->merged_settings === null) {
1081 // Merge settings to allow access to both categories. Memoized:
1082 // this getter is called many times per request and each category
1083 // read decrypts every sensitive option again.
1084 $this->merged_settings = array_merge(
1085 $this->settings_manager->get_settings('integrations'),
1086 $this->settings_manager->get_settings('seo_analytics')
1087 );
1088 }
1089
1090 return $this->merged_settings[$key] ?? $fallback;
1091 }
1092
1093 /**
1094 * One-click setup for Google Search Console verification
1095 * Following ThinkRank setup patterns
1096 *
1097 * @param string $site_url Site URL to verify
1098 * @return array Setup results
1099 */
1100 public function setup_search_console_verification(string $site_url): array {
1101 try {
1102 if (!$this->search_console_client) {
1103 return [
1104 'success' => false,
1105 'message' => 'Search Console API key not configured'
1106 ];
1107 }
1108
1109 $verification_result = $this->search_console_client->verify_site($site_url);
1110
1111 if ($verification_result['success']) {
1112 // Update settings with verified site URL
1113 $this->settings_manager->update_settings(['search_console_property' => $site_url], 'seo_analytics');
1114 $this->merged_settings = null;
1115 }
1116
1117 return $verification_result;
1118 } catch (\Exception $e) {
1119 return [
1120 'success' => false,
1121 'message' => $e->getMessage()
1122 ];
1123 }
1124 }
1125
1126 /**
1127 * Every transient the dashboard and opportunities caches are written
1128 * under. One list, so "Refresh data", the settings save and any later
1129 * caller clear the same keys the readers use.
1130 *
1131 * @since 2.14.2
1132 * @return string[]
1133 */
1134 public static function dashboard_cache_keys(): array {
1135 $keys = [];
1136 foreach (self::CACHED_DASHBOARD_RANGES as $range) {
1137 $keys[] = self::DASHBOARD_CACHE_PREFIX . $range;
1138 }
1139 foreach (self::CACHED_OPPORTUNITY_RANGES as $range) {
1140 $keys[] = self::OPPORTUNITIES_CACHE_PREFIX . $range;
1141 }
1142 return $keys;
1143 }
1144
1145 /**
1146 * Force refresh of all cached data
1147 *
1148 * @return array Refresh results
1149 */
1150 public function refresh_data(): array {
1151 // Clear all analytics-related transients, including the previous-period
1152 // ranges used for trend comparison (14d/60d/180d) and the separately
1153 // cached Core Web Vitals payload.
1154 $cache_keys = array_merge(
1155 self::dashboard_cache_keys(),
1156 ['indexing_status', 'thinkrank_dashboard_cwv']
1157 );
1158
1159 // Also clear PageSpeed-derived caches. Their keys are md5-derived from
1160 // URL + device, so compute them for the URL/device combinations the
1161 // plugin actually tests.
1162 foreach (array_unique([home_url(), get_site_url()]) as $url) {
1163 foreach (['mobile', 'desktop'] as $device) {
1164 $psi_hash = md5($url . '|' . $device);
1165 $legacy_hash = md5($url . '_' . $device);
1166 $cache_keys[] = 'thinkrank_psi_snapshot_' . $psi_hash;
1167 $cache_keys[] = 'thinkrank_psi_failure_' . $psi_hash;
1168 $cache_keys[] = 'thinkrank_core_web_vitals_' . $legacy_hash;
1169 $cache_keys[] = 'thinkrank_opportunities_' . $legacy_hash;
1170 $cache_keys[] = 'thinkrank_diagnostics_' . $legacy_hash;
1171 }
1172 }
1173
1174 $cleared = 0;
1175 foreach ($cache_keys as $key) {
1176 if (delete_transient($key)) {
1177 $cleared++;
1178 }
1179 }
1180
1181 return [
1182 'success' => true,
1183 'message' => "Cleared {$cleared} cached data entries",
1184 'cleared_count' => $cleared,
1185 'timestamp' => current_time('mysql')
1186 ];
1187 }
1188
1189 /**
1190 * Get client status for debugging
1191 *
1192 * @return array Client status information
1193 */
1194 public function get_client_status(): array {
1195 return [
1196 'google_analytics' => [
1197 'initialized' => !is_null($this->analytics_client),
1198 'api_key_configured' => !empty($this->get_setting('google_analytics_api_key')),
1199 'property_id_configured' => !empty($this->get_setting('seo_analytics_google_analytics_property_id'))
1200 ],
1201 'search_console' => [
1202 'initialized' => !is_null($this->search_console_client),
1203 'api_key_configured' => !empty($this->get_setting('google_search_console_api_key')),
1204 'site_url_configured' => !empty($this->get_setting('search_console_property'))
1205 ],
1206 'pagespeed' => [
1207 'initialized' => !is_null($this->pagespeed_client),
1208 'api_key_configured' => !empty($this->get_setting('google_pagespeed_api_key'))
1209 ],
1210 'cache_duration' => $this->cache_duration,
1211 'last_checked' => current_time('mysql')
1212 ];
1213 }
1214
1215 /**
1216 * Cleanup expired cache data
1217 * Following ThinkRank cache cleanup patterns
1218 *
1219 * @return void
1220 */
1221 public function cleanup_cache(): void {
1222 // WordPress handles transient cleanup automatically
1223 // This method is for future custom cache cleanup if needed
1224 }
1225 }
1226