PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.22
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.22
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync-heartbeat-manager.php

class-metasync-heartbeat-manager.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.22, at includes/class-metasync-heartbeat-manager.php

1,293 lines 47.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 /**
7 * Heartbeat / connection-monitoring manager.
8 *
9 * Extracted from Metasync_Admin to keep the admin class focused on UI concerns.
10 * All cron scheduling, heartbeat API connectivity checks, public-hash fetching,
11 * burst-mode logic, and related logging live here.
12 *
13 * @package Metasync
14 * @subpackage Metasync/includes
15 */
16 class Metasync_Heartbeat_Manager
17 {
18 /** @var self|null */
19 private static $instance = null;
20
21 /**
22 * Get singleton instance.
23 *
24 * @return self
25 */
26 public static function instance()
27 {
28 if (self::$instance === null) {
29 self::$instance = new self();
30 }
31 return self::$instance;
32 }
33
34 private function __construct() {}
35
36 // ------------------------------------------------------------------
37 // Heartbeat connectivity (cache-only for frontend)
38 // ------------------------------------------------------------------
39
40 /**
41 * Check if heartbeat API is properly connected (frontend – cache only).
42 * Frontend should NEVER trigger API calls – only use cached results from cron job.
43 * Returns false immediately if plugin API key is not configured.
44 * Uses graceful fallback to last known state when cache is missing.
45 */
46 public function is_heartbeat_connected($general_settings = null)
47 {
48 if ($general_settings === null) {
49 $general_settings = Metasync::get_option('general') ?? [];
50 }
51
52 $searchatlas_api_key = Metasync::get_searchatlas_api_key();
53 if ($searchatlas_api_key === false) {
54 $searchatlas_api_key = '';
55 }
56
57 if (empty($searchatlas_api_key)) {
58 return false;
59 }
60
61 $cache_key = 'metasync_heartbeat_status_cache';
62 $cached_result = get_transient($cache_key);
63
64 if ($cached_result !== false) {
65 return $cached_result['status'];
66 }
67
68 $last_known_state = $this->get_last_known_connection_state();
69
70 if ($last_known_state !== null) {
71 return $last_known_state;
72 }
73
74 return false;
75 }
76
77 /**
78 * Single source of truth for the admin connection status badge.
79 *
80 * Derives the label from the heartbeat round-trip RESULT that the cron
81 * records in is_heartbeat_connected() — NOT from mere API-key presence,
82 * which stays truthy after Search Atlas revokes a key and is the root cause
83 * of's misleading "Connected" badge.
84 *
85 * Deliberately result-based (200 → connected, non-200/401 → disconnected)
86 * with no time-based "stale" tier: last_heartbeat_at only advances when the
87 * 2h WP-cron actually fires, and WP-cron only fires on traffic, so a
88 * low-traffic-but-healthy site would otherwise be wrongly flagged degraded.
89 *
90 * @param array|null $general_settings Optional pre-loaded general options.
91 * @return array{class:string,text:string,state:string} Badge CSS modifier, label, machine state.
92 */
93 public function get_connection_badge($general_settings = null)
94 {
95 if ($general_settings === null) {
96 $general_settings = Metasync::get_option('general') ?? [];
97 }
98
99 if (!$this->is_heartbeat_connected($general_settings)) {
100 return array('class' => 'disconnected', 'text' => 'Not Connected', 'state' => 'disconnected');
101 }
102
103 return array('class' => 'connected', 'text' => 'Connected', 'state' => 'connected');
104 }
105
106 // ------------------------------------------------------------------
107 // Public-hash fetching (OTTO API)
108 // ------------------------------------------------------------------
109
110 /**
111 * Fetch public hash from OTTO API for the given pixel UUID.
112 *
113 * @param string $otto_pixel_uuid The OTTO pixel UUID to fetch hash for.
114 * @param string $jwt_token The JWT authentication token.
115 * @return string|false The public hash on success, false on failure.
116 */
117 public function fetch_public_hash($otto_pixel_uuid, $jwt_token)
118 {
119 $cache_duration = 3600;
120 $api_timeout = 15;
121 $max_retries = 3;
122 $base_retry_delay = 1;
123
124 if (!$this->validate_fetch_hash_inputs($otto_pixel_uuid, $jwt_token)) {
125 $this->log_fetch_hash_error('error', 'Invalid input parameters provided', [
126 'uuid_provided' => !empty($otto_pixel_uuid),
127 'token_provided' => !empty($jwt_token)
128 ]);
129 return false;
130 }
131
132 $otto_pixel_uuid = sanitize_text_field(trim($otto_pixel_uuid));
133 $jwt_token = sanitize_text_field(trim($jwt_token));
134
135 $cached_hash = $this->get_cached_public_hash($otto_pixel_uuid);
136 if ($cached_hash !== false) {
137 $this->log_fetch_hash_error('info', 'Public hash retrieved from cache', [
138 'uuid' => substr($otto_pixel_uuid, 0, 8) . '...'
139 ]);
140 return $cached_hash;
141 }
142
143 $api_url = $this->build_otto_api_url($otto_pixel_uuid);
144 $headers = $this->prepare_api_headers($jwt_token);
145
146 # Bail before the retry loop if the host is already known to be bad.
147 #
148 # Backoff case: without this, all three attempts are rejected instantly by the
149 # pre_http_request filter, but the loop still sleeps 1s + 2s between them,
150 # holding a PHP worker for 3s to accomplish nothing. Measured at 3.01s.
151 #
152 # Breaker case: the retry loop's worst case against a hanging endpoint is
153 # 3 x 15s timeout + 1s + 2s of sleep = 48s of one worker. The suggestions
154 # path runs on the same host, so if its breaker has tripped we already know
155 # this call cannot succeed. This only helps when page traffic has tripped the
156 # breaker first — an admin loading this page on an idle site still pays the
157 # full 48s, which is why the retry budget itself is flagged for follow-up.
158 $endpoint_unhealthy = (class_exists('Metasync_API_Backoff_Manager')
159 && Metasync_API_Backoff_Manager::get_instance()->is_endpoint_in_backoff($api_url))
160 || (class_exists('Metasync_Otto_Transient_Cache')
161 && Metasync_Otto_Transient_Cache::is_host_breaker_open($api_url));
162
163 if ($endpoint_unhealthy) {
164 $this->log_fetch_hash_error('error', 'Public hash fetch skipped - endpoint unhealthy', [
165 'uuid' => substr($otto_pixel_uuid, 0, 8) . '...'
166 ]);
167 return false;
168 }
169
170 for ($attempt = 1; $attempt <= $max_retries; $attempt++) {
171 $this->log_fetch_hash_error('info', 'Attempting to fetch public hash from API', [
172 'attempt' => $attempt,
173 'max_retries' => $max_retries,
174 'uuid' => substr($otto_pixel_uuid, 0, 8) . '...'
175 ]);
176
177 $response = wp_remote_get($api_url, [
178 'headers' => $headers,
179 'timeout' => $api_timeout,
180 'sslverify' => true,
181 'redirection' => 2,
182 'user-agent' => 'WordPress MetaSync Plugin/' . (defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0')
183 ]);
184
185 if (is_wp_error($response)) {
186 $error_message = $response->get_error_message();
187 $error_code = $response->get_error_code();
188
189 if (class_exists('Metasync_Error_Logger')) {
190 Metasync_Error_Logger::log(
191 Metasync_Error_Logger::CATEGORY_NETWORK_ERROR,
192 Metasync_Error_Logger::SEVERITY_ERROR,
193 'OTTO API network request failed',
194 [
195 'attempt' => $attempt,
196 'error_code' => $error_code,
197 'error_message' => $error_message,
198 'api_endpoint' => 'OTTO Projects API',
199 'operation' => 'fetch_public_hash',
200 'will_retry' => $attempt < $max_retries,
201 'max_retries' => $max_retries
202 ]
203 );
204 }
205
206 $this->log_fetch_hash_error('error', 'HTTP request failed', [
207 'attempt' => $attempt,
208 'error_code' => $error_code,
209 'error_message' => $error_message,
210 'will_retry' => $attempt < $max_retries
211 ]);
212
213 # A backoff block is not a transient network blip — the
214 # endpoint is deliberately closed for minutes. Retrying (and
215 # sleeping between retries) can only burn worker time.
216 if ($error_code === 'api_backoff_active') {
217 return false;
218 }
219
220 if ($attempt < $max_retries) {
221 $this->apply_exponential_backoff($attempt, $base_retry_delay);
222 continue;
223 }
224
225 return false;
226 }
227
228 $result = $this->process_api_response($response, $attempt, $max_retries);
229
230 if ($result === 'retry' && $attempt < $max_retries) {
231 $this->apply_exponential_backoff($attempt, $base_retry_delay);
232 continue;
233 }
234
235 if ($result !== false && $result !== 'retry') {
236 $this->cache_public_hash($otto_pixel_uuid, $result, $cache_duration);
237 $this->log_fetch_hash_error('info', 'Public hash successfully retrieved and cached', [
238 'uuid' => substr($otto_pixel_uuid, 0, 8) . '...',
239 'attempt' => $attempt
240 ]);
241 return $result;
242 }
243
244 break;
245 }
246
247 $this->log_fetch_hash_error('error', 'Failed to fetch public hash after all retry attempts', [
248 'uuid' => substr($otto_pixel_uuid, 0, 8) . '...',
249 'total_attempts' => $max_retries
250 ]);
251
252 return false;
253 }
254
255 // ------------------------------------------------------------------
256 // Public-hash helpers (private)
257 // ------------------------------------------------------------------
258
259 private function validate_fetch_hash_inputs($uuid, $token)
260 {
261 if (empty($uuid) || empty($token)) {
262 return false;
263 }
264 if (!is_string($uuid) || strlen($uuid) < 10) {
265 return false;
266 }
267 if (!is_string($token) || substr_count($token, '.') < 2) {
268 return false;
269 }
270 return true;
271 }
272
273 /**
274 * Build the transient key used to cache the OTTO public hash.
275 *
276 * Centralised so that get / set / clear paths cannot drift apart and so
277 * the two non-heartbeat callers (connect manager, otto debug admin page)
278 * can re-use the same derivation.
279 *
280 * @param string $uuid OTTO pixel UUID.
281 * @return string
282 */
283 public static function public_hash_cache_key($uuid)
284 {
285 return 'metasync_public_hash_' . hash('sha256', $uuid . get_current_blog_id());
286 }
287
288 private function get_cached_public_hash($uuid)
289 {
290 $cache_key = self::public_hash_cache_key($uuid);
291 return get_transient($cache_key);
292 }
293
294 private function cache_public_hash($uuid, $hash, $duration)
295 {
296 $cache_key = self::public_hash_cache_key($uuid);
297 set_transient($cache_key, sanitize_text_field($hash), $duration);
298 }
299
300 private function build_otto_api_url($uuid)
301 {
302 $base_url = class_exists('Metasync_Endpoint_Manager')
303 ? Metasync_Endpoint_Manager::get_endpoint('OTTO_PROJECTS')
304 : 'https://sa.searchatlas.com/api/v2/otto-projects';
305
306 $base_url = rtrim($base_url, '/') . '/';
307
308 return $base_url . urlencode($uuid) . '/';
309 }
310
311 private function prepare_api_headers($jwt_token)
312 {
313 return [
314 'Accept' => 'application/json',
315 'Authorization' => 'Bearer ' . $jwt_token,
316 'Content-Type' => 'application/json',
317 'User-Agent' => 'WordPress MetaSync Plugin/' . (defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0'),
318 'Cache-Control' => 'no-cache',
319 'X-Requested-With' => 'XMLHttpRequest'
320 ];
321 }
322
323 private function process_api_response($response, $attempt, $max_retries)
324 {
325 $status_code = wp_remote_retrieve_response_code($response);
326 $body = wp_remote_retrieve_body($response);
327
328 switch ($status_code) {
329 case 200:
330 return $this->extract_public_hash_from_response($body);
331
332 case 401:
333 case 403:
334 if (class_exists('Metasync_Error_Logger')) {
335 Metasync_Error_Logger::log(
336 Metasync_Error_Logger::CATEGORY_AUTHENTICATION_FAILURE,
337 Metasync_Error_Logger::SEVERITY_ERROR,
338 'OTTO API authentication failed',
339 [
340 'status_code' => $status_code,
341 'attempt' => $attempt,
342 'api_endpoint' => 'OTTO Projects API',
343 'operation' => 'fetch_public_hash',
344 'http_status' => $status_code === 401 ? 'Unauthorized' : 'Forbidden'
345 ]
346 );
347 }
348
349 $this->log_fetch_hash_error('error', 'Authentication failed', [
350 'status_code' => $status_code,
351 'attempt' => $attempt
352 ]);
353 return false;
354
355 case 404:
356 $this->log_fetch_hash_error('error', 'OTTO project not found', [
357 'status_code' => $status_code,
358 'attempt' => $attempt
359 ]);
360 return false;
361
362 case 429:
363 if (class_exists('Metasync_Error_Logger')) {
364 Metasync_Error_Logger::log(
365 Metasync_Error_Logger::CATEGORY_API_RATE_LIMIT,
366 Metasync_Error_Logger::SEVERITY_WARNING,
367 'OTTO API throttled upstream',
368 [
369 'status_code' => $status_code,
370 'attempt' => $attempt,
371 'will_retry' => $attempt < $max_retries,
372 'api_endpoint' => 'OTTO Projects API',
373 'operation' => 'fetch_public_hash',
374 'max_retries' => $max_retries
375 ]
376 );
377 }
378
379 $this->log_fetch_hash_error('warning', 'API rate limit exceeded', [
380 'status_code' => $status_code,
381 'attempt' => $attempt,
382 'will_retry' => $attempt < $max_retries
383 ]);
384 return 'retry';
385
386 case 500:
387 case 502:
388 case 503:
389 case 504:
390 $this->log_fetch_hash_error('warning', 'Server error encountered', [
391 'status_code' => $status_code,
392 'attempt' => $attempt,
393 'will_retry' => $attempt < $max_retries
394 ]);
395 return 'retry';
396
397 default:
398 $this->log_fetch_hash_error('error', 'Unexpected HTTP status code', [
399 'status_code' => $status_code,
400 'attempt' => $attempt,
401 'response_body' => substr($body, 0, 200)
402 ]);
403 return false;
404 }
405 }
406
407 private function extract_public_hash_from_response($body)
408 {
409 if (empty($body)) {
410 $this->log_fetch_hash_error('error', 'Empty response body received');
411 return false;
412 }
413
414 $data = json_decode($body, true);
415 $json_error = json_last_error();
416
417 if ($json_error !== JSON_ERROR_NONE) {
418 $this->log_fetch_hash_error('error', 'Invalid JSON response', [
419 'json_error' => $json_error,
420 'json_error_msg' => json_last_error_msg(),
421 'body_preview' => substr($body, 0, 200)
422 ]);
423 return false;
424 }
425
426 if (!is_array($data)) {
427 $this->log_fetch_hash_error('error', 'Response data is not an array', [
428 'data_type' => gettype($data)
429 ]);
430 return false;
431 }
432
433 $possible_hash_fields = [
434 'public_share_hash',
435 'public_hash',
436 'publicHash',
437 'hash',
438 'public_key',
439 'publicKey'
440 ];
441
442 foreach ($possible_hash_fields as $field) {
443 if (isset($data[$field]) && !empty($data[$field]) && is_string($data[$field])) {
444 $hash = sanitize_text_field(trim($data[$field]));
445
446 if (preg_match('/^[a-zA-Z0-9_-]{10,}$/', $hash)) {
447 $this->log_fetch_hash_error('info', 'Public hash extracted successfully', [
448 'field_name' => $field,
449 'hash_length' => strlen($hash)
450 ]);
451 return $hash;
452 }
453 }
454 }
455
456 $this->log_fetch_hash_error('error', 'Public hash not found in response', [
457 'available_fields' => array_keys($data),
458 'searched_fields' => $possible_hash_fields
459 ]);
460
461 return false;
462 }
463
464 private function apply_exponential_backoff($attempt, $base_delay)
465 {
466 $delay = $base_delay * pow(2, $attempt - 1);
467 $max_delay = 30;
468 $delay = min($delay, $max_delay);
469
470 if (class_exists('Metasync_Error_Logger')) {
471 Metasync_Error_Logger::log(
472 Metasync_Error_Logger::CATEGORY_API_BACKOFF,
473 Metasync_Error_Logger::SEVERITY_INFO,
474 'Retry scheduled - applying backoff delay',
475 [
476 'attempt' => $attempt,
477 'delay_seconds' => $delay,
478 'base_delay' => $base_delay,
479 'max_delay' => $max_delay,
480 'api_endpoint' => 'OTTO Projects API',
481 'operation' => 'fetch_public_hash'
482 ]
483 );
484 }
485
486 $this->log_fetch_hash_error('info', 'Applying retry delay', [
487 'attempt' => $attempt,
488 'delay_seconds' => $delay
489 ]);
490
491 sleep($delay);
492 }
493
494 private function log_fetch_hash_error($level, $message, $context = [])
495 {
496 if ($level === 'info') {
497 return;
498 }
499
500 $full_context = array_merge([
501 'operation' => 'fetch_public_hash',
502 'timestamp' => current_time('mysql'),
503 'site_url' => get_site_url()
504 ], $context);
505
506 $log_message = sprintf(
507 'OTTO_API_%s: %s',
508 strtoupper($level),
509 $message
510 );
511
512 if (!empty($full_context)) {
513 $context_parts = [];
514 foreach ($full_context as $key => $value) {
515 if (is_array($value)) {
516 $value = json_encode($value);
517 } elseif (is_bool($value)) {
518 $value = $value ? 'true' : 'false';
519 } elseif (is_string($value) && strlen($value) > 100) {
520 $value = substr($value, 0, 100) . '...';
521 }
522 $context_parts[] = "{$key}={$value}";
523 }
524 $log_message .= ' | ' . implode(', ', $context_parts);
525 }
526
527 error_log($log_message);
528 }
529
530 public function clear_public_hash_cache($otto_pixel_uuid = '')
531 {
532 if (empty($otto_pixel_uuid)) {
533 $general_options = Metasync::get_option('general');
534 $otto_pixel_uuid = isset($general_options['otto_pixel_uuid']) ? $general_options['otto_pixel_uuid'] : '';
535 }
536
537 if (!empty($otto_pixel_uuid)) {
538 $cache_key = self::public_hash_cache_key($otto_pixel_uuid);
539 delete_transient($cache_key);
540 }
541 }
542
543 // ------------------------------------------------------------------
544 // Connection state persistence
545 // ------------------------------------------------------------------
546
547 private function get_last_known_connection_state()
548 {
549 return get_option('metasync_last_known_connection_state', null);
550 }
551
552 private function set_last_known_connection_state($is_connected)
553 {
554 $success = update_option('metasync_last_known_connection_state', (bool) $is_connected);
555 return $success;
556 }
557
558 // ------------------------------------------------------------------
559 // Logging helpers
560 // ------------------------------------------------------------------
561
562 private function log_heartbeat($level, $event, $details = array())
563 {
564 if ($level == 'info') {
565 return;
566 }
567 if ($this->should_throttle_log($level, $event, $details)) {
568 return;
569 }
570
571 $context = array(
572 'event' => $event,
573 'level' => strtoupper($level),
574 'plugin_version' => defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown',
575 'site_url' => get_site_url(),
576 );
577
578 $context = array_merge($context, $details);
579
580 $message = sprintf(
581 'HEARTBEAT_%s: %s',
582 strtoupper($level),
583 $event
584 );
585
586 if (!empty($details)) {
587 $details_formatted = array();
588 foreach ($details as $key => $value) {
589 if (is_array($value)) {
590 $value = json_encode($value);
591 } elseif (is_bool($value)) {
592 $value = $value ? 'true' : 'false';
593 } elseif (is_string($value) && strlen($value) > 200) {
594 $value = $this->smart_truncate($value, 200);
595 }
596 $details_formatted[] = "{$key}={$value}";
597 }
598 $message .= ' | ' . implode(', ', $details_formatted);
599 }
600
601 error_log($message);
602
603 if ($level === 'error' || $level === 'critical') {
604 $this->store_heartbeat_error_log($event, $details);
605 }
606 }
607
608 private function smart_truncate($string, $length = 200)
609 {
610 if (strlen($string) <= $length) {
611 return $string;
612 }
613
614 $truncated = substr($string, 0, $length);
615 $last_space = strrpos($truncated, ' ');
616
617 if ($last_space !== false && $last_space > $length * 0.75) {
618 $truncated = substr($truncated, 0, $last_space);
619 }
620
621 $truncated = strip_tags($truncated);
622 return $truncated . '... [truncated]';
623 }
624
625 private function should_throttle_log($level, $event, $details = array())
626 {
627 if ($level === 'error' || $level === 'critical') {
628 return false;
629 }
630
631 if (strpos($event, 'Cache hit') !== false || strpos($event, 'No cached heartbeat status found') !== false) {
632 static $last_cache_log_time = 0;
633 static $last_cache_status = '';
634 $current_time = time();
635
636 $current_status = isset($details['status']) ? $details['status'] : 'UNKNOWN';
637
638 if ($current_status !== $last_cache_status ||
639 ($current_time - $last_cache_log_time) > 300 ||
640 $last_cache_log_time === 0) {
641
642 $last_cache_log_time = $current_time;
643 $last_cache_status = $current_status;
644 return false;
645 }
646
647 return true;
648 }
649
650 return false;
651 }
652
653 private function store_heartbeat_error_log($event, $details)
654 {
655 try {
656 if (class_exists('Metasync_HeartBeat_Error_Monitor_Database')) {
657 $error_db = new Metasync_HeartBeat_Error_Monitor_Database();
658 $error_db->add(array(
659 'attribute_name' => 'heartbeat_connectivity',
660 'object_count' => 1,
661 'error_description' => json_encode(array(
662 'event' => $event,
663 'details' => $details,
664 'timestamp' => current_time('mysql')
665 )),
666 'created_at' => current_time('mysql')
667 ));
668 }
669 } catch (Exception $e) {
670 error_log('Failed to store heartbeat error log: ' . $e->getMessage());
671 }
672 }
673
674 // ------------------------------------------------------------------
675 // Heartbeat API connectivity test
676 // ------------------------------------------------------------------
677
678 private function test_heartbeat_api_connection($general_settings)
679 {
680 $searchatlas_api_key = Metasync::get_searchatlas_api_key();
681 if ($searchatlas_api_key === false) {
682 $searchatlas_api_key = '';
683 }
684 $apikey = $general_settings['apikey'] ?? '';
685
686 $start_time = microtime(true);
687 $api_key_type = strpos($searchatlas_api_key, 'pub-') === 0 ? 'publisher' : 'regular';
688
689 $sync_request = new Metasync_Sync_Requests();
690 $response = $sync_request->SyncCustomerParams($apikey);
691
692 $request_duration = round((microtime(true) - $start_time) * 1000, 2);
693
694 if (is_wp_error($response)) {
695 $this->log_heartbeat('error', 'Heartbeat test via SyncCustomerParams failed', array(
696 'error_code' => $response->get_error_code(),
697 'error_message' => $response->get_error_message(),
698 'request_duration_ms' => $request_duration,
699 'error_type' => 'wp_error'
700 ));
701 return false;
702 }
703
704 $status_code = wp_remote_retrieve_response_code($response);
705 $body = wp_remote_retrieve_body($response);
706
707 if ($status_code !== 200) {
708 $this->log_heartbeat('error', 'Heartbeat test returned non-200 status', array(
709 'status_code' => $status_code,
710 'response_body' => $this->smart_truncate($body, 300),
711 'request_duration_ms' => $request_duration,
712 'error_type' => 'http_status_error'
713 ));
714 return false;
715 }
716
717 # Throttle timestamp lives in a dedicated option key so it never
718 # round-trips the main blob.
719 Metasync::set_heartbeat_throttle(['last_heartbeat_at' => gmdate('Y-m-d\TH:i:s\Z')]);
720
721 # Re-read the fresh blob immediately before writing send_auth_token_timestamp
722 # so a concurrent settings save during this code path is not clobbered.
723 $_fresh = Metasync::get_option();
724 if (!isset($_fresh['general'])) { $_fresh['general'] = []; }
725 $_fresh['general']['send_auth_token_timestamp'] = current_time('mysql');
726 Metasync::set_option($_fresh);
727
728 return true;
729 }
730
731 // ------------------------------------------------------------------
732 // Cron scheduling
733 // ------------------------------------------------------------------
734
735 public function schedule_heartbeat_cron()
736 {
737 $this->unschedule_heartbeat_cron();
738
739 if (!wp_next_scheduled('metasync_heartbeat_cron_check')) {
740 $scheduled = wp_schedule_event(time(), 'metasync_every_2_hours', 'metasync_heartbeat_cron_check');
741
742 if (!$scheduled) {
743 $this->log_heartbeat('error', 'Failed to schedule heartbeat cron job');
744 }
745 }
746 }
747
748 /**
749 * Build the heartbeat API URL for backoff check (same host as SyncCustomerParams uses).
750 *
751 * @param array $general_settings Plugin general options.
752 * @return string|null Heartbeat URL or null if not determinable.
753 */
754 private function get_heartbeat_api_url_for_backoff_check($general_settings) {
755 $api_key = Metasync::get_searchatlas_api_key();
756 if ($api_key === false) {
757 $api_key = '';
758 }
759 if (empty($api_key)) {
760 return null;
761 }
762 if (strpos($api_key, 'pub-') === 0) {
763 $domain = class_exists('Metasync_Endpoint_Manager')
764 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
765 : Metasync::API_DOMAIN;
766 return $domain . '/api/publisher/one-click-publishing/wp-website-heartbeat/';
767 }
768 $domain = class_exists('Metasync_Endpoint_Manager')
769 ? Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN')
770 : Metasync::CA_API_DOMAIN;
771 return $domain . '/api/wp-website-heartbeat/';
772 }
773
774 /**
775 * Validate a candidate API key against the Search Atlas backend.
776 *
777 * Public wrapper around `connection_ping()` for use outside this class
778 * (e.g. pre-save validation in the settings handler).
779 *
780 * @param string $api_key The API key to validate.
781 * @return array{connected: bool, otto_pixel_uuid: string|null, network_error?: bool}
782 */
783 public function validate_api_key( string $api_key ): array
784 {
785 return $this->connection_ping( $api_key );
786 }
787
788 /**
789 * Lightweight connection-ping — checks whether the site is registered on the backend
790 * without sending any heavy payload or triggering Celery tasks.
791 *
792 * @param string|null $api_key_override Optional API key to use instead of the saved key.
793 * @return array{connected: bool, otto_pixel_uuid: string|null, network_error?: bool}
794 */
795 private function connection_ping( ?string $api_key_override = null ): array
796 {
797 $default = ['connected' => false, 'otto_pixel_uuid' => null];
798
799 $searchatlas_api_key = Metasync::get_searchatlas_api_key();
800 if ($searchatlas_api_key === false) {
801 $searchatlas_api_key = '';
802 }
803
804 if ( $api_key_override !== null && $api_key_override !== '' ) {
805 $searchatlas_api_key = $api_key_override;
806 }
807
808 if (empty($searchatlas_api_key)) {
809 $this->log_heartbeat('error', 'Connection ping skipped - no API key configured');
810 return $default;
811 }
812
813 $raw_hostname = wp_parse_url(get_home_url(), PHP_URL_HOST);
814 $bare_hostname = preg_replace('/^www\./i', '', $raw_hostname);
815
816 // Try both original and www-stripped hostnames to support sites registered
817 // with or without www on SearchAtlas.
818 $hostnames_to_try = ($raw_hostname !== $bare_hostname)
819 ? [$raw_hostname, $bare_hostname]
820 : [$bare_hostname];
821
822 if (strpos($searchatlas_api_key, 'pub-') === 0) {
823 $domain = class_exists('Metasync_Endpoint_Manager')
824 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
825 : Metasync::API_DOMAIN;
826 $base_url = $domain . '/api/publisher/one-click-publishing/wp-website-heartbeat/connection-ping/?hostname=';
827 } else {
828 $domain = class_exists('Metasync_Endpoint_Manager')
829 ? Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN')
830 : Metasync::CA_API_DOMAIN;
831 $base_url = $domain . '/api/wp-website-heartbeat/connection-ping/?hostname=';
832 }
833
834 foreach ($hostnames_to_try as $hostname) {
835 $url = $base_url . rawurlencode($hostname);
836
837 $response = wp_remote_get($url, [
838 'headers' => ['x-api-key' => $searchatlas_api_key],
839 'timeout' => 10,
840 ]);
841
842 if (is_wp_error($response)) {
843 $this->log_heartbeat('error', 'Connection ping failed (WP_Error)', [
844 'hostname' => $hostname,
845 'error_code' => $response->get_error_code(),
846 'error_message' => $response->get_error_message(),
847 ]);
848 $default['network_error'] = true;
849 return $default;
850 }
851
852 $status_code = wp_remote_retrieve_response_code($response);
853 if ($status_code !== 200) {
854 $this->log_heartbeat('error', 'Connection ping returned non-200 status', [
855 'hostname' => $hostname,
856 'status_code' => $status_code,
857 'response_body' => $this->smart_truncate(wp_remote_retrieve_body($response), 300),
858 ]);
859 return $default;
860 }
861
862 $body = wp_remote_retrieve_body($response);
863 $data = json_decode($body, true);
864
865 if (!is_array($data)) {
866 $this->log_heartbeat('error', 'Connection ping returned invalid JSON', [
867 'hostname' => $hostname,
868 'response_body' => $this->smart_truncate($body, 300),
869 ]);
870 return $default;
871 }
872
873 $connected = (bool) ($data['connected'] ?? false);
874 $otto_pixel_uuid = $data['otto_pixel_uuid'] ?? null;
875
876 if ($connected) {
877 $this->log_heartbeat('info', 'Connection ping completed', [
878 'hostname' => $hostname,
879 'connected' => true,
880 'uuid_prefix' => $otto_pixel_uuid ? substr($otto_pixel_uuid, 0, 8) . '...' : 'none',
881 ]);
882 return ['connected' => true, 'otto_pixel_uuid' => $otto_pixel_uuid];
883 }
884 }
885
886 $this->log_heartbeat('info', 'Connection ping completed — not connected on any hostname variant', [
887 'hostnames_tried' => $hostnames_to_try,
888 ]);
889
890 return $default;
891 }
892
893 public function unschedule_heartbeat_cron()
894 {
895 $timestamp = wp_next_scheduled('metasync_heartbeat_cron_check');
896 if ($timestamp) {
897 wp_unschedule_event($timestamp, 'metasync_heartbeat_cron_check');
898 }
899 }
900
901 /**
902 * Background cron job execution – performs actual heartbeat check.
903 * This method should ONLY be called by the cron job, never by frontend.
904 */
905 public function execute_heartbeat_cron_check()
906 {
907 $general_settings = Metasync::get_option('general') ?? [];
908
909 $searchatlas_api_key = Metasync::get_searchatlas_api_key();
910 if ($searchatlas_api_key === false) {
911 $searchatlas_api_key = '';
912 }
913
914 if (empty($searchatlas_api_key)) {
915 $cache_data = array(
916 'status' => false,
917 'timestamp' => time(),
918 'cached_until' => time() + 300,
919 'updated_by' => 'cron_job_no_api_key'
920 );
921
922 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
923
924 $this->set_last_known_connection_state(false);
925
926 return false;
927 }
928
929 $is_connected = $this->test_heartbeat_api_connection($general_settings);
930
931 // When heartbeat failed due to api_backoff_active, do not overwrite cache or last_known.
932 // This preserves the optimistic CONNECTED state set after callback so the dashboard iframe stays visible.
933 if (!$is_connected && class_exists('Metasync_API_Backoff_Manager')) {
934 $heartbeat_url = $this->get_heartbeat_api_url_for_backoff_check($general_settings);
935 $backoff_manager = Metasync_API_Backoff_Manager::get_instance();
936 if ($heartbeat_url && $backoff_manager->is_endpoint_in_backoff($heartbeat_url)) {
937 return false;
938 }
939 }
940
941 $cache_data = array(
942 'status' => $is_connected,
943 'timestamp' => time(),
944 'cached_until' => time() + 300,
945 'updated_by' => 'cron_job'
946 );
947
948 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
949
950 $this->set_last_known_connection_state($is_connected);
951
952 return $is_connected;
953 }
954
955 /**
956 * Add custom cron schedule for 2-hour intervals and daily cleanup.
957 */
958 public function add_heartbeat_cron_schedule($schedules)
959 {
960 $schedules['metasync_every_2_hours'] = array(
961 'interval' => 2 * HOUR_IN_SECONDS,
962 'display' => esc_html(sprintf(__('Every 2 Hours (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
963 );
964
965 $schedules['metasync_every_2_minutes'] = array(
966 'interval' => 2 * MINUTE_IN_SECONDS,
967 'display' => esc_html(sprintf(__('Every 2 Minutes (%s Burst)', 'metasync'), Metasync::get_effective_plugin_name()))
968 );
969 $schedules['metasync_every_5_minutes'] = array(
970 'interval' => 5 * MINUTE_IN_SECONDS,
971 'display' => esc_html(sprintf(__('Every 5 Minutes (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
972 );
973 // 10-minute heartbeat cadence used for UNREGISTERED + KEY_PENDING short-interval behavior
974 $schedules['metasync_every_10_minutes'] = array(
975 'interval' => 10 * MINUTE_IN_SECONDS,
976 'display' => esc_html(sprintf(__('Every 10 Minutes (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
977 );
978
979 $schedules['metasync_daily_cleanup'] = array(
980 'interval' => DAY_IN_SECONDS,
981 'display' => esc_html(sprintf(__('Daily (%s Cleanup)', 'metasync'), Metasync::get_effective_plugin_name()))
982 );
983
984 $schedules['metasync_weekly'] = array(
985 'interval' => 7 * DAY_IN_SECONDS,
986 'display' => esc_html(sprintf(__('Weekly (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
987 );
988
989 return $schedules;
990 }
991
992 // ------------------------------------------------------------------
993 // Heartbeat state machine (PR3)
994 // ------------------------------------------------------------------
995
996 public function get_heartbeat_state()
997 {
998 $general = Metasync::get_option('general') ?? [];
999 $api_key = Metasync::get_searchatlas_api_key();
1000 if ($api_key === false) {
1001 $api_key = '';
1002 }
1003 if (empty($api_key)) {
1004 return 'UNREGISTERED';
1005 }
1006 $state = $general['heartbeat_state'] ?? '';
1007 return ($state === 'CONNECTED') ? 'CONNECTED' : 'KEY_PENDING';
1008 }
1009
1010 public function set_heartbeat_state_key_pending()
1011 {
1012 $options = Metasync::get_option();
1013 if (!isset($options['general'])) {
1014 $options['general'] = [];
1015 }
1016 $options['general']['heartbeat_state'] = 'KEY_PENDING';
1017 $options['general']['heartbeat_state_changed_at'] = time();
1018 Metasync::set_option($options);
1019 delete_option('metasync_burst_attempt_count');
1020 $this->maybe_schedule_heartbeat_cron();
1021 }
1022
1023 /**
1024 * PR3: Burst cron — run heartbeat when state is KEY_PENDING.
1025 * After 5 attempts with no confirmation, stop burst and fall back to 2-hour cron.
1026 */
1027 public function execute_burst_heartbeat()
1028 {
1029 if ($this->get_heartbeat_state() !== 'KEY_PENDING') {
1030 return;
1031 }
1032 $count = (int) get_option('metasync_burst_attempt_count', 0);
1033 $count++;
1034 update_option('metasync_burst_attempt_count', $count);
1035
1036 $ping = $this->connection_ping();
1037
1038 if ($ping['connected'] === true) {
1039 $options = Metasync::get_option();
1040 $general = $options['general'] ?? [];
1041 $general['heartbeat_state'] = 'CONNECTED';
1042 $general['heartbeat_state_changed_at'] = time();
1043
1044 if (!empty($ping['otto_pixel_uuid']) && (empty($general['otto_pixel_uuid']) || $general['otto_pixel_uuid'] !== $ping['otto_pixel_uuid'])) {
1045 $general['otto_pixel_uuid'] = sanitize_text_field($ping['otto_pixel_uuid']);
1046 }
1047
1048 $options['general'] = $general;
1049 Metasync::set_option($options);
1050
1051 $cache_data = [
1052 'status' => true,
1053 'timestamp' => time(),
1054 'cached_until' => time() + 300,
1055 'updated_by' => 'burst_ping_connected',
1056 ];
1057 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
1058
1059 $this->log_heartbeat('info', 'Burst ping: site is CONNECTED', [
1060 'attempt' => $count,
1061 'otto_pixel_uuid' => !empty($ping['otto_pixel_uuid']) ? substr($ping['otto_pixel_uuid'], 0, 8) . '...' : 'none',
1062 ]);
1063
1064 delete_option('metasync_burst_attempt_count');
1065 $this->unschedule_burst_heartbeat_cron();
1066 $this->maybe_schedule_heartbeat_cron();
1067 return;
1068 }
1069
1070 $this->log_heartbeat('info', 'Burst ping: not connected yet', ['attempt' => $count]);
1071
1072 if ($count >= 5) {
1073 $this->unschedule_burst_heartbeat_cron();
1074 update_option('metasync_burst_gave_up', true);
1075 if (!wp_next_scheduled('metasync_heartbeat_cron_check')) {
1076 wp_schedule_event(time(), 'metasync_every_2_hours', 'metasync_heartbeat_cron_check');
1077 }
1078 }
1079 }
1080
1081 /**
1082 * Announce cron — send pre-SSO announce when state is UNREGISTERED.
1083 * Hard cap of 5 total pings per activation lifecycle (ping 1 on activation, pings 2-5 here).
1084 */
1085 public function execute_announce_cron()
1086 {
1087 $general = Metasync::get_option('general') ?? [];
1088 $announce_api_key = Metasync::get_searchatlas_api_key();
1089 if ($announce_api_key === false) {
1090 $announce_api_key = '';
1091 }
1092 if (!empty($announce_api_key)) {
1093 $this->unschedule_announce_cron();
1094 return;
1095 }
1096
1097 $count = (int) get_option('metasync_announce_attempt_count', 0);
1098 if ($count >= 5) {
1099 $this->unschedule_announce_cron();
1100 return;
1101 }
1102
1103 $count++;
1104 update_option('metasync_announce_attempt_count', $count);
1105
1106 if (!class_exists('Metasync_Activator')) {
1107 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-activator.php';
1108 }
1109 Metasync_Activator::send_announce_ping();
1110
1111 if ($count >= 5) {
1112 $this->unschedule_announce_cron();
1113 }
1114 }
1115
1116 public function unschedule_burst_heartbeat_cron()
1117 {
1118 $timestamp = wp_next_scheduled('metasync_burst_heartbeat');
1119 if ($timestamp) {
1120 wp_unschedule_event($timestamp, 'metasync_burst_heartbeat');
1121 }
1122 }
1123
1124 public function unschedule_announce_cron()
1125 {
1126 $timestamp = wp_next_scheduled('metasync_announce_cron');
1127 if ($timestamp) {
1128 wp_unschedule_event($timestamp, 'metasync_announce_cron');
1129 }
1130 }
1131
1132 /**
1133 * Maybe schedule heartbeat cron job based on PR3 state.
1134 * UNREGISTERED: announce cron every 10 min. KEY_PENDING: burst every 10 min. CONNECTED: 2-hour only.
1135 *
1136 * Throttled to run at most once per 5 minutes to prevent concurrent page loads
1137 * from racing on the wp_cron option and producing `could_not_set` errors.
1138 */
1139 public function maybe_schedule_heartbeat_cron()
1140 {
1141 // Skip if already evaluated recently — avoids cron-option race conditions on busy sites
1142 if (get_transient('metasync_cron_schedule_checked')) {
1143 return;
1144 }
1145 // Atomic lock: only one process should schedule cron events at a time.
1146 // wp_schedule_event() modifies the shared cron option; concurrent calls
1147 // race on the same row and produce `could_not_set` DB errors.
1148 $lock_key = 'metasync_cron_schedule_lock';
1149 if (!get_transient($lock_key)) {
1150 set_transient($lock_key, 1, 30); // 30-second lock
1151 } else {
1152 return; // Another process is already scheduling
1153 }
1154 set_transient('metasync_cron_schedule_checked', 1, 5 * MINUTE_IN_SECONDS);
1155
1156 $state = $this->get_heartbeat_state();
1157
1158 if ($state === 'UNREGISTERED') {
1159 $this->unschedule_heartbeat_cron();
1160 $this->unschedule_burst_heartbeat_cron();
1161 if (!wp_next_scheduled('metasync_announce_cron') && (int) get_option('metasync_announce_attempt_count', 0) < 5) {
1162 wp_schedule_event(time(), 'metasync_every_10_minutes', 'metasync_announce_cron');
1163 }
1164 return;
1165 }
1166
1167 $this->unschedule_announce_cron();
1168
1169 if ($state === 'KEY_PENDING') {
1170 if (get_option('metasync_burst_gave_up')) {
1171 $this->unschedule_burst_heartbeat_cron();
1172 if (!wp_next_scheduled('metasync_heartbeat_cron_check')) {
1173 wp_schedule_event(time(), 'metasync_every_2_hours', 'metasync_heartbeat_cron_check');
1174 }
1175 return;
1176 }
1177 $this->unschedule_heartbeat_cron();
1178 $this->unschedule_burst_heartbeat_cron();
1179 if (!wp_next_scheduled('metasync_burst_heartbeat')) {
1180 delete_option('metasync_burst_attempt_count');
1181 wp_schedule_event(time(), 'metasync_every_10_minutes', 'metasync_burst_heartbeat');
1182 }
1183 return;
1184 }
1185
1186 if ($state === 'CONNECTED') {
1187 $this->unschedule_burst_heartbeat_cron();
1188 delete_option('metasync_burst_attempt_count');
1189 delete_option('metasync_burst_gave_up');
1190 $this->schedule_heartbeat_cron();
1191 }
1192 }
1193
1194
1195 // ------------------------------------------------------------------
1196 // Immediate heartbeat trigger
1197 // ------------------------------------------------------------------
1198
1199 public function trigger_immediate_heartbeat_check($context = 'Manual trigger')
1200 {
1201 static $last_immediate_check = 0;
1202 $current_time = time();
1203
1204 if (($current_time - $last_immediate_check) < 10) {
1205 return true;
1206 }
1207
1208 $last_immediate_check = $current_time;
1209
1210 $general_settings = Metasync::get_option('general') ?? [];
1211 $searchatlas_api_key = Metasync::get_searchatlas_api_key();
1212 if ($searchatlas_api_key === false) {
1213 $searchatlas_api_key = '';
1214 }
1215
1216 if (empty($searchatlas_api_key)) {
1217 delete_transient('metasync_heartbeat_status_cache');
1218 Metasync_Admin_Navigation::invalidate_admin_bar_status_cache();
1219
1220 $cache_data = array(
1221 'status' => false,
1222 'timestamp' => time(),
1223 'cached_until' => time() + 300,
1224 'updated_by' => 'immediate_check_no_api_key'
1225 );
1226
1227 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
1228
1229 $this->set_last_known_connection_state(false);
1230
1231 return false;
1232 }
1233
1234 delete_transient('metasync_heartbeat_status_cache');
1235 Metasync_Admin_Navigation::invalidate_admin_bar_status_cache();
1236
1237 $result = $this->execute_heartbeat_cron_check();
1238
1239 return $result;
1240 }
1241
1242 public function handle_immediate_heartbeat_trigger($context = 'WordPress action trigger')
1243 {
1244 $this->trigger_immediate_heartbeat_check($context);
1245 }
1246
1247 // ------------------------------------------------------------------
1248 // AJAX burst ping (PR3)
1249 // ------------------------------------------------------------------
1250
1251 public function ajax_burst_ping()
1252 {
1253 if (!Metasync::current_user_has_plugin_access() || empty($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'metasync_burst_ping')) {
1254 wp_send_json_error(array('message' => 'Unauthorized'));
1255 return;
1256 }
1257 $state = $this->get_heartbeat_state();
1258 if ($state === 'KEY_PENDING') {
1259 $_POST['is_heart_beat'] = true;
1260 $_POST['is_burst'] = true;
1261 $general = Metasync::get_option('general') ?? [];
1262 $apikey = $general['apikey'] ?? '';
1263 $sync = new Metasync_Sync_Requests();
1264 $sync->SyncCustomerParams($apikey);
1265 $state = $this->get_heartbeat_state();
1266 if ($state === 'CONNECTED') {
1267 $this->maybe_schedule_heartbeat_cron();
1268 }
1269 wp_send_json_success(array('state' => $state, 'heartbeat_confirmed' => ($state === 'CONNECTED')));
1270 return;
1271 }
1272 wp_send_json_success(array('state' => $state, 'heartbeat_confirmed' => true));
1273 }
1274
1275 // ------------------------------------------------------------------
1276 // Heartbeat cache update after sync
1277 // ------------------------------------------------------------------
1278
1279 public function update_heartbeat_cache_after_sync($is_connected, $context = 'Sync operation')
1280 {
1281 $cache_data = array(
1282 'status' => $is_connected,
1283 'timestamp' => time(),
1284 'cached_until' => time() + 300,
1285 'updated_by' => 'sync_operation'
1286 );
1287
1288 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
1289
1290 return $is_connected;
1291 }
1292 }
1293