PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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.7.2, at includes/class-metasync-heartbeat-manager.php

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