PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-heartbeat-manager.php +286 -67 2.6.5 → 2.7.2 View file →
@@ -17,8 +17,11 @@
17 17 {
18 18 /** @var self|null */
19 19 private static $instance = null;
20 20
21 + /** @var array Failure class of the last heartbeat test, for analytics. */
22 + private $last_failure = array();
23 +
21 24 /**
22 25 * Get singleton instance.
23 26 *
24 27 * @return self
@@ -48,9 +51,12 @@
48 51 if ($general_settings === null) {
49 52 $general_settings = Metasync::get_option('general') ?? [];
50 53 }
51 54
52 - $searchatlas_api_key = $general_settings['searchatlas_api_key'] ?? '';
55 + $searchatlas_api_key = Metasync::get_searchatlas_api_key();
56 + if ($searchatlas_api_key === false) {
57 + $searchatlas_api_key = '';
58 + }
53 59
54 60 if (empty($searchatlas_api_key)) {
55 61 return false;
56 62 }
@@ -70,8 +76,37 @@
70 76
71 77 return false;
72 78 }
73 79
80 + /**
81 + * Single source of truth for the admin connection status badge.
82 + *
83 + * Derives the label from the heartbeat round-trip RESULT that the cron
84 + * records in is_heartbeat_connected() — NOT from mere API-key presence,
85 + * which stays truthy after Search Atlas revokes a key and is the root cause
86 + * of's misleading "Connected" badge.
87 + *
88 + * Deliberately result-based (200 → connected, non-200/401 → disconnected)
89 + * with no time-based "stale" tier: last_heartbeat_at only advances when the
90 + * 2h WP-cron actually fires, and WP-cron only fires on traffic, so a
91 + * low-traffic-but-healthy site would otherwise be wrongly flagged degraded.
92 + *
93 + * @param array|null $general_settings Optional pre-loaded general options.
94 + * @return array{class:string,text:string,state:string} Badge CSS modifier, label, machine state.
95 + */
96 + public function get_connection_badge($general_settings = null)
97 + {
98 + if ($general_settings === null) {
99 + $general_settings = Metasync::get_option('general') ?? [];
100 + }
101 +
102 + if (!$this->is_heartbeat_connected($general_settings)) {
103 + return array('class' => 'disconnected', 'text' => 'Not Connected', 'state' => 'disconnected');
104 + }
105 +
106 + return array('class' => 'connected', 'text' => 'Connected', 'state' => 'connected');
107 + }
108 +
74 109 // ------------------------------------------------------------------
75 110 // Public-hash fetching (OTTO API)
76 111 // ------------------------------------------------------------------
77 112
@@ -110,8 +145,37 @@
110 145
111 146 $api_url = $this->build_otto_api_url($otto_pixel_uuid);
112 147 $headers = $this->prepare_api_headers($jwt_token);
113 148
149 + # Bail before the retry loop if the host is already known to be bad.
150 + #
151 + # Backoff case: without this, all three attempts are rejected instantly by the
152 + # pre_http_request filter, but the loop still sleeps 1s + 2s between them,
153 + # holding a PHP worker for 3s to accomplish nothing. Measured at 3.01s.
154 + #
155 + # Breaker case: the retry loop's worst case against a hanging endpoint is
156 + # 3 x 15s timeout + 1s + 2s of sleep = 48s of one worker. The suggestions
157 + # path runs on the same host, so if its breaker has tripped we already know
158 + # this call cannot succeed. This only helps when page traffic has tripped the
159 + # breaker first — an admin loading this page on an idle site still pays the
160 + # full 48s, which is why the retry budget itself is flagged for follow-up.
161 + #
162 + # Both helpers are checked with class_provides() rather than class_exists():
163 + # they live in files separate from this one, so a partially updated install
164 + # can leave a previous copy of either class loaded that predates the method
165 + # being called. See class_provides() for why that matters here.
166 + $endpoint_unhealthy = (self::class_provides('Metasync_API_Backoff_Manager', 'is_endpoint_in_backoff')
167 + && Metasync_API_Backoff_Manager::get_instance()->is_endpoint_in_backoff($api_url))
168 + || (self::class_provides('Metasync_Otto_Transient_Cache', 'is_host_breaker_open')
169 + && Metasync_Otto_Transient_Cache::is_host_breaker_open($api_url));
170 +
171 + if ($endpoint_unhealthy) {
172 + $this->log_fetch_hash_error('error', 'Public hash fetch skipped - endpoint unhealthy', [
173 + 'uuid' => substr($otto_pixel_uuid, 0, 8) . '...'
174 + ]);
175 + return false;
176 + }
177 +
114 178 for ($attempt = 1; $attempt <= $max_retries; $attempt++) {
115 179 $this->log_fetch_hash_error('info', 'Attempting to fetch public hash from API', [
116 180 'attempt' => $attempt,
117 181 'max_retries' => $max_retries,
@@ -153,8 +217,15 @@
153 217 'error_message' => $error_message,
154 218 'will_retry' => $attempt < $max_retries
155 219 ]);
156 220
221 + # A backoff block is not a transient network blip — the
222 + # endpoint is deliberately closed for minutes. Retrying (and
223 + # sleeping between retries) can only burn worker time.
224 + if ($error_code === 'api_backoff_active') {
225 + return false;
226 + }
227 +
157 228 if ($attempt < $max_retries) {
158 229 $this->apply_exponential_backoff($attempt, $base_retry_delay);
159 230 continue;
160 231 }
@@ -192,8 +263,32 @@
192 263 // ------------------------------------------------------------------
193 264 // Public-hash helpers (private)
194 265 // ------------------------------------------------------------------
195 266
267 + /**
268 + * Is $method actually available on $class in this process right now?
269 + *
270 + * A partially updated install can leave a newer copy of one plugin file
271 + * beside an older copy of another — stale opcache bytecode for a single file
272 + * is enough. class_exists() is satisfied by the older copy, and calling a
273 + * method it does not declare is a fatal: that is how the health checks in
274 + * fetch_public_hash() brought down the whole Dashboard admin page with
275 + * "Call to undefined method ...::is_host_breaker_open()".
276 + *
277 + * $class and $method are parameters rather than literals at the call site so
278 + * the check survives static analysis, which would otherwise narrow a literal
279 + * method_exists() on a known class to a constant true — the skew this guards
280 + * against exists only at runtime.
281 + *
282 + * @param string $class Class about to be called.
283 + * @param string $method Method about to be called on it.
284 + * @return bool
285 + */
286 + private static function class_provides($class, $method)
287 + {
288 + return class_exists($class) && method_exists($class, $method);
289 + }
290 +
196 291 private function validate_fetch_hash_inputs($uuid, $token)
197 292 {
198 293 if (empty($uuid) || empty($token)) {
199 294 return false;
@@ -206,17 +301,32 @@
206 301 }
207 302 return true;
208 303 }
209 304
305 + /**
306 + * Build the transient key used to cache the OTTO public hash.
307 + *
308 + * Centralised so that get / set / clear paths cannot drift apart and so
309 + * the two non-heartbeat callers (connect manager, otto debug admin page)
310 + * can re-use the same derivation.
311 + *
312 + * @param string $uuid OTTO pixel UUID.
313 + * @return string
314 + */
315 + public static function public_hash_cache_key($uuid)
316 + {
317 + return 'metasync_public_hash_' . hash('sha256', $uuid . get_current_blog_id());
318 + }
319 +
210 320 private function get_cached_public_hash($uuid)
211 321 {
212 - $cache_key = 'metasync_public_hash_' . hash('sha256', $uuid . get_current_blog_id());
322 + $cache_key = self::public_hash_cache_key($uuid);
213 323 return get_transient($cache_key);
214 324 }
215 325
216 326 private function cache_public_hash($uuid, $hash, $duration)
217 327 {
218 - $cache_key = 'metasync_public_hash_' . hash('sha256', $uuid . get_current_blog_id());
328 + $cache_key = self::public_hash_cache_key($uuid);
219 329 set_transient($cache_key, sanitize_text_field($hash), $duration);
220 330 }
221 331
222 332 private function build_otto_api_url($uuid)
@@ -285,9 +395,9 @@
285 395 if (class_exists('Metasync_Error_Logger')) {
286 396 Metasync_Error_Logger::log(
287 397 Metasync_Error_Logger::CATEGORY_API_RATE_LIMIT,
288 398 Metasync_Error_Logger::SEVERITY_WARNING,
289 - 'OTTO API rate limit exceeded',
399 + 'OTTO API throttled upstream',
290 400 [
291 401 'status_code' => $status_code,
292 402 'attempt' => $attempt,
293 403 'will_retry' => $attempt < $max_retries,
@@ -392,9 +502,9 @@
392 502 if (class_exists('Metasync_Error_Logger')) {
393 503 Metasync_Error_Logger::log(
394 504 Metasync_Error_Logger::CATEGORY_API_BACKOFF,
395 505 Metasync_Error_Logger::SEVERITY_INFO,
396 - 'API backoff active - applying exponential retry delay',
506 + 'Retry scheduled - applying backoff delay',
397 507 [
398 508 'attempt' => $attempt,
399 509 'delay_seconds' => $delay,
400 510 'base_delay' => $base_delay,
@@ -445,9 +555,9 @@
445 555 }
446 556 $log_message .= ' | ' . implode(', ', $context_parts);
447 557 }
448 558
449 - error_log($log_message);
559 + error_log($log_message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
450 560 }
451 561
452 562 public function clear_public_hash_cache($otto_pixel_uuid = '')
453 563 {
@@ -456,9 +566,9 @@
456 566 $otto_pixel_uuid = isset($general_options['otto_pixel_uuid']) ? $general_options['otto_pixel_uuid'] : '';
457 567 }
458 568
459 569 if (!empty($otto_pixel_uuid)) {
460 - $cache_key = 'metasync_public_hash_' . md5($otto_pixel_uuid);
570 + $cache_key = self::public_hash_cache_key($otto_pixel_uuid);
461 571 delete_transient($cache_key);
462 572 }
463 573 }
464 574
@@ -519,9 +629,9 @@
519 629 }
520 630 $message .= ' | ' . implode(', ', $details_formatted);
521 631 }
522 632
523 - error_log($message);
633 + error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
524 634
525 635 if ($level === 'error' || $level === 'critical') {
526 636 $this->store_heartbeat_error_log($event, $details);
527 637 }
@@ -539,9 +649,9 @@
539 649 if ($last_space !== false && $last_space > $length * 0.75) {
540 650 $truncated = substr($truncated, 0, $last_space);
541 651 }
542 652
543 - $truncated = strip_tags($truncated);
653 + $truncated = wp_strip_all_tags($truncated);
544 654 return $truncated . '... [truncated]';
545 655 }
546 656
547 657 private function should_throttle_log($level, $event, $details = array())
@@ -588,9 +698,9 @@
588 698 'created_at' => current_time('mysql')
589 699 ));
590 700 }
591 701 } catch (Exception $e) {
592 - error_log('Failed to store heartbeat error log: ' . $e->getMessage());
702 + error_log('Failed to store heartbeat error log: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
593 703 }
594 704 }
595 705
596 706 // ------------------------------------------------------------------
@@ -598,9 +708,12 @@
598 708 // ------------------------------------------------------------------
599 709
600 710 private function test_heartbeat_api_connection($general_settings)
601 711 {
602 - $searchatlas_api_key = $general_settings['searchatlas_api_key'] ?? '';
712 + $searchatlas_api_key = Metasync::get_searchatlas_api_key();
713 + if ($searchatlas_api_key === false) {
714 + $searchatlas_api_key = '';
715 + }
603 716 $apikey = $general_settings['apikey'] ?? '';
604 717
605 718 $start_time = microtime(true);
606 719 $api_key_type = strpos($searchatlas_api_key, 'pub-') === 0 ? 'publisher' : 'regular';
@@ -608,10 +721,12 @@
608 721 $sync_request = new Metasync_Sync_Requests();
609 722 $response = $sync_request->SyncCustomerParams($apikey);
610 723
611 724 $request_duration = round((microtime(true) - $start_time) * 1000, 2);
725 + $this->last_failure = array();
612 726
613 727 if (is_wp_error($response)) {
728 + $this->last_failure = array('error_class' => 'transport', 'status_code' => 0);
614 729 $this->log_heartbeat('error', 'Heartbeat test via SyncCustomerParams failed', array(
615 730 'error_code' => $response->get_error_code(),
616 731 'error_message' => $response->get_error_message(),
617 732 'request_duration_ms' => $request_duration,
@@ -623,8 +738,13 @@
623 738 $status_code = wp_remote_retrieve_response_code($response);
624 739 $body = wp_remote_retrieve_body($response);
625 740
626 741 if ($status_code !== 200) {
742 + // SyncCustomerParams() returns null for transport errors and non-200
743 + // replies alike, so only a real response carries a status to report.
744 + $this->last_failure = is_array($response)
745 + ? array('error_class' => 'http', 'status_code' => (int) $status_code)
746 + : array('error_class' => 'no_response', 'status_code' => 0);
627 747 $this->log_heartbeat('error', 'Heartbeat test returned non-200 status', array(
628 748 'status_code' => $status_code,
629 749 'response_body' => $this->smart_truncate($body, 300),
630 750 'request_duration_ms' => $request_duration,
@@ -632,14 +752,19 @@
632 752 ));
633 753 return false;
634 754 }
635 755
636 - $options = Metasync::get_option();
637 - if (!isset($options['general'])) { $options['general'] = []; }
638 - $options['general']['send_auth_token_timestamp'] = current_time('mysql');
639 - $options['general']['last_heartbeat_at'] = gmdate('Y-m-d\TH:i:s\Z');
640 - Metasync::set_option($options);
756 + # Throttle timestamp lives in a dedicated option key so it never
757 + # round-trips the main blob.
758 + Metasync::set_heartbeat_throttle(['last_heartbeat_at' => gmdate('Y-m-d\TH:i:s\Z')]);
641 759
760 + # Re-read the fresh blob immediately before writing send_auth_token_timestamp
761 + # so a concurrent settings save during this code path is not clobbered.
762 + $_fresh = Metasync::get_option();
763 + if (!isset($_fresh['general'])) { $_fresh['general'] = []; }
764 + $_fresh['general']['send_auth_token_timestamp'] = current_time('mysql');
765 + Metasync::set_option($_fresh);
766 +
642 767 return true;
643 768 }
644 769
645 770 // ------------------------------------------------------------------
@@ -665,9 +790,12 @@
665 790 * @param array $general_settings Plugin general options.
666 791 * @return string|null Heartbeat URL or null if not determinable.
667 792 */
668 793 private function get_heartbeat_api_url_for_backoff_check($general_settings) {
669 - $api_key = $general_settings['searchatlas_api_key'] ?? '';
794 + $api_key = Metasync::get_searchatlas_api_key();
795 + if ($api_key === false) {
796 + $api_key = '';
797 + }
670 798 if (empty($api_key)) {
671 799 return null;
672 800 }
673 801 if (strpos($api_key, 'pub-') === 0) {
@@ -682,80 +810,124 @@
682 810 return $domain . '/api/wp-website-heartbeat/';
683 811 }
684 812
685 813 /**
814 + * Validate a candidate API key against the Search Atlas backend.
815 + *
816 + * Public wrapper around `connection_ping()` for use outside this class
817 + * (e.g. pre-save validation in the settings handler).
818 + *
819 + * @param string $api_key The API key to validate.
820 + * @return array{connected: bool, otto_pixel_uuid: string|null, network_error?: bool}
821 + */
822 + public function validate_api_key( string $api_key ): array
823 + {
824 + return $this->connection_ping( $api_key );
825 + }
826 +
827 + /**
686 828 * Lightweight connection-ping — checks whether the site is registered on the backend
687 829 * without sending any heavy payload or triggering Celery tasks.
688 830 *
689 - * @return array{connected: bool, otto_pixel_uuid: string|null}
831 + * @param string|null $api_key_override Optional API key to use instead of the saved key.
832 + * @return array{connected: bool, otto_pixel_uuid: string|null, network_error?: bool}
690 833 */
691 - private function connection_ping(): array
834 + private function connection_ping( ?string $api_key_override = null ): array
692 835 {
693 836 $default = ['connected' => false, 'otto_pixel_uuid' => null];
694 837
695 - $general = Metasync::get_option('general') ?? [];
696 - $searchatlas_api_key = $general['searchatlas_api_key'] ?? '';
838 + $searchatlas_api_key = Metasync::get_searchatlas_api_key();
839 + if ($searchatlas_api_key === false) {
840 + $searchatlas_api_key = '';
841 + }
697 842
843 + if ( $api_key_override !== null && $api_key_override !== '' ) {
844 + $searchatlas_api_key = $api_key_override;
845 + }
846 +
698 847 if (empty($searchatlas_api_key)) {
699 848 $this->log_heartbeat('error', 'Connection ping skipped - no API key configured');
700 849 return $default;
701 850 }
702 851
703 - $hostname = wp_parse_url(get_home_url(), PHP_URL_HOST);
852 + $raw_hostname = wp_parse_url(get_home_url(), PHP_URL_HOST);
853 + $bare_hostname = preg_replace('/^www\./i', '', $raw_hostname);
704 854
855 + // Try both original and www-stripped hostnames to support sites registered
856 + // with or without www on SearchAtlas.
857 + $hostnames_to_try = ($raw_hostname !== $bare_hostname)
858 + ? [$raw_hostname, $bare_hostname]
859 + : [$bare_hostname];
860 +
705 861 if (strpos($searchatlas_api_key, 'pub-') === 0) {
706 862 $domain = class_exists('Metasync_Endpoint_Manager')
707 863 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
708 864 : Metasync::API_DOMAIN;
709 - $url = $domain . '/api/publisher/one-click-publishing/wp-website-heartbeat/connection-ping/?hostname=' . rawurlencode($hostname);
865 + $base_url = $domain . '/api/publisher/one-click-publishing/wp-website-heartbeat/connection-ping/?hostname=';
710 866 } else {
711 867 $domain = class_exists('Metasync_Endpoint_Manager')
712 868 ? Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN')
713 869 : Metasync::CA_API_DOMAIN;
714 - $url = $domain . '/api/wp-website-heartbeat/connection-ping/?hostname=' . rawurlencode($hostname);
870 + $base_url = $domain . '/api/wp-website-heartbeat/connection-ping/?hostname=';
715 871 }
716 872
717 - $response = wp_remote_get($url, [
718 - 'headers' => ['x-api-key' => $searchatlas_api_key],
719 - 'timeout' => 10,
720 - ]);
873 + foreach ($hostnames_to_try as $hostname) {
874 + $url = $base_url . rawurlencode($hostname);
721 875
722 - if (is_wp_error($response)) {
723 - $this->log_heartbeat('error', 'Connection ping failed (WP_Error)', [
724 - 'error_code' => $response->get_error_code(),
725 - 'error_message' => $response->get_error_message(),
876 + $response = wp_remote_get($url, [
877 + 'headers' => ['x-api-key' => $searchatlas_api_key],
878 + 'timeout' => 10,
726 879 ]);
727 - return $default;
728 - }
729 880
730 - $status_code = wp_remote_retrieve_response_code($response);
731 - if ($status_code !== 200) {
732 - $this->log_heartbeat('error', 'Connection ping returned non-200 status', [
733 - 'status_code' => $status_code,
734 - 'response_body' => $this->smart_truncate(wp_remote_retrieve_body($response), 300),
735 - ]);
736 - return $default;
737 - }
881 + if (is_wp_error($response)) {
882 + $this->log_heartbeat('error', 'Connection ping failed (WP_Error)', [
883 + 'hostname' => $hostname,
884 + 'error_code' => $response->get_error_code(),
885 + 'error_message' => $response->get_error_message(),
886 + ]);
887 + $default['network_error'] = true;
888 + return $default;
889 + }
738 890
739 - $body = wp_remote_retrieve_body($response);
740 - $data = json_decode($body, true);
891 + $status_code = wp_remote_retrieve_response_code($response);
892 + if ($status_code !== 200) {
893 + $this->log_heartbeat('error', 'Connection ping returned non-200 status', [
894 + 'hostname' => $hostname,
895 + 'status_code' => $status_code,
896 + 'response_body' => $this->smart_truncate(wp_remote_retrieve_body($response), 300),
897 + ]);
898 + return $default;
899 + }
741 900
742 - if (!is_array($data)) {
743 - $this->log_heartbeat('error', 'Connection ping returned invalid JSON', [
744 - 'response_body' => $this->smart_truncate($body, 300),
745 - ]);
746 - return $default;
901 + $body = wp_remote_retrieve_body($response);
902 + $data = json_decode($body, true);
903 +
904 + if (!is_array($data)) {
905 + $this->log_heartbeat('error', 'Connection ping returned invalid JSON', [
906 + 'hostname' => $hostname,
907 + 'response_body' => $this->smart_truncate($body, 300),
908 + ]);
909 + return $default;
910 + }
911 +
912 + $connected = (bool) ($data['connected'] ?? false);
913 + $otto_pixel_uuid = $data['otto_pixel_uuid'] ?? null;
914 +
915 + if ($connected) {
916 + $this->log_heartbeat('info', 'Connection ping completed', [
917 + 'hostname' => $hostname,
918 + 'connected' => true,
919 + 'uuid_prefix' => $otto_pixel_uuid ? substr($otto_pixel_uuid, 0, 8) . '...' : 'none',
920 + ]);
921 + return ['connected' => true, 'otto_pixel_uuid' => $otto_pixel_uuid];
922 + }
747 923 }
748 924
749 - $connected = (bool) ($data['connected'] ?? false);
750 - $otto_pixel_uuid = $data['otto_pixel_uuid'] ?? null;
751 -
752 - $this->log_heartbeat('info', 'Connection ping completed', [
753 - 'connected' => $connected,
754 - 'uuid_prefix' => $otto_pixel_uuid ? substr($otto_pixel_uuid, 0, 8) . '...' : 'none',
925 + $this->log_heartbeat('info', 'Connection ping completed — not connected on any hostname variant', [
926 + 'hostnames_tried' => $hostnames_to_try,
755 927 ]);
756 928
757 - return ['connected' => $connected, 'otto_pixel_uuid' => $otto_pixel_uuid];
929 + return $default;
758 930 }
759 931
760 932 public function unschedule_heartbeat_cron()
761 933 {
@@ -772,9 +944,12 @@
772 944 public function execute_heartbeat_cron_check()
773 945 {
774 946 $general_settings = Metasync::get_option('general') ?? [];
775 947
776 - $searchatlas_api_key = $general_settings['searchatlas_api_key'] ?? '';
948 + $searchatlas_api_key = Metasync::get_searchatlas_api_key();
949 + if ($searchatlas_api_key === false) {
950 + $searchatlas_api_key = '';
951 + }
777 952
778 953 if (empty($searchatlas_api_key)) {
779 954 $cache_data = array(
780 955 'status' => false,
@@ -810,14 +985,36 @@
810 985 );
811 986
812 987 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
813 988
989 + $was_connected = $this->get_last_known_connection_state();
814 990 $this->set_last_known_connection_state($is_connected);
991 + $this->track_connection_change($was_connected, $is_connected);
815 992
816 993 return $is_connected;
817 994 }
818 995
819 996 /**
997 + * Consent-gated analytics: only the connected -> disconnected edge, so a
998 + * site that stays down does not report on every cron tick.
999 + *
1000 + * @param mixed $was_connected Stored state before this check. The stored
1001 + * bool comes back from the DB as '1' / '',
1002 + * hence the loose check.
1003 + * @param bool $is_connected Result of this check.
1004 + */
1005 + private function track_connection_change($was_connected, $is_connected)
1006 + {
1007 + if (empty($was_connected) || $is_connected) {
1008 + return;
1009 + }
1010 + Metasync_PostHog::feature_once('connection_lost', array(
1011 + 'error_class' => $this->last_failure['error_class'] ?? 'unknown',
1012 + 'status_code' => $this->last_failure['status_code'] ?? 0,
1013 + ), 'heartbeat', DAY_IN_SECONDS);
1014 + }
1015 +
1016 + /**
820 1017 * Add custom cron schedule for 2-hour intervals and daily cleanup.
821 1018 */
822 1019 public function add_heartbeat_cron_schedule($schedules)
823 1020 {
@@ -822,33 +1019,39 @@
822 1019 public function add_heartbeat_cron_schedule($schedules)
823 1020 {
824 1021 $schedules['metasync_every_2_hours'] = array(
825 1022 'interval' => 2 * HOUR_IN_SECONDS,
826 - 'display' => esc_html__('Every 2 Hours (MetaSync)', 'metasync')
1023 + /* translators: %s: effective plugin name (whitelabel-aware). */
1024 + 'display' => esc_html(sprintf(__('Every 2 Hours (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
827 1025 );
828 1026
829 1027 $schedules['metasync_every_2_minutes'] = array(
830 1028 'interval' => 2 * MINUTE_IN_SECONDS,
831 - 'display' => esc_html__('Every 2 Minutes (MetaSync Burst)', 'metasync')
1029 + /* translators: %s: effective plugin name (whitelabel-aware). */
1030 + 'display' => esc_html(sprintf(__('Every 2 Minutes (%s Burst)', 'metasync'), Metasync::get_effective_plugin_name()))
832 1031 );
833 1032 $schedules['metasync_every_5_minutes'] = array(
834 1033 'interval' => 5 * MINUTE_IN_SECONDS,
835 - 'display' => esc_html__('Every 5 Minutes (MetaSync)', 'metasync')
1034 + /* translators: %s: effective plugin name (whitelabel-aware). */
1035 + 'display' => esc_html(sprintf(__('Every 5 Minutes (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
836 1036 );
837 1037 // 10-minute heartbeat cadence used for UNREGISTERED + KEY_PENDING short-interval behavior
838 1038 $schedules['metasync_every_10_minutes'] = array(
839 1039 'interval' => 10 * MINUTE_IN_SECONDS,
840 - 'display' => esc_html__('Every 10 Minutes (MetaSync)', 'metasync')
1040 + /* translators: %s: effective plugin name (whitelabel-aware). */
1041 + 'display' => esc_html(sprintf(__('Every 10 Minutes (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
841 1042 );
842 1043
843 1044 $schedules['metasync_daily_cleanup'] = array(
844 1045 'interval' => DAY_IN_SECONDS,
845 - 'display' => esc_html__('Daily (MetaSync Cleanup)', 'metasync')
1046 + /* translators: %s: effective plugin name (whitelabel-aware). */
1047 + 'display' => esc_html(sprintf(__('Daily (%s Cleanup)', 'metasync'), Metasync::get_effective_plugin_name()))
846 1048 );
847 1049
848 1050 $schedules['metasync_weekly'] = array(
849 1051 'interval' => 7 * DAY_IN_SECONDS,
850 - 'display' => esc_html__('Weekly (MetaSync)', 'metasync')
1052 + /* translators: %s: effective plugin name (whitelabel-aware). */
1053 + 'display' => esc_html(sprintf(__('Weekly (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
851 1054 );
852 1055
853 1056 return $schedules;
854 1057 }
@@ -859,9 +1062,12 @@
859 1062
860 1063 public function get_heartbeat_state()
861 1064 {
862 1065 $general = Metasync::get_option('general') ?? [];
863 - $api_key = $general['searchatlas_api_key'] ?? '';
1066 + $api_key = Metasync::get_searchatlas_api_key();
1067 + if ($api_key === false) {
1068 + $api_key = '';
1069 + }
864 1070 if (empty($api_key)) {
865 1071 return 'UNREGISTERED';
866 1072 }
867 1073 $state = $general['heartbeat_state'] ?? '';
@@ -901,9 +1107,13 @@
901 1107 $general = $options['general'] ?? [];
902 1108 $general['heartbeat_state'] = 'CONNECTED';
903 1109 $general['heartbeat_state_changed_at'] = time();
904 1110
905 - if (!empty($ping['otto_pixel_uuid']) && (empty($general['otto_pixel_uuid']) || $general['otto_pixel_uuid'] !== $ping['otto_pixel_uuid'])) {
1111 + // The UUID comes from the Search Atlas connection ping, but it is
1112 + // still gated by the shared UUID check: a non-UUID (username or
1113 + // other arbitrary text) must never be persisted here either.
1114 + if (!empty($ping['otto_pixel_uuid']) && Metasync::is_valid_uuid($ping['otto_pixel_uuid'])
1115 + && (empty($general['otto_pixel_uuid']) || $general['otto_pixel_uuid'] !== $ping['otto_pixel_uuid'])) {
906 1116 $general['otto_pixel_uuid'] = sanitize_text_field($ping['otto_pixel_uuid']);
907 1117 }
908 1118
909 1119 $options['general'] = $general;
@@ -945,9 +1155,13 @@
945 1155 */
946 1156 public function execute_announce_cron()
947 1157 {
948 1158 $general = Metasync::get_option('general') ?? [];
949 - if (!empty($general['searchatlas_api_key'] ?? '')) {
1159 + $announce_api_key = Metasync::get_searchatlas_api_key();
1160 + if ($announce_api_key === false) {
1161 + $announce_api_key = '';
1162 + }
1163 + if (!empty($announce_api_key)) {
950 1164 $this->unschedule_announce_cron();
951 1165 return;
952 1166 }
953 1167
@@ -1064,12 +1278,16 @@
1064 1278
1065 1279 $last_immediate_check = $current_time;
1066 1280
1067 1281 $general_settings = Metasync::get_option('general') ?? [];
1068 - $searchatlas_api_key = $general_settings['searchatlas_api_key'] ?? '';
1282 + $searchatlas_api_key = Metasync::get_searchatlas_api_key();
1283 + if ($searchatlas_api_key === false) {
1284 + $searchatlas_api_key = '';
1285 + }
1069 1286
1070 1287 if (empty($searchatlas_api_key)) {
1071 1288 delete_transient('metasync_heartbeat_status_cache');
1289 + Metasync_Admin_Navigation::invalidate_admin_bar_status_cache();
1072 1290
1073 1291 $cache_data = array(
1074 1292 'status' => false,
1075 1293 'timestamp' => time(),
@@ -1084,8 +1302,9 @@
1084 1302 return false;
1085 1303 }
1086 1304
1087 1305 delete_transient('metasync_heartbeat_status_cache');
1306 + Metasync_Admin_Navigation::invalidate_admin_bar_status_cache();
1088 1307
1089 1308 $result = $this->execute_heartbeat_cron_check();
1090 1309
1091 1310 return $result;