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

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