PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 6.3.3
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v6.3.3
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 6.3.3, at includes/mlsimport-telemetry.php

779 lines 26.5 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 $allowed = array( 'imported', 'updated', 'deleted', 'syncs', 'token_failures' );
62 if ( ! in_array( $metric, $allowed, true ) ) {
63 return;
64 }
65 global $mlsimport_telemetry_pending;
66 if ( ! isset( $mlsimport_telemetry_pending[ $metric ] ) ) {
67 $mlsimport_telemetry_pending[ $metric ] = 0;
68 }
69 $mlsimport_telemetry_pending[ $metric ] += $amount;
70 }
71
72 // ---------------------------------------------------------------------------
73 // §1 Public API — flush (registered on 'shutdown')
74 // ---------------------------------------------------------------------------
75
76 /**
77 * Flush accumulated counter deltas into today's daily bucket.
78 * No-op when nothing is pending. Reads + writes the single option
79 * 'mlsimport_telemetry_state' exactly once, prunes buckets older than 8 days,
80 * resets the pending array. Registered on the 'shutdown' action.
81 *
82 * @return void
83 */
84 function mlsimport_telemetry_flush(): void {
85 global $mlsimport_telemetry_pending;
86
87 if ( empty( $mlsimport_telemetry_pending ) ) {
88 return;
89 }
90
91 $state = get_option( 'mlsimport_telemetry_state', array() );
92 if ( ! is_array( $state ) ) {
93 $state = array();
94 }
95
96 if ( ! isset( $state['daily'] ) || ! is_array( $state['daily'] ) ) {
97 $state['daily'] = array();
98 }
99
100 $today = gmdate( 'Y-m-d' );
101 $bucket = isset( $state['daily'][ $today ] ) ? $state['daily'][ $today ] : array();
102
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 );
112
113 foreach ( $mlsimport_telemetry_pending as $metric => $delta ) {
114 if ( isset( $bucket[ $metric ] ) ) {
115 $bucket[ $metric ] += $delta;
116 }
117 }
118
119 $state['daily'][ $today ] = $bucket;
120 $state['daily'] = mlsimport_telemetry_prune_buckets( $state['daily'], $today );
121
122 update_option( 'mlsimport_telemetry_state', $state, false );
123
124 // Reset pending.
125 $mlsimport_telemetry_pending = array();
126 }
127
128 add_action( 'shutdown', 'mlsimport_telemetry_flush' );
129
130 // ---------------------------------------------------------------------------
131 // §1 Public API — immediate key setter
132 // ---------------------------------------------------------------------------
133
134 /**
135 * Set a non-counter "last X" field on mlsimport_telemetry_state.
136 * $key ∈ last_sync_success | last_sync_failed | last_sync_failed_code |
137 * last_sync_attempt | last_feed_found | last_admin_load | last_import_task_load.
138 * Immediate small read-modify-write; option saved with autoload = 'no'.
139 *
140 * @param string $key The state key to set.
141 * @param mixed $value The value to store.
142 * @return void
143 */
144 function mlsimport_telemetry_set( string $key, $value ): void {
145 $state = get_option( 'mlsimport_telemetry_state', array() );
146 if ( ! is_array( $state ) ) {
147 $state = array();
148 }
149 $state[ $key ] = $value;
150 update_option( 'mlsimport_telemetry_state', $state, false );
151 }
152
153 /**
154 * Set a "first time only" lifecycle stamp on mlsimport_telemetry_state.
155 * Unlike mlsimport_telemetry_set(), this is a no-op when $key already holds a
156 * non-empty value — the first occurrence wins. Used for installed_at /
157 * account_connected_at / mls_connected_at. Saved with autoload = 'no'.
158 *
159 * @param string $key The state key to set once.
160 * @param mixed $value The value to store on the first call.
161 * @return void
162 */
163 function mlsimport_telemetry_set_once( string $key, $value ): void {
164 $state = get_option( 'mlsimport_telemetry_state', array() );
165 if ( ! is_array( $state ) ) {
166 $state = array();
167 }
168 if ( ! empty( $state[ $key ] ) ) {
169 return;
170 }
171 $state[ $key ] = $value;
172 update_option( 'mlsimport_telemetry_state', $state, false );
173 }
174
175 /**
176 * Record the first-completion time of an onboarding-wizard step into the
177 * 'onboarding_steps' map on mlsimport_telemetry_state. First completion wins;
178 * re-running a step does not move the timestamp. Saved with autoload = 'no'.
179 *
180 * @param string $step The onboarding step ID (e.g. 'account', 'field-mapping').
181 * @return void
182 */
183 function mlsimport_telemetry_mark_onboarding_step( string $step ): void {
184 if ( '' === $step ) {
185 return;
186 }
187 $state = get_option( 'mlsimport_telemetry_state', array() );
188 if ( ! is_array( $state ) ) {
189 $state = array();
190 }
191 if ( ! isset( $state['onboarding_steps'] ) || ! is_array( $state['onboarding_steps'] ) ) {
192 $state['onboarding_steps'] = array();
193 }
194 if ( isset( $state['onboarding_steps'][ $step ] ) ) {
195 return;
196 }
197 $state['onboarding_steps'][ $step ] = time();
198 update_option( 'mlsimport_telemetry_state', $state, false );
199 }
200
201 // ---------------------------------------------------------------------------
202 // §1 Pure helpers
203 // ---------------------------------------------------------------------------
204
205 /**
206 * Positive epoch -> "Y-m-d\TH:i:s\Z" (UTC). 0 / empty -> null. Pure.
207 *
208 * @param int $epoch Unix timestamp.
209 * @return string|null ISO 8601 UTC string or null.
210 */
211 function mlsimport_telemetry_iso( int $epoch ): ?string {
212 if ( $epoch <= 0 ) {
213 return null;
214 }
215 return gmdate( 'Y-m-d\TH:i:s\Z', $epoch );
216 }
217
218 /**
219 * Drop daily-bucket keys older than $keep_days relative to $today. Pure.
220 *
221 * @param array $daily Daily bucket map (YYYY-MM-DD => array).
222 * @param string $today Reference date string 'Y-m-d'.
223 * @param int $keep_days Number of days to keep (default 8).
224 * @return array Pruned daily map.
225 */
226 function mlsimport_telemetry_prune_buckets( array $daily, string $today, int $keep_days = 8 ): array {
227 $cutoff = gmdate( 'Y-m-d', strtotime( $today ) - ( $keep_days * DAY_IN_SECONDS ) );
228 foreach ( array_keys( $daily ) as $date ) {
229 if ( $date < $cutoff ) {
230 unset( $daily[ $date ] );
231 }
232 }
233 return $daily;
234 }
235
236 /**
237 * Sum the last $days daily buckets ending at $today.
238 * Returns [ 'imported'=>int, 'updated'=>int, 'deleted'=>int, 'syncs'=>int,
239 * 'token_failures'=>int ]. Pure.
240 *
241 * @param array $daily Daily bucket map.
242 * @param string $today Reference date string 'Y-m-d'.
243 * @param int $days Number of days to sum (default 7).
244 * @return array<string,int> Summed counters.
245 */
246 function mlsimport_telemetry_sum_buckets( array $daily, string $today, int $days = 7 ): array {
247 $sums = array(
248 'imported' => 0,
249 'updated' => 0,
250 'deleted' => 0,
251 'syncs' => 0,
252 'token_failures' => 0,
253 );
254
255 for ( $i = 0; $i < $days; $i++ ) {
256 $date = gmdate( 'Y-m-d', strtotime( $today ) - ( $i * DAY_IN_SECONDS ) );
257 if ( ! isset( $daily[ $date ] ) || ! is_array( $daily[ $date ] ) ) {
258 continue;
259 }
260 foreach ( $sums as $key => $_ ) {
261 if ( isset( $daily[ $date ][ $key ] ) ) {
262 $sums[ $key ] += (int) $daily[ $date ][ $key ];
263 }
264 }
265 }
266
267 return $sums;
268 }
269
270 /**
271 * True when $last_sent equals $today (UTC 'Y-m-d' strings). Pure.
272 *
273 * @param string $last_sent Previously stored send date.
274 * @param string $today Today's UTC date.
275 * @return bool
276 */
277 function mlsimport_telemetry_already_sent_today( string $last_sent, string $today ): bool {
278 return $last_sent === $today;
279 }
280
281 // ---------------------------------------------------------------------------
282 // §1 Completeness sampler
283 // ---------------------------------------------------------------------------
284
285 /**
286 * Per-theme meta keys used for data-completeness checks.
287 * Keys: price, address, coordinate.
288 *
289 * WpResidence / WpEstate: use shared RESO-mapped meta names.
290 * Houzez: coordinates are stored in a combined `fave_property_location` meta.
291 * RealHomes: coordinates are stored in `REAL_HOMES_property_location`.
292 *
293 * @return array<string,array<string,string>>
294 */
295 function mlsimport_telemetry_theme_meta_map(): array {
296 return array(
297 // WpResidence (991) and WpEstate (994) share the same RESO-mapped meta names.
298 'ResidenceClass' => array(
299 'price' => 'property_price',
300 'address' => 'property_address',
301 'coordinate' => 'property_latitude',
302 ),
303 'EstateClass' => array(
304 'price' => 'property_price',
305 'address' => 'property_address',
306 'coordinate' => 'property_latitude',
307 ),
308 // Houzez (992): uses fave_property_location for combined lat,lng.
309 'HouzezClass' => array(
310 'price' => 'property_price',
311 'address' => 'property_address',
312 'coordinate' => 'fave_property_location',
313 ),
314 // RealHomes (993): uses REAL_HOMES_property_location for combined lat,lng.
315 'RealHomesClass' => array(
316 'price' => 'property_price',
317 'address' => 'property_address',
318 'coordinate' => 'REAL_HOMES_property_location',
319 ),
320 );
321 }
322
323 /**
324 * Sample the 20 most-recent property posts holding a 'ListingKey' meta.
325 * Returns [ 'with_photos_percent'=>int, 'with_price_percent'=>int,
326 * 'with_address_percent'=>int, 'with_coordinates_percent'=>int ]
327 * (integer percentages 0-100; all 0 when the sample is empty).
328 *
329 * @return array<string,int>
330 */
331 function mlsimport_telemetry_sample_completeness(): array {
332 $empty = array(
333 'with_photos_percent' => 0,
334 'with_price_percent' => 0,
335 'with_address_percent' => 0,
336 'with_coordinates_percent' => 0,
337 );
338
339 // Resolve the active theme adapter class.
340 global $mlsimport;
341 $env_class = '';
342 if (
343 isset( $mlsimport ) &&
344 isset( $mlsimport->admin ) &&
345 isset( $mlsimport->admin->env_data ) &&
346 is_object( $mlsimport->admin->env_data )
347 ) {
348 $env_class = get_class( $mlsimport->admin->env_data );
349 }
350
351 $meta_map = mlsimport_telemetry_theme_meta_map();
352 // Default fallback — shared RESO keys used by WpResidence / WpEstate.
353 $keys = isset( $meta_map[ $env_class ] )
354 ? $meta_map[ $env_class ]
355 : array(
356 'price' => 'property_price',
357 'address' => 'property_address',
358 'coordinate' => 'property_latitude',
359 );
360
361 // Determine the post type from the adapter; fall back to 'estate_property'.
362 $post_type = 'estate_property';
363 if (
364 isset( $mlsimport ) &&
365 isset( $mlsimport->admin ) &&
366 isset( $mlsimport->admin->env_data ) &&
367 is_object( $mlsimport->admin->env_data ) &&
368 method_exists( $mlsimport->admin->env_data, 'get_property_post_type' )
369 ) {
370 $post_type = $mlsimport->admin->env_data->get_property_post_type();
371 }
372
373 // Fetch the 20 most-recent posts that have a ListingKey meta.
374 $args = array(
375 'post_type' => $post_type,
376 'post_status' => 'any',
377 'posts_per_page' => 20,
378 'fields' => 'ids',
379 'orderby' => 'date',
380 'order' => 'DESC',
381 'meta_query' => array(
382 array(
383 'key' => 'ListingKey',
384 'compare' => 'EXISTS',
385 ),
386 ),
387 'no_found_rows' => true,
388 );
389
390 $post_ids = function_exists( 'get_posts' ) ? get_posts( $args ) : array();
391
392 if ( empty( $post_ids ) ) {
393 return $empty;
394 }
395
396 $total = count( $post_ids );
397 $photos = 0;
398 $price = 0;
399 $address = 0;
400 $coordinates = 0;
401
402 foreach ( $post_ids as $pid ) {
403 if ( has_post_thumbnail( $pid ) ) {
404 $photos++;
405 }
406 if ( '' !== get_post_meta( $pid, $keys['price'], true ) ) {
407 $price++;
408 }
409 if ( '' !== get_post_meta( $pid, $keys['address'], true ) ) {
410 $address++;
411 }
412 if ( '' !== get_post_meta( $pid, $keys['coordinate'], true ) ) {
413 $coordinates++;
414 }
415 }
416
417 return array(
418 'with_photos_percent' => (int) round( $photos / $total * 100 ),
419 'with_price_percent' => (int) round( $price / $total * 100 ),
420 'with_address_percent' => (int) round( $address / $total * 100 ),
421 'with_coordinates_percent' => (int) round( $coordinates / $total * 100 ),
422 );
423 }
424
425 // ---------------------------------------------------------------------------
426 // §1 Payload collector
427 // ---------------------------------------------------------------------------
428
429 /**
430 * Build the full human-readable heartbeat payload (see §5 for the shape).
431 * Converts stored epochs to ISO 8601 via mlsimport_telemetry_iso().
432 * Generates + persists mlsimport_admin_options['mlsimport_install_uuid'] if absent.
433 *
434 * @return array The structured heartbeat payload.
435 */
436 function mlsimport_telemetry_collect_payload(): array {
437 // --- Install UUID ---
438 $opts = get_option( 'mlsimport_admin_options', array() );
439 if ( ! is_array( $opts ) ) {
440 $opts = array();
441 }
442 if ( empty( $opts['mlsimport_install_uuid'] ) ) {
443 $opts['mlsimport_install_uuid'] = wp_generate_uuid4();
444 update_option( 'mlsimport_admin_options', $opts );
445 }
446
447 // --- Telemetry state ---
448 $state = get_option( 'mlsimport_telemetry_state', array() );
449 if ( ! is_array( $state ) ) {
450 $state = array();
451 }
452 $daily = isset( $state['daily'] ) && is_array( $state['daily'] ) ? $state['daily'] : array();
453
454 $today = gmdate( 'Y-m-d' );
455 $sums = mlsimport_telemetry_sum_buckets( $daily, $today, 7 );
456
457 // --- sync_health ---
458 $last_sync_success = isset( $state['last_sync_success'] ) ? (int) $state['last_sync_success'] : 0;
459 $last_sync_failed = isset( $state['last_sync_failed'] ) ? (int) $state['last_sync_failed'] : 0;
460 $last_sync_failed_code = isset( $state['last_sync_failed_code'] ) ? (string) $state['last_sync_failed_code'] : '';
461 $last_feed_found = isset( $state['last_feed_found'] ) ? (int) $state['last_feed_found'] : 0;
462 $last_admin_load = isset( $state['last_admin_load'] ) ? (int) $state['last_admin_load'] : 0;
463 $last_import_task_load = isset( $state['last_import_task_load'] ) ? (int) $state['last_import_task_load'] : 0;
464
465 // --- lifecycle / onboarding funnel ---
466 $installed_at = isset( $state['installed_at'] ) ? (int) $state['installed_at'] : 0;
467 $account_connected_at = isset( $state['account_connected_at'] ) ? (int) $state['account_connected_at'] : 0;
468 $mls_connected_at = isset( $state['mls_connected_at'] ) ? (int) $state['mls_connected_at'] : 0;
469 $last_field_mgmt = isset( $state['last_field_management'] ) ? (int) $state['last_field_management'] : 0;
470 $onboarding_steps = array();
471 if ( isset( $state['onboarding_steps'] ) && is_array( $state['onboarding_steps'] ) ) {
472 foreach ( $state['onboarding_steps'] as $step_id => $step_epoch ) {
473 $onboarding_steps[ (string) $step_id ] = mlsimport_telemetry_iso( (int) $step_epoch );
474 }
475 }
476
477 // WP cron working: daily event is scheduled.
478 if ( function_exists( 'wp_next_scheduled' ) ) {
479 $wp_cron_working = ( false !== wp_next_scheduled( 'mlsimport_daily_telemetry_event' ) ) ||
480 ( false !== wp_next_scheduled( 'event_mls_import_auto' ) );
481 } else {
482 $wp_cron_working = false;
483 }
484
485 // --- output: active listings ---
486 $post_type = 'estate_property';
487 global $mlsimport;
488 if (
489 isset( $mlsimport ) &&
490 isset( $mlsimport->admin ) &&
491 isset( $mlsimport->admin->env_data ) &&
492 is_object( $mlsimport->admin->env_data ) &&
493 method_exists( $mlsimport->admin->env_data, 'get_property_post_type' )
494 ) {
495 $post_type = $mlsimport->admin->env_data->get_property_post_type();
496 }
497
498 $active_listings = 0;
499 if ( class_exists( 'WP_Query' ) ) {
500 $active_count_query = new WP_Query( array(
501 'post_type' => $post_type,
502 'post_status' => 'publish',
503 'posts_per_page' => 1,
504 'fields' => 'ids',
505 'no_found_rows' => false,
506 ) );
507 $active_listings = (int) $active_count_query->found_posts;
508 }
509
510 // --- data completeness ---
511 $completeness = mlsimport_telemetry_sample_completeness();
512
513 // --- configuration: import tasks ---
514 $raw_tasks_query = function_exists( 'get_posts' ) ? get_posts( array(
515 'post_type' => 'mlsimport_item',
516 'post_status' => 'any',
517 'posts_per_page' => -1,
518 'fields' => 'ids',
519 'no_found_rows' => true,
520 ) ) : array();
521
522 $import_tasks = array();
523 $auto_update_any = false;
524 foreach ( $raw_tasks_query as $task_id ) {
525 $how_many = (int) get_post_meta( $task_id, 'mlsimport_item_how_many', true );
526 $stat_cron = (int) get_post_meta( $task_id, 'mlsimport_item_stat_cron', true );
527 $auto_upd = ( 1 === $stat_cron );
528 if ( $auto_upd ) {
529 $auto_update_any = true;
530 }
531 $import_tasks[] = array(
532 'import_limit' => $how_many,
533 'auto_update' => $auto_upd,
534 );
535 }
536
537 // --- MLS provider / ID ---
538 $mls_provider = '';
539 $mls_id = 0;
540 if ( isset( $opts['mlsimport_mls_name'] ) && '' !== $opts['mlsimport_mls_name'] ) {
541 $mls_id = (int) $opts['mlsimport_mls_name'];
542 }
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
554 // Theme label.
555 $theme_label = '';
556 if (
557 isset( $mlsimport ) &&
558 isset( $mlsimport->admin ) &&
559 isset( $mlsimport->admin->env_data ) &&
560 is_object( $mlsimport->admin->env_data )
561 ) {
562 $env_class = get_class( $mlsimport->admin->env_data );
563 $theme_label = ( 'stdClass' !== $env_class ) ? $env_class : '';
564 }
565
566 // The real plugin stores the account name under 'mlsimport_username'.
567 // The unit test bootstrap seeds it under 'account' (legacy key).
568 // Read both; prefer 'mlsimport_username' (canonical).
569 if ( ! empty( $opts['mlsimport_username'] ) ) {
570 $account = (string) $opts['mlsimport_username'];
571 } elseif ( ! empty( $opts['account'] ) ) {
572 $account = (string) $opts['account'];
573 } else {
574 $account = '';
575 }
576
577 return array(
578 'event_type' => 'daily_telemetry',
579 'reported_at' => gmdate( 'Y-m-d\TH:i:s\Z' ),
580 'install' => array(
581 'install_id' => (string) $opts['mlsimport_install_uuid'],
582 'account' => $account,
583 'site_url' => (string) home_url(),
584 ),
585 'sync_health' => array(
586 'last_successful_sync' => mlsimport_telemetry_iso( $last_sync_success ),
587 'last_failed_sync' => mlsimport_telemetry_iso( $last_sync_failed ),
588 'last_failure_code' => $last_sync_failed_code,
589 'syncs_last_7_days' => (int) $sums['syncs'],
590 'token_refresh_failures_last_7_days' => (int) $sums['token_failures'],
591 'wp_cron_working' => (bool) $wp_cron_working,
592 ),
593 'feed' => array(
594 'listings_found_in_feed' => $last_feed_found,
595 ),
596 'output' => array(
597 'imported_last_7_days' => (int) $sums['imported'],
598 'updated_last_7_days' => (int) $sums['updated'],
599 'deleted_last_7_days' => (int) $sums['deleted'],
600 'active_listings_on_site' => $active_listings,
601 'data_completeness' => array(
602 'with_photos_percent' => (int) $completeness['with_photos_percent'],
603 'with_price_percent' => (int) $completeness['with_price_percent'],
604 'with_address_percent' => (int) $completeness['with_address_percent'],
605 'with_coordinates_percent' => (int) $completeness['with_coordinates_percent'],
606 ),
607 ),
608 'engagement' => array(
609 'last_admin_page_view' => mlsimport_telemetry_iso( $last_admin_load ),
610 'last_import_task_page_view' => mlsimport_telemetry_iso( $last_import_task_load ),
611 ),
612 'configuration' => array(
613 'mls_provider' => $mls_provider,
614 'mls_id' => $mls_id,
615 'import_tasks' => $import_tasks,
616 'import_tasks_count' => count( $import_tasks ),
617 'auto_update_enabled' => (bool) $auto_update_any,
618 ),
619 'environment' => array(
620 'plugin_version' => defined( 'MLSIMPORT_VERSION' ) ? MLSIMPORT_VERSION : '',
621 'php_version' => PHP_VERSION,
622 'wordpress_version' => get_bloginfo( 'version' ),
623 'theme' => $theme_label,
624 ),
625 'lifecycle' => array(
626 'installed_at' => mlsimport_telemetry_iso( $installed_at ),
627 'account_connected_at' => mlsimport_telemetry_iso( $account_connected_at ),
628 'mls_connected_at' => mlsimport_telemetry_iso( $mls_connected_at ),
629 'last_field_management' => mlsimport_telemetry_iso( $last_field_mgmt ),
630 'onboarding_steps' => (object) $onboarding_steps,
631 ),
632 );
633 }
634
635 // ---------------------------------------------------------------------------
636 // §1 Daily cron handler
637 // ---------------------------------------------------------------------------
638
639 /**
640 * Daily cron handler. No-op if already sent today. Builds the payload and calls
641 * ThemeImport::globalApiRequestSaasFireAndForget('user-activity', $payload) inside
642 * try/catch(\Throwable). Sets mlsimport_telemetry_last_sent on a non-false return.
643 * Hooked to 'mlsimport_daily_telemetry_event'.
644 *
645 * @return void
646 */
647 function mlsimport_telemetry_run_daily(): void {
648 $last_sent = (string) get_option( 'mlsimport_telemetry_last_sent', '' );
649 $today = gmdate( 'Y-m-d' );
650
651 if ( mlsimport_telemetry_already_sent_today( $last_sent, $today ) ) {
652 return;
653 }
654
655 $payload = mlsimport_telemetry_collect_payload();
656
657 try {
658 $result = ThemeImport::globalApiRequestSaasFireAndForget( 'user-activity', $payload );
659 } catch ( \Throwable $e ) {
660 // Fire-and-forget: transport errors are silently discarded.
661 return;
662 }
663
664 if ( false !== $result ) {
665 update_option( 'mlsimport_telemetry_last_sent', $today, false );
666 }
667 }
668
669 // ---------------------------------------------------------------------------
670 // §1 Admin engagement tracker (registered on 'admin_init')
671 // ---------------------------------------------------------------------------
672
673 /**
674 * Record admin-page engagement timestamps. Updates last_admin_load (and
675 * last_import_task_load on the Import Task editor) only when the stored value is
676 * older than 10 minutes. Hooked to 'admin_init'.
677 *
678 * Throttle logic (Pre-mortem Scenario 4):
679 * - A missing stored timestamp is treated as epoch 0 (far in the past), so the
680 * very first admin page view writes once.
681 * - Subsequent views within the 10-minute window do not write again.
682 *
683 * @return void
684 */
685 function mlsimport_telemetry_track_admin_load(): void {
686 // Only run on genuine admin requests — skip AJAX, CLI, cron.
687 if ( ! is_admin() || wp_doing_ajax() || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
688 return;
689 }
690
691 // Detect the screen from $pagenow + request vars. get_current_screen() is
692 // not yet populated on 'admin_init', so a screen-object lookup misses every
693 // real page load — $pagenow and $_GET are reliably set this early.
694 global $pagenow;
695 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
696 $post_type = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : '';
697
698 // Import Task list / editor — edit.php, post-new.php, or post.php for the
699 // mlsimport_item CPT.
700 $is_import_task_screen = (
701 ( ( 'edit.php' === $pagenow || 'post-new.php' === $pagenow ) && 'mlsimport_item' === $post_type ) ||
702 ( 'post.php' === $pagenow && isset( $_GET['post'] ) && 'mlsimport_item' === get_post_type( (int) $_GET['post'] ) )
703 );
704
705 // Any MLSImport admin screen — a plugin menu page or the import-task editor.
706 $is_mlsimport_screen = ( $is_import_task_screen || 0 === strpos( $page, 'mlsimport' ) );
707
708 if ( ! $is_mlsimport_screen ) {
709 return;
710 }
711
712 $state = get_option( 'mlsimport_telemetry_state', array() );
713 if ( ! is_array( $state ) ) {
714 $state = array();
715 }
716
717 $now = time();
718 $threshold = 10 * MINUTE_IN_SECONDS; // 600 seconds.
719 $did_write = false;
720
721 // Throttle: only update last_admin_load when stored value is older than 10 min.
722 // A missing key defaults to 0, which is always older than 10 min — writes once.
723 $stored_admin = isset( $state['last_admin_load'] ) ? (int) $state['last_admin_load'] : 0;
724 if ( ( $now - $stored_admin ) >= $threshold ) {
725 $state['last_admin_load'] = $now;
726 $did_write = true;
727 }
728
729 if ( $is_import_task_screen ) {
730 $stored_task = isset( $state['last_import_task_load'] ) ? (int) $state['last_import_task_load'] : 0;
731 if ( ( $now - $stored_task ) >= $threshold ) {
732 $state['last_import_task_load'] = $now;
733 $did_write = true;
734 }
735 }
736
737 if ( $did_write ) {
738 update_option( 'mlsimport_telemetry_state', $state, false );
739 }
740 }
741
742 add_action( 'admin_init', 'mlsimport_telemetry_track_admin_load' );
743
744 /**
745 * Record import-field management activity. Fires on the field-selector
746 * progressive-save AJAX actions; throttled to one write per 10 minutes so a
747 * burst of chunked field saves causes a single option write. autoload = 'no'.
748 *
749 * @return void
750 */
751 function mlsimport_telemetry_track_field_management(): void {
752 $state = get_option( 'mlsimport_telemetry_state', array() );
753 if ( ! is_array( $state ) ) {
754 $state = array();
755 }
756 $now = time();
757 $stored = isset( $state['last_field_management'] ) ? (int) $state['last_field_management'] : 0;
758 if ( ( $now - $stored ) < 10 * MINUTE_IN_SECONDS ) {
759 return;
760 }
761 $state['last_field_management'] = $now;
762 update_option( 'mlsimport_telemetry_state', $state, false );
763 }
764
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 );
779