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 +143 -17 2.6.16 → 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 }
@@ -76,9 +82,9 @@
76 82 *
77 83 * Derives the label from the heartbeat round-trip RESULT that the cron
78 84 * records in is_heartbeat_connected() — NOT from mere API-key presence,
79 85 * which stays truthy after Search Atlas revokes a key and is the root cause
80 - * of WP-476's misleading "Connected" badge.
86 + * of's misleading "Connected" badge.
81 87 *
82 88 * Deliberately result-based (200 → connected, non-200/401 → disconnected)
83 89 * with no time-based "stale" tier: last_heartbeat_at only advances when the
84 90 * 2h WP-cron actually fires, and WP-cron only fires on traffic, so a
@@ -139,8 +145,37 @@
139 145
140 146 $api_url = $this->build_otto_api_url($otto_pixel_uuid);
141 147 $headers = $this->prepare_api_headers($jwt_token);
142 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 +
143 178 for ($attempt = 1; $attempt <= $max_retries; $attempt++) {
144 179 $this->log_fetch_hash_error('info', 'Attempting to fetch public hash from API', [
145 180 'attempt' => $attempt,
146 181 'max_retries' => $max_retries,
@@ -182,8 +217,15 @@
182 217 'error_message' => $error_message,
183 218 'will_retry' => $attempt < $max_retries
184 219 ]);
185 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 +
186 228 if ($attempt < $max_retries) {
187 229 $this->apply_exponential_backoff($attempt, $base_retry_delay);
188 230 continue;
189 231 }
@@ -221,8 +263,32 @@
221 263 // ------------------------------------------------------------------
222 264 // Public-hash helpers (private)
223 265 // ------------------------------------------------------------------
224 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 +
225 291 private function validate_fetch_hash_inputs($uuid, $token)
226 292 {
227 293 if (empty($uuid) || empty($token)) {
228 294 return false;
@@ -329,9 +395,9 @@
329 395 if (class_exists('Metasync_Error_Logger')) {
330 396 Metasync_Error_Logger::log(
331 397 Metasync_Error_Logger::CATEGORY_API_RATE_LIMIT,
332 398 Metasync_Error_Logger::SEVERITY_WARNING,
333 - 'OTTO API rate limit exceeded',
399 + 'OTTO API throttled upstream',
334 400 [
335 401 'status_code' => $status_code,
336 402 'attempt' => $attempt,
337 403 'will_retry' => $attempt < $max_retries,
@@ -436,9 +502,9 @@
436 502 if (class_exists('Metasync_Error_Logger')) {
437 503 Metasync_Error_Logger::log(
438 504 Metasync_Error_Logger::CATEGORY_API_BACKOFF,
439 505 Metasync_Error_Logger::SEVERITY_INFO,
440 - 'API backoff active - applying exponential retry delay',
506 + 'Retry scheduled - applying backoff delay',
441 507 [
442 508 'attempt' => $attempt,
443 509 'delay_seconds' => $delay,
444 510 'base_delay' => $base_delay,
@@ -489,9 +555,9 @@
489 555 }
490 556 $log_message .= ' | ' . implode(', ', $context_parts);
491 557 }
492 558
493 - error_log($log_message);
559 + error_log($log_message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
494 560 }
495 561
496 562 public function clear_public_hash_cache($otto_pixel_uuid = '')
497 563 {
@@ -563,9 +629,9 @@
563 629 }
564 630 $message .= ' | ' . implode(', ', $details_formatted);
565 631 }
566 632
567 - error_log($message);
633 + error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
568 634
569 635 if ($level === 'error' || $level === 'critical') {
570 636 $this->store_heartbeat_error_log($event, $details);
571 637 }
@@ -583,9 +649,9 @@
583 649 if ($last_space !== false && $last_space > $length * 0.75) {
584 650 $truncated = substr($truncated, 0, $last_space);
585 651 }
586 652
587 - $truncated = strip_tags($truncated);
653 + $truncated = wp_strip_all_tags($truncated);
588 654 return $truncated . '... [truncated]';
589 655 }
590 656
591 657 private function should_throttle_log($level, $event, $details = array())
@@ -632,9 +698,9 @@
632 698 'created_at' => current_time('mysql')
633 699 ));
634 700 }
635 701 } catch (Exception $e) {
636 - 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
637 703 }
638 704 }
639 705
640 706 // ------------------------------------------------------------------
@@ -642,9 +708,12 @@
642 708 // ------------------------------------------------------------------
643 709
644 710 private function test_heartbeat_api_connection($general_settings)
645 711 {
646 - $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 + }
647 716 $apikey = $general_settings['apikey'] ?? '';
648 717
649 718 $start_time = microtime(true);
650 719 $api_key_type = strpos($searchatlas_api_key, 'pub-') === 0 ? 'publisher' : 'regular';
@@ -652,10 +721,12 @@
652 721 $sync_request = new Metasync_Sync_Requests();
653 722 $response = $sync_request->SyncCustomerParams($apikey);
654 723
655 724 $request_duration = round((microtime(true) - $start_time) * 1000, 2);
725 + $this->last_failure = array();
656 726
657 727 if (is_wp_error($response)) {
728 + $this->last_failure = array('error_class' => 'transport', 'status_code' => 0);
658 729 $this->log_heartbeat('error', 'Heartbeat test via SyncCustomerParams failed', array(
659 730 'error_code' => $response->get_error_code(),
660 731 'error_message' => $response->get_error_message(),
661 732 'request_duration_ms' => $request_duration,
@@ -667,8 +738,13 @@
667 738 $status_code = wp_remote_retrieve_response_code($response);
668 739 $body = wp_remote_retrieve_body($response);
669 740
670 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);
671 747 $this->log_heartbeat('error', 'Heartbeat test returned non-200 status', array(
672 748 'status_code' => $status_code,
673 749 'response_body' => $this->smart_truncate($body, 300),
674 750 'request_duration_ms' => $request_duration,
@@ -714,9 +790,12 @@
714 790 * @param array $general_settings Plugin general options.
715 791 * @return string|null Heartbeat URL or null if not determinable.
716 792 */
717 793 private function get_heartbeat_api_url_for_backoff_check($general_settings) {
718 - $api_key = $general_settings['searchatlas_api_key'] ?? '';
794 + $api_key = Metasync::get_searchatlas_api_key();
795 + if ($api_key === false) {
796 + $api_key = '';
797 + }
719 798 if (empty($api_key)) {
720 799 return null;
721 800 }
722 801 if (strpos($api_key, 'pub-') === 0) {
@@ -755,10 +834,12 @@
755 834 private function connection_ping( ?string $api_key_override = null ): array
756 835 {
757 836 $default = ['connected' => false, 'otto_pixel_uuid' => null];
758 837
759 - $general = Metasync::get_option('general') ?? [];
760 - $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 + }
761 842
762 843 if ( $api_key_override !== null && $api_key_override !== '' ) {
763 844 $searchatlas_api_key = $api_key_override;
764 845 }
@@ -863,9 +944,12 @@
863 944 public function execute_heartbeat_cron_check()
864 945 {
865 946 $general_settings = Metasync::get_option('general') ?? [];
866 947
867 - $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 + }
868 952
869 953 if (empty($searchatlas_api_key)) {
870 954 $cache_data = array(
871 955 'status' => false,
@@ -901,14 +985,36 @@
901 985 );
902 986
903 987 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
904 988
989 + $was_connected = $this->get_last_known_connection_state();
905 990 $this->set_last_known_connection_state($is_connected);
991 + $this->track_connection_change($was_connected, $is_connected);
906 992
907 993 return $is_connected;
908 994 }
909 995
910 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 + /**
911 1017 * Add custom cron schedule for 2-hour intervals and daily cleanup.
912 1018 */
913 1019 public function add_heartbeat_cron_schedule($schedules)
914 1020 {
@@ -913,32 +1019,38 @@
913 1019 public function add_heartbeat_cron_schedule($schedules)
914 1020 {
915 1021 $schedules['metasync_every_2_hours'] = array(
916 1022 'interval' => 2 * HOUR_IN_SECONDS,
1023 + /* translators: %s: effective plugin name (whitelabel-aware). */
917 1024 'display' => esc_html(sprintf(__('Every 2 Hours (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
918 1025 );
919 1026
920 1027 $schedules['metasync_every_2_minutes'] = array(
921 1028 'interval' => 2 * MINUTE_IN_SECONDS,
1029 + /* translators: %s: effective plugin name (whitelabel-aware). */
922 1030 'display' => esc_html(sprintf(__('Every 2 Minutes (%s Burst)', 'metasync'), Metasync::get_effective_plugin_name()))
923 1031 );
924 1032 $schedules['metasync_every_5_minutes'] = array(
925 1033 'interval' => 5 * MINUTE_IN_SECONDS,
1034 + /* translators: %s: effective plugin name (whitelabel-aware). */
926 1035 'display' => esc_html(sprintf(__('Every 5 Minutes (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
927 1036 );
928 1037 // 10-minute heartbeat cadence used for UNREGISTERED + KEY_PENDING short-interval behavior
929 1038 $schedules['metasync_every_10_minutes'] = array(
930 1039 'interval' => 10 * MINUTE_IN_SECONDS,
1040 + /* translators: %s: effective plugin name (whitelabel-aware). */
931 1041 'display' => esc_html(sprintf(__('Every 10 Minutes (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
932 1042 );
933 1043
934 1044 $schedules['metasync_daily_cleanup'] = array(
935 1045 'interval' => DAY_IN_SECONDS,
1046 + /* translators: %s: effective plugin name (whitelabel-aware). */
936 1047 'display' => esc_html(sprintf(__('Daily (%s Cleanup)', 'metasync'), Metasync::get_effective_plugin_name()))
937 1048 );
938 1049
939 1050 $schedules['metasync_weekly'] = array(
940 1051 'interval' => 7 * DAY_IN_SECONDS,
1052 + /* translators: %s: effective plugin name (whitelabel-aware). */
941 1053 'display' => esc_html(sprintf(__('Weekly (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
942 1054 );
943 1055
944 1056 return $schedules;
@@ -950,9 +1062,12 @@
950 1062
951 1063 public function get_heartbeat_state()
952 1064 {
953 1065 $general = Metasync::get_option('general') ?? [];
954 - $api_key = $general['searchatlas_api_key'] ?? '';
1066 + $api_key = Metasync::get_searchatlas_api_key();
1067 + if ($api_key === false) {
1068 + $api_key = '';
1069 + }
955 1070 if (empty($api_key)) {
956 1071 return 'UNREGISTERED';
957 1072 }
958 1073 $state = $general['heartbeat_state'] ?? '';
@@ -992,9 +1107,13 @@
992 1107 $general = $options['general'] ?? [];
993 1108 $general['heartbeat_state'] = 'CONNECTED';
994 1109 $general['heartbeat_state_changed_at'] = time();
995 1110
996 - 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'])) {
997 1116 $general['otto_pixel_uuid'] = sanitize_text_field($ping['otto_pixel_uuid']);
998 1117 }
999 1118
1000 1119 $options['general'] = $general;
@@ -1036,9 +1155,13 @@
1036 1155 */
1037 1156 public function execute_announce_cron()
1038 1157 {
1039 1158 $general = Metasync::get_option('general') ?? [];
1040 - 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)) {
1041 1164 $this->unschedule_announce_cron();
1042 1165 return;
1043 1166 }
1044 1167
@@ -1155,9 +1278,12 @@
1155 1278
1156 1279 $last_immediate_check = $current_time;
1157 1280
1158 1281 $general_settings = Metasync::get_option('general') ?? [];
1159 - $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 + }
1160 1286
1161 1287 if (empty($searchatlas_api_key)) {
1162 1288 delete_transient('metasync_heartbeat_status_cache');
1163 1289 Metasync_Admin_Navigation::invalidate_admin_bar_status_cache();