PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2
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 6.0.5 6.0.7 All 35 releases
mlsimport / includes / mlsimport-telemetry.php

mlsimport-telemetry.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.2, at includes/mlsimport-telemetry.php

1,044 lines 39.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MLSImport Daily Telemetry Heartbeat
4 *
5 * Accumulates per-request import/sync counters in memory, flushes them once per
6 * request (on `shutdown`) into rolling daily wp_options buckets, and POSTs a
7 * structured heartbeat payload to the SaaS `user-activity` endpoint once per UTC day.
8 *
9 * Procedural include — matches the style of help_functions.php and
10 * mlsimport-onboarding.php. No class wrapper.
11 *
12 * @link https://mlsimport.com/
13 * @since 6.3.0
14 *
15 * @package Mlsimport
16 * @subpackage Mlsimport/includes
17 */
18
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit; // Exit if accessed directly.
21 }
22
23 // WordPress defines DAY_IN_SECONDS = 86400; provide a fallback for unit-test
24 // environments that load this file without the full WP bootstrap.
25 if ( ! defined( 'DAY_IN_SECONDS' ) ) {
26 define( 'DAY_IN_SECONDS', 86400 );
27 }
28
29 // WordPress defines MINUTE_IN_SECONDS = 60; same fallback pattern.
30 if ( ! defined( 'MINUTE_IN_SECONDS' ) ) {
31 define( 'MINUTE_IN_SECONDS', 60 );
32 }
33
34 // ---------------------------------------------------------------------------
35 // Request-scoped accumulator
36 // ---------------------------------------------------------------------------
37
38 /**
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.
42 * Written to wp_options exactly once — on shutdown — by mlsimport_telemetry_flush().
43 *
44 * @var array<int,array<string,int>>
45 */
46 $mlsimport_telemetry_pending = array();
47
48 // ---------------------------------------------------------------------------
49 // §1 Public API — counter accumulator
50 // ---------------------------------------------------------------------------
51
52 /**
53 * Add an in-memory counter delta for the current request.
54 * Allowed $metric: 'imported' | 'updated' | 'deleted' | 'syncs' | 'token_failures'.
55 * No DB access — deltas are written to wp_options once, on shutdown, by flush().
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 *
64 * @param string $metric One of the five allowed metric keys.
65 * @param int $amount Amount to add (default 1).
66 * @param int $mls_id Connection the activity belongs to (0 = unattributed).
67 * @return void
68 */
69 function mlsimport_telemetry_bump( string $metric, int $amount = 1, int $mls_id = 0 ): void {
70 // Whitelist of accepted metric keys.
71 $allowed = array( 'imported', 'updated', 'deleted', 'syncs', 'token_failures' );
72 // Guard: silently ignore an unknown metric key.
73 if ( ! in_array( $metric, $allowed, true ) ) {
74 return;
75 }
76 // Reach the request-scoped accumulator.
77 global $mlsimport_telemetry_pending;
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;
83 }
84 // Add the delta (no DB touch here — flush writes on shutdown).
85 $mlsimport_telemetry_pending[ $mls_id ][ $metric ] += $amount;
86 }
87
88 // ---------------------------------------------------------------------------
89 // §1 Public API — flush (registered on 'shutdown')
90 // ---------------------------------------------------------------------------
91
92 /**
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.
135 * No-op when nothing is pending. Reads + writes the single option
136 * 'mlsimport_telemetry_state' exactly once, prunes buckets older than 8 days,
137 * resets the pending array. Registered on the 'shutdown' action.
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 *
146 * @return void
147 */
148 function mlsimport_telemetry_flush(): void {
149 // Reach the request-scoped accumulator.
150 global $mlsimport_telemetry_pending;
151
152 // Nothing accumulated this request — do not read or write the option.
153 if ( empty( $mlsimport_telemetry_pending ) ) {
154 return;
155 }
156
157 // Load the persisted state; coerce a corrupt/legacy value back to an array.
158 $state = get_option( 'mlsimport_telemetry_state', array() );
159 if ( ! is_array( $state ) ) {
160 $state = array();
161 }
162
163 // Ensure the global and per-connection bucket maps exist.
164 if ( ! isset( $state['daily'] ) || ! is_array( $state['daily'] ) ) {
165 $state['daily'] = array();
166 }
167 if ( ! isset( $state['daily_mls'] ) || ! is_array( $state['daily_mls'] ) ) {
168 $state['daily_mls'] = array();
169 }
170
171 // Today's UTC date is the bucket key everywhere.
172 $today = gmdate( 'Y-m-d' );
173
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 );
178
179 // Unattributed (account-level) deltas stop at the global map.
180 if ( $mls_id <= 0 ) {
181 continue;
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 }
197 }
198
199 // Single write, non-autoloaded.
200 update_option( 'mlsimport_telemetry_state', $state, false );
201
202 // Reset pending.
203 $mlsimport_telemetry_pending = array();
204 }
205
206 add_action( 'shutdown', 'mlsimport_telemetry_flush' );
207
208 // ---------------------------------------------------------------------------
209 // §1 Public API — immediate key setter
210 // ---------------------------------------------------------------------------
211
212 /**
213 * Set a non-counter "last X" field on mlsimport_telemetry_state.
214 * $key ∈ last_sync_success | last_sync_failed | last_sync_failed_code |
215 * last_sync_attempt | last_feed_found | last_admin_load | last_import_task_load.
216 * Immediate small read-modify-write; option saved with autoload = 'no'.
217 *
218 * @param string $key The state key to set.
219 * @param mixed $value The value to store.
220 * @return void
221 */
222 function mlsimport_telemetry_set( string $key, $value ): void {
223 // Load the persisted state; coerce a non-array back to an array.
224 $state = get_option( 'mlsimport_telemetry_state', array() );
225 if ( ! is_array( $state ) ) {
226 $state = array();
227 }
228 // Overwrite the key unconditionally, then persist (non-autoloaded).
229 $state[ $key ] = $value;
230 update_option( 'mlsimport_telemetry_state', $state, false );
231 }
232
233 /**
234 * Set a "first time only" lifecycle stamp on mlsimport_telemetry_state.
235 * Unlike mlsimport_telemetry_set(), this is a no-op when $key already holds a
236 * non-empty value — the first occurrence wins. Used for installed_at /
237 * account_connected_at / mls_connected_at. Saved with autoload = 'no'.
238 *
239 * @param string $key The state key to set once.
240 * @param mixed $value The value to store on the first call.
241 * @return void
242 */
243 function mlsimport_telemetry_set_once( string $key, $value ): void {
244 // Load the persisted state; coerce a non-array back to an array.
245 $state = get_option( 'mlsimport_telemetry_state', array() );
246 if ( ! is_array( $state ) ) {
247 $state = array();
248 }
249 // First occurrence wins — bail if the stamp already holds a non-empty value.
250 if ( ! empty( $state[ $key ] ) ) {
251 return;
252 }
253 // Record the value and persist (non-autoloaded).
254 $state[ $key ] = $value;
255 update_option( 'mlsimport_telemetry_state', $state, false );
256 }
257
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 /**
337 * Record the first-completion time of an onboarding-wizard step into the
338 * 'onboarding_steps' map on mlsimport_telemetry_state. First completion wins;
339 * re-running a step does not move the timestamp. Saved with autoload = 'no'.
340 *
341 * @param string $step The onboarding step ID (e.g. 'account', 'field-mapping').
342 * @return void
343 */
344 function mlsimport_telemetry_mark_onboarding_step( string $step ): void {
345 // Guard: ignore an empty step id.
346 if ( '' === $step ) {
347 return;
348 }
349 // Load the persisted state; coerce a non-array back to an array.
350 $state = get_option( 'mlsimport_telemetry_state', array() );
351 if ( ! is_array( $state ) ) {
352 $state = array();
353 }
354 // Ensure the onboarding-steps map exists.
355 if ( ! isset( $state['onboarding_steps'] ) || ! is_array( $state['onboarding_steps'] ) ) {
356 $state['onboarding_steps'] = array();
357 }
358 // First completion wins — do not move an existing timestamp.
359 if ( isset( $state['onboarding_steps'][ $step ] ) ) {
360 return;
361 }
362 // Stamp the step with the current epoch and persist (non-autoloaded).
363 $state['onboarding_steps'][ $step ] = time();
364 update_option( 'mlsimport_telemetry_state', $state, false );
365 }
366
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 // ---------------------------------------------------------------------------
412 // §1 Pure helpers
413 // ---------------------------------------------------------------------------
414
415 /**
416 * Positive epoch -> "Y-m-d\TH:i:s\Z" (UTC). 0 / empty -> null. Pure.
417 *
418 * @param int $epoch Unix timestamp.
419 * @return string|null ISO 8601 UTC string or null.
420 */
421 function mlsimport_telemetry_iso( int $epoch ): ?string {
422 // Non-positive epoch means "never" — represent as null.
423 if ( $epoch <= 0 ) {
424 return null;
425 }
426 // Format the epoch as an ISO 8601 UTC string.
427 return gmdate( 'Y-m-d\TH:i:s\Z', $epoch );
428 }
429
430 /**
431 * Drop daily-bucket keys older than $keep_days relative to $today. Pure.
432 *
433 * @param array $daily Daily bucket map (YYYY-MM-DD => array).
434 * @param string $today Reference date string 'Y-m-d'.
435 * @param int $keep_days Number of days to keep (default 8).
436 * @return array Pruned daily map.
437 */
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).
440 $cutoff = gmdate( 'Y-m-d', strtotime( $today ) - ( $keep_days * DAY_IN_SECONDS ) );
441 // Drop any bucket whose date string sorts before the cutoff.
442 foreach ( array_keys( $daily ) as $date ) {
443 if ( $date < $cutoff ) {
444 unset( $daily[ $date ] );
445 }
446 }
447 return $daily;
448 }
449
450 /**
451 * Sum the last $days daily buckets ending at $today.
452 * Returns [ 'imported'=>int, 'updated'=>int, 'deleted'=>int, 'syncs'=>int,
453 * 'token_failures'=>int ]. Pure.
454 *
455 * @param array $daily Daily bucket map.
456 * @param string $today Reference date string 'Y-m-d'.
457 * @param int $days Number of days to sum (default 7).
458 * @return array<string,int> Summed counters.
459 */
460 function mlsimport_telemetry_sum_buckets( array $daily, string $today, int $days = 7 ): array {
461 $sums = array(
462 'imported' => 0,
463 'updated' => 0,
464 'deleted' => 0,
465 'syncs' => 0,
466 'token_failures' => 0,
467 );
468
469 // Walk back $days days from $today, accumulating each present bucket.
470 for ( $i = 0; $i < $days; $i++ ) {
471 // The date for this step back from today.
472 $date = gmdate( 'Y-m-d', strtotime( $today ) - ( $i * DAY_IN_SECONDS ) );
473 // Skip a missing or malformed bucket.
474 if ( ! isset( $daily[ $date ] ) || ! is_array( $daily[ $date ] ) ) {
475 continue;
476 }
477 // Add each counter this bucket carries into the running totals.
478 foreach ( $sums as $key => $_ ) {
479 if ( isset( $daily[ $date ][ $key ] ) ) {
480 $sums[ $key ] += (int) $daily[ $date ][ $key ];
481 }
482 }
483 }
484
485 return $sums;
486 }
487
488 /**
489 * True when $last_sent equals $today (UTC 'Y-m-d' strings). Pure.
490 *
491 * @param string $last_sent Previously stored send date.
492 * @param string $today Today's UTC date.
493 * @return bool
494 */
495 function mlsimport_telemetry_already_sent_today( string $last_sent, string $today ): bool {
496 return $last_sent === $today;
497 }
498
499 // ---------------------------------------------------------------------------
500 // §1 Completeness sampler
501 // ---------------------------------------------------------------------------
502
503 /**
504 * Per-theme meta keys used for data-completeness checks.
505 * Keys: price, address, coordinate.
506 *
507 * WpResidence / WpEstate: use shared RESO-mapped meta names.
508 * Houzez: coordinates are stored in a combined `fave_property_location` meta.
509 * RealHomes: coordinates are stored in `REAL_HOMES_property_location`.
510 *
511 * @return array<string,array<string,string>>
512 */
513 function mlsimport_telemetry_theme_meta_map(): array {
514 return array(
515 // WpResidence (991) and WpEstate (994) share the same RESO-mapped meta names.
516 'ResidenceClass' => array(
517 'price' => 'property_price',
518 'address' => 'property_address',
519 'coordinate' => 'property_latitude',
520 ),
521 'EstateClass' => array(
522 'price' => 'property_price',
523 'address' => 'property_address',
524 'coordinate' => 'property_latitude',
525 ),
526 // Houzez (992): uses fave_property_location for combined lat,lng.
527 'HouzezClass' => array(
528 'price' => 'property_price',
529 'address' => 'property_address',
530 'coordinate' => 'fave_property_location',
531 ),
532 // RealHomes (993): uses REAL_HOMES_property_location for combined lat,lng.
533 'RealHomesClass' => array(
534 'price' => 'property_price',
535 'address' => 'property_address',
536 'coordinate' => 'REAL_HOMES_property_location',
537 ),
538 );
539 }
540
541 /**
542 * Sample the 20 most-recent property posts holding a 'ListingKey' meta.
543 * Returns [ 'with_photos_percent'=>int, 'with_price_percent'=>int,
544 * 'with_address_percent'=>int, 'with_coordinates_percent'=>int ]
545 * (integer percentages 0-100; all 0 when the sample is empty).
546 *
547 * @return array<string,int>
548 */
549 function mlsimport_telemetry_sample_completeness(): array {
550 $empty = array(
551 'with_photos_percent' => 0,
552 'with_price_percent' => 0,
553 'with_address_percent' => 0,
554 'with_coordinates_percent' => 0,
555 );
556
557 // Resolve the active theme adapter class.
558 global $mlsimport;
559 $env_class = '';
560 if (
561 isset( $mlsimport ) &&
562 isset( $mlsimport->admin ) &&
563 isset( $mlsimport->admin->env_data ) &&
564 is_object( $mlsimport->admin->env_data )
565 ) {
566 $env_class = get_class( $mlsimport->admin->env_data );
567 }
568
569 $meta_map = mlsimport_telemetry_theme_meta_map();
570 // Default fallback — shared RESO keys used by WpResidence / WpEstate.
571 $keys = isset( $meta_map[ $env_class ] )
572 ? $meta_map[ $env_class ]
573 : array(
574 'price' => 'property_price',
575 'address' => 'property_address',
576 'coordinate' => 'property_latitude',
577 );
578
579 // Determine the post type from the adapter; fall back to 'estate_property'.
580 $post_type = 'estate_property';
581 if (
582 isset( $mlsimport ) &&
583 isset( $mlsimport->admin ) &&
584 isset( $mlsimport->admin->env_data ) &&
585 is_object( $mlsimport->admin->env_data ) &&
586 method_exists( $mlsimport->admin->env_data, 'get_property_post_type' )
587 ) {
588 $post_type = $mlsimport->admin->env_data->get_property_post_type();
589 }
590
591 // Fetch the 20 most-recent posts that have a ListingKey meta.
592 $args = array(
593 'post_type' => $post_type,
594 'post_status' => 'any',
595 'posts_per_page' => 20,
596 'fields' => 'ids',
597 'orderby' => 'date',
598 'order' => 'DESC',
599 'meta_query' => array(
600 array(
601 'key' => '_mlsimport_listing_key',
602 'compare' => 'EXISTS',
603 ),
604 ),
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,
609 );
610
611 // Run the query (guard for environments without get_posts()).
612 $post_ids = function_exists( 'get_posts' ) ? get_posts( $args ) : array();
613
614 // No sample — return all-zero percentages.
615 if ( empty( $post_ids ) ) {
616 return $empty;
617 }
618
619 // Denominator + per-field hit counters.
620 $total = count( $post_ids );
621 $photos = 0;
622 $price = 0;
623 $address = 0;
624 $coordinates = 0;
625
626 // Tally how many sampled posts carry each field.
627 foreach ( $post_ids as $pid ) {
628 // Featured image present?
629 if ( has_post_thumbnail( $pid ) ) {
630 $photos++;
631 }
632 // Price meta non-empty?
633 if ( '' !== get_post_meta( $pid, $keys['price'], true ) ) {
634 $price++;
635 }
636 // Address meta non-empty?
637 if ( '' !== get_post_meta( $pid, $keys['address'], true ) ) {
638 $address++;
639 }
640 // Coordinate meta non-empty?
641 if ( '' !== get_post_meta( $pid, $keys['coordinate'], true ) ) {
642 $coordinates++;
643 }
644 }
645
646 // Convert each tally to an integer 0-100 percentage of the sample.
647 return array(
648 'with_photos_percent' => (int) round( $photos / $total * 100 ),
649 'with_price_percent' => (int) round( $price / $total * 100 ),
650 'with_address_percent' => (int) round( $address / $total * 100 ),
651 'with_coordinates_percent' => (int) round( $coordinates / $total * 100 ),
652 );
653 }
654
655 // ---------------------------------------------------------------------------
656 // §1 Payload collector
657 // ---------------------------------------------------------------------------
658
659 /**
660 * Build the full human-readable heartbeat payload (see §5 for the shape).
661 * Converts stored epochs to ISO 8601 via mlsimport_telemetry_iso().
662 * Generates + persists mlsimport_admin_options['mlsimport_install_uuid'] if absent.
663 *
664 * @return array The structured heartbeat payload.
665 */
666 function mlsimport_telemetry_collect_payload(): array {
667 // --- Install UUID ---
668 $opts = get_option( 'mlsimport_admin_options', array() );
669 if ( ! is_array( $opts ) ) {
670 $opts = array();
671 }
672 if ( empty( $opts['mlsimport_install_uuid'] ) ) {
673 $opts['mlsimport_install_uuid'] = wp_generate_uuid4();
674 update_option( 'mlsimport_admin_options', $opts );
675 }
676
677 // --- Telemetry state ---
678 $state = get_option( 'mlsimport_telemetry_state', array() );
679 if ( ! is_array( $state ) ) {
680 $state = array();
681 }
682 $daily = isset( $state['daily'] ) && is_array( $state['daily'] ) ? $state['daily'] : array();
683
684 $today = gmdate( 'Y-m-d' );
685 $sums = mlsimport_telemetry_sum_buckets( $daily, $today, 7 );
686
687 // --- sync_health ---
688 $last_sync_success = isset( $state['last_sync_success'] ) ? (int) $state['last_sync_success'] : 0;
689 $last_sync_failed = isset( $state['last_sync_failed'] ) ? (int) $state['last_sync_failed'] : 0;
690 $last_sync_failed_code = isset( $state['last_sync_failed_code'] ) ? (string) $state['last_sync_failed_code'] : '';
691 $last_feed_found = isset( $state['last_feed_found'] ) ? (int) $state['last_feed_found'] : 0;
692 $last_admin_load = isset( $state['last_admin_load'] ) ? (int) $state['last_admin_load'] : 0;
693 $last_import_task_load = isset( $state['last_import_task_load'] ) ? (int) $state['last_import_task_load'] : 0;
694
695 // --- lifecycle / onboarding funnel ---
696 $installed_at = isset( $state['installed_at'] ) ? (int) $state['installed_at'] : 0;
697 $account_connected_at = isset( $state['account_connected_at'] ) ? (int) $state['account_connected_at'] : 0;
698 $mls_connected_at = isset( $state['mls_connected_at'] ) ? (int) $state['mls_connected_at'] : 0;
699 $last_field_mgmt = isset( $state['last_field_management'] ) ? (int) $state['last_field_management'] : 0;
700 $onboarding_steps = array();
701 if ( isset( $state['onboarding_steps'] ) && is_array( $state['onboarding_steps'] ) ) {
702 foreach ( $state['onboarding_steps'] as $step_id => $step_epoch ) {
703 $onboarding_steps[ (string) $step_id ] = mlsimport_telemetry_iso( (int) $step_epoch );
704 }
705 }
706
707 // WP cron working: daily event is scheduled.
708 if ( function_exists( 'wp_next_scheduled' ) ) {
709 $wp_cron_working = ( false !== wp_next_scheduled( 'mlsimport_daily_telemetry_event' ) ) ||
710 ( false !== wp_next_scheduled( 'event_mls_import_auto' ) );
711 } else {
712 $wp_cron_working = false;
713 }
714
715 // --- output: active listings ---
716 $post_type = 'estate_property';
717 global $mlsimport;
718 if (
719 isset( $mlsimport ) &&
720 isset( $mlsimport->admin ) &&
721 isset( $mlsimport->admin->env_data ) &&
722 is_object( $mlsimport->admin->env_data ) &&
723 method_exists( $mlsimport->admin->env_data, 'get_property_post_type' )
724 ) {
725 $post_type = $mlsimport->admin->env_data->get_property_post_type();
726 }
727
728 $active_listings = 0;
729 if ( class_exists( 'WP_Query' ) ) {
730 $active_count_query = new WP_Query( array(
731 'post_type' => $post_type,
732 'post_status' => 'publish',
733 'posts_per_page' => 1,
734 'fields' => 'ids',
735 'no_found_rows' => false,
736 ) );
737 $active_listings = (int) $active_count_query->found_posts;
738 }
739
740 // --- data completeness ---
741 $completeness = mlsimport_telemetry_sample_completeness();
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
752 // --- configuration: import tasks ---
753 $raw_tasks_query = function_exists( 'get_posts' ) ? get_posts( array(
754 'post_type' => 'mlsimport_item',
755 'post_status' => 'any',
756 'posts_per_page' => -1,
757 'fields' => 'ids',
758 'no_found_rows' => true,
759 ) ) : array();
760
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;
769 foreach ( $raw_tasks_query as $task_id ) {
770 $how_many = (int) get_post_meta( $task_id, 'mlsimport_item_how_many', true );
771 $stat_cron = (int) get_post_meta( $task_id, 'mlsimport_item_stat_cron', true );
772 $auto_upd = ( 1 === $stat_cron );
773 if ( $auto_upd ) {
774 $auto_update_any = true;
775 }
776 $import_tasks[] = array(
777 'import_limit' => $how_many,
778 'auto_update' => $auto_upd,
779 );
780 }
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
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.
794 $mls_provider = '';
795 $mls_id = 0;
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 }
814 }
815
816 // Theme label.
817 $theme_label = '';
818 if (
819 isset( $mlsimport ) &&
820 isset( $mlsimport->admin ) &&
821 isset( $mlsimport->admin->env_data ) &&
822 is_object( $mlsimport->admin->env_data )
823 ) {
824 $env_class = get_class( $mlsimport->admin->env_data );
825 $theme_label = ( 'stdClass' !== $env_class ) ? $env_class : '';
826 }
827
828 // The real plugin stores the account name under 'mlsimport_username'.
829 // The unit test bootstrap seeds it under 'account' (legacy key).
830 // Read both; prefer 'mlsimport_username' (canonical).
831 if ( ! empty( $opts['mlsimport_username'] ) ) {
832 $account = (string) $opts['mlsimport_username'];
833 } elseif ( ! empty( $opts['account'] ) ) {
834 $account = (string) $opts['account'];
835 } else {
836 $account = '';
837 }
838
839 return array(
840 'event_type' => 'daily_telemetry',
841 'reported_at' => gmdate( 'Y-m-d\TH:i:s\Z' ),
842 'install' => array(
843 'install_id' => (string) $opts['mlsimport_install_uuid'],
844 'account' => $account,
845 'site_url' => (string) home_url(),
846 ),
847 'sync_health' => array(
848 'last_successful_sync' => mlsimport_telemetry_iso( $last_sync_success ),
849 'last_failed_sync' => mlsimport_telemetry_iso( $last_sync_failed ),
850 'last_failure_code' => $last_sync_failed_code,
851 'syncs_last_7_days' => (int) $sums['syncs'],
852 'token_refresh_failures_last_7_days' => (int) $sums['token_failures'],
853 'wp_cron_working' => (bool) $wp_cron_working,
854 ),
855 'feed' => array(
856 'listings_found_in_feed' => $last_feed_found,
857 ),
858 'output' => array(
859 'imported_last_7_days' => (int) $sums['imported'],
860 'updated_last_7_days' => (int) $sums['updated'],
861 'deleted_last_7_days' => (int) $sums['deleted'],
862 'active_listings_on_site' => $active_listings,
863 'data_completeness' => array(
864 'with_photos_percent' => (int) $completeness['with_photos_percent'],
865 'with_price_percent' => (int) $completeness['with_price_percent'],
866 'with_address_percent' => (int) $completeness['with_address_percent'],
867 'with_coordinates_percent' => (int) $completeness['with_coordinates_percent'],
868 ),
869 ),
870 'import_performance' => $import_performance,
871 'engagement' => array(
872 'last_admin_page_view' => mlsimport_telemetry_iso( $last_admin_load ),
873 'last_import_task_page_view' => mlsimport_telemetry_iso( $last_import_task_load ),
874 ),
875 'configuration' => array(
876 'mls_provider' => $mls_provider,
877 'mls_id' => $mls_id,
878 'import_tasks' => $import_tasks,
879 'import_tasks_count' => count( $import_tasks ),
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
890 ),
891 'environment' => array(
892 'plugin_version' => defined( 'MLSIMPORT_VERSION' ) ? MLSIMPORT_VERSION : '',
893 'php_version' => PHP_VERSION,
894 'wordpress_version' => get_bloginfo( 'version' ),
895 'theme' => $theme_label,
896 ),
897 'lifecycle' => array(
898 'installed_at' => mlsimport_telemetry_iso( $installed_at ),
899 'account_connected_at' => mlsimport_telemetry_iso( $account_connected_at ),
900 'mls_connected_at' => mlsimport_telemetry_iso( $mls_connected_at ),
901 'last_field_management' => mlsimport_telemetry_iso( $last_field_mgmt ),
902 'onboarding_steps' => (object) $onboarding_steps,
903 ),
904 );
905 }
906
907 // ---------------------------------------------------------------------------
908 // §1 Daily cron handler
909 // ---------------------------------------------------------------------------
910
911 /**
912 * Daily cron handler. No-op if already sent today. Builds the payload and calls
913 * ThemeImport::globalApiRequestSaasFireAndForget('user-activity', $payload) inside
914 * try/catch(\Throwable). Sets mlsimport_telemetry_last_sent on a non-false return.
915 * Hooked to 'mlsimport_daily_telemetry_event'.
916 *
917 * @return void
918 */
919 function mlsimport_telemetry_run_daily(): void {
920 $last_sent = (string) get_option( 'mlsimport_telemetry_last_sent', '' );
921 $today = gmdate( 'Y-m-d' );
922
923 if ( mlsimport_telemetry_already_sent_today( $last_sent, $today ) ) {
924 return;
925 }
926
927 $payload = mlsimport_telemetry_collect_payload();
928
929 try {
930 $result = ThemeImport::globalApiRequestSaasFireAndForget( 'user-activity', $payload );
931 } catch ( \Throwable $e ) {
932 // Fire-and-forget: transport errors are silently discarded.
933 return;
934 }
935
936 if ( false !== $result ) {
937 update_option( 'mlsimport_telemetry_last_sent', $today, false );
938 }
939 }
940
941 // ---------------------------------------------------------------------------
942 // §1 Admin engagement tracker (registered on 'admin_init')
943 // ---------------------------------------------------------------------------
944
945 /**
946 * Record admin-page engagement timestamps. Updates last_admin_load (and
947 * last_import_task_load on the Import Task editor) only when the stored value is
948 * older than 10 minutes. Hooked to 'admin_init'.
949 *
950 * Throttle logic (Pre-mortem Scenario 4):
951 * - A missing stored timestamp is treated as epoch 0 (far in the past), so the
952 * very first admin page view writes once.
953 * - Subsequent views within the 10-minute window do not write again.
954 *
955 * @return void
956 */
957 function mlsimport_telemetry_track_admin_load(): void {
958 // Only run on genuine admin requests — skip AJAX, CLI, cron.
959 if ( ! is_admin() || wp_doing_ajax() || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
960 return;
961 }
962
963 // Detect the screen from $pagenow + request vars. get_current_screen() is
964 // not yet populated on 'admin_init', so a screen-object lookup misses every
965 // real page load — $pagenow and $_GET are reliably set this early.
966 global $pagenow;
967 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
968 $post_type = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : '';
969
970 // Import Task list / editor — edit.php, post-new.php, or post.php for the
971 // mlsimport_item CPT.
972 $is_import_task_screen = (
973 ( ( 'edit.php' === $pagenow || 'post-new.php' === $pagenow ) && 'mlsimport_item' === $post_type ) ||
974 ( 'post.php' === $pagenow && isset( $_GET['post'] ) && 'mlsimport_item' === get_post_type( (int) $_GET['post'] ) )
975 );
976
977 // Any MLSImport admin screen — a plugin menu page or the import-task editor.
978 $is_mlsimport_screen = ( $is_import_task_screen || 0 === strpos( $page, 'mlsimport' ) );
979
980 if ( ! $is_mlsimport_screen ) {
981 return;
982 }
983
984 $state = get_option( 'mlsimport_telemetry_state', array() );
985 if ( ! is_array( $state ) ) {
986 $state = array();
987 }
988
989 $now = time();
990 $threshold = 10 * MINUTE_IN_SECONDS; // 600 seconds.
991 $did_write = false;
992
993 // Throttle: only update last_admin_load when stored value is older than 10 min.
994 // A missing key defaults to 0, which is always older than 10 min — writes once.
995 $stored_admin = isset( $state['last_admin_load'] ) ? (int) $state['last_admin_load'] : 0;
996 if ( ( $now - $stored_admin ) >= $threshold ) {
997 $state['last_admin_load'] = $now;
998 $did_write = true;
999 }
1000
1001 if ( $is_import_task_screen ) {
1002 $stored_task = isset( $state['last_import_task_load'] ) ? (int) $state['last_import_task_load'] : 0;
1003 if ( ( $now - $stored_task ) >= $threshold ) {
1004 $state['last_import_task_load'] = $now;
1005 $did_write = true;
1006 }
1007 }
1008
1009 if ( $did_write ) {
1010 update_option( 'mlsimport_telemetry_state', $state, false );
1011 }
1012 }
1013
1014 add_action( 'admin_init', 'mlsimport_telemetry_track_admin_load' );
1015
1016 /**
1017 * Record import-field management activity. Fires on the field-selector
1018 * progressive-save AJAX actions; throttled to one write per 10 minutes so a
1019 * burst of chunked field saves causes a single option write. autoload = 'no'.
1020 *
1021 * @return void
1022 */
1023 function mlsimport_telemetry_track_field_management(): void {
1024 // Load the persisted state; coerce a non-array back to an array.
1025 $state = get_option( 'mlsimport_telemetry_state', array() );
1026 if ( ! is_array( $state ) ) {
1027 $state = array();
1028 }
1029 // Current time and the last-recorded field-management stamp (missing = 0).
1030 $now = time();
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.
1033 if ( ( $now - $stored ) < 10 * MINUTE_IN_SECONDS ) {
1034 return;
1035 }
1036 // Record the activity and persist (non-autoloaded).
1037 $state['last_field_management'] = $now;
1038 update_option( 'mlsimport_telemetry_state', $state, false );
1039 }
1040
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 );
1044