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 +319 -54 6.3.77.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,22 +53,37 @@
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 {
70 + // Whitelist of accepted metric keys.
61 71 $allowed = array( 'imported', 'updated', 'deleted', 'syncs', 'token_failures' );
72 + // Guard: silently ignore an unknown metric key.
62 73 if ( ! in_array( $metric, $allowed, true ) ) {
63 74 return;
64 75 }
76 + // Reach the request-scoped accumulator.
65 77 global $mlsimport_telemetry_pending;
66 - if ( ! isset( $mlsimport_telemetry_pending[ $metric ] ) ) {
67 - $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;
68 83 }
69 - $mlsimport_telemetry_pending[ $metric ] += $amount;
84 + // Add the delta (no DB touch here — flush writes on shutdown).
85 + $mlsimport_telemetry_pending[ $mls_id ][ $metric ] += $amount;
70 86 }
71 87
72 88 // ---------------------------------------------------------------------------
73 89 // §1 Public API — flush (registered on 'shutdown')
@@ -73,53 +89,115 @@
73 89 // §1 Public API — flush (registered on 'shutdown')
74 90 // ---------------------------------------------------------------------------
75 91
76 92 /**
77 - * 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.
78 135 * No-op when nothing is pending. Reads + writes the single option
79 136 * 'mlsimport_telemetry_state' exactly once, prunes buckets older than 8 days,
80 137 * resets the pending array. Registered on the 'shutdown' action.
81 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 + *
82 146 * @return void
83 147 */
84 148 function mlsimport_telemetry_flush(): void {
149 + // Reach the request-scoped accumulator.
85 150 global $mlsimport_telemetry_pending;
86 151
152 + // Nothing accumulated this request — do not read or write the option.
87 153 if ( empty( $mlsimport_telemetry_pending ) ) {
88 154 return;
89 155 }
90 156
157 + // Load the persisted state; coerce a corrupt/legacy value back to an array.
91 158 $state = get_option( 'mlsimport_telemetry_state', array() );
92 159 if ( ! is_array( $state ) ) {
93 160 $state = array();
94 161 }
95 162
163 + // Ensure the global and per-connection bucket maps exist.
96 164 if ( ! isset( $state['daily'] ) || ! is_array( $state['daily'] ) ) {
97 165 $state['daily'] = array();
98 166 }
167 + if ( ! isset( $state['daily_mls'] ) || ! is_array( $state['daily_mls'] ) ) {
168 + $state['daily_mls'] = array();
169 + }
99 170
100 - $today = gmdate( 'Y-m-d' );
101 - $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' );
102 173
103 - // Initialise zero-base for all five counters in this bucket.
104 - $defaults = array(
105 - 'imported' => 0,
106 - 'updated' => 0,
107 - 'deleted' => 0,
108 - 'syncs' => 0,
109 - 'token_failures' => 0,
110 - );
111 - $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 );
112 178
113 - foreach ( $mlsimport_telemetry_pending as $metric => $delta ) {
114 - if ( isset( $bucket[ $metric ] ) ) {
115 - $bucket[ $metric ] += $delta;
179 + // Unattributed (account-level) deltas stop at the global map.
180 + if ( $mls_id <= 0 ) {
181 + continue;
116 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 + }
117 197 }
118 198
119 - $state['daily'][ $today ] = $bucket;
120 - $state['daily'] = mlsimport_telemetry_prune_buckets( $state['daily'], $today );
121 -
199 + // Single write, non-autoloaded.
122 200 update_option( 'mlsimport_telemetry_state', $state, false );
123 201
124 202 // Reset pending.
125 203 $mlsimport_telemetry_pending = array();
@@ -141,12 +219,14 @@
141 219 * @param mixed $value The value to store.
142 220 * @return void
143 221 */
144 222 function mlsimport_telemetry_set( string $key, $value ): void {
223 + // Load the persisted state; coerce a non-array back to an array.
145 224 $state = get_option( 'mlsimport_telemetry_state', array() );
146 225 if ( ! is_array( $state ) ) {
147 226 $state = array();
148 227 }
228 + // Overwrite the key unconditionally, then persist (non-autoloaded).
149 229 $state[ $key ] = $value;
150 230 update_option( 'mlsimport_telemetry_state', $state, false );
151 231 }
152 232
@@ -160,20 +240,101 @@
160 240 * @param mixed $value The value to store on the first call.
161 241 * @return void
162 242 */
163 243 function mlsimport_telemetry_set_once( string $key, $value ): void {
244 + // Load the persisted state; coerce a non-array back to an array.
164 245 $state = get_option( 'mlsimport_telemetry_state', array() );
165 246 if ( ! is_array( $state ) ) {
166 247 $state = array();
167 248 }
249 + // First occurrence wins — bail if the stamp already holds a non-empty value.
168 250 if ( ! empty( $state[ $key ] ) ) {
169 251 return;
170 252 }
253 + // Record the value and persist (non-autoloaded).
171 254 $state[ $key ] = $value;
172 255 update_option( 'mlsimport_telemetry_state', $state, false );
173 256 }
174 257
175 258 /**
259 + * Record the outcome of one listings request into sync-health telemetry
260 + * (GitHub issue #207).
261 + *
262 + * Called from the single choke point every import path routes through
263 + * (Mlsimport_Admin::mlsimport_make_listing_requests()), with the already
264 + * normalized API answer. Stamps last_sync_success when the pull returned a
265 + * feed, so a cron run that dies later in its loop still leaves fresh
266 + * success evidence — the previous end-of-loop-only stamp left actively
267 + * syncing sites reporting last_successful_sync = "never".
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 + *
274 + * @param mixed $answer The normalized listings API answer array.
275 + * @param int $mls_id Connection the pull ran for (0 = unattributed).
276 + * @return void
277 + */
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 +
283 + // A successful pull always carries the feed count under 'results'.
284 + if ( is_array( $answer ) && isset( $answer['results'] ) ) {
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 + }
290 + return;
291 + }
292 + // Anything else is a failed pull: stamp when it happened and a real
293 + // failure class — previously every failure surfaced as "unknown".
294 + $code = mlsimport_telemetry_classify_sync_failure( $answer );
295 + mlsimport_telemetry_set( 'last_sync_failed', time() );
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 + }
301 +}
302 +
303 +/**
304 + * Map a failed listings answer to a short failure class for
305 + * sync_health.last_failure_code. Pure — inspects only the answer shape and
306 + * the message strings globalApiRequestCurlSaas() actually produces.
307 + *
308 + * @param mixed $answer The normalized failed listings API answer.
309 + * @return string One of the short failure-class codes.
310 + */
311 +function mlsimport_telemetry_classify_sync_failure( $answer ): string {
312 + // A provider-rule rejection already carries a machine code under 'type'
313 + // (set by mlsimport_make_listing_requests()) — pass it through as-is.
314 + if ( is_array( $answer ) && ! empty( $answer['type'] ) ) {
315 + return (string) $answer['type'];
316 + }
317 + $message = is_array( $answer ) && isset( $answer['message'] ) ? (string) $answer['message'] : '';
318 + // The exact string ThemeImport returns when the SaaS JWT cannot be
319 + // minted/refreshed (bad account credentials, token endpoint down).
320 + if ( 'Token validation failed' === $message ) {
321 + return 'token';
322 + }
323 + // WP_Error transport messages pass through verbatim; cURL timeouts read
324 + // 'cURL error 28: Operation timed out after N milliseconds ...'.
325 + if ( false !== stripos( $message, 'timed out' ) ) {
326 + return 'timeout';
327 + }
328 + // AWS API Gateway rejections decode to {"message":"Unauthorized"} /
329 + // {"message":"Forbidden"} with no 'results' key.
330 + if ( false !== stripos( $message, 'unauthorized' ) || false !== stripos( $message, 'forbidden' ) ) {
331 + return 'auth';
332 + }
333 + return 'api_error';
334 +}
335 +
336 +/**
176 337 * Record the first-completion time of an onboarding-wizard step into the
177 338 * 'onboarding_steps' map on mlsimport_telemetry_state. First completion wins;
178 339 * re-running a step does not move the timestamp. Saved with autoload = 'no'.
179 340 *
@@ -180,26 +341,75 @@
180 341 * @param string $step The onboarding step ID (e.g. 'account', 'field-mapping').
181 342 * @return void
182 343 */
183 344 function mlsimport_telemetry_mark_onboarding_step( string $step ): void {
345 + // Guard: ignore an empty step id.
184 346 if ( '' === $step ) {
185 347 return;
186 348 }
349 + // Load the persisted state; coerce a non-array back to an array.
187 350 $state = get_option( 'mlsimport_telemetry_state', array() );
188 351 if ( ! is_array( $state ) ) {
189 352 $state = array();
190 353 }
354 + // Ensure the onboarding-steps map exists.
191 355 if ( ! isset( $state['onboarding_steps'] ) || ! is_array( $state['onboarding_steps'] ) ) {
192 356 $state['onboarding_steps'] = array();
193 357 }
358 + // First completion wins — do not move an existing timestamp.
194 359 if ( isset( $state['onboarding_steps'][ $step ] ) ) {
195 360 return;
196 361 }
362 + // Stamp the step with the current epoch and persist (non-autoloaded).
197 363 $state['onboarding_steps'][ $step ] = time();
198 364 update_option( 'mlsimport_telemetry_state', $state, false );
199 365 }
200 366
201 367 // ---------------------------------------------------------------------------
368 +// §1 Import performance snapshot (GitHub issue #216)
369 +// ---------------------------------------------------------------------------
370 +
371 +/**
372 + * Build the import-performance snapshot for one finished Import Run. Pure.
373 + *
374 + * Answers support's "is it us or the host?" question from data the run
375 + * machinery already tracks:
376 + * - elapsed_seconds: wall time from the run's started_at to its finish, across
377 + * every chunk worker — not just the finishing request.
378 + * - workers: 1 + chunk hand-offs + watchdog revivals. Any revival means a
379 + * worker died without handing off, i.e. the host killed it.
380 + * - queue_depth: pending worker actions at finish — backlog evidence.
381 + * - peak_memory_mb: peak PHP memory of the finishing worker.
382 + *
383 + * @param array $run Final Import Run record (started_at, source,
384 + * expected, handoffs, revive_count).
385 + * @param array $result Final public Import Run Result.
386 + * @param int $now Finish time (Unix epoch).
387 + * @param int $peak_memory_bytes memory_get_peak_usage(true) of the finisher.
388 + * @param int $queue_depth Pending worker actions for the import hook.
389 + * @return array<string,int|string> The snapshot stored under 'last_import_run'.
390 + */
391 +function mlsimport_telemetry_import_run_snapshot( array $run, array $result, int $now, int $peak_memory_bytes, int $queue_depth ): array {
392 + // Wall time across the whole worker chain; guard against a missing or
393 + // future started_at leaving a negative duration.
394 + $started_at = (int) ( $run['started_at'] ?? $now );
395 + return array(
396 + 'source' => (string) ( $run['source'] ?? '' ),
397 + 'state' => (string) ( $result['state'] ?? '' ),
398 + 'expected' => (int) ( $run['expected'] ?? 0 ),
399 + 'saved' => (int) ( $result['saved'] ?? 0 ),
400 + 'failed' => (int) ( $result['failed'] ?? 0 ),
401 + 'elapsed_seconds' => max( 0, $now - $started_at ),
402 + // One initial worker, plus one per chunk hand-off, plus one per
403 + // watchdog revival (a revival is a worker the host killed).
404 + 'workers' => 1 + (int) ( $run['handoffs'] ?? 0 ) + (int) ( $run['revive_count'] ?? 0 ),
405 + 'peak_memory_mb' => (int) round( $peak_memory_bytes / 1048576 ),
406 + 'queue_depth' => $queue_depth,
407 + 'finished_at' => $now,
408 + );
409 +}
410 +
411 +// ---------------------------------------------------------------------------
202 412 // §1 Pure helpers
203 413 // ---------------------------------------------------------------------------
204 414
205 415 /**
@@ -208,11 +418,13 @@
208 418 * @param int $epoch Unix timestamp.
209 419 * @return string|null ISO 8601 UTC string or null.
210 420 */
211 421 function mlsimport_telemetry_iso( int $epoch ): ?string {
422 + // Non-positive epoch means "never" — represent as null.
212 423 if ( $epoch <= 0 ) {
213 424 return null;
214 425 }
426 + // Format the epoch as an ISO 8601 UTC string.
215 427 return gmdate( 'Y-m-d\TH:i:s\Z', $epoch );
216 428 }
217 429
218 430 /**
@@ -223,9 +435,11 @@
223 435 * @param int $keep_days Number of days to keep (default 8).
224 436 * @return array Pruned daily map.
225 437 */
226 438 function mlsimport_telemetry_prune_buckets( array $daily, string $today, int $keep_days = 8 ): array {
439 + // Compute the oldest date to keep (today minus the retention window).
227 440 $cutoff = gmdate( 'Y-m-d', strtotime( $today ) - ( $keep_days * DAY_IN_SECONDS ) );
441 + // Drop any bucket whose date string sorts before the cutoff.
228 442 foreach ( array_keys( $daily ) as $date ) {
229 443 if ( $date < $cutoff ) {
230 444 unset( $daily[ $date ] );
231 445 }
@@ -251,13 +465,17 @@
251 465 'syncs' => 0,
252 466 'token_failures' => 0,
253 467 );
254 468
469 + // Walk back $days days from $today, accumulating each present bucket.
255 470 for ( $i = 0; $i < $days; $i++ ) {
471 + // The date for this step back from today.
256 472 $date = gmdate( 'Y-m-d', strtotime( $today ) - ( $i * DAY_IN_SECONDS ) );
473 + // Skip a missing or malformed bucket.
257 474 if ( ! isset( $daily[ $date ] ) || ! is_array( $daily[ $date ] ) ) {
258 475 continue;
259 476 }
477 + // Add each counter this bucket carries into the running totals.
260 478 foreach ( $sums as $key => $_ ) {
261 479 if ( isset( $daily[ $date ][ $key ] ) ) {
262 480 $sums[ $key ] += (int) $daily[ $date ][ $key ];
263 481 }
@@ -379,21 +597,27 @@
379 597 'orderby' => 'date',
380 598 'order' => 'DESC',
381 599 'meta_query' => array(
382 600 array(
383 - 'key' => 'ListingKey',
601 + 'key' => '_mlsimport_listing_key',
384 602 'compare' => 'EXISTS',
385 603 ),
386 604 ),
387 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,
388 609 );
389 610
611 + // Run the query (guard for environments without get_posts()).
390 612 $post_ids = function_exists( 'get_posts' ) ? get_posts( $args ) : array();
391 613
614 + // No sample — return all-zero percentages.
392 615 if ( empty( $post_ids ) ) {
393 616 return $empty;
394 617 }
395 618
619 + // Denominator + per-field hit counters.
396 620 $total = count( $post_ids );
397 621 $photos = 0;
398 622 $price = 0;
399 623 $address = 0;
@@ -398,23 +622,29 @@
398 622 $price = 0;
399 623 $address = 0;
400 624 $coordinates = 0;
401 625
626 + // Tally how many sampled posts carry each field.
402 627 foreach ( $post_ids as $pid ) {
628 + // Featured image present?
403 629 if ( has_post_thumbnail( $pid ) ) {
404 630 $photos++;
405 631 }
632 + // Price meta non-empty?
406 633 if ( '' !== get_post_meta( $pid, $keys['price'], true ) ) {
407 634 $price++;
408 635 }
636 + // Address meta non-empty?
409 637 if ( '' !== get_post_meta( $pid, $keys['address'], true ) ) {
410 638 $address++;
411 639 }
640 + // Coordinate meta non-empty?
412 641 if ( '' !== get_post_meta( $pid, $keys['coordinate'], true ) ) {
413 642 $coordinates++;
414 643 }
415 644 }
416 645
646 + // Convert each tally to an integer 0-100 percentage of the sample.
417 647 return array(
418 648 'with_photos_percent' => (int) round( $photos / $total * 100 ),
419 649 'with_price_percent' => (int) round( $price / $total * 100 ),
420 650 'with_address_percent' => (int) round( $address / $total * 100 ),
@@ -509,8 +739,17 @@
509 739
510 740 // --- data completeness ---
511 741 $completeness = mlsimport_telemetry_sample_completeness();
512 742
743 + // --- import performance (issue #216) ---
744 + // The latest finished-run snapshot, recorded at finish_run(). Null means
745 + // no run has ever finished on this install — distinct from a missing field.
746 + $import_performance = null;
747 + if ( isset( $state['last_import_run'] ) && is_array( $state['last_import_run'] ) ) {
748 + $import_performance = $state['last_import_run'];
749 + $import_performance['finished_at'] = mlsimport_telemetry_iso( (int) ( $import_performance['finished_at'] ?? 0 ) );
750 + }
751 +
513 752 // --- configuration: import tasks ---
514 753 $raw_tasks_query = function_exists( 'get_posts' ) ? get_posts( array(
515 754 'post_type' => 'mlsimport_item',
516 755 'post_status' => 'any',
@@ -518,10 +757,16 @@
518 757 'fields' => 'ids',
519 758 'no_found_rows' => true,
520 759 ) ) : array();
521 760
522 - $import_tasks = array();
523 - $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;
524 769 foreach ( $raw_tasks_query as $task_id ) {
525 770 $how_many = (int) get_post_meta( $task_id, 'mlsimport_item_how_many', true );
526 771 $stat_cron = (int) get_post_meta( $task_id, 'mlsimport_item_stat_cron', true );
527 772 $auto_upd = ( 1 === $stat_cron );
@@ -533,24 +778,41 @@
533 778 'auto_update' => $auto_upd,
534 779 );
535 780 }
536 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 +
537 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.
538 794 $mls_provider = '';
539 795 $mls_id = 0;
540 - if ( isset( $opts['mlsimport_mls_name'] ) && '' !== $opts['mlsimport_mls_name'] ) {
541 - $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 + }
542 814 }
543 - // Derive MLS provider label from the theme/MLS env class name if available.
544 - if (
545 - isset( $mlsimport ) &&
546 - isset( $mlsimport->admin ) &&
547 - isset( $mlsimport->admin->mls_env_data ) &&
548 - is_object( $mlsimport->admin->mls_env_data )
549 - ) {
550 - $mls_class = get_class( $mlsimport->admin->mls_env_data );
551 - $mls_provider = ( 'stdClass' !== $mls_class ) ? $mls_class : '';
552 - }
553 815
554 816 // Theme label.
555 817 $theme_label = '';
556 818 if (
@@ -604,8 +866,9 @@
604 866 'with_address_percent' => (int) $completeness['with_address_percent'],
605 867 'with_coordinates_percent' => (int) $completeness['with_coordinates_percent'],
606 868 ),
607 869 ),
870 + 'import_performance' => $import_performance,
608 871 'engagement' => array(
609 872 'last_admin_page_view' => mlsimport_telemetry_iso( $last_admin_load ),
610 873 'last_import_task_page_view' => mlsimport_telemetry_iso( $last_import_task_load ),
611 874 ),
@@ -615,8 +878,17 @@
615 878 'import_tasks' => $import_tasks,
616 879 'import_tasks_count' => count( $import_tasks ),
617 880 'auto_update_enabled' => (bool) $auto_update_any,
618 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
890 + ),
619 891 'environment' => array(
620 892 'plugin_version' => defined( 'MLSIMPORT_VERSION' ) ? MLSIMPORT_VERSION : '',
621 893 'php_version' => PHP_VERSION,
622 894 'wordpress_version' => get_bloginfo( 'version' ),
@@ -748,31 +1020,24 @@
748 1020 *
749 1021 * @return void
750 1022 */
751 1023 function mlsimport_telemetry_track_field_management(): void {
1024 + // Load the persisted state; coerce a non-array back to an array.
752 1025 $state = get_option( 'mlsimport_telemetry_state', array() );
753 1026 if ( ! is_array( $state ) ) {
754 1027 $state = array();
755 1028 }
1029 + // Current time and the last-recorded field-management stamp (missing = 0).
756 1030 $now = time();
757 1031 $stored = isset( $state['last_field_management'] ) ? (int) $state['last_field_management'] : 0;
1032 + // Throttle: skip if the last write was under 10 minutes ago.
758 1033 if ( ( $now - $stored ) < 10 * MINUTE_IN_SECONDS ) {
759 1034 return;
760 1035 }
1036 + // Record the activity and persist (non-autoloaded).
761 1037 $state['last_field_management'] = $now;
762 1038 update_option( 'mlsimport_telemetry_state', $state, false );
763 1039 }
764 1040
765 -// Field-selector progressive-save AJAX actions — "managing import fields".
766 -// Priority 1 so the timestamp is recorded before the real save handler runs.
767 -foreach (
768 - array(
769 - 'mlsimport_save_field_chunk',
770 - 'mlsimport_save_field_option',
771 - 'mlsimport_save_field_position',
772 - 'mlsimport_save_bulk_import',
773 - 'mlsimport_save_bulk_admin',
774 - ) as $mlsimport_field_action
775 -) {
776 - add_action( 'wp_ajax_' . $mlsimport_field_action, 'mlsimport_telemetry_track_field_management', 1 );
777 -}
778 -unset( $mlsimport_field_action );
1041 +// The single compact mutation endpoint is the Field Configuration activity
1042 +// seam. Priority 1 records activity before validation/persistence runs.
1043 +add_action( 'wp_ajax_mlsimport_change_field_configuration', 'mlsimport_telemetry_track_field_management', 1 );