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

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

1,204 lines 43.4 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 $raw_hostname = wp_parse_url(get_home_url(), PHP_URL_HOST);
738 $bare_hostname = preg_replace('/^www\./i', '', $raw_hostname);
739
740 // Try both original and www-stripped hostnames to support sites registered
741 // with or without www on SearchAtlas.
742 $hostnames_to_try = ($raw_hostname !== $bare_hostname)
743 ? [$raw_hostname, $bare_hostname]
744 : [$bare_hostname];
745
746 if (strpos($searchatlas_api_key, 'pub-') === 0) {
747 $domain = class_exists('Metasync_Endpoint_Manager')
748 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
749 : Metasync::API_DOMAIN;
750 $base_url = $domain . '/api/publisher/one-click-publishing/wp-website-heartbeat/connection-ping/?hostname=';
751 } else {
752 $domain = class_exists('Metasync_Endpoint_Manager')
753 ? Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN')
754 : Metasync::CA_API_DOMAIN;
755 $base_url = $domain . '/api/wp-website-heartbeat/connection-ping/?hostname=';
756 }
757
758 foreach ($hostnames_to_try as $hostname) {
759 $url = $base_url . rawurlencode($hostname);
760
761 $response = wp_remote_get($url, [
762 'headers' => ['x-api-key' => $searchatlas_api_key],
763 'timeout' => 10,
764 ]);
765
766 if (is_wp_error($response)) {
767 $this->log_heartbeat('error', 'Connection ping failed (WP_Error)', [
768 'hostname' => $hostname,
769 'error_code' => $response->get_error_code(),
770 'error_message' => $response->get_error_message(),
771 ]);
772 $default['network_error'] = true;
773 return $default;
774 }
775
776 $status_code = wp_remote_retrieve_response_code($response);
777 if ($status_code !== 200) {
778 $this->log_heartbeat('error', 'Connection ping returned non-200 status', [
779 'hostname' => $hostname,
780 'status_code' => $status_code,
781 'response_body' => $this->smart_truncate(wp_remote_retrieve_body($response), 300),
782 ]);
783 return $default;
784 }
785
786 $body = wp_remote_retrieve_body($response);
787 $data = json_decode($body, true);
788
789 if (!is_array($data)) {
790 $this->log_heartbeat('error', 'Connection ping returned invalid JSON', [
791 'hostname' => $hostname,
792 'response_body' => $this->smart_truncate($body, 300),
793 ]);
794 return $default;
795 }
796
797 $connected = (bool) ($data['connected'] ?? false);
798 $otto_pixel_uuid = $data['otto_pixel_uuid'] ?? null;
799
800 if ($connected) {
801 $this->log_heartbeat('info', 'Connection ping completed', [
802 'hostname' => $hostname,
803 'connected' => true,
804 'uuid_prefix' => $otto_pixel_uuid ? substr($otto_pixel_uuid, 0, 8) . '...' : 'none',
805 ]);
806 return ['connected' => true, 'otto_pixel_uuid' => $otto_pixel_uuid];
807 }
808 }
809
810 $this->log_heartbeat('info', 'Connection ping completed — not connected on any hostname variant', [
811 'hostnames_tried' => $hostnames_to_try,
812 ]);
813
814 return $default;
815 }
816
817 public function unschedule_heartbeat_cron()
818 {
819 $timestamp = wp_next_scheduled('metasync_heartbeat_cron_check');
820 if ($timestamp) {
821 wp_unschedule_event($timestamp, 'metasync_heartbeat_cron_check');
822 }
823 }
824
825 /**
826 * Background cron job execution – performs actual heartbeat check.
827 * This method should ONLY be called by the cron job, never by frontend.
828 */
829 public function execute_heartbeat_cron_check()
830 {
831 $general_settings = Metasync::get_option('general') ?? [];
832
833 $searchatlas_api_key = $general_settings['searchatlas_api_key'] ?? '';
834
835 if (empty($searchatlas_api_key)) {
836 $cache_data = array(
837 'status' => false,
838 'timestamp' => time(),
839 'cached_until' => time() + 300,
840 'updated_by' => 'cron_job_no_api_key'
841 );
842
843 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
844
845 $this->set_last_known_connection_state(false);
846
847 return false;
848 }
849
850 $is_connected = $this->test_heartbeat_api_connection($general_settings);
851
852 // When heartbeat failed due to api_backoff_active, do not overwrite cache or last_known.
853 // This preserves the optimistic CONNECTED state set after callback so the dashboard iframe stays visible.
854 if (!$is_connected && class_exists('Metasync_API_Backoff_Manager')) {
855 $heartbeat_url = $this->get_heartbeat_api_url_for_backoff_check($general_settings);
856 $backoff_manager = Metasync_API_Backoff_Manager::get_instance();
857 if ($heartbeat_url && $backoff_manager->is_endpoint_in_backoff($heartbeat_url)) {
858 return false;
859 }
860 }
861
862 $cache_data = array(
863 'status' => $is_connected,
864 'timestamp' => time(),
865 'cached_until' => time() + 300,
866 'updated_by' => 'cron_job'
867 );
868
869 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
870
871 $this->set_last_known_connection_state($is_connected);
872
873 return $is_connected;
874 }
875
876 /**
877 * Add custom cron schedule for 2-hour intervals and daily cleanup.
878 */
879 public function add_heartbeat_cron_schedule($schedules)
880 {
881 $schedules['metasync_every_2_hours'] = array(
882 'interval' => 2 * HOUR_IN_SECONDS,
883 'display' => esc_html__('Every 2 Hours (MetaSync)', 'metasync')
884 );
885
886 $schedules['metasync_every_2_minutes'] = array(
887 'interval' => 2 * MINUTE_IN_SECONDS,
888 'display' => esc_html__('Every 2 Minutes (MetaSync Burst)', 'metasync')
889 );
890 $schedules['metasync_every_5_minutes'] = array(
891 'interval' => 5 * MINUTE_IN_SECONDS,
892 'display' => esc_html__('Every 5 Minutes (MetaSync)', 'metasync')
893 );
894 // 10-minute heartbeat cadence used for UNREGISTERED + KEY_PENDING short-interval behavior
895 $schedules['metasync_every_10_minutes'] = array(
896 'interval' => 10 * MINUTE_IN_SECONDS,
897 'display' => esc_html__('Every 10 Minutes (MetaSync)', 'metasync')
898 );
899
900 $schedules['metasync_daily_cleanup'] = array(
901 'interval' => DAY_IN_SECONDS,
902 'display' => esc_html__('Daily (MetaSync Cleanup)', 'metasync')
903 );
904
905 $schedules['metasync_weekly'] = array(
906 'interval' => 7 * DAY_IN_SECONDS,
907 'display' => esc_html__('Weekly (MetaSync)', 'metasync')
908 );
909
910 return $schedules;
911 }
912
913 // ------------------------------------------------------------------
914 // Heartbeat state machine (PR3)
915 // ------------------------------------------------------------------
916
917 public function get_heartbeat_state()
918 {
919 $general = Metasync::get_option('general') ?? [];
920 $api_key = $general['searchatlas_api_key'] ?? '';
921 if (empty($api_key)) {
922 return 'UNREGISTERED';
923 }
924 $state = $general['heartbeat_state'] ?? '';
925 return ($state === 'CONNECTED') ? 'CONNECTED' : 'KEY_PENDING';
926 }
927
928 public function set_heartbeat_state_key_pending()
929 {
930 $options = Metasync::get_option();
931 if (!isset($options['general'])) {
932 $options['general'] = [];
933 }
934 $options['general']['heartbeat_state'] = 'KEY_PENDING';
935 $options['general']['heartbeat_state_changed_at'] = time();
936 Metasync::set_option($options);
937 delete_option('metasync_burst_attempt_count');
938 $this->maybe_schedule_heartbeat_cron();
939 }
940
941 /**
942 * PR3: Burst cron — run heartbeat when state is KEY_PENDING.
943 * After 5 attempts with no confirmation, stop burst and fall back to 2-hour cron.
944 */
945 public function execute_burst_heartbeat()
946 {
947 if ($this->get_heartbeat_state() !== 'KEY_PENDING') {
948 return;
949 }
950 $count = (int) get_option('metasync_burst_attempt_count', 0);
951 $count++;
952 update_option('metasync_burst_attempt_count', $count);
953
954 $ping = $this->connection_ping();
955
956 if ($ping['connected'] === true) {
957 $options = Metasync::get_option();
958 $general = $options['general'] ?? [];
959 $general['heartbeat_state'] = 'CONNECTED';
960 $general['heartbeat_state_changed_at'] = time();
961
962 if (!empty($ping['otto_pixel_uuid']) && (empty($general['otto_pixel_uuid']) || $general['otto_pixel_uuid'] !== $ping['otto_pixel_uuid'])) {
963 $general['otto_pixel_uuid'] = sanitize_text_field($ping['otto_pixel_uuid']);
964 }
965
966 $options['general'] = $general;
967 Metasync::set_option($options);
968
969 $cache_data = [
970 'status' => true,
971 'timestamp' => time(),
972 'cached_until' => time() + 300,
973 'updated_by' => 'burst_ping_connected',
974 ];
975 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
976
977 $this->log_heartbeat('info', 'Burst ping: site is CONNECTED', [
978 'attempt' => $count,
979 'otto_pixel_uuid' => !empty($ping['otto_pixel_uuid']) ? substr($ping['otto_pixel_uuid'], 0, 8) . '...' : 'none',
980 ]);
981
982 delete_option('metasync_burst_attempt_count');
983 $this->unschedule_burst_heartbeat_cron();
984 $this->maybe_schedule_heartbeat_cron();
985 return;
986 }
987
988 $this->log_heartbeat('info', 'Burst ping: not connected yet', ['attempt' => $count]);
989
990 if ($count >= 5) {
991 $this->unschedule_burst_heartbeat_cron();
992 update_option('metasync_burst_gave_up', true);
993 if (!wp_next_scheduled('metasync_heartbeat_cron_check')) {
994 wp_schedule_event(time(), 'metasync_every_2_hours', 'metasync_heartbeat_cron_check');
995 }
996 }
997 }
998
999 /**
1000 * Announce cron — send pre-SSO announce when state is UNREGISTERED.
1001 * Hard cap of 5 total pings per activation lifecycle (ping 1 on activation, pings 2-5 here).
1002 */
1003 public function execute_announce_cron()
1004 {
1005 $general = Metasync::get_option('general') ?? [];
1006 if (!empty($general['searchatlas_api_key'] ?? '')) {
1007 $this->unschedule_announce_cron();
1008 return;
1009 }
1010
1011 $count = (int) get_option('metasync_announce_attempt_count', 0);
1012 if ($count >= 5) {
1013 $this->unschedule_announce_cron();
1014 return;
1015 }
1016
1017 $count++;
1018 update_option('metasync_announce_attempt_count', $count);
1019
1020 if (!class_exists('Metasync_Activator')) {
1021 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-activator.php';
1022 }
1023 Metasync_Activator::send_announce_ping();
1024
1025 if ($count >= 5) {
1026 $this->unschedule_announce_cron();
1027 }
1028 }
1029
1030 public function unschedule_burst_heartbeat_cron()
1031 {
1032 $timestamp = wp_next_scheduled('metasync_burst_heartbeat');
1033 if ($timestamp) {
1034 wp_unschedule_event($timestamp, 'metasync_burst_heartbeat');
1035 }
1036 }
1037
1038 public function unschedule_announce_cron()
1039 {
1040 $timestamp = wp_next_scheduled('metasync_announce_cron');
1041 if ($timestamp) {
1042 wp_unschedule_event($timestamp, 'metasync_announce_cron');
1043 }
1044 }
1045
1046 /**
1047 * Maybe schedule heartbeat cron job based on PR3 state.
1048 * UNREGISTERED: announce cron every 10 min. KEY_PENDING: burst every 10 min. CONNECTED: 2-hour only.
1049 *
1050 * Throttled to run at most once per 5 minutes to prevent concurrent page loads
1051 * from racing on the wp_cron option and producing `could_not_set` errors.
1052 */
1053 public function maybe_schedule_heartbeat_cron()
1054 {
1055 // Skip if already evaluated recently — avoids cron-option race conditions on busy sites
1056 if (get_transient('metasync_cron_schedule_checked')) {
1057 return;
1058 }
1059 // Atomic lock: only one process should schedule cron events at a time.
1060 // wp_schedule_event() modifies the shared cron option; concurrent calls
1061 // race on the same row and produce `could_not_set` DB errors.
1062 $lock_key = 'metasync_cron_schedule_lock';
1063 if (!get_transient($lock_key)) {
1064 set_transient($lock_key, 1, 30); // 30-second lock
1065 } else {
1066 return; // Another process is already scheduling
1067 }
1068 set_transient('metasync_cron_schedule_checked', 1, 5 * MINUTE_IN_SECONDS);
1069
1070 $state = $this->get_heartbeat_state();
1071
1072 if ($state === 'UNREGISTERED') {
1073 $this->unschedule_heartbeat_cron();
1074 $this->unschedule_burst_heartbeat_cron();
1075 if (!wp_next_scheduled('metasync_announce_cron') && (int) get_option('metasync_announce_attempt_count', 0) < 5) {
1076 wp_schedule_event(time(), 'metasync_every_10_minutes', 'metasync_announce_cron');
1077 }
1078 return;
1079 }
1080
1081 $this->unschedule_announce_cron();
1082
1083 if ($state === 'KEY_PENDING') {
1084 if (get_option('metasync_burst_gave_up')) {
1085 $this->unschedule_burst_heartbeat_cron();
1086 if (!wp_next_scheduled('metasync_heartbeat_cron_check')) {
1087 wp_schedule_event(time(), 'metasync_every_2_hours', 'metasync_heartbeat_cron_check');
1088 }
1089 return;
1090 }
1091 $this->unschedule_heartbeat_cron();
1092 $this->unschedule_burst_heartbeat_cron();
1093 if (!wp_next_scheduled('metasync_burst_heartbeat')) {
1094 delete_option('metasync_burst_attempt_count');
1095 wp_schedule_event(time(), 'metasync_every_10_minutes', 'metasync_burst_heartbeat');
1096 }
1097 return;
1098 }
1099
1100 if ($state === 'CONNECTED') {
1101 $this->unschedule_burst_heartbeat_cron();
1102 delete_option('metasync_burst_attempt_count');
1103 delete_option('metasync_burst_gave_up');
1104 $this->schedule_heartbeat_cron();
1105 }
1106 }
1107
1108
1109 // ------------------------------------------------------------------
1110 // Immediate heartbeat trigger
1111 // ------------------------------------------------------------------
1112
1113 public function trigger_immediate_heartbeat_check($context = 'Manual trigger')
1114 {
1115 static $last_immediate_check = 0;
1116 $current_time = time();
1117
1118 if (($current_time - $last_immediate_check) < 10) {
1119 return true;
1120 }
1121
1122 $last_immediate_check = $current_time;
1123
1124 $general_settings = Metasync::get_option('general') ?? [];
1125 $searchatlas_api_key = $general_settings['searchatlas_api_key'] ?? '';
1126
1127 if (empty($searchatlas_api_key)) {
1128 delete_transient('metasync_heartbeat_status_cache');
1129 Metasync_Admin_Navigation::invalidate_admin_bar_status_cache();
1130
1131 $cache_data = array(
1132 'status' => false,
1133 'timestamp' => time(),
1134 'cached_until' => time() + 300,
1135 'updated_by' => 'immediate_check_no_api_key'
1136 );
1137
1138 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
1139
1140 $this->set_last_known_connection_state(false);
1141
1142 return false;
1143 }
1144
1145 delete_transient('metasync_heartbeat_status_cache');
1146 Metasync_Admin_Navigation::invalidate_admin_bar_status_cache();
1147
1148 $result = $this->execute_heartbeat_cron_check();
1149
1150 return $result;
1151 }
1152
1153 public function handle_immediate_heartbeat_trigger($context = 'WordPress action trigger')
1154 {
1155 $this->trigger_immediate_heartbeat_check($context);
1156 }
1157
1158 // ------------------------------------------------------------------
1159 // AJAX burst ping (PR3)
1160 // ------------------------------------------------------------------
1161
1162 public function ajax_burst_ping()
1163 {
1164 if (!Metasync::current_user_has_plugin_access() || empty($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'metasync_burst_ping')) {
1165 wp_send_json_error(array('message' => 'Unauthorized'));
1166 return;
1167 }
1168 $state = $this->get_heartbeat_state();
1169 if ($state === 'KEY_PENDING') {
1170 $_POST['is_heart_beat'] = true;
1171 $_POST['is_burst'] = true;
1172 $general = Metasync::get_option('general') ?? [];
1173 $apikey = $general['apikey'] ?? '';
1174 $sync = new Metasync_Sync_Requests();
1175 $sync->SyncCustomerParams($apikey);
1176 $state = $this->get_heartbeat_state();
1177 if ($state === 'CONNECTED') {
1178 $this->maybe_schedule_heartbeat_cron();
1179 }
1180 wp_send_json_success(array('state' => $state, 'heartbeat_confirmed' => ($state === 'CONNECTED')));
1181 return;
1182 }
1183 wp_send_json_success(array('state' => $state, 'heartbeat_confirmed' => true));
1184 }
1185
1186 // ------------------------------------------------------------------
1187 // Heartbeat cache update after sync
1188 // ------------------------------------------------------------------
1189
1190 public function update_heartbeat_cache_after_sync($is_connected, $context = 'Sync operation')
1191 {
1192 $cache_data = array(
1193 'status' => $is_connected,
1194 'timestamp' => time(),
1195 'cached_until' => time() + 300,
1196 'updated_by' => 'sync_operation'
1197 );
1198
1199 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
1200
1201 return $is_connected;
1202 }
1203 }
1204