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

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

926 lines 34.2 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.
40 * Keys: imported | updated | deleted | syncs | token_failures.
41 * Written to wp_options exactly once — on shutdown — by mlsimport_telemetry_flush().
42 *
43 * @var array<string,int>
44 */
45 $mlsimport_telemetry_pending = array();
46
47 // ---------------------------------------------------------------------------
48 // §1 Public API — counter accumulator
49 // ---------------------------------------------------------------------------
50
51 /**
52 * Add an in-memory counter delta for the current request.
53 * Allowed $metric: 'imported' | 'updated' | 'deleted' | 'syncs' | 'token_failures'.
54 * No DB access — deltas are written to wp_options once, on shutdown, by flush().
55 *
56 * @param string $metric One of the five allowed metric keys.
57 * @param int $amount Amount to add (default 1).
58 * @return void
59 */
60 function mlsimport_telemetry_bump( string $metric, int $amount = 1 ): void {
61 // Whitelist of accepted metric keys.
62 $allowed = array( 'imported', 'updated', 'deleted', 'syncs', 'token_failures' );
63 // Guard: silently ignore an unknown metric key.
64 if ( ! in_array( $metric, $allowed, true ) ) {
65 return;
66 }
67 // Reach the request-scoped accumulator.
68 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;
72 }
73 // Add the delta (no DB touch here — flush writes on shutdown).
74 $mlsimport_telemetry_pending[ $metric ] += $amount;
75 }
76
77 // ---------------------------------------------------------------------------
78 // §1 Public API — flush (registered on 'shutdown')
79 // ---------------------------------------------------------------------------
80
81 /**
82 * Flush accumulated counter deltas into today's daily bucket.
83 * No-op when nothing is pending. Reads + writes the single option
84 * 'mlsimport_telemetry_state' exactly once, prunes buckets older than 8 days,
85 * resets the pending array. Registered on the 'shutdown' action.
86 *
87 * @return void
88 */
89 function mlsimport_telemetry_flush(): void {
90 // Reach the request-scoped accumulator.
91 global $mlsimport_telemetry_pending;
92
93 // Nothing accumulated this request — do not read or write the option.
94 if ( empty( $mlsimport_telemetry_pending ) ) {
95 return;
96 }
97
98 // Load the persisted state; coerce a corrupt/legacy value back to an array.
99 $state = get_option( 'mlsimport_telemetry_state', array() );
100 if ( ! is_array( $state ) ) {
101 $state = array();
102 }
103
104 // Ensure the daily bucket map exists.
105 if ( ! isset( $state['daily'] ) || ! is_array( $state['daily'] ) ) {
106 $state['daily'] = array();
107 }
108
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();
112
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 );
123
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;
128 }
129 }
130
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 // Single write, non-autoloaded.
136 update_option( 'mlsimport_telemetry_state', $state, false );
137
138 // Reset pending.
139 $mlsimport_telemetry_pending = array();
140 }
141
142 add_action( 'shutdown', 'mlsimport_telemetry_flush' );
143
144 // ---------------------------------------------------------------------------
145 // §1 Public API — immediate key setter
146 // ---------------------------------------------------------------------------
147
148 /**
149 * Set a non-counter "last X" field on mlsimport_telemetry_state.
150 * $key ∈ last_sync_success | last_sync_failed | last_sync_failed_code |
151 * last_sync_attempt | last_feed_found | last_admin_load | last_import_task_load.
152 * Immediate small read-modify-write; option saved with autoload = 'no'.
153 *
154 * @param string $key The state key to set.
155 * @param mixed $value The value to store.
156 * @return void
157 */
158 function mlsimport_telemetry_set( string $key, $value ): void {
159 // Load the persisted state; coerce a non-array back to an array.
160 $state = get_option( 'mlsimport_telemetry_state', array() );
161 if ( ! is_array( $state ) ) {
162 $state = array();
163 }
164 // Overwrite the key unconditionally, then persist (non-autoloaded).
165 $state[ $key ] = $value;
166 update_option( 'mlsimport_telemetry_state', $state, false );
167 }
168
169 /**
170 * Set a "first time only" lifecycle stamp on mlsimport_telemetry_state.
171 * Unlike mlsimport_telemetry_set(), this is a no-op when $key already holds a
172 * non-empty value — the first occurrence wins. Used for installed_at /
173 * account_connected_at / mls_connected_at. Saved with autoload = 'no'.
174 *
175 * @param string $key The state key to set once.
176 * @param mixed $value The value to store on the first call.
177 * @return void
178 */
179 function mlsimport_telemetry_set_once( string $key, $value ): void {
180 // Load the persisted state; coerce a non-array back to an array.
181 $state = get_option( 'mlsimport_telemetry_state', array() );
182 if ( ! is_array( $state ) ) {
183 $state = array();
184 }
185 // First occurrence wins — bail if the stamp already holds a non-empty value.
186 if ( ! empty( $state[ $key ] ) ) {
187 return;
188 }
189 // Record the value and persist (non-autoloaded).
190 $state[ $key ] = $value;
191 update_option( 'mlsimport_telemetry_state', $state, false );
192 }
193
194 /**
195 * Record the outcome of one listings request into sync-health telemetry
196 * (GitHub issue #207).
197 *
198 * Called from the single choke point every import path routes through
199 * (Mlsimport_Admin::mlsimport_make_listing_requests()), with the already
200 * normalized API answer. Stamps last_sync_success when the pull returned a
201 * feed, so a cron run that dies later in its loop still leaves fresh
202 * success evidence — the previous end-of-loop-only stamp left actively
203 * syncing sites reporting last_successful_sync = "never".
204 *
205 * @param mixed $answer The normalized listings API answer array.
206 * @return void
207 */
208 function mlsimport_telemetry_record_sync_result( $answer ): void {
209 // A successful pull always carries the feed count under 'results'.
210 if ( is_array( $answer ) && isset( $answer['results'] ) ) {
211 mlsimport_telemetry_set( 'last_sync_success', time() );
212 return;
213 }
214 // Anything else is a failed pull: stamp when it happened and a real
215 // failure class — previously every failure surfaced as "unknown".
216 mlsimport_telemetry_set( 'last_sync_failed', time() );
217 mlsimport_telemetry_set( 'last_sync_failed_code', mlsimport_telemetry_classify_sync_failure( $answer ) );
218 }
219
220 /**
221 * Map a failed listings answer to a short failure class for
222 * sync_health.last_failure_code. Pure — inspects only the answer shape and
223 * the message strings globalApiRequestCurlSaas() actually produces.
224 *
225 * @param mixed $answer The normalized failed listings API answer.
226 * @return string One of the short failure-class codes.
227 */
228 function mlsimport_telemetry_classify_sync_failure( $answer ): string {
229 // A provider-rule rejection already carries a machine code under 'type'
230 // (set by mlsimport_make_listing_requests()) — pass it through as-is.
231 if ( is_array( $answer ) && ! empty( $answer['type'] ) ) {
232 return (string) $answer['type'];
233 }
234 $message = is_array( $answer ) && isset( $answer['message'] ) ? (string) $answer['message'] : '';
235 // The exact string ThemeImport returns when the SaaS JWT cannot be
236 // minted/refreshed (bad account credentials, token endpoint down).
237 if ( 'Token validation failed' === $message ) {
238 return 'token';
239 }
240 // WP_Error transport messages pass through verbatim; cURL timeouts read
241 // 'cURL error 28: Operation timed out after N milliseconds ...'.
242 if ( false !== stripos( $message, 'timed out' ) ) {
243 return 'timeout';
244 }
245 // AWS API Gateway rejections decode to {"message":"Unauthorized"} /
246 // {"message":"Forbidden"} with no 'results' key.
247 if ( false !== stripos( $message, 'unauthorized' ) || false !== stripos( $message, 'forbidden' ) ) {
248 return 'auth';
249 }
250 return 'api_error';
251 }
252
253 /**
254 * Record the first-completion time of an onboarding-wizard step into the
255 * 'onboarding_steps' map on mlsimport_telemetry_state. First completion wins;
256 * re-running a step does not move the timestamp. Saved with autoload = 'no'.
257 *
258 * @param string $step The onboarding step ID (e.g. 'account', 'field-mapping').
259 * @return void
260 */
261 function mlsimport_telemetry_mark_onboarding_step( string $step ): void {
262 // Guard: ignore an empty step id.
263 if ( '' === $step ) {
264 return;
265 }
266 // Load the persisted state; coerce a non-array back to an array.
267 $state = get_option( 'mlsimport_telemetry_state', array() );
268 if ( ! is_array( $state ) ) {
269 $state = array();
270 }
271 // Ensure the onboarding-steps map exists.
272 if ( ! isset( $state['onboarding_steps'] ) || ! is_array( $state['onboarding_steps'] ) ) {
273 $state['onboarding_steps'] = array();
274 }
275 // First completion wins — do not move an existing timestamp.
276 if ( isset( $state['onboarding_steps'][ $step ] ) ) {
277 return;
278 }
279 // Stamp the step with the current epoch and persist (non-autoloaded).
280 $state['onboarding_steps'][ $step ] = time();
281 update_option( 'mlsimport_telemetry_state', $state, false );
282 }
283
284 // ---------------------------------------------------------------------------
285 // §1 Import performance snapshot (GitHub issue #216)
286 // ---------------------------------------------------------------------------
287
288 /**
289 * Build the import-performance snapshot for one finished Import Run. Pure.
290 *
291 * Answers support's "is it us or the host?" question from data the run
292 * machinery already tracks:
293 * - elapsed_seconds: wall time from the run's started_at to its finish, across
294 * every chunk worker — not just the finishing request.
295 * - workers: 1 + chunk hand-offs + watchdog revivals. Any revival means a
296 * worker died without handing off, i.e. the host killed it.
297 * - queue_depth: pending worker actions at finish — backlog evidence.
298 * - peak_memory_mb: peak PHP memory of the finishing worker.
299 *
300 * @param array $run Final Import Run record (started_at, source,
301 * expected, handoffs, revive_count).
302 * @param array $result Final public Import Run Result.
303 * @param int $now Finish time (Unix epoch).
304 * @param int $peak_memory_bytes memory_get_peak_usage(true) of the finisher.
305 * @param int $queue_depth Pending worker actions for the import hook.
306 * @return array<string,int|string> The snapshot stored under 'last_import_run'.
307 */
308 function mlsimport_telemetry_import_run_snapshot( array $run, array $result, int $now, int $peak_memory_bytes, int $queue_depth ): array {
309 // Wall time across the whole worker chain; guard against a missing or
310 // future started_at leaving a negative duration.
311 $started_at = (int) ( $run['started_at'] ?? $now );
312 return array(
313 'source' => (string) ( $run['source'] ?? '' ),
314 'state' => (string) ( $result['state'] ?? '' ),
315 'expected' => (int) ( $run['expected'] ?? 0 ),
316 'saved' => (int) ( $result['saved'] ?? 0 ),
317 'failed' => (int) ( $result['failed'] ?? 0 ),
318 'elapsed_seconds' => max( 0, $now - $started_at ),
319 // One initial worker, plus one per chunk hand-off, plus one per
320 // watchdog revival (a revival is a worker the host killed).
321 'workers' => 1 + (int) ( $run['handoffs'] ?? 0 ) + (int) ( $run['revive_count'] ?? 0 ),
322 'peak_memory_mb' => (int) round( $peak_memory_bytes / 1048576 ),
323 'queue_depth' => $queue_depth,
324 'finished_at' => $now,
325 );
326 }
327
328 // ---------------------------------------------------------------------------
329 // §1 Pure helpers
330 // ---------------------------------------------------------------------------
331
332 /**
333 * Positive epoch -> "Y-m-d\TH:i:s\Z" (UTC). 0 / empty -> null. Pure.
334 *
335 * @param int $epoch Unix timestamp.
336 * @return string|null ISO 8601 UTC string or null.
337 */
338 function mlsimport_telemetry_iso( int $epoch ): ?string {
339 // Non-positive epoch means "never" — represent as null.
340 if ( $epoch <= 0 ) {
341 return null;
342 }
343 // Format the epoch as an ISO 8601 UTC string.
344 return gmdate( 'Y-m-d\TH:i:s\Z', $epoch );
345 }
346
347 /**
348 * Drop daily-bucket keys older than $keep_days relative to $today. Pure.
349 *
350 * @param array $daily Daily bucket map (YYYY-MM-DD => array).
351 * @param string $today Reference date string 'Y-m-d'.
352 * @param int $keep_days Number of days to keep (default 8).
353 * @return array Pruned daily map.
354 */
355 function mlsimport_telemetry_prune_buckets( array $daily, string $today, int $keep_days = 8 ): array {
356 // Compute the oldest date to keep (today minus the retention window).
357 $cutoff = gmdate( 'Y-m-d', strtotime( $today ) - ( $keep_days * DAY_IN_SECONDS ) );
358 // Drop any bucket whose date string sorts before the cutoff.
359 foreach ( array_keys( $daily ) as $date ) {
360 if ( $date < $cutoff ) {
361 unset( $daily[ $date ] );
362 }
363 }
364 return $daily;
365 }
366
367 /**
368 * Sum the last $days daily buckets ending at $today.
369 * Returns [ 'imported'=>int, 'updated'=>int, 'deleted'=>int, 'syncs'=>int,
370 * 'token_failures'=>int ]. Pure.
371 *
372 * @param array $daily Daily bucket map.
373 * @param string $today Reference date string 'Y-m-d'.
374 * @param int $days Number of days to sum (default 7).
375 * @return array<string,int> Summed counters.
376 */
377 function mlsimport_telemetry_sum_buckets( array $daily, string $today, int $days = 7 ): array {
378 $sums = array(
379 'imported' => 0,
380 'updated' => 0,
381 'deleted' => 0,
382 'syncs' => 0,
383 'token_failures' => 0,
384 );
385
386 // Walk back $days days from $today, accumulating each present bucket.
387 for ( $i = 0; $i < $days; $i++ ) {
388 // The date for this step back from today.
389 $date = gmdate( 'Y-m-d', strtotime( $today ) - ( $i * DAY_IN_SECONDS ) );
390 // Skip a missing or malformed bucket.
391 if ( ! isset( $daily[ $date ] ) || ! is_array( $daily[ $date ] ) ) {
392 continue;
393 }
394 // Add each counter this bucket carries into the running totals.
395 foreach ( $sums as $key => $_ ) {
396 if ( isset( $daily[ $date ][ $key ] ) ) {
397 $sums[ $key ] += (int) $daily[ $date ][ $key ];
398 }
399 }
400 }
401
402 return $sums;
403 }
404
405 /**
406 * True when $last_sent equals $today (UTC 'Y-m-d' strings). Pure.
407 *
408 * @param string $last_sent Previously stored send date.
409 * @param string $today Today's UTC date.
410 * @return bool
411 */
412 function mlsimport_telemetry_already_sent_today( string $last_sent, string $today ): bool {
413 return $last_sent === $today;
414 }
415
416 // ---------------------------------------------------------------------------
417 // §1 Completeness sampler
418 // ---------------------------------------------------------------------------
419
420 /**
421 * Per-theme meta keys used for data-completeness checks.
422 * Keys: price, address, coordinate.
423 *
424 * WpResidence / WpEstate: use shared RESO-mapped meta names.
425 * Houzez: coordinates are stored in a combined `fave_property_location` meta.
426 * RealHomes: coordinates are stored in `REAL_HOMES_property_location`.
427 *
428 * @return array<string,array<string,string>>
429 */
430 function mlsimport_telemetry_theme_meta_map(): array {
431 return array(
432 // WpResidence (991) and WpEstate (994) share the same RESO-mapped meta names.
433 'ResidenceClass' => array(
434 'price' => 'property_price',
435 'address' => 'property_address',
436 'coordinate' => 'property_latitude',
437 ),
438 'EstateClass' => array(
439 'price' => 'property_price',
440 'address' => 'property_address',
441 'coordinate' => 'property_latitude',
442 ),
443 // Houzez (992): uses fave_property_location for combined lat,lng.
444 'HouzezClass' => array(
445 'price' => 'property_price',
446 'address' => 'property_address',
447 'coordinate' => 'fave_property_location',
448 ),
449 // RealHomes (993): uses REAL_HOMES_property_location for combined lat,lng.
450 'RealHomesClass' => array(
451 'price' => 'property_price',
452 'address' => 'property_address',
453 'coordinate' => 'REAL_HOMES_property_location',
454 ),
455 );
456 }
457
458 /**
459 * Sample the 20 most-recent property posts holding a 'ListingKey' meta.
460 * Returns [ 'with_photos_percent'=>int, 'with_price_percent'=>int,
461 * 'with_address_percent'=>int, 'with_coordinates_percent'=>int ]
462 * (integer percentages 0-100; all 0 when the sample is empty).
463 *
464 * @return array<string,int>
465 */
466 function mlsimport_telemetry_sample_completeness(): array {
467 $empty = array(
468 'with_photos_percent' => 0,
469 'with_price_percent' => 0,
470 'with_address_percent' => 0,
471 'with_coordinates_percent' => 0,
472 );
473
474 // Resolve the active theme adapter class.
475 global $mlsimport;
476 $env_class = '';
477 if (
478 isset( $mlsimport ) &&
479 isset( $mlsimport->admin ) &&
480 isset( $mlsimport->admin->env_data ) &&
481 is_object( $mlsimport->admin->env_data )
482 ) {
483 $env_class = get_class( $mlsimport->admin->env_data );
484 }
485
486 $meta_map = mlsimport_telemetry_theme_meta_map();
487 // Default fallback — shared RESO keys used by WpResidence / WpEstate.
488 $keys = isset( $meta_map[ $env_class ] )
489 ? $meta_map[ $env_class ]
490 : array(
491 'price' => 'property_price',
492 'address' => 'property_address',
493 'coordinate' => 'property_latitude',
494 );
495
496 // Determine the post type from the adapter; fall back to 'estate_property'.
497 $post_type = 'estate_property';
498 if (
499 isset( $mlsimport ) &&
500 isset( $mlsimport->admin ) &&
501 isset( $mlsimport->admin->env_data ) &&
502 is_object( $mlsimport->admin->env_data ) &&
503 method_exists( $mlsimport->admin->env_data, 'get_property_post_type' )
504 ) {
505 $post_type = $mlsimport->admin->env_data->get_property_post_type();
506 }
507
508 // Fetch the 20 most-recent posts that have a ListingKey meta.
509 $args = array(
510 'post_type' => $post_type,
511 'post_status' => 'any',
512 'posts_per_page' => 20,
513 'fields' => 'ids',
514 'orderby' => 'date',
515 'order' => 'DESC',
516 'meta_query' => array(
517 array(
518 'key' => '_mlsimport_listing_key',
519 'compare' => 'EXISTS',
520 ),
521 ),
522 'no_found_rows' => true,
523 );
524
525 // Run the query (guard for environments without get_posts()).
526 $post_ids = function_exists( 'get_posts' ) ? get_posts( $args ) : array();
527
528 // No sample — return all-zero percentages.
529 if ( empty( $post_ids ) ) {
530 return $empty;
531 }
532
533 // Denominator + per-field hit counters.
534 $total = count( $post_ids );
535 $photos = 0;
536 $price = 0;
537 $address = 0;
538 $coordinates = 0;
539
540 // Tally how many sampled posts carry each field.
541 foreach ( $post_ids as $pid ) {
542 // Featured image present?
543 if ( has_post_thumbnail( $pid ) ) {
544 $photos++;
545 }
546 // Price meta non-empty?
547 if ( '' !== get_post_meta( $pid, $keys['price'], true ) ) {
548 $price++;
549 }
550 // Address meta non-empty?
551 if ( '' !== get_post_meta( $pid, $keys['address'], true ) ) {
552 $address++;
553 }
554 // Coordinate meta non-empty?
555 if ( '' !== get_post_meta( $pid, $keys['coordinate'], true ) ) {
556 $coordinates++;
557 }
558 }
559
560 // Convert each tally to an integer 0-100 percentage of the sample.
561 return array(
562 'with_photos_percent' => (int) round( $photos / $total * 100 ),
563 'with_price_percent' => (int) round( $price / $total * 100 ),
564 'with_address_percent' => (int) round( $address / $total * 100 ),
565 'with_coordinates_percent' => (int) round( $coordinates / $total * 100 ),
566 );
567 }
568
569 // ---------------------------------------------------------------------------
570 // §1 Payload collector
571 // ---------------------------------------------------------------------------
572
573 /**
574 * Build the full human-readable heartbeat payload (see §5 for the shape).
575 * Converts stored epochs to ISO 8601 via mlsimport_telemetry_iso().
576 * Generates + persists mlsimport_admin_options['mlsimport_install_uuid'] if absent.
577 *
578 * @return array The structured heartbeat payload.
579 */
580 function mlsimport_telemetry_collect_payload(): array {
581 // --- Install UUID ---
582 $opts = get_option( 'mlsimport_admin_options', array() );
583 if ( ! is_array( $opts ) ) {
584 $opts = array();
585 }
586 if ( empty( $opts['mlsimport_install_uuid'] ) ) {
587 $opts['mlsimport_install_uuid'] = wp_generate_uuid4();
588 update_option( 'mlsimport_admin_options', $opts );
589 }
590
591 // --- Telemetry state ---
592 $state = get_option( 'mlsimport_telemetry_state', array() );
593 if ( ! is_array( $state ) ) {
594 $state = array();
595 }
596 $daily = isset( $state['daily'] ) && is_array( $state['daily'] ) ? $state['daily'] : array();
597
598 $today = gmdate( 'Y-m-d' );
599 $sums = mlsimport_telemetry_sum_buckets( $daily, $today, 7 );
600
601 // --- sync_health ---
602 $last_sync_success = isset( $state['last_sync_success'] ) ? (int) $state['last_sync_success'] : 0;
603 $last_sync_failed = isset( $state['last_sync_failed'] ) ? (int) $state['last_sync_failed'] : 0;
604 $last_sync_failed_code = isset( $state['last_sync_failed_code'] ) ? (string) $state['last_sync_failed_code'] : '';
605 $last_feed_found = isset( $state['last_feed_found'] ) ? (int) $state['last_feed_found'] : 0;
606 $last_admin_load = isset( $state['last_admin_load'] ) ? (int) $state['last_admin_load'] : 0;
607 $last_import_task_load = isset( $state['last_import_task_load'] ) ? (int) $state['last_import_task_load'] : 0;
608
609 // --- lifecycle / onboarding funnel ---
610 $installed_at = isset( $state['installed_at'] ) ? (int) $state['installed_at'] : 0;
611 $account_connected_at = isset( $state['account_connected_at'] ) ? (int) $state['account_connected_at'] : 0;
612 $mls_connected_at = isset( $state['mls_connected_at'] ) ? (int) $state['mls_connected_at'] : 0;
613 $last_field_mgmt = isset( $state['last_field_management'] ) ? (int) $state['last_field_management'] : 0;
614 $onboarding_steps = array();
615 if ( isset( $state['onboarding_steps'] ) && is_array( $state['onboarding_steps'] ) ) {
616 foreach ( $state['onboarding_steps'] as $step_id => $step_epoch ) {
617 $onboarding_steps[ (string) $step_id ] = mlsimport_telemetry_iso( (int) $step_epoch );
618 }
619 }
620
621 // WP cron working: daily event is scheduled.
622 if ( function_exists( 'wp_next_scheduled' ) ) {
623 $wp_cron_working = ( false !== wp_next_scheduled( 'mlsimport_daily_telemetry_event' ) ) ||
624 ( false !== wp_next_scheduled( 'event_mls_import_auto' ) );
625 } else {
626 $wp_cron_working = false;
627 }
628
629 // --- output: active listings ---
630 $post_type = 'estate_property';
631 global $mlsimport;
632 if (
633 isset( $mlsimport ) &&
634 isset( $mlsimport->admin ) &&
635 isset( $mlsimport->admin->env_data ) &&
636 is_object( $mlsimport->admin->env_data ) &&
637 method_exists( $mlsimport->admin->env_data, 'get_property_post_type' )
638 ) {
639 $post_type = $mlsimport->admin->env_data->get_property_post_type();
640 }
641
642 $active_listings = 0;
643 if ( class_exists( 'WP_Query' ) ) {
644 $active_count_query = new WP_Query( array(
645 'post_type' => $post_type,
646 'post_status' => 'publish',
647 'posts_per_page' => 1,
648 'fields' => 'ids',
649 'no_found_rows' => false,
650 ) );
651 $active_listings = (int) $active_count_query->found_posts;
652 }
653
654 // --- data completeness ---
655 $completeness = mlsimport_telemetry_sample_completeness();
656
657 // --- import performance (issue #216) ---
658 // The latest finished-run snapshot, recorded at finish_run(). Null means
659 // no run has ever finished on this install — distinct from a missing field.
660 $import_performance = null;
661 if ( isset( $state['last_import_run'] ) && is_array( $state['last_import_run'] ) ) {
662 $import_performance = $state['last_import_run'];
663 $import_performance['finished_at'] = mlsimport_telemetry_iso( (int) ( $import_performance['finished_at'] ?? 0 ) );
664 }
665
666 // --- configuration: import tasks ---
667 $raw_tasks_query = function_exists( 'get_posts' ) ? get_posts( array(
668 'post_type' => 'mlsimport_item',
669 'post_status' => 'any',
670 'posts_per_page' => -1,
671 'fields' => 'ids',
672 'no_found_rows' => true,
673 ) ) : array();
674
675 $import_tasks = array();
676 $auto_update_any = false;
677 foreach ( $raw_tasks_query as $task_id ) {
678 $how_many = (int) get_post_meta( $task_id, 'mlsimport_item_how_many', true );
679 $stat_cron = (int) get_post_meta( $task_id, 'mlsimport_item_stat_cron', true );
680 $auto_upd = ( 1 === $stat_cron );
681 if ( $auto_upd ) {
682 $auto_update_any = true;
683 }
684 $import_tasks[] = array(
685 'import_limit' => $how_many,
686 'auto_update' => $auto_upd,
687 );
688 }
689
690 // --- MLS provider / ID ---
691 $mls_provider = '';
692 $mls_id = 0;
693 if ( isset( $opts['mlsimport_mls_name'] ) && '' !== $opts['mlsimport_mls_name'] ) {
694 $mls_id = (int) $opts['mlsimport_mls_name'];
695 }
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
707 // Theme label.
708 $theme_label = '';
709 if (
710 isset( $mlsimport ) &&
711 isset( $mlsimport->admin ) &&
712 isset( $mlsimport->admin->env_data ) &&
713 is_object( $mlsimport->admin->env_data )
714 ) {
715 $env_class = get_class( $mlsimport->admin->env_data );
716 $theme_label = ( 'stdClass' !== $env_class ) ? $env_class : '';
717 }
718
719 // The real plugin stores the account name under 'mlsimport_username'.
720 // The unit test bootstrap seeds it under 'account' (legacy key).
721 // Read both; prefer 'mlsimport_username' (canonical).
722 if ( ! empty( $opts['mlsimport_username'] ) ) {
723 $account = (string) $opts['mlsimport_username'];
724 } elseif ( ! empty( $opts['account'] ) ) {
725 $account = (string) $opts['account'];
726 } else {
727 $account = '';
728 }
729
730 return array(
731 'event_type' => 'daily_telemetry',
732 'reported_at' => gmdate( 'Y-m-d\TH:i:s\Z' ),
733 'install' => array(
734 'install_id' => (string) $opts['mlsimport_install_uuid'],
735 'account' => $account,
736 'site_url' => (string) home_url(),
737 ),
738 'sync_health' => array(
739 'last_successful_sync' => mlsimport_telemetry_iso( $last_sync_success ),
740 'last_failed_sync' => mlsimport_telemetry_iso( $last_sync_failed ),
741 'last_failure_code' => $last_sync_failed_code,
742 'syncs_last_7_days' => (int) $sums['syncs'],
743 'token_refresh_failures_last_7_days' => (int) $sums['token_failures'],
744 'wp_cron_working' => (bool) $wp_cron_working,
745 ),
746 'feed' => array(
747 'listings_found_in_feed' => $last_feed_found,
748 ),
749 'output' => array(
750 'imported_last_7_days' => (int) $sums['imported'],
751 'updated_last_7_days' => (int) $sums['updated'],
752 'deleted_last_7_days' => (int) $sums['deleted'],
753 'active_listings_on_site' => $active_listings,
754 'data_completeness' => array(
755 'with_photos_percent' => (int) $completeness['with_photos_percent'],
756 'with_price_percent' => (int) $completeness['with_price_percent'],
757 'with_address_percent' => (int) $completeness['with_address_percent'],
758 'with_coordinates_percent' => (int) $completeness['with_coordinates_percent'],
759 ),
760 ),
761 'import_performance' => $import_performance,
762 'engagement' => array(
763 'last_admin_page_view' => mlsimport_telemetry_iso( $last_admin_load ),
764 'last_import_task_page_view' => mlsimport_telemetry_iso( $last_import_task_load ),
765 ),
766 'configuration' => array(
767 'mls_provider' => $mls_provider,
768 'mls_id' => $mls_id,
769 'import_tasks' => $import_tasks,
770 'import_tasks_count' => count( $import_tasks ),
771 'auto_update_enabled' => (bool) $auto_update_any,
772 ),
773 'environment' => array(
774 'plugin_version' => defined( 'MLSIMPORT_VERSION' ) ? MLSIMPORT_VERSION : '',
775 'php_version' => PHP_VERSION,
776 'wordpress_version' => get_bloginfo( 'version' ),
777 'theme' => $theme_label,
778 ),
779 'lifecycle' => array(
780 'installed_at' => mlsimport_telemetry_iso( $installed_at ),
781 'account_connected_at' => mlsimport_telemetry_iso( $account_connected_at ),
782 'mls_connected_at' => mlsimport_telemetry_iso( $mls_connected_at ),
783 'last_field_management' => mlsimport_telemetry_iso( $last_field_mgmt ),
784 'onboarding_steps' => (object) $onboarding_steps,
785 ),
786 );
787 }
788
789 // ---------------------------------------------------------------------------
790 // §1 Daily cron handler
791 // ---------------------------------------------------------------------------
792
793 /**
794 * Daily cron handler. No-op if already sent today. Builds the payload and calls
795 * ThemeImport::globalApiRequestSaasFireAndForget('user-activity', $payload) inside
796 * try/catch(\Throwable). Sets mlsimport_telemetry_last_sent on a non-false return.
797 * Hooked to 'mlsimport_daily_telemetry_event'.
798 *
799 * @return void
800 */
801 function mlsimport_telemetry_run_daily(): void {
802 $last_sent = (string) get_option( 'mlsimport_telemetry_last_sent', '' );
803 $today = gmdate( 'Y-m-d' );
804
805 if ( mlsimport_telemetry_already_sent_today( $last_sent, $today ) ) {
806 return;
807 }
808
809 $payload = mlsimport_telemetry_collect_payload();
810
811 try {
812 $result = ThemeImport::globalApiRequestSaasFireAndForget( 'user-activity', $payload );
813 } catch ( \Throwable $e ) {
814 // Fire-and-forget: transport errors are silently discarded.
815 return;
816 }
817
818 if ( false !== $result ) {
819 update_option( 'mlsimport_telemetry_last_sent', $today, false );
820 }
821 }
822
823 // ---------------------------------------------------------------------------
824 // §1 Admin engagement tracker (registered on 'admin_init')
825 // ---------------------------------------------------------------------------
826
827 /**
828 * Record admin-page engagement timestamps. Updates last_admin_load (and
829 * last_import_task_load on the Import Task editor) only when the stored value is
830 * older than 10 minutes. Hooked to 'admin_init'.
831 *
832 * Throttle logic (Pre-mortem Scenario 4):
833 * - A missing stored timestamp is treated as epoch 0 (far in the past), so the
834 * very first admin page view writes once.
835 * - Subsequent views within the 10-minute window do not write again.
836 *
837 * @return void
838 */
839 function mlsimport_telemetry_track_admin_load(): void {
840 // Only run on genuine admin requests — skip AJAX, CLI, cron.
841 if ( ! is_admin() || wp_doing_ajax() || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
842 return;
843 }
844
845 // Detect the screen from $pagenow + request vars. get_current_screen() is
846 // not yet populated on 'admin_init', so a screen-object lookup misses every
847 // real page load — $pagenow and $_GET are reliably set this early.
848 global $pagenow;
849 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
850 $post_type = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : '';
851
852 // Import Task list / editor — edit.php, post-new.php, or post.php for the
853 // mlsimport_item CPT.
854 $is_import_task_screen = (
855 ( ( 'edit.php' === $pagenow || 'post-new.php' === $pagenow ) && 'mlsimport_item' === $post_type ) ||
856 ( 'post.php' === $pagenow && isset( $_GET['post'] ) && 'mlsimport_item' === get_post_type( (int) $_GET['post'] ) )
857 );
858
859 // Any MLSImport admin screen — a plugin menu page or the import-task editor.
860 $is_mlsimport_screen = ( $is_import_task_screen || 0 === strpos( $page, 'mlsimport' ) );
861
862 if ( ! $is_mlsimport_screen ) {
863 return;
864 }
865
866 $state = get_option( 'mlsimport_telemetry_state', array() );
867 if ( ! is_array( $state ) ) {
868 $state = array();
869 }
870
871 $now = time();
872 $threshold = 10 * MINUTE_IN_SECONDS; // 600 seconds.
873 $did_write = false;
874
875 // Throttle: only update last_admin_load when stored value is older than 10 min.
876 // A missing key defaults to 0, which is always older than 10 min — writes once.
877 $stored_admin = isset( $state['last_admin_load'] ) ? (int) $state['last_admin_load'] : 0;
878 if ( ( $now - $stored_admin ) >= $threshold ) {
879 $state['last_admin_load'] = $now;
880 $did_write = true;
881 }
882
883 if ( $is_import_task_screen ) {
884 $stored_task = isset( $state['last_import_task_load'] ) ? (int) $state['last_import_task_load'] : 0;
885 if ( ( $now - $stored_task ) >= $threshold ) {
886 $state['last_import_task_load'] = $now;
887 $did_write = true;
888 }
889 }
890
891 if ( $did_write ) {
892 update_option( 'mlsimport_telemetry_state', $state, false );
893 }
894 }
895
896 add_action( 'admin_init', 'mlsimport_telemetry_track_admin_load' );
897
898 /**
899 * Record import-field management activity. Fires on the field-selector
900 * progressive-save AJAX actions; throttled to one write per 10 minutes so a
901 * burst of chunked field saves causes a single option write. autoload = 'no'.
902 *
903 * @return void
904 */
905 function mlsimport_telemetry_track_field_management(): void {
906 // Load the persisted state; coerce a non-array back to an array.
907 $state = get_option( 'mlsimport_telemetry_state', array() );
908 if ( ! is_array( $state ) ) {
909 $state = array();
910 }
911 // Current time and the last-recorded field-management stamp (missing = 0).
912 $now = time();
913 $stored = isset( $state['last_field_management'] ) ? (int) $state['last_field_management'] : 0;
914 // Throttle: skip if the last write was under 10 minutes ago.
915 if ( ( $now - $stored ) < 10 * MINUTE_IN_SECONDS ) {
916 return;
917 }
918 // Record the activity and persist (non-autoloaded).
919 $state['last_field_management'] = $now;
920 update_option( 'mlsimport_telemetry_state', $state, false );
921 }
922
923 // The single compact mutation endpoint is the Field Configuration activity
924 // seam. Priority 1 records activity before validation/persistence runs.
925 add_action( 'wp_ajax_mlsimport_change_field_configuration', 'mlsimport_telemetry_track_field_management', 1 );
926