PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2.1
7.2.2 7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 All 37 releases
← All changes | includes/mlsimport-telemetry.php +165 -47 7.1.1 → 7.2.1 View file →
@@ -35,13 +35,14 @@
35 35 // Request-scoped accumulator
36 36 // ---------------------------------------------------------------------------
37 37
38 38 /**
39 - * In-memory counter deltas for the current request.
40 - * Keys: imported | updated | deleted | syncs | token_failures.
39 + * In-memory counter deltas for the current request, nested per connection:
40 + * mls_id => (imported | updated | deleted | syncs | token_failures => delta).
41 + * mls_id 0 holds unattributed (account-level) deltas — issue #283.
41 42 * Written to wp_options exactly once — on shutdown — by mlsimport_telemetry_flush().
42 43 *
43 - * @var array<string,int>
44 + * @var array<int,array<string,int>>
44 45 */
45 46 $mlsimport_telemetry_pending = array();
46 47
47 48 // ---------------------------------------------------------------------------
@@ -52,13 +53,21 @@
52 53 * Add an in-memory counter delta for the current request.
53 54 * Allowed $metric: 'imported' | 'updated' | 'deleted' | 'syncs' | 'token_failures'.
54 55 * No DB access — deltas are written to wp_options once, on shutdown, by flush().
55 56 *
57 + * Multi-MLS (issue #283): callers pass the connection the activity belongs to
58 + * (they have it in hand from the task binding). Flush folds every delta into
59 + * the unchanged GLOBAL daily bucket AND, for a positive id, into that
60 + * connection's own bucket — so global sums stay the sum of the per-connection
61 + * buckets. mls_id 0 = account-level activity with no owning connection
62 + * (e.g. SaaS token refresh failures), counted globally only.
63 + *
56 64 * @param string $metric One of the five allowed metric keys.
57 65 * @param int $amount Amount to add (default 1).
66 + * @param int $mls_id Connection the activity belongs to (0 = unattributed).
58 67 * @return void
59 68 */
60 -function mlsimport_telemetry_bump( string $metric, int $amount = 1 ): void {
69 +function mlsimport_telemetry_bump( string $metric, int $amount = 1, int $mls_id = 0 ): void {
61 70 // Whitelist of accepted metric keys.
62 71 $allowed = array( 'imported', 'updated', 'deleted', 'syncs', 'token_failures' );
63 72 // Guard: silently ignore an unknown metric key.
64 73 if ( ! in_array( $metric, $allowed, true ) ) {
@@ -65,14 +74,16 @@
65 74 return;
66 75 }
67 76 // Reach the request-scoped accumulator.
68 77 global $mlsimport_telemetry_pending;
69 - // Lazily zero-initialise this metric's slot on first use.
70 - if ( ! isset( $mlsimport_telemetry_pending[ $metric ] ) ) {
71 - $mlsimport_telemetry_pending[ $metric ] = 0;
78 + // Normalize a negative id to the unattributed slot.
79 + $mls_id = max( 0, $mls_id );
80 + // Lazily zero-initialise this connection+metric slot on first use.
81 + if ( ! isset( $mlsimport_telemetry_pending[ $mls_id ][ $metric ] ) ) {
82 + $mlsimport_telemetry_pending[ $mls_id ][ $metric ] = 0;
72 83 }
73 84 // Add the delta (no DB touch here — flush writes on shutdown).
74 - $mlsimport_telemetry_pending[ $metric ] += $amount;
85 + $mlsimport_telemetry_pending[ $mls_id ][ $metric ] += $amount;
75 86 }
76 87
77 88 // ---------------------------------------------------------------------------
78 89 // §1 Public API — flush (registered on 'shutdown')
@@ -78,13 +89,61 @@
78 89 // §1 Public API — flush (registered on 'shutdown')
79 90 // ---------------------------------------------------------------------------
80 91
81 92 /**
82 - * Flush accumulated counter deltas into today's daily bucket.
93 + * Fold one connection's pending deltas into one daily-bucket map. Pure.
94 + *
95 + * Step by step:
96 + * 1. Zero-base today's bucket for all five counters (keeping accumulated values).
97 + * 2. Add each pending delta into its counter.
98 + * 3. Prune buckets older than the retention window.
99 + *
100 + * Shared by flush() for the GLOBAL map ('daily') and every per-connection
101 + * map ('daily_mls'[mls_id]) so both fold the same one way (issue #283).
102 + *
103 + * @param array $daily Daily bucket map (YYYY-MM-DD => counters).
104 + * @param array $pending Metric => delta for this request.
105 + * @param string $today Today's UTC date 'Y-m-d'.
106 + * @return array The updated, pruned daily map.
107 + */
108 +function mlsimport_telemetry_fold_bucket( array $daily, array $pending, string $today ): array {
109 + // Step 1: zero-base for all five counters in today's bucket.
110 + $bucket = array_merge(
111 + array(
112 + 'imported' => 0,
113 + 'updated' => 0,
114 + 'deleted' => 0,
115 + 'syncs' => 0,
116 + 'token_failures' => 0,
117 + ),
118 + isset( $daily[ $today ] ) && is_array( $daily[ $today ] ) ? $daily[ $today ] : array()
119 + );
120 +
121 + // Step 2: fold this request's deltas into the bucket.
122 + foreach ( $pending as $metric => $delta ) {
123 + if ( isset( $bucket[ $metric ] ) ) {
124 + $bucket[ $metric ] += $delta;
125 + }
126 + }
127 +
128 + // Step 3: store the bucket, drop buckets past the retention window.
129 + $daily[ $today ] = $bucket;
130 + return mlsimport_telemetry_prune_buckets( $daily, $today );
131 +}
132 +
133 +/**
134 + * Flush accumulated counter deltas into today's daily buckets.
83 135 * No-op when nothing is pending. Reads + writes the single option
84 136 * 'mlsimport_telemetry_state' exactly once, prunes buckets older than 8 days,
85 137 * resets the pending array. Registered on the 'shutdown' action.
86 138 *
139 + * Multi-MLS (issue #283): pending deltas arrive nested per connection.
140 + * Every delta folds into the unchanged GLOBAL 'daily' map; a positive
141 + * connection id additionally folds into that connection's own map under
142 + * 'daily_mls' — so the global 7-day sums equal the sum of the per-connection
143 + * buckets by construction. A connection whose fold carried import activity
144 + * (imported/updated/deleted) also gets its 'connection_last_import' stamp.
145 + *
87 146 * @return void
88 147 */
89 148 function mlsimport_telemetry_flush(): void {
90 149 // Reach the request-scoped accumulator.
@@ -100,39 +159,44 @@
100 159 if ( ! is_array( $state ) ) {
101 160 $state = array();
102 161 }
103 162
104 - // Ensure the daily bucket map exists.
163 + // Ensure the global and per-connection bucket maps exist.
105 164 if ( ! isset( $state['daily'] ) || ! is_array( $state['daily'] ) ) {
106 165 $state['daily'] = array();
107 166 }
167 + if ( ! isset( $state['daily_mls'] ) || ! is_array( $state['daily_mls'] ) ) {
168 + $state['daily_mls'] = array();
169 + }
108 170
109 - // Today's UTC date is the bucket key; start from any existing bucket.
110 - $today = gmdate( 'Y-m-d' );
111 - $bucket = isset( $state['daily'][ $today ] ) ? $state['daily'][ $today ] : array();
171 + // Today's UTC date is the bucket key everywhere.
172 + $today = gmdate( 'Y-m-d' );
112 173
113 - // Initialise zero-base for all five counters in this bucket.
114 - $defaults = array(
115 - 'imported' => 0,
116 - 'updated' => 0,
117 - 'deleted' => 0,
118 - 'syncs' => 0,
119 - 'token_failures' => 0,
120 - );
121 - // Fill any missing counters with 0 while keeping already-accumulated values.
122 - $bucket = array_merge( $defaults, $bucket );
174 + // Fold every connection's deltas — each connection once, globals once each.
175 + foreach ( $mlsimport_telemetry_pending as $mls_id => $pending ) {
176 + // Every delta counts globally (legacy fields unchanged).
177 + $state['daily'] = mlsimport_telemetry_fold_bucket( $state['daily'], $pending, $today );
123 178
124 - // Fold this request's pending deltas into today's bucket.
125 - foreach ( $mlsimport_telemetry_pending as $metric => $delta ) {
126 - if ( isset( $bucket[ $metric ] ) ) {
127 - $bucket[ $metric ] += $delta;
179 + // Unattributed (account-level) deltas stop at the global map.
180 + if ( $mls_id <= 0 ) {
181 + continue;
128 182 }
183 +
184 + // This connection's own bucket map.
185 + $mls_daily = is_array( $state['daily_mls'][ $mls_id ] ?? null ) ? $state['daily_mls'][ $mls_id ] : array();
186 + $state['daily_mls'][ $mls_id ] = mlsimport_telemetry_fold_bucket( $mls_daily, $pending, $today );
187 +
188 + // Import activity stamps this connection's last-import time (#283) —
189 + // syncs/token ticks alone are not imports and do not move it.
190 + $activity = (int) ( $pending['imported'] ?? 0 ) + (int) ( $pending['updated'] ?? 0 ) + (int) ( $pending['deleted'] ?? 0 );
191 + if ( $activity > 0 ) {
192 + if ( ! isset( $state['connection_last_import'] ) || ! is_array( $state['connection_last_import'] ) ) {
193 + $state['connection_last_import'] = array();
194 + }
195 + $state['connection_last_import'][ $mls_id ] = time();
196 + }
129 197 }
130 198
131 - // Store the updated bucket, then drop buckets older than the retention window.
132 - $state['daily'][ $today ] = $bucket;
133 - $state['daily'] = mlsimport_telemetry_prune_buckets( $state['daily'], $today );
134 -
135 199 // Single write, non-autoloaded.
136 200 update_option( 'mlsimport_telemetry_state', $state, false );
137 201
138 202 // Reset pending.
@@ -201,21 +265,40 @@
201 265 * feed, so a cron run that dies later in its loop still leaves fresh
202 266 * success evidence — the previous end-of-loop-only stamp left actively
203 267 * syncing sites reporting last_successful_sync = "never".
204 268 *
269 + * Multi-MLS (issue #283): the caller passes the connection the pull ran for
270 + * (in hand from the task binding). Each pull ticks that connection's 'syncs'
271 + * counter, and the outcome is additionally stamped into the per-connection
272 + * success/failure maps — the GLOBAL sync_health stamps stay exactly as before.
273 + *
205 274 * @param mixed $answer The normalized listings API answer array.
275 + * @param int $mls_id Connection the pull ran for (0 = unattributed).
206 276 * @return void
207 277 */
208 -function mlsimport_telemetry_record_sync_result( $answer ): void {
278 +function mlsimport_telemetry_record_sync_result( $answer, int $mls_id = 0 ): void {
279 + // One pull = one sync tick, counted against its own connection (#283).
280 + // This is also what makes syncs_last_7_days a live counter again.
281 + mlsimport_telemetry_bump( 'syncs', 1, $mls_id );
282 +
209 283 // A successful pull always carries the feed count under 'results'.
210 284 if ( is_array( $answer ) && isset( $answer['results'] ) ) {
211 285 mlsimport_telemetry_set( 'last_sync_success', time() );
286 + // Per-connection success stamp (#283).
287 + if ( $mls_id > 0 ) {
288 + mlsimport_telemetry_record_connection_sync( $mls_id, true );
289 + }
212 290 return;
213 291 }
214 292 // Anything else is a failed pull: stamp when it happened and a real
215 293 // failure class — previously every failure surfaced as "unknown".
294 + $code = mlsimport_telemetry_classify_sync_failure( $answer );
216 295 mlsimport_telemetry_set( 'last_sync_failed', time() );
217 - mlsimport_telemetry_set( 'last_sync_failed_code', mlsimport_telemetry_classify_sync_failure( $answer ) );
296 + mlsimport_telemetry_set( 'last_sync_failed_code', $code );
297 + // Per-connection failure stamp (#283).
298 + if ( $mls_id > 0 ) {
299 + mlsimport_telemetry_record_connection_sync( $mls_id, false, $code );
300 + }
218 301 }
219 302
220 303 /**
221 304 * Map a failed listings answer to a short failure class for
@@ -519,8 +602,11 @@
519 602 'compare' => 'EXISTS',
520 603 ),
521 604 ),
522 605 'no_found_rows' => true,
606 + // Telemetry samples STORED listings; dedupe-hidden copies (#282) are
607 + // stored and must count.
608 + 'mlsimport_include_hidden' => true,
523 609 );
524 610
525 611 // Run the query (guard for environments without get_posts()).
526 612 $post_ids = function_exists( 'get_posts' ) ? get_posts( $args ) : array();
@@ -671,10 +757,16 @@
671 757 'fields' => 'ids',
672 758 'no_found_rows' => true,
673 759 ) ) : array();
674 760
675 - $import_tasks = array();
676 - $auto_update_any = false;
761 + // --- connections registry (issue #283) ---
762 + // One record per registered MLS, priority-sorted (1 first). The
763 + // class_exists guard mirrors the get_posts/WP_Query guards above: legacy
764 + // unit harnesses load this file without the registry class.
765 + $connection_records = class_exists( 'Mlsimport_Connections' ) ? Mlsimport_Connections::all() : array();
766 +
767 + $import_tasks = array();
768 + $auto_update_any = false;
677 769 foreach ( $raw_tasks_query as $task_id ) {
678 770 $how_many = (int) get_post_meta( $task_id, 'mlsimport_item_how_many', true );
679 771 $stat_cron = (int) get_post_meta( $task_id, 'mlsimport_item_stat_cron', true );
680 772 $auto_upd = ( 1 === $stat_cron );
@@ -686,24 +778,41 @@
686 778 'auto_update' => $auto_upd,
687 779 );
688 780 }
689 781
782 + // Per-connection workload (issue #283): task/paused/listing counts per
783 + // connection, gathered by the module that owns the per-connection half
784 + // of the heartbeat. Skipped entirely on an empty registry (also keeps
785 + // legacy unit harnesses off the binding-module functions).
786 + $connection_workload = $connection_records
787 + ? mlsimport_telemetry_gather_connection_workload( $connection_records, $raw_tasks_query, $post_type )
788 + : array();
789 +
690 790 // --- MLS provider / ID ---
791 + // Legacy singular fields (decision #272): filled from the PRIORITY-1
792 + // connection so the current portal keeps working while it learns the
793 + // connections array. An empty registry keeps the pre-multi-MLS derivation.
691 794 $mls_provider = '';
692 795 $mls_id = 0;
693 - if ( isset( $opts['mlsimport_mls_name'] ) && '' !== $opts['mlsimport_mls_name'] ) {
694 - $mls_id = (int) $opts['mlsimport_mls_name'];
796 + if ( $connection_records ) {
797 + $priority_one = reset( $connection_records );
798 + $mls_id = (int) $priority_one['mls_id'];
799 + $mls_provider = (string) $priority_one['provider_type'];
800 + } else {
801 + if ( isset( $opts['mlsimport_mls_name'] ) && '' !== $opts['mlsimport_mls_name'] ) {
802 + $mls_id = (int) $opts['mlsimport_mls_name'];
803 + }
804 + // Derive MLS provider label from the theme/MLS env class name if available.
805 + if (
806 + isset( $mlsimport ) &&
807 + isset( $mlsimport->admin ) &&
808 + isset( $mlsimport->admin->mls_env_data ) &&
809 + is_object( $mlsimport->admin->mls_env_data )
810 + ) {
811 + $mls_class = get_class( $mlsimport->admin->mls_env_data );
812 + $mls_provider = ( 'stdClass' !== $mls_class ) ? $mls_class : '';
813 + }
695 814 }
696 - // Derive MLS provider label from the theme/MLS env class name if available.
697 - if (
698 - isset( $mlsimport ) &&
699 - isset( $mlsimport->admin ) &&
700 - isset( $mlsimport->admin->mls_env_data ) &&
701 - is_object( $mlsimport->admin->mls_env_data )
702 - ) {
703 - $mls_class = get_class( $mlsimport->admin->mls_env_data );
704 - $mls_provider = ( 'stdClass' !== $mls_class ) ? $mls_class : '';
705 - }
706 815
707 816 // Theme label.
708 817 $theme_label = '';
709 818 if (
@@ -768,8 +877,17 @@
768 877 'mls_id' => $mls_id,
769 878 'import_tasks' => $import_tasks,
770 879 'import_tasks_count' => count( $import_tasks ),
771 880 'auto_update_enabled' => (bool) $auto_update_any,
881 + ),
882 + // Per-connection health (issue #283, decision #272): one entry per
883 + // registered connection, priority order; a single-connection install
884 + // sends the identical shape with a one-entry array.
885 + 'connections' => mlsimport_telemetry_connections_payload(
886 + $connection_records,
887 + $state,
888 + $today,
889 + $connection_workload
772 890 ),
773 891 'environment' => array(
774 892 'plugin_version' => defined( 'MLSIMPORT_VERSION' ) ? MLSIMPORT_VERSION : '',
775 893 'php_version' => PHP_VERSION,