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 +78 -7 2.6.23 → 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
@@ -154,11 +157,16 @@
154 157 # path runs on the same host, so if its breaker has tripped we already know
155 158 # this call cannot succeed. This only helps when page traffic has tripped the
156 159 # breaker first — an admin loading this page on an idle site still pays the
157 160 # full 48s, which is why the retry budget itself is flagged for follow-up.
158 - $endpoint_unhealthy = (class_exists('Metasync_API_Backoff_Manager')
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')
159 167 && Metasync_API_Backoff_Manager::get_instance()->is_endpoint_in_backoff($api_url))
160 - || (class_exists('Metasync_Otto_Transient_Cache')
168 + || (self::class_provides('Metasync_Otto_Transient_Cache', 'is_host_breaker_open')
161 169 && Metasync_Otto_Transient_Cache::is_host_breaker_open($api_url));
162 170
163 171 if ($endpoint_unhealthy) {
164 172 $this->log_fetch_hash_error('error', 'Public hash fetch skipped - endpoint unhealthy', [
@@ -255,8 +263,32 @@
255 263 // ------------------------------------------------------------------
256 264 // Public-hash helpers (private)
257 265 // ------------------------------------------------------------------
258 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 +
259 291 private function validate_fetch_hash_inputs($uuid, $token)
260 292 {
261 293 if (empty($uuid) || empty($token)) {
262 294 return false;
@@ -523,9 +555,9 @@
523 555 }
524 556 $log_message .= ' | ' . implode(', ', $context_parts);
525 557 }
526 558
527 - error_log($log_message);
559 + error_log($log_message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
528 560 }
529 561
530 562 public function clear_public_hash_cache($otto_pixel_uuid = '')
531 563 {
@@ -597,9 +629,9 @@
597 629 }
598 630 $message .= ' | ' . implode(', ', $details_formatted);
599 631 }
600 632
601 - error_log($message);
633 + error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
602 634
603 635 if ($level === 'error' || $level === 'critical') {
604 636 $this->store_heartbeat_error_log($event, $details);
605 637 }
@@ -617,9 +649,9 @@
617 649 if ($last_space !== false && $last_space > $length * 0.75) {
618 650 $truncated = substr($truncated, 0, $last_space);
619 651 }
620 652
621 - $truncated = strip_tags($truncated);
653 + $truncated = wp_strip_all_tags($truncated);
622 654 return $truncated . '... [truncated]';
623 655 }
624 656
625 657 private function should_throttle_log($level, $event, $details = array())
@@ -666,9 +698,9 @@
666 698 'created_at' => current_time('mysql')
667 699 ));
668 700 }
669 701 } catch (Exception $e) {
670 - 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
671 703 }
672 704 }
673 705
674 706 // ------------------------------------------------------------------
@@ -689,10 +721,12 @@
689 721 $sync_request = new Metasync_Sync_Requests();
690 722 $response = $sync_request->SyncCustomerParams($apikey);
691 723
692 724 $request_duration = round((microtime(true) - $start_time) * 1000, 2);
725 + $this->last_failure = array();
693 726
694 727 if (is_wp_error($response)) {
728 + $this->last_failure = array('error_class' => 'transport', 'status_code' => 0);
695 729 $this->log_heartbeat('error', 'Heartbeat test via SyncCustomerParams failed', array(
696 730 'error_code' => $response->get_error_code(),
697 731 'error_message' => $response->get_error_message(),
698 732 'request_duration_ms' => $request_duration,
@@ -704,8 +738,13 @@
704 738 $status_code = wp_remote_retrieve_response_code($response);
705 739 $body = wp_remote_retrieve_body($response);
706 740
707 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);
708 747 $this->log_heartbeat('error', 'Heartbeat test returned non-200 status', array(
709 748 'status_code' => $status_code,
710 749 'response_body' => $this->smart_truncate($body, 300),
711 750 'request_duration_ms' => $request_duration,
@@ -946,14 +985,36 @@
946 985 );
947 986
948 987 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
949 988
989 + $was_connected = $this->get_last_known_connection_state();
950 990 $this->set_last_known_connection_state($is_connected);
991 + $this->track_connection_change($was_connected, $is_connected);
951 992
952 993 return $is_connected;
953 994 }
954 995
955 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 + /**
956 1017 * Add custom cron schedule for 2-hour intervals and daily cleanup.
957 1018 */
958 1019 public function add_heartbeat_cron_schedule($schedules)
959 1020 {
@@ -958,32 +1019,38 @@
958 1019 public function add_heartbeat_cron_schedule($schedules)
959 1020 {
960 1021 $schedules['metasync_every_2_hours'] = array(
961 1022 'interval' => 2 * HOUR_IN_SECONDS,
1023 + /* translators: %s: effective plugin name (whitelabel-aware). */
962 1024 'display' => esc_html(sprintf(__('Every 2 Hours (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
963 1025 );
964 1026
965 1027 $schedules['metasync_every_2_minutes'] = array(
966 1028 'interval' => 2 * MINUTE_IN_SECONDS,
1029 + /* translators: %s: effective plugin name (whitelabel-aware). */
967 1030 'display' => esc_html(sprintf(__('Every 2 Minutes (%s Burst)', 'metasync'), Metasync::get_effective_plugin_name()))
968 1031 );
969 1032 $schedules['metasync_every_5_minutes'] = array(
970 1033 'interval' => 5 * MINUTE_IN_SECONDS,
1034 + /* translators: %s: effective plugin name (whitelabel-aware). */
971 1035 'display' => esc_html(sprintf(__('Every 5 Minutes (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
972 1036 );
973 1037 // 10-minute heartbeat cadence used for UNREGISTERED + KEY_PENDING short-interval behavior
974 1038 $schedules['metasync_every_10_minutes'] = array(
975 1039 'interval' => 10 * MINUTE_IN_SECONDS,
1040 + /* translators: %s: effective plugin name (whitelabel-aware). */
976 1041 'display' => esc_html(sprintf(__('Every 10 Minutes (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
977 1042 );
978 1043
979 1044 $schedules['metasync_daily_cleanup'] = array(
980 1045 'interval' => DAY_IN_SECONDS,
1046 + /* translators: %s: effective plugin name (whitelabel-aware). */
981 1047 'display' => esc_html(sprintf(__('Daily (%s Cleanup)', 'metasync'), Metasync::get_effective_plugin_name()))
982 1048 );
983 1049
984 1050 $schedules['metasync_weekly'] = array(
985 1051 'interval' => 7 * DAY_IN_SECONDS,
1052 + /* translators: %s: effective plugin name (whitelabel-aware). */
986 1053 'display' => esc_html(sprintf(__('Weekly (%s)', 'metasync'), Metasync::get_effective_plugin_name()))
987 1054 );
988 1055
989 1056 return $schedules;
@@ -1040,9 +1107,13 @@
1040 1107 $general = $options['general'] ?? [];
1041 1108 $general['heartbeat_state'] = 'CONNECTED';
1042 1109 $general['heartbeat_state_changed_at'] = time();
1043 1110
1044 - 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'])) {
1045 1116 $general['otto_pixel_uuid'] = sanitize_text_field($ping['otto_pixel_uuid']);
1046 1117 }
1047 1118
1048 1119 $options['general'] = $general;