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 +109 -7 2.6.21 → 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
@@ -142,8 +145,37 @@
142 145
143 146 $api_url = $this->build_otto_api_url($otto_pixel_uuid);
144 147 $headers = $this->prepare_api_headers($jwt_token);
145 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 +
146 178 for ($attempt = 1; $attempt <= $max_retries; $attempt++) {
147 179 $this->log_fetch_hash_error('info', 'Attempting to fetch public hash from API', [
148 180 'attempt' => $attempt,
149 181 'max_retries' => $max_retries,
@@ -185,8 +217,15 @@
185 217 'error_message' => $error_message,
186 218 'will_retry' => $attempt < $max_retries
187 219 ]);
188 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 +
189 228 if ($attempt < $max_retries) {
190 229 $this->apply_exponential_backoff($attempt, $base_retry_delay);
191 230 continue;
192 231 }
@@ -224,8 +263,32 @@
224 263 // ------------------------------------------------------------------
225 264 // Public-hash helpers (private)
226 265 // ------------------------------------------------------------------
227 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 +
228 291 private function validate_fetch_hash_inputs($uuid, $token)
229 292 {
230 293 if (empty($uuid) || empty($token)) {
231 294 return false;
@@ -332,9 +395,9 @@
332 395 if (class_exists('Metasync_Error_Logger')) {
333 396 Metasync_Error_Logger::log(
334 397 Metasync_Error_Logger::CATEGORY_API_RATE_LIMIT,
335 398 Metasync_Error_Logger::SEVERITY_WARNING,
336 - 'OTTO API rate limit exceeded',
399 + 'OTTO API throttled upstream',
337 400 [
338 401 'status_code' => $status_code,
339 402 'attempt' => $attempt,
340 403 'will_retry' => $attempt < $max_retries,
@@ -439,9 +502,9 @@
439 502 if (class_exists('Metasync_Error_Logger')) {
440 503 Metasync_Error_Logger::log(
441 504 Metasync_Error_Logger::CATEGORY_API_BACKOFF,
442 505 Metasync_Error_Logger::SEVERITY_INFO,
443 - 'API backoff active - applying exponential retry delay',
506 + 'Retry scheduled - applying backoff delay',
444 507 [
445 508 'attempt' => $attempt,
446 509 'delay_seconds' => $delay,
447 510 'base_delay' => $base_delay,
@@ -492,9 +555,9 @@
492 555 }
493 556 $log_message .= ' | ' . implode(', ', $context_parts);
494 557 }
495 558
496 - error_log($log_message);
559 + error_log($log_message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
497 560 }
498 561
499 562 public function clear_public_hash_cache($otto_pixel_uuid = '')
500 563 {
@@ -566,9 +629,9 @@
566 629 }
567 630 $message .= ' | ' . implode(', ', $details_formatted);
568 631 }
569 632
570 - error_log($message);
633 + error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
571 634
572 635 if ($level === 'error' || $level === 'critical') {
573 636 $this->store_heartbeat_error_log($event, $details);
574 637 }
@@ -586,9 +649,9 @@
586 649 if ($last_space !== false && $last_space > $length * 0.75) {
587 650 $truncated = substr($truncated, 0, $last_space);
588 651 }
589 652
590 - $truncated = strip_tags($truncated);
653 + $truncated = wp_strip_all_tags($truncated);
591 654 return $truncated . '... [truncated]';
592 655 }
593 656
594 657 private function should_throttle_log($level, $event, $details = array())
@@ -635,9 +698,9 @@
635 698 'created_at' => current_time('mysql')
636 699 ));
637 700 }
638 701 } catch (Exception $e) {
639 - 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
640 703 }
641 704 }
642 705
643 706 // ------------------------------------------------------------------
@@ -658,10 +721,12 @@
658 721 $sync_request = new Metasync_Sync_Requests();
659 722 $response = $sync_request->SyncCustomerParams($apikey);
660 723
661 724 $request_duration = round((microtime(true) - $start_time) * 1000, 2);
725 + $this->last_failure = array();
662 726
663 727 if (is_wp_error($response)) {
728 + $this->last_failure = array('error_class' => 'transport', 'status_code' => 0);
664 729 $this->log_heartbeat('error', 'Heartbeat test via SyncCustomerParams failed', array(
665 730 'error_code' => $response->get_error_code(),
666 731 'error_message' => $response->get_error_message(),
667 732 'request_duration_ms' => $request_duration,
@@ -673,8 +738,13 @@
673 738 $status_code = wp_remote_retrieve_response_code($response);
674 739 $body = wp_remote_retrieve_body($response);
675 740
676 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);
677 747 $this->log_heartbeat('error', 'Heartbeat test returned non-200 status', array(
678 748 'status_code' => $status_code,
679 749 'response_body' => $this->smart_truncate($body, 300),
680 750 'request_duration_ms' => $request_duration,
@@ -915,14 +985,36 @@
915 985 );
916 986
917 987 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
918 988
989 + $was_connected = $this->get_last_known_connection_state();
919 990 $this->set_last_known_connection_state($is_connected);
991 + $this->track_connection_change($was_connected, $is_connected);
920 992
921 993 return $is_connected;
922 994 }
923 995
924 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 + /**
925 1017 * Add custom cron schedule for 2-hour intervals and daily cleanup.
926 1018 */
927 1019 public function add_heartbeat_cron_schedule($schedules)
928 1020 {
@@ -927,32 +1019,38 @@
927 1019 public function add_heartbeat_cron_schedule($schedules)
928 1020 {
929 1021 $schedules['metasync_every_2_hours'] = array(
930 1022 'interval' => 2 * HOUR_IN_SECONDS,
1023 + /* translators: %s: effective plugin name (whitelabel-aware). */
931 1024 'display' => esc_html(sprintf(__('Every 2 Hours (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
932 1025 );
933 1026
934 1027 $schedules['metasync_every_2_minutes'] = array(
935 1028 'interval' => 2 * MINUTE_IN_SECONDS,
1029 + /* translators: %s: effective plugin name (whitelabel-aware). */
936 1030 'display' => esc_html(sprintf(__('Every 2 Minutes (%s Burst)', 'metasync'), Metasync::get_effective_plugin_name()))
937 1031 );
938 1032 $schedules['metasync_every_5_minutes'] = array(
939 1033 'interval' => 5 * MINUTE_IN_SECONDS,
1034 + /* translators: %s: effective plugin name (whitelabel-aware). */
940 1035 'display' => esc_html(sprintf(__('Every 5 Minutes (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
941 1036 );
942 1037 // 10-minute heartbeat cadence used for UNREGISTERED + KEY_PENDING short-interval behavior
943 1038 $schedules['metasync_every_10_minutes'] = array(
944 1039 'interval' => 10 * MINUTE_IN_SECONDS,
1040 + /* translators: %s: effective plugin name (whitelabel-aware). */
945 1041 'display' => esc_html(sprintf(__('Every 10 Minutes (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
946 1042 );
947 1043
948 1044 $schedules['metasync_daily_cleanup'] = array(
949 1045 'interval' => DAY_IN_SECONDS,
1046 + /* translators: %s: effective plugin name (whitelabel-aware). */
950 1047 'display' => esc_html(sprintf(__('Daily (%s Cleanup)', 'metasync'), Metasync::get_effective_plugin_name()))
951 1048 );
952 1049
953 1050 $schedules['metasync_weekly'] = array(
954 1051 'interval' => 7 * DAY_IN_SECONDS,
1052 + /* translators: %s: effective plugin name (whitelabel-aware). */
955 1053 'display' => esc_html(sprintf(__('Weekly (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
956 1054 );
957 1055
958 1056 return $schedules;
@@ -1009,9 +1107,13 @@
1009 1107 $general = $options['general'] ?? [];
1010 1108 $general['heartbeat_state'] = 'CONNECTED';
1011 1109 $general['heartbeat_state_changed_at'] = time();
1012 1110
1013 - 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'])) {
1014 1116 $general['otto_pixel_uuid'] = sanitize_text_field($ping['otto_pixel_uuid']);
1015 1117 }
1016 1118
1017 1119 $options['general'] = $general;