get_last_known_connection_state(); if ($last_known_state !== null) { return $last_known_state; } return false; } // ------------------------------------------------------------------ // Public-hash fetching (OTTO API) // ------------------------------------------------------------------ /** * Fetch public hash from OTTO API for the given pixel UUID. * * @param string $otto_pixel_uuid The OTTO pixel UUID to fetch hash for. * @param string $jwt_token The JWT authentication token. * @return string|false The public hash on success, false on failure. */ public function fetch_public_hash($otto_pixel_uuid, $jwt_token) { $cache_duration = 3600; $api_timeout = 15; $max_retries = 3; $base_retry_delay = 1; if (!$this->validate_fetch_hash_inputs($otto_pixel_uuid, $jwt_token)) { $this->log_fetch_hash_error('error', 'Invalid input parameters provided', [ 'uuid_provided' => !empty($otto_pixel_uuid), 'token_provided' => !empty($jwt_token) ]); return false; } $otto_pixel_uuid = sanitize_text_field(trim($otto_pixel_uuid)); $jwt_token = sanitize_text_field(trim($jwt_token)); $cached_hash = $this->get_cached_public_hash($otto_pixel_uuid); if ($cached_hash !== false) { $this->log_fetch_hash_error('info', 'Public hash retrieved from cache', [ 'uuid' => substr($otto_pixel_uuid, 0, 8) . '...' ]); return $cached_hash; } $api_url = $this->build_otto_api_url($otto_pixel_uuid); $headers = $this->prepare_api_headers($jwt_token); for ($attempt = 1; $attempt <= $max_retries; $attempt++) { $this->log_fetch_hash_error('info', 'Attempting to fetch public hash from API', [ 'attempt' => $attempt, 'max_retries' => $max_retries, 'uuid' => substr($otto_pixel_uuid, 0, 8) . '...' ]); $response = wp_remote_get($api_url, [ 'headers' => $headers, 'timeout' => $api_timeout, 'sslverify' => true, 'redirection' => 2, 'user-agent' => 'WordPress MetaSync Plugin/' . (defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0') ]); if (is_wp_error($response)) { $error_message = $response->get_error_message(); $error_code = $response->get_error_code(); if (class_exists('Metasync_Error_Logger')) { Metasync_Error_Logger::log( Metasync_Error_Logger::CATEGORY_NETWORK_ERROR, Metasync_Error_Logger::SEVERITY_ERROR, 'OTTO API network request failed', [ 'attempt' => $attempt, 'error_code' => $error_code, 'error_message' => $error_message, 'api_endpoint' => 'OTTO Projects API', 'operation' => 'fetch_public_hash', 'will_retry' => $attempt < $max_retries, 'max_retries' => $max_retries ] ); } $this->log_fetch_hash_error('error', 'HTTP request failed', [ 'attempt' => $attempt, 'error_code' => $error_code, 'error_message' => $error_message, 'will_retry' => $attempt < $max_retries ]); if ($attempt < $max_retries) { $this->apply_exponential_backoff($attempt, $base_retry_delay); continue; } return false; } $result = $this->process_api_response($response, $attempt, $max_retries); if ($result === 'retry' && $attempt < $max_retries) { $this->apply_exponential_backoff($attempt, $base_retry_delay); continue; } if ($result !== false && $result !== 'retry') { $this->cache_public_hash($otto_pixel_uuid, $result, $cache_duration); $this->log_fetch_hash_error('info', 'Public hash successfully retrieved and cached', [ 'uuid' => substr($otto_pixel_uuid, 0, 8) . '...', 'attempt' => $attempt ]); return $result; } break; } $this->log_fetch_hash_error('error', 'Failed to fetch public hash after all retry attempts', [ 'uuid' => substr($otto_pixel_uuid, 0, 8) . '...', 'total_attempts' => $max_retries ]); return false; } // ------------------------------------------------------------------ // Public-hash helpers (private) // ------------------------------------------------------------------ private function validate_fetch_hash_inputs($uuid, $token) { if (empty($uuid) || empty($token)) { return false; } if (!is_string($uuid) || strlen($uuid) < 10) { return false; } if (!is_string($token) || substr_count($token, '.') < 2) { return false; } return true; } /** * Build the transient key used to cache the OTTO public hash. * * Centralised so that get / set / clear paths cannot drift apart and so * the two non-heartbeat callers (connect manager, otto debug admin page) * can re-use the same derivation. * * @param string $uuid OTTO pixel UUID. * @return string */ public static function public_hash_cache_key($uuid) { return 'metasync_public_hash_' . hash('sha256', $uuid . get_current_blog_id()); } private function get_cached_public_hash($uuid) { $cache_key = self::public_hash_cache_key($uuid); return get_transient($cache_key); } private function cache_public_hash($uuid, $hash, $duration) { $cache_key = self::public_hash_cache_key($uuid); set_transient($cache_key, sanitize_text_field($hash), $duration); } private function build_otto_api_url($uuid) { $base_url = class_exists('Metasync_Endpoint_Manager') ? Metasync_Endpoint_Manager::get_endpoint('OTTO_PROJECTS') : 'https://sa.searchatlas.com/api/v2/otto-projects'; $base_url = rtrim($base_url, '/') . '/'; return $base_url . urlencode($uuid) . '/'; } private function prepare_api_headers($jwt_token) { return [ 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $jwt_token, 'Content-Type' => 'application/json', 'User-Agent' => 'WordPress MetaSync Plugin/' . (defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0'), 'Cache-Control' => 'no-cache', 'X-Requested-With' => 'XMLHttpRequest' ]; } private function process_api_response($response, $attempt, $max_retries) { $status_code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); switch ($status_code) { case 200: return $this->extract_public_hash_from_response($body); case 401: case 403: if (class_exists('Metasync_Error_Logger')) { Metasync_Error_Logger::log( Metasync_Error_Logger::CATEGORY_AUTHENTICATION_FAILURE, Metasync_Error_Logger::SEVERITY_ERROR, 'OTTO API authentication failed', [ 'status_code' => $status_code, 'attempt' => $attempt, 'api_endpoint' => 'OTTO Projects API', 'operation' => 'fetch_public_hash', 'http_status' => $status_code === 401 ? 'Unauthorized' : 'Forbidden' ] ); } $this->log_fetch_hash_error('error', 'Authentication failed', [ 'status_code' => $status_code, 'attempt' => $attempt ]); return false; case 404: $this->log_fetch_hash_error('error', 'OTTO project not found', [ 'status_code' => $status_code, 'attempt' => $attempt ]); return false; case 429: if (class_exists('Metasync_Error_Logger')) { Metasync_Error_Logger::log( Metasync_Error_Logger::CATEGORY_API_RATE_LIMIT, Metasync_Error_Logger::SEVERITY_WARNING, 'OTTO API rate limit exceeded', [ 'status_code' => $status_code, 'attempt' => $attempt, 'will_retry' => $attempt < $max_retries, 'api_endpoint' => 'OTTO Projects API', 'operation' => 'fetch_public_hash', 'max_retries' => $max_retries ] ); } $this->log_fetch_hash_error('warning', 'API rate limit exceeded', [ 'status_code' => $status_code, 'attempt' => $attempt, 'will_retry' => $attempt < $max_retries ]); return 'retry'; case 500: case 502: case 503: case 504: $this->log_fetch_hash_error('warning', 'Server error encountered', [ 'status_code' => $status_code, 'attempt' => $attempt, 'will_retry' => $attempt < $max_retries ]); return 'retry'; default: $this->log_fetch_hash_error('error', 'Unexpected HTTP status code', [ 'status_code' => $status_code, 'attempt' => $attempt, 'response_body' => substr($body, 0, 200) ]); return false; } } private function extract_public_hash_from_response($body) { if (empty($body)) { $this->log_fetch_hash_error('error', 'Empty response body received'); return false; } $data = json_decode($body, true); $json_error = json_last_error(); if ($json_error !== JSON_ERROR_NONE) { $this->log_fetch_hash_error('error', 'Invalid JSON response', [ 'json_error' => $json_error, 'json_error_msg' => json_last_error_msg(), 'body_preview' => substr($body, 0, 200) ]); return false; } if (!is_array($data)) { $this->log_fetch_hash_error('error', 'Response data is not an array', [ 'data_type' => gettype($data) ]); return false; } $possible_hash_fields = [ 'public_share_hash', 'public_hash', 'publicHash', 'hash', 'public_key', 'publicKey' ]; foreach ($possible_hash_fields as $field) { if (isset($data[$field]) && !empty($data[$field]) && is_string($data[$field])) { $hash = sanitize_text_field(trim($data[$field])); if (preg_match('/^[a-zA-Z0-9_-]{10,}$/', $hash)) { $this->log_fetch_hash_error('info', 'Public hash extracted successfully', [ 'field_name' => $field, 'hash_length' => strlen($hash) ]); return $hash; } } } $this->log_fetch_hash_error('error', 'Public hash not found in response', [ 'available_fields' => array_keys($data), 'searched_fields' => $possible_hash_fields ]); return false; } private function apply_exponential_backoff($attempt, $base_delay) { $delay = $base_delay * pow(2, $attempt - 1); $max_delay = 30; $delay = min($delay, $max_delay); if (class_exists('Metasync_Error_Logger')) { Metasync_Error_Logger::log( Metasync_Error_Logger::CATEGORY_API_BACKOFF, Metasync_Error_Logger::SEVERITY_INFO, 'API backoff active - applying exponential retry delay', [ 'attempt' => $attempt, 'delay_seconds' => $delay, 'base_delay' => $base_delay, 'max_delay' => $max_delay, 'api_endpoint' => 'OTTO Projects API', 'operation' => 'fetch_public_hash' ] ); } $this->log_fetch_hash_error('info', 'Applying retry delay', [ 'attempt' => $attempt, 'delay_seconds' => $delay ]); sleep($delay); } private function log_fetch_hash_error($level, $message, $context = []) { if ($level === 'info') { return; } $full_context = array_merge([ 'operation' => 'fetch_public_hash', 'timestamp' => current_time('mysql'), 'site_url' => get_site_url() ], $context); $log_message = sprintf( 'OTTO_API_%s: %s', strtoupper($level), $message ); if (!empty($full_context)) { $context_parts = []; foreach ($full_context as $key => $value) { if (is_array($value)) { $value = json_encode($value); } elseif (is_bool($value)) { $value = $value ? 'true' : 'false'; } elseif (is_string($value) && strlen($value) > 100) { $value = substr($value, 0, 100) . '...'; } $context_parts[] = "{$key}={$value}"; } $log_message .= ' | ' . implode(', ', $context_parts); } error_log($log_message); } public function clear_public_hash_cache($otto_pixel_uuid = '') { if (empty($otto_pixel_uuid)) { $general_options = Metasync::get_option('general'); $otto_pixel_uuid = isset($general_options['otto_pixel_uuid']) ? $general_options['otto_pixel_uuid'] : ''; } if (!empty($otto_pixel_uuid)) { $cache_key = self::public_hash_cache_key($otto_pixel_uuid); delete_transient($cache_key); } } // ------------------------------------------------------------------ // Connection state persistence // ------------------------------------------------------------------ private function get_last_known_connection_state() { return get_option('metasync_last_known_connection_state', null); } private function set_last_known_connection_state($is_connected) { $success = update_option('metasync_last_known_connection_state', (bool) $is_connected); return $success; } // ------------------------------------------------------------------ // Logging helpers // ------------------------------------------------------------------ private function log_heartbeat($level, $event, $details = array()) { if ($level == 'info') { return; } if ($this->should_throttle_log($level, $event, $details)) { return; } $context = array( 'event' => $event, 'level' => strtoupper($level), 'plugin_version' => defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown', 'site_url' => get_site_url(), ); $context = array_merge($context, $details); $message = sprintf( 'HEARTBEAT_%s: %s', strtoupper($level), $event ); if (!empty($details)) { $details_formatted = array(); foreach ($details as $key => $value) { if (is_array($value)) { $value = json_encode($value); } elseif (is_bool($value)) { $value = $value ? 'true' : 'false'; } elseif (is_string($value) && strlen($value) > 200) { $value = $this->smart_truncate($value, 200); } $details_formatted[] = "{$key}={$value}"; } $message .= ' | ' . implode(', ', $details_formatted); } error_log($message); if ($level === 'error' || $level === 'critical') { $this->store_heartbeat_error_log($event, $details); } } private function smart_truncate($string, $length = 200) { if (strlen($string) <= $length) { return $string; } $truncated = substr($string, 0, $length); $last_space = strrpos($truncated, ' '); if ($last_space !== false && $last_space > $length * 0.75) { $truncated = substr($truncated, 0, $last_space); } $truncated = strip_tags($truncated); return $truncated . '... [truncated]'; } private function should_throttle_log($level, $event, $details = array()) { if ($level === 'error' || $level === 'critical') { return false; } if (strpos($event, 'Cache hit') !== false || strpos($event, 'No cached heartbeat status found') !== false) { static $last_cache_log_time = 0; static $last_cache_status = ''; $current_time = time(); $current_status = isset($details['status']) ? $details['status'] : 'UNKNOWN'; if ($current_status !== $last_cache_status || ($current_time - $last_cache_log_time) > 300 || $last_cache_log_time === 0) { $last_cache_log_time = $current_time; $last_cache_status = $current_status; return false; } return true; } return false; } private function store_heartbeat_error_log($event, $details) { try { if (class_exists('Metasync_HeartBeat_Error_Monitor_Database')) { $error_db = new Metasync_HeartBeat_Error_Monitor_Database(); $error_db->add(array( 'attribute_name' => 'heartbeat_connectivity', 'object_count' => 1, 'error_description' => json_encode(array( 'event' => $event, 'details' => $details, 'timestamp' => current_time('mysql') )), 'created_at' => current_time('mysql') )); } } catch (Exception $e) { error_log('Failed to store heartbeat error log: ' . $e->getMessage()); } } // ------------------------------------------------------------------ // Heartbeat API connectivity test // ------------------------------------------------------------------ private function test_heartbeat_api_connection($general_settings) { $searchatlas_api_key = $general_settings['searchatlas_api_key'] ?? ''; $apikey = $general_settings['apikey'] ?? ''; $start_time = microtime(true); $api_key_type = strpos($searchatlas_api_key, 'pub-') === 0 ? 'publisher' : 'regular'; $sync_request = new Metasync_Sync_Requests(); $response = $sync_request->SyncCustomerParams($apikey); $request_duration = round((microtime(true) - $start_time) * 1000, 2); if (is_wp_error($response)) { $this->log_heartbeat('error', 'Heartbeat test via SyncCustomerParams failed', array( 'error_code' => $response->get_error_code(), 'error_message' => $response->get_error_message(), 'request_duration_ms' => $request_duration, 'error_type' => 'wp_error' )); return false; } $status_code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); if ($status_code !== 200) { $this->log_heartbeat('error', 'Heartbeat test returned non-200 status', array( 'status_code' => $status_code, 'response_body' => $this->smart_truncate($body, 300), 'request_duration_ms' => $request_duration, 'error_type' => 'http_status_error' )); return false; } # Throttle timestamp lives in a dedicated option key so it never # round-trips the main blob. Metasync::set_heartbeat_throttle(['last_heartbeat_at' => gmdate('Y-m-d\TH:i:s\Z')]); # Re-read the fresh blob immediately before writing send_auth_token_timestamp # so a concurrent settings save during this code path is not clobbered. $_fresh = Metasync::get_option(); if (!isset($_fresh['general'])) { $_fresh['general'] = []; } $_fresh['general']['send_auth_token_timestamp'] = current_time('mysql'); Metasync::set_option($_fresh); return true; } // ------------------------------------------------------------------ // Cron scheduling // ------------------------------------------------------------------ public function schedule_heartbeat_cron() { $this->unschedule_heartbeat_cron(); if (!wp_next_scheduled('metasync_heartbeat_cron_check')) { $scheduled = wp_schedule_event(time(), 'metasync_every_2_hours', 'metasync_heartbeat_cron_check'); if (!$scheduled) { $this->log_heartbeat('error', 'Failed to schedule heartbeat cron job'); } } } /** * Build the heartbeat API URL for backoff check (same host as SyncCustomerParams uses). * * @param array $general_settings Plugin general options. * @return string|null Heartbeat URL or null if not determinable. */ private function get_heartbeat_api_url_for_backoff_check($general_settings) { $api_key = $general_settings['searchatlas_api_key'] ?? ''; if (empty($api_key)) { return null; } if (strpos($api_key, 'pub-') === 0) { $domain = class_exists('Metasync_Endpoint_Manager') ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN') : Metasync::API_DOMAIN; return $domain . '/api/publisher/one-click-publishing/wp-website-heartbeat/'; } $domain = class_exists('Metasync_Endpoint_Manager') ? Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN') : Metasync::CA_API_DOMAIN; return $domain . '/api/wp-website-heartbeat/'; } /** * Validate a candidate API key against the Search Atlas backend. * * Public wrapper around `connection_ping()` for use outside this class * (e.g. pre-save validation in the settings handler). * * @param string $api_key The API key to validate. * @return array{connected: bool, otto_pixel_uuid: string|null, network_error?: bool} */ public function validate_api_key( string $api_key ): array { return $this->connection_ping( $api_key ); } /** * Lightweight connection-ping — checks whether the site is registered on the backend * without sending any heavy payload or triggering Celery tasks. * * @param string|null $api_key_override Optional API key to use instead of the saved key. * @return array{connected: bool, otto_pixel_uuid: string|null, network_error?: bool} */ private function connection_ping( ?string $api_key_override = null ): array { $default = ['connected' => false, 'otto_pixel_uuid' => null]; $general = Metasync::get_option('general') ?? []; $searchatlas_api_key = $general['searchatlas_api_key'] ?? ''; if ( $api_key_override !== null && $api_key_override !== '' ) { $searchatlas_api_key = $api_key_override; } if (empty($searchatlas_api_key)) { $this->log_heartbeat('error', 'Connection ping skipped - no API key configured'); return $default; } $raw_hostname = wp_parse_url(get_home_url(), PHP_URL_HOST); $bare_hostname = preg_replace('/^www\./i', '', $raw_hostname); // Try both original and www-stripped hostnames to support sites registered // with or without www on SearchAtlas. $hostnames_to_try = ($raw_hostname !== $bare_hostname) ? [$raw_hostname, $bare_hostname] : [$bare_hostname]; if (strpos($searchatlas_api_key, 'pub-') === 0) { $domain = class_exists('Metasync_Endpoint_Manager') ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN') : Metasync::API_DOMAIN; $base_url = $domain . '/api/publisher/one-click-publishing/wp-website-heartbeat/connection-ping/?hostname='; } else { $domain = class_exists('Metasync_Endpoint_Manager') ? Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN') : Metasync::CA_API_DOMAIN; $base_url = $domain . '/api/wp-website-heartbeat/connection-ping/?hostname='; } foreach ($hostnames_to_try as $hostname) { $url = $base_url . rawurlencode($hostname); $response = wp_remote_get($url, [ 'headers' => ['x-api-key' => $searchatlas_api_key], 'timeout' => 10, ]); if (is_wp_error($response)) { $this->log_heartbeat('error', 'Connection ping failed (WP_Error)', [ 'hostname' => $hostname, 'error_code' => $response->get_error_code(), 'error_message' => $response->get_error_message(), ]); $default['network_error'] = true; return $default; } $status_code = wp_remote_retrieve_response_code($response); if ($status_code !== 200) { $this->log_heartbeat('error', 'Connection ping returned non-200 status', [ 'hostname' => $hostname, 'status_code' => $status_code, 'response_body' => $this->smart_truncate(wp_remote_retrieve_body($response), 300), ]); return $default; } $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); if (!is_array($data)) { $this->log_heartbeat('error', 'Connection ping returned invalid JSON', [ 'hostname' => $hostname, 'response_body' => $this->smart_truncate($body, 300), ]); return $default; } $connected = (bool) ($data['connected'] ?? false); $otto_pixel_uuid = $data['otto_pixel_uuid'] ?? null; if ($connected) { $this->log_heartbeat('info', 'Connection ping completed', [ 'hostname' => $hostname, 'connected' => true, 'uuid_prefix' => $otto_pixel_uuid ? substr($otto_pixel_uuid, 0, 8) . '...' : 'none', ]); return ['connected' => true, 'otto_pixel_uuid' => $otto_pixel_uuid]; } } $this->log_heartbeat('info', 'Connection ping completed — not connected on any hostname variant', [ 'hostnames_tried' => $hostnames_to_try, ]); return $default; } public function unschedule_heartbeat_cron() { $timestamp = wp_next_scheduled('metasync_heartbeat_cron_check'); if ($timestamp) { wp_unschedule_event($timestamp, 'metasync_heartbeat_cron_check'); } } /** * Background cron job execution – performs actual heartbeat check. * This method should ONLY be called by the cron job, never by frontend. */ public function execute_heartbeat_cron_check() { $general_settings = Metasync::get_option('general') ?? []; $searchatlas_api_key = $general_settings['searchatlas_api_key'] ?? ''; if (empty($searchatlas_api_key)) { $cache_data = array( 'status' => false, 'timestamp' => time(), 'cached_until' => time() + 300, 'updated_by' => 'cron_job_no_api_key' ); set_transient('metasync_heartbeat_status_cache', $cache_data, 300); $this->set_last_known_connection_state(false); return false; } $is_connected = $this->test_heartbeat_api_connection($general_settings); // When heartbeat failed due to api_backoff_active, do not overwrite cache or last_known. // This preserves the optimistic CONNECTED state set after callback so the dashboard iframe stays visible. if (!$is_connected && class_exists('Metasync_API_Backoff_Manager')) { $heartbeat_url = $this->get_heartbeat_api_url_for_backoff_check($general_settings); $backoff_manager = Metasync_API_Backoff_Manager::get_instance(); if ($heartbeat_url && $backoff_manager->is_endpoint_in_backoff($heartbeat_url)) { return false; } } $cache_data = array( 'status' => $is_connected, 'timestamp' => time(), 'cached_until' => time() + 300, 'updated_by' => 'cron_job' ); set_transient('metasync_heartbeat_status_cache', $cache_data, 300); $this->set_last_known_connection_state($is_connected); return $is_connected; } /** * Add custom cron schedule for 2-hour intervals and daily cleanup. */ public function add_heartbeat_cron_schedule($schedules) { $schedules['metasync_every_2_hours'] = array( 'interval' => 2 * HOUR_IN_SECONDS, 'display' => esc_html__('Every 2 Hours (MetaSync)', 'metasync') ); $schedules['metasync_every_2_minutes'] = array( 'interval' => 2 * MINUTE_IN_SECONDS, 'display' => esc_html__('Every 2 Minutes (MetaSync Burst)', 'metasync') ); $schedules['metasync_every_5_minutes'] = array( 'interval' => 5 * MINUTE_IN_SECONDS, 'display' => esc_html__('Every 5 Minutes (MetaSync)', 'metasync') ); // 10-minute heartbeat cadence used for UNREGISTERED + KEY_PENDING short-interval behavior $schedules['metasync_every_10_minutes'] = array( 'interval' => 10 * MINUTE_IN_SECONDS, 'display' => esc_html__('Every 10 Minutes (MetaSync)', 'metasync') ); $schedules['metasync_daily_cleanup'] = array( 'interval' => DAY_IN_SECONDS, 'display' => esc_html__('Daily (MetaSync Cleanup)', 'metasync') ); $schedules['metasync_weekly'] = array( 'interval' => 7 * DAY_IN_SECONDS, 'display' => esc_html__('Weekly (MetaSync)', 'metasync') ); return $schedules; } // ------------------------------------------------------------------ // Heartbeat state machine (PR3) // ------------------------------------------------------------------ public function get_heartbeat_state() { $general = Metasync::get_option('general') ?? []; $api_key = $general['searchatlas_api_key'] ?? ''; if (empty($api_key)) { return 'UNREGISTERED'; } $state = $general['heartbeat_state'] ?? ''; return ($state === 'CONNECTED') ? 'CONNECTED' : 'KEY_PENDING'; } public function set_heartbeat_state_key_pending() { $options = Metasync::get_option(); if (!isset($options['general'])) { $options['general'] = []; } $options['general']['heartbeat_state'] = 'KEY_PENDING'; $options['general']['heartbeat_state_changed_at'] = time(); Metasync::set_option($options); delete_option('metasync_burst_attempt_count'); $this->maybe_schedule_heartbeat_cron(); } /** * PR3: Burst cron — run heartbeat when state is KEY_PENDING. * After 5 attempts with no confirmation, stop burst and fall back to 2-hour cron. */ public function execute_burst_heartbeat() { if ($this->get_heartbeat_state() !== 'KEY_PENDING') { return; } $count = (int) get_option('metasync_burst_attempt_count', 0); $count++; update_option('metasync_burst_attempt_count', $count); $ping = $this->connection_ping(); if ($ping['connected'] === true) { $options = Metasync::get_option(); $general = $options['general'] ?? []; $general['heartbeat_state'] = 'CONNECTED'; $general['heartbeat_state_changed_at'] = time(); if (!empty($ping['otto_pixel_uuid']) && (empty($general['otto_pixel_uuid']) || $general['otto_pixel_uuid'] !== $ping['otto_pixel_uuid'])) { $general['otto_pixel_uuid'] = sanitize_text_field($ping['otto_pixel_uuid']); } $options['general'] = $general; Metasync::set_option($options); $cache_data = [ 'status' => true, 'timestamp' => time(), 'cached_until' => time() + 300, 'updated_by' => 'burst_ping_connected', ]; set_transient('metasync_heartbeat_status_cache', $cache_data, 300); $this->log_heartbeat('info', 'Burst ping: site is CONNECTED', [ 'attempt' => $count, 'otto_pixel_uuid' => !empty($ping['otto_pixel_uuid']) ? substr($ping['otto_pixel_uuid'], 0, 8) . '...' : 'none', ]); delete_option('metasync_burst_attempt_count'); $this->unschedule_burst_heartbeat_cron(); $this->maybe_schedule_heartbeat_cron(); return; } $this->log_heartbeat('info', 'Burst ping: not connected yet', ['attempt' => $count]); if ($count >= 5) { $this->unschedule_burst_heartbeat_cron(); update_option('metasync_burst_gave_up', true); if (!wp_next_scheduled('metasync_heartbeat_cron_check')) { wp_schedule_event(time(), 'metasync_every_2_hours', 'metasync_heartbeat_cron_check'); } } } /** * Announce cron — send pre-SSO announce when state is UNREGISTERED. * Hard cap of 5 total pings per activation lifecycle (ping 1 on activation, pings 2-5 here). */ public function execute_announce_cron() { $general = Metasync::get_option('general') ?? []; if (!empty($general['searchatlas_api_key'] ?? '')) { $this->unschedule_announce_cron(); return; } $count = (int) get_option('metasync_announce_attempt_count', 0); if ($count >= 5) { $this->unschedule_announce_cron(); return; } $count++; update_option('metasync_announce_attempt_count', $count); if (!class_exists('Metasync_Activator')) { require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-activator.php'; } Metasync_Activator::send_announce_ping(); if ($count >= 5) { $this->unschedule_announce_cron(); } } public function unschedule_burst_heartbeat_cron() { $timestamp = wp_next_scheduled('metasync_burst_heartbeat'); if ($timestamp) { wp_unschedule_event($timestamp, 'metasync_burst_heartbeat'); } } public function unschedule_announce_cron() { $timestamp = wp_next_scheduled('metasync_announce_cron'); if ($timestamp) { wp_unschedule_event($timestamp, 'metasync_announce_cron'); } } /** * Maybe schedule heartbeat cron job based on PR3 state. * UNREGISTERED: announce cron every 10 min. KEY_PENDING: burst every 10 min. CONNECTED: 2-hour only. * * Throttled to run at most once per 5 minutes to prevent concurrent page loads * from racing on the wp_cron option and producing `could_not_set` errors. */ public function maybe_schedule_heartbeat_cron() { // Skip if already evaluated recently — avoids cron-option race conditions on busy sites if (get_transient('metasync_cron_schedule_checked')) { return; } // Atomic lock: only one process should schedule cron events at a time. // wp_schedule_event() modifies the shared cron option; concurrent calls // race on the same row and produce `could_not_set` DB errors. $lock_key = 'metasync_cron_schedule_lock'; if (!get_transient($lock_key)) { set_transient($lock_key, 1, 30); // 30-second lock } else { return; // Another process is already scheduling } set_transient('metasync_cron_schedule_checked', 1, 5 * MINUTE_IN_SECONDS); $state = $this->get_heartbeat_state(); if ($state === 'UNREGISTERED') { $this->unschedule_heartbeat_cron(); $this->unschedule_burst_heartbeat_cron(); if (!wp_next_scheduled('metasync_announce_cron') && (int) get_option('metasync_announce_attempt_count', 0) < 5) { wp_schedule_event(time(), 'metasync_every_10_minutes', 'metasync_announce_cron'); } return; } $this->unschedule_announce_cron(); if ($state === 'KEY_PENDING') { if (get_option('metasync_burst_gave_up')) { $this->unschedule_burst_heartbeat_cron(); if (!wp_next_scheduled('metasync_heartbeat_cron_check')) { wp_schedule_event(time(), 'metasync_every_2_hours', 'metasync_heartbeat_cron_check'); } return; } $this->unschedule_heartbeat_cron(); $this->unschedule_burst_heartbeat_cron(); if (!wp_next_scheduled('metasync_burst_heartbeat')) { delete_option('metasync_burst_attempt_count'); wp_schedule_event(time(), 'metasync_every_10_minutes', 'metasync_burst_heartbeat'); } return; } if ($state === 'CONNECTED') { $this->unschedule_burst_heartbeat_cron(); delete_option('metasync_burst_attempt_count'); delete_option('metasync_burst_gave_up'); $this->schedule_heartbeat_cron(); } } // ------------------------------------------------------------------ // Immediate heartbeat trigger // ------------------------------------------------------------------ public function trigger_immediate_heartbeat_check($context = 'Manual trigger') { static $last_immediate_check = 0; $current_time = time(); if (($current_time - $last_immediate_check) < 10) { return true; } $last_immediate_check = $current_time; $general_settings = Metasync::get_option('general') ?? []; $searchatlas_api_key = $general_settings['searchatlas_api_key'] ?? ''; if (empty($searchatlas_api_key)) { delete_transient('metasync_heartbeat_status_cache'); Metasync_Admin_Navigation::invalidate_admin_bar_status_cache(); $cache_data = array( 'status' => false, 'timestamp' => time(), 'cached_until' => time() + 300, 'updated_by' => 'immediate_check_no_api_key' ); set_transient('metasync_heartbeat_status_cache', $cache_data, 300); $this->set_last_known_connection_state(false); return false; } delete_transient('metasync_heartbeat_status_cache'); Metasync_Admin_Navigation::invalidate_admin_bar_status_cache(); $result = $this->execute_heartbeat_cron_check(); return $result; } public function handle_immediate_heartbeat_trigger($context = 'WordPress action trigger') { $this->trigger_immediate_heartbeat_check($context); } // ------------------------------------------------------------------ // AJAX burst ping (PR3) // ------------------------------------------------------------------ public function ajax_burst_ping() { if (!Metasync::current_user_has_plugin_access() || empty($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'metasync_burst_ping')) { wp_send_json_error(array('message' => 'Unauthorized')); return; } $state = $this->get_heartbeat_state(); if ($state === 'KEY_PENDING') { $_POST['is_heart_beat'] = true; $_POST['is_burst'] = true; $general = Metasync::get_option('general') ?? []; $apikey = $general['apikey'] ?? ''; $sync = new Metasync_Sync_Requests(); $sync->SyncCustomerParams($apikey); $state = $this->get_heartbeat_state(); if ($state === 'CONNECTED') { $this->maybe_schedule_heartbeat_cron(); } wp_send_json_success(array('state' => $state, 'heartbeat_confirmed' => ($state === 'CONNECTED'))); return; } wp_send_json_success(array('state' => $state, 'heartbeat_confirmed' => true)); } // ------------------------------------------------------------------ // Heartbeat cache update after sync // ------------------------------------------------------------------ public function update_heartbeat_cache_after_sync($is_connected, $context = 'Sync operation') { $cache_data = array( 'status' => $is_connected, 'timestamp' => time(), 'cached_until' => time() + 300, 'updated_by' => 'sync_operation' ); set_transient('metasync_heartbeat_status_cache', $cache_data, 300); return $is_connected; } }