PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.25
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.25
2.7.1 2.7.2 2.7.0 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 All 141 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.25, at includes/class-metasync-heartbeat-manager.php

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