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

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