# mlsimport/7.1.1/includes/mlsimport-telemetry.php

MLSImport: IDX Plugin &amp; MLS Plugin for Real Estate Listings, version 7.1.1. 926 lines.

- Page: https://pluginprobe.com/plugins/mlsimport/7.1.1/code/includes/mlsimport-telemetry.php
- Raw: https://pluginprobe.com/plugins/mlsimport/7.1.1/raw/includes/mlsimport-telemetry.php
- Modified: 2026-08-25T08:18:58+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/mlsimport/7.1.1/code/includes/mlsimport-telemetry.php#L10-L20`.

```php
<?php
/**
 * MLSImport Daily Telemetry Heartbeat
 *
 * Accumulates per-request import/sync counters in memory, flushes them once per
 * request (on `shutdown`) into rolling daily wp_options buckets, and POSTs a
 * structured heartbeat payload to the SaaS `user-activity` endpoint once per UTC day.
 *
 * Procedural include — matches the style of help_functions.php and
 * mlsimport-onboarding.php. No class wrapper.
 *
 * @link       https://mlsimport.com/
 * @since      6.3.0
 *
 * @package    Mlsimport
 * @subpackage Mlsimport/includes
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

// WordPress defines DAY_IN_SECONDS = 86400; provide a fallback for unit-test
// environments that load this file without the full WP bootstrap.
if ( ! defined( 'DAY_IN_SECONDS' ) ) {
	define( 'DAY_IN_SECONDS', 86400 );
}

// WordPress defines MINUTE_IN_SECONDS = 60; same fallback pattern.
if ( ! defined( 'MINUTE_IN_SECONDS' ) ) {
	define( 'MINUTE_IN_SECONDS', 60 );
}

// ---------------------------------------------------------------------------
// Request-scoped accumulator
// ---------------------------------------------------------------------------

/**
 * In-memory counter deltas for the current request.
 * Keys: imported | updated | deleted | syncs | token_failures.
 * Written to wp_options exactly once — on shutdown — by mlsimport_telemetry_flush().
 *
 * @var array<string,int>
 */
$mlsimport_telemetry_pending = array();

// ---------------------------------------------------------------------------
// §1 Public API — counter accumulator
// ---------------------------------------------------------------------------

/**
 * Add an in-memory counter delta for the current request.
 * Allowed $metric: 'imported' | 'updated' | 'deleted' | 'syncs' | 'token_failures'.
 * No DB access — deltas are written to wp_options once, on shutdown, by flush().
 *
 * @param string $metric One of the five allowed metric keys.
 * @param int    $amount Amount to add (default 1).
 * @return void
 */
function mlsimport_telemetry_bump( string $metric, int $amount = 1 ): void {
	// Whitelist of accepted metric keys.
	$allowed = array( 'imported', 'updated', 'deleted', 'syncs', 'token_failures' );
	// Guard: silently ignore an unknown metric key.
	if ( ! in_array( $metric, $allowed, true ) ) {
		return;
	}
	// Reach the request-scoped accumulator.
	global $mlsimport_telemetry_pending;
	// Lazily zero-initialise this metric's slot on first use.
	if ( ! isset( $mlsimport_telemetry_pending[ $metric ] ) ) {
		$mlsimport_telemetry_pending[ $metric ] = 0;
	}
	// Add the delta (no DB touch here — flush writes on shutdown).
	$mlsimport_telemetry_pending[ $metric ] += $amount;
}

// ---------------------------------------------------------------------------
// §1 Public API — flush (registered on 'shutdown')
// ---------------------------------------------------------------------------

/**
 * Flush accumulated counter deltas into today's daily bucket.
 * No-op when nothing is pending. Reads + writes the single option
 * 'mlsimport_telemetry_state' exactly once, prunes buckets older than 8 days,
 * resets the pending array. Registered on the 'shutdown' action.
 *
 * @return void
 */
function mlsimport_telemetry_flush(): void {
	// Reach the request-scoped accumulator.
	global $mlsimport_telemetry_pending;

	// Nothing accumulated this request — do not read or write the option.
	if ( empty( $mlsimport_telemetry_pending ) ) {
		return;
	}

	// Load the persisted state; coerce a corrupt/legacy value back to an array.
	$state = get_option( 'mlsimport_telemetry_state', array() );
	if ( ! is_array( $state ) ) {
		$state = array();
	}

	// Ensure the daily bucket map exists.
	if ( ! isset( $state['daily'] ) || ! is_array( $state['daily'] ) ) {
		$state['daily'] = array();
	}

	// Today's UTC date is the bucket key; start from any existing bucket.
	$today  = gmdate( 'Y-m-d' );
	$bucket = isset( $state['daily'][ $today ] ) ? $state['daily'][ $today ] : array();

	// Initialise zero-base for all five counters in this bucket.
	$defaults = array(
		'imported'       => 0,
		'updated'        => 0,
		'deleted'        => 0,
		'syncs'          => 0,
		'token_failures' => 0,
	);
	// Fill any missing counters with 0 while keeping already-accumulated values.
	$bucket = array_merge( $defaults, $bucket );

	// Fold this request's pending deltas into today's bucket.
	foreach ( $mlsimport_telemetry_pending as $metric => $delta ) {
		if ( isset( $bucket[ $metric ] ) ) {
			$bucket[ $metric ] += $delta;
		}
	}

	// Store the updated bucket, then drop buckets older than the retention window.
	$state['daily'][ $today ] = $bucket;
	$state['daily']           = mlsimport_telemetry_prune_buckets( $state['daily'], $today );

	// Single write, non-autoloaded.
	update_option( 'mlsimport_telemetry_state', $state, false );

	// Reset pending.
	$mlsimport_telemetry_pending = array();
}

add_action( 'shutdown', 'mlsimport_telemetry_flush' );

// ---------------------------------------------------------------------------
// §1 Public API — immediate key setter
// ---------------------------------------------------------------------------

/**
 * Set a non-counter "last X" field on mlsimport_telemetry_state.
 * $key ∈ last_sync_success | last_sync_failed | last_sync_failed_code |
 *        last_sync_attempt | last_feed_found | last_admin_load | last_import_task_load.
 * Immediate small read-modify-write; option saved with autoload = 'no'.
 *
 * @param string $key   The state key to set.
 * @param mixed  $value The value to store.
 * @return void
 */
function mlsimport_telemetry_set( string $key, $value ): void {
	// Load the persisted state; coerce a non-array back to an array.
	$state = get_option( 'mlsimport_telemetry_state', array() );
	if ( ! is_array( $state ) ) {
		$state = array();
	}
	// Overwrite the key unconditionally, then persist (non-autoloaded).
	$state[ $key ] = $value;
	update_option( 'mlsimport_telemetry_state', $state, false );
}

/**
 * Set a "first time only" lifecycle stamp on mlsimport_telemetry_state.
 * Unlike mlsimport_telemetry_set(), this is a no-op when $key already holds a
 * non-empty value — the first occurrence wins. Used for installed_at /
 * account_connected_at / mls_connected_at. Saved with autoload = 'no'.
 *
 * @param string $key   The state key to set once.
 * @param mixed  $value The value to store on the first call.
 * @return void
 */
function mlsimport_telemetry_set_once( string $key, $value ): void {
	// Load the persisted state; coerce a non-array back to an array.
	$state = get_option( 'mlsimport_telemetry_state', array() );
	if ( ! is_array( $state ) ) {
		$state = array();
	}
	// First occurrence wins — bail if the stamp already holds a non-empty value.
	if ( ! empty( $state[ $key ] ) ) {
		return;
	}
	// Record the value and persist (non-autoloaded).
	$state[ $key ] = $value;
	update_option( 'mlsimport_telemetry_state', $state, false );
}

/**
 * Record the outcome of one listings request into sync-health telemetry
 * (GitHub issue #207).
 *
 * Called from the single choke point every import path routes through
 * (Mlsimport_Admin::mlsimport_make_listing_requests()), with the already
 * normalized API answer. Stamps last_sync_success when the pull returned a
 * feed, so a cron run that dies later in its loop still leaves fresh
 * success evidence — the previous end-of-loop-only stamp left actively
 * syncing sites reporting last_successful_sync = "never".
 *
 * @param mixed $answer The normalized listings API answer array.
 * @return void
 */
function mlsimport_telemetry_record_sync_result( $answer ): void {
	// A successful pull always carries the feed count under 'results'.
	if ( is_array( $answer ) && isset( $answer['results'] ) ) {
		mlsimport_telemetry_set( 'last_sync_success', time() );
		return;
	}
	// Anything else is a failed pull: stamp when it happened and a real
	// failure class — previously every failure surfaced as "unknown".
	mlsimport_telemetry_set( 'last_sync_failed', time() );
	mlsimport_telemetry_set( 'last_sync_failed_code', mlsimport_telemetry_classify_sync_failure( $answer ) );
}

/**
 * Map a failed listings answer to a short failure class for
 * sync_health.last_failure_code. Pure — inspects only the answer shape and
 * the message strings globalApiRequestCurlSaas() actually produces.
 *
 * @param mixed $answer The normalized failed listings API answer.
 * @return string One of the short failure-class codes.
 */
function mlsimport_telemetry_classify_sync_failure( $answer ): string {
	// A provider-rule rejection already carries a machine code under 'type'
	// (set by mlsimport_make_listing_requests()) — pass it through as-is.
	if ( is_array( $answer ) && ! empty( $answer['type'] ) ) {
		return (string) $answer['type'];
	}
	$message = is_array( $answer ) && isset( $answer['message'] ) ? (string) $answer['message'] : '';
	// The exact string ThemeImport returns when the SaaS JWT cannot be
	// minted/refreshed (bad account credentials, token endpoint down).
	if ( 'Token validation failed' === $message ) {
		return 'token';
	}
	// WP_Error transport messages pass through verbatim; cURL timeouts read
	// 'cURL error 28: Operation timed out after N milliseconds ...'.
	if ( false !== stripos( $message, 'timed out' ) ) {
		return 'timeout';
	}
	// AWS API Gateway rejections decode to {"message":"Unauthorized"} /
	// {"message":"Forbidden"} with no 'results' key.
	if ( false !== stripos( $message, 'unauthorized' ) || false !== stripos( $message, 'forbidden' ) ) {
		return 'auth';
	}
	return 'api_error';
}

/**
 * Record the first-completion time of an onboarding-wizard step into the
 * 'onboarding_steps' map on mlsimport_telemetry_state. First completion wins;
 * re-running a step does not move the timestamp. Saved with autoload = 'no'.
 *
 * @param string $step The onboarding step ID (e.g. 'account', 'field-mapping').
 * @return void
 */
function mlsimport_telemetry_mark_onboarding_step( string $step ): void {
	// Guard: ignore an empty step id.
	if ( '' === $step ) {
		return;
	}
	// Load the persisted state; coerce a non-array back to an array.
	$state = get_option( 'mlsimport_telemetry_state', array() );
	if ( ! is_array( $state ) ) {
		$state = array();
	}
	// Ensure the onboarding-steps map exists.
	if ( ! isset( $state['onboarding_steps'] ) || ! is_array( $state['onboarding_steps'] ) ) {
		$state['onboarding_steps'] = array();
	}
	// First completion wins — do not move an existing timestamp.
	if ( isset( $state['onboarding_steps'][ $step ] ) ) {
		return;
	}
	// Stamp the step with the current epoch and persist (non-autoloaded).
	$state['onboarding_steps'][ $step ] = time();
	update_option( 'mlsimport_telemetry_state', $state, false );
}

// ---------------------------------------------------------------------------
// §1 Import performance snapshot (GitHub issue #216)
// ---------------------------------------------------------------------------

/**
 * Build the import-performance snapshot for one finished Import Run. Pure.
 *
 * Answers support's "is it us or the host?" question from data the run
 * machinery already tracks:
 * - elapsed_seconds: wall time from the run's started_at to its finish, across
 *   every chunk worker — not just the finishing request.
 * - workers: 1 + chunk hand-offs + watchdog revivals. Any revival means a
 *   worker died without handing off, i.e. the host killed it.
 * - queue_depth: pending worker actions at finish — backlog evidence.
 * - peak_memory_mb: peak PHP memory of the finishing worker.
 *
 * @param array $run               Final Import Run record (started_at, source,
 *                                 expected, handoffs, revive_count).
 * @param array $result            Final public Import Run Result.
 * @param int   $now               Finish time (Unix epoch).
 * @param int   $peak_memory_bytes memory_get_peak_usage(true) of the finisher.
 * @param int   $queue_depth       Pending worker actions for the import hook.
 * @return array<string,int|string> The snapshot stored under 'last_import_run'.
 */
function mlsimport_telemetry_import_run_snapshot( array $run, array $result, int $now, int $peak_memory_bytes, int $queue_depth ): array {
	// Wall time across the whole worker chain; guard against a missing or
	// future started_at leaving a negative duration.
	$started_at = (int) ( $run['started_at'] ?? $now );
	return array(
		'source'          => (string) ( $run['source'] ?? '' ),
		'state'           => (string) ( $result['state'] ?? '' ),
		'expected'        => (int) ( $run['expected'] ?? 0 ),
		'saved'           => (int) ( $result['saved'] ?? 0 ),
		'failed'          => (int) ( $result['failed'] ?? 0 ),
		'elapsed_seconds' => max( 0, $now - $started_at ),
		// One initial worker, plus one per chunk hand-off, plus one per
		// watchdog revival (a revival is a worker the host killed).
		'workers'         => 1 + (int) ( $run['handoffs'] ?? 0 ) + (int) ( $run['revive_count'] ?? 0 ),
		'peak_memory_mb'  => (int) round( $peak_memory_bytes / 1048576 ),
		'queue_depth'     => $queue_depth,
		'finished_at'     => $now,
	);
}

// ---------------------------------------------------------------------------
// §1 Pure helpers
// ---------------------------------------------------------------------------

/**
 * Positive epoch -> "Y-m-d\TH:i:s\Z" (UTC). 0 / empty -> null. Pure.
 *
 * @param int $epoch Unix timestamp.
 * @return string|null ISO 8601 UTC string or null.
 */
function mlsimport_telemetry_iso( int $epoch ): ?string {
	// Non-positive epoch means "never" — represent as null.
	if ( $epoch <= 0 ) {
		return null;
	}
	// Format the epoch as an ISO 8601 UTC string.
	return gmdate( 'Y-m-d\TH:i:s\Z', $epoch );
}

/**
 * Drop daily-bucket keys older than $keep_days relative to $today. Pure.
 *
 * @param array  $daily     Daily bucket map (YYYY-MM-DD => array).
 * @param string $today     Reference date string 'Y-m-d'.
 * @param int    $keep_days Number of days to keep (default 8).
 * @return array Pruned daily map.
 */
function mlsimport_telemetry_prune_buckets( array $daily, string $today, int $keep_days = 8 ): array {
	// Compute the oldest date to keep (today minus the retention window).
	$cutoff = gmdate( 'Y-m-d', strtotime( $today ) - ( $keep_days * DAY_IN_SECONDS ) );
	// Drop any bucket whose date string sorts before the cutoff.
	foreach ( array_keys( $daily ) as $date ) {
		if ( $date < $cutoff ) {
			unset( $daily[ $date ] );
		}
	}
	return $daily;
}

/**
 * Sum the last $days daily buckets ending at $today.
 * Returns [ 'imported'=>int, 'updated'=>int, 'deleted'=>int, 'syncs'=>int,
 *           'token_failures'=>int ]. Pure.
 *
 * @param array  $daily Daily bucket map.
 * @param string $today Reference date string 'Y-m-d'.
 * @param int    $days  Number of days to sum (default 7).
 * @return array<string,int> Summed counters.
 */
function mlsimport_telemetry_sum_buckets( array $daily, string $today, int $days = 7 ): array {
	$sums = array(
		'imported'       => 0,
		'updated'        => 0,
		'deleted'        => 0,
		'syncs'          => 0,
		'token_failures' => 0,
	);

	// Walk back $days days from $today, accumulating each present bucket.
	for ( $i = 0; $i < $days; $i++ ) {
		// The date for this step back from today.
		$date = gmdate( 'Y-m-d', strtotime( $today ) - ( $i * DAY_IN_SECONDS ) );
		// Skip a missing or malformed bucket.
		if ( ! isset( $daily[ $date ] ) || ! is_array( $daily[ $date ] ) ) {
			continue;
		}
		// Add each counter this bucket carries into the running totals.
		foreach ( $sums as $key => $_ ) {
			if ( isset( $daily[ $date ][ $key ] ) ) {
				$sums[ $key ] += (int) $daily[ $date ][ $key ];
			}
		}
	}

	return $sums;
}

/**
 * True when $last_sent equals $today (UTC 'Y-m-d' strings). Pure.
 *
 * @param string $last_sent Previously stored send date.
 * @param string $today     Today's UTC date.
 * @return bool
 */
function mlsimport_telemetry_already_sent_today( string $last_sent, string $today ): bool {
	return $last_sent === $today;
}

// ---------------------------------------------------------------------------
// §1 Completeness sampler
// ---------------------------------------------------------------------------

/**
 * Per-theme meta keys used for data-completeness checks.
 * Keys: price, address, coordinate.
 *
 * WpResidence / WpEstate: use shared RESO-mapped meta names.
 * Houzez: coordinates are stored in a combined `fave_property_location` meta.
 * RealHomes: coordinates are stored in `REAL_HOMES_property_location`.
 *
 * @return array<string,array<string,string>>
 */
function mlsimport_telemetry_theme_meta_map(): array {
	return array(
		// WpResidence (991) and WpEstate (994) share the same RESO-mapped meta names.
		'ResidenceClass' => array(
			'price'      => 'property_price',
			'address'    => 'property_address',
			'coordinate' => 'property_latitude',
		),
		'EstateClass'    => array(
			'price'      => 'property_price',
			'address'    => 'property_address',
			'coordinate' => 'property_latitude',
		),
		// Houzez (992): uses fave_property_location for combined lat,lng.
		'HouzezClass'    => array(
			'price'      => 'property_price',
			'address'    => 'property_address',
			'coordinate' => 'fave_property_location',
		),
		// RealHomes (993): uses REAL_HOMES_property_location for combined lat,lng.
		'RealHomesClass' => array(
			'price'      => 'property_price',
			'address'    => 'property_address',
			'coordinate' => 'REAL_HOMES_property_location',
		),
	);
}

/**
 * Sample the 20 most-recent property posts holding a 'ListingKey' meta.
 * Returns [ 'with_photos_percent'=>int, 'with_price_percent'=>int,
 *           'with_address_percent'=>int, 'with_coordinates_percent'=>int ]
 * (integer percentages 0-100; all 0 when the sample is empty).
 *
 * @return array<string,int>
 */
function mlsimport_telemetry_sample_completeness(): array {
	$empty = array(
		'with_photos_percent'      => 0,
		'with_price_percent'       => 0,
		'with_address_percent'     => 0,
		'with_coordinates_percent' => 0,
	);

	// Resolve the active theme adapter class.
	global $mlsimport;
	$env_class = '';
	if (
		isset( $mlsimport ) &&
		isset( $mlsimport->admin ) &&
		isset( $mlsimport->admin->env_data ) &&
		is_object( $mlsimport->admin->env_data )
	) {
		$env_class = get_class( $mlsimport->admin->env_data );
	}

	$meta_map = mlsimport_telemetry_theme_meta_map();
	// Default fallback — shared RESO keys used by WpResidence / WpEstate.
	$keys = isset( $meta_map[ $env_class ] )
		? $meta_map[ $env_class ]
		: array(
			'price'      => 'property_price',
			'address'    => 'property_address',
			'coordinate' => 'property_latitude',
		);

	// Determine the post type from the adapter; fall back to 'estate_property'.
	$post_type = 'estate_property';
	if (
		isset( $mlsimport ) &&
		isset( $mlsimport->admin ) &&
		isset( $mlsimport->admin->env_data ) &&
		is_object( $mlsimport->admin->env_data ) &&
		method_exists( $mlsimport->admin->env_data, 'get_property_post_type' )
	) {
		$post_type = $mlsimport->admin->env_data->get_property_post_type();
	}

	// Fetch the 20 most-recent posts that have a ListingKey meta.
	$args = array(
		'post_type'      => $post_type,
		'post_status'    => 'any',
		'posts_per_page' => 20,
		'fields'         => 'ids',
		'orderby'        => 'date',
		'order'          => 'DESC',
		'meta_query'     => array(
			array(
				'key'     => '_mlsimport_listing_key',
				'compare' => 'EXISTS',
			),
		),
		'no_found_rows'  => true,
	);

	// Run the query (guard for environments without get_posts()).
	$post_ids = function_exists( 'get_posts' ) ? get_posts( $args ) : array();

	// No sample — return all-zero percentages.
	if ( empty( $post_ids ) ) {
		return $empty;
	}

	// Denominator + per-field hit counters.
	$total       = count( $post_ids );
	$photos      = 0;
	$price       = 0;
	$address     = 0;
	$coordinates = 0;

	// Tally how many sampled posts carry each field.
	foreach ( $post_ids as $pid ) {
		// Featured image present?
		if ( has_post_thumbnail( $pid ) ) {
			$photos++;
		}
		// Price meta non-empty?
		if ( '' !== get_post_meta( $pid, $keys['price'], true ) ) {
			$price++;
		}
		// Address meta non-empty?
		if ( '' !== get_post_meta( $pid, $keys['address'], true ) ) {
			$address++;
		}
		// Coordinate meta non-empty?
		if ( '' !== get_post_meta( $pid, $keys['coordinate'], true ) ) {
			$coordinates++;
		}
	}

	// Convert each tally to an integer 0-100 percentage of the sample.
	return array(
		'with_photos_percent'      => (int) round( $photos / $total * 100 ),
		'with_price_percent'       => (int) round( $price / $total * 100 ),
		'with_address_percent'     => (int) round( $address / $total * 100 ),
		'with_coordinates_percent' => (int) round( $coordinates / $total * 100 ),
	);
}

// ---------------------------------------------------------------------------
// §1 Payload collector
// ---------------------------------------------------------------------------

/**
 * Build the full human-readable heartbeat payload (see §5 for the shape).
 * Converts stored epochs to ISO 8601 via mlsimport_telemetry_iso().
 * Generates + persists mlsimport_admin_options['mlsimport_install_uuid'] if absent.
 *
 * @return array The structured heartbeat payload.
 */
function mlsimport_telemetry_collect_payload(): array {
	// --- Install UUID ---
	$opts = get_option( 'mlsimport_admin_options', array() );
	if ( ! is_array( $opts ) ) {
		$opts = array();
	}
	if ( empty( $opts['mlsimport_install_uuid'] ) ) {
		$opts['mlsimport_install_uuid'] = wp_generate_uuid4();
		update_option( 'mlsimport_admin_options', $opts );
	}

	// --- Telemetry state ---
	$state = get_option( 'mlsimport_telemetry_state', array() );
	if ( ! is_array( $state ) ) {
		$state = array();
	}
	$daily = isset( $state['daily'] ) && is_array( $state['daily'] ) ? $state['daily'] : array();

	$today  = gmdate( 'Y-m-d' );
	$sums   = mlsimport_telemetry_sum_buckets( $daily, $today, 7 );

	// --- sync_health ---
	$last_sync_success      = isset( $state['last_sync_success'] ) ? (int) $state['last_sync_success'] : 0;
	$last_sync_failed       = isset( $state['last_sync_failed'] ) ? (int) $state['last_sync_failed'] : 0;
	$last_sync_failed_code  = isset( $state['last_sync_failed_code'] ) ? (string) $state['last_sync_failed_code'] : '';
	$last_feed_found        = isset( $state['last_feed_found'] ) ? (int) $state['last_feed_found'] : 0;
	$last_admin_load        = isset( $state['last_admin_load'] ) ? (int) $state['last_admin_load'] : 0;
	$last_import_task_load  = isset( $state['last_import_task_load'] ) ? (int) $state['last_import_task_load'] : 0;

	// --- lifecycle / onboarding funnel ---
	$installed_at         = isset( $state['installed_at'] ) ? (int) $state['installed_at'] : 0;
	$account_connected_at = isset( $state['account_connected_at'] ) ? (int) $state['account_connected_at'] : 0;
	$mls_connected_at     = isset( $state['mls_connected_at'] ) ? (int) $state['mls_connected_at'] : 0;
	$last_field_mgmt      = isset( $state['last_field_management'] ) ? (int) $state['last_field_management'] : 0;
	$onboarding_steps     = array();
	if ( isset( $state['onboarding_steps'] ) && is_array( $state['onboarding_steps'] ) ) {
		foreach ( $state['onboarding_steps'] as $step_id => $step_epoch ) {
			$onboarding_steps[ (string) $step_id ] = mlsimport_telemetry_iso( (int) $step_epoch );
		}
	}

	// WP cron working: daily event is scheduled.
	if ( function_exists( 'wp_next_scheduled' ) ) {
		$wp_cron_working = ( false !== wp_next_scheduled( 'mlsimport_daily_telemetry_event' ) ) ||
		                   ( false !== wp_next_scheduled( 'event_mls_import_auto' ) );
	} else {
		$wp_cron_working = false;
	}

	// --- output: active listings ---
	$post_type = 'estate_property';
	global $mlsimport;
	if (
		isset( $mlsimport ) &&
		isset( $mlsimport->admin ) &&
		isset( $mlsimport->admin->env_data ) &&
		is_object( $mlsimport->admin->env_data ) &&
		method_exists( $mlsimport->admin->env_data, 'get_property_post_type' )
	) {
		$post_type = $mlsimport->admin->env_data->get_property_post_type();
	}

	$active_listings = 0;
	if ( class_exists( 'WP_Query' ) ) {
		$active_count_query = new WP_Query( array(
			'post_type'      => $post_type,
			'post_status'    => 'publish',
			'posts_per_page' => 1,
			'fields'         => 'ids',
			'no_found_rows'  => false,
		) );
		$active_listings = (int) $active_count_query->found_posts;
	}

	// --- data completeness ---
	$completeness = mlsimport_telemetry_sample_completeness();

	// --- import performance (issue #216) ---
	// The latest finished-run snapshot, recorded at finish_run(). Null means
	// no run has ever finished on this install — distinct from a missing field.
	$import_performance = null;
	if ( isset( $state['last_import_run'] ) && is_array( $state['last_import_run'] ) ) {
		$import_performance                = $state['last_import_run'];
		$import_performance['finished_at'] = mlsimport_telemetry_iso( (int) ( $import_performance['finished_at'] ?? 0 ) );
	}

	// --- configuration: import tasks ---
	$raw_tasks_query = function_exists( 'get_posts' ) ? get_posts( array(
		'post_type'      => 'mlsimport_item',
		'post_status'    => 'any',
		'posts_per_page' => -1,
		'fields'         => 'ids',
		'no_found_rows'  => true,
	) ) : array();

	$import_tasks       = array();
	$auto_update_any    = false;
	foreach ( $raw_tasks_query as $task_id ) {
		$how_many   = (int) get_post_meta( $task_id, 'mlsimport_item_how_many', true );
		$stat_cron  = (int) get_post_meta( $task_id, 'mlsimport_item_stat_cron', true );
		$auto_upd   = ( 1 === $stat_cron );
		if ( $auto_upd ) {
			$auto_update_any = true;
		}
		$import_tasks[] = array(
			'import_limit' => $how_many,
			'auto_update'  => $auto_upd,
		);
	}

	// --- MLS provider / ID ---
	$mls_provider = '';
	$mls_id       = 0;
	if ( isset( $opts['mlsimport_mls_name'] ) && '' !== $opts['mlsimport_mls_name'] ) {
		$mls_id = (int) $opts['mlsimport_mls_name'];
	}
	// Derive MLS provider label from the theme/MLS env class name if available.
	if (
		isset( $mlsimport ) &&
		isset( $mlsimport->admin ) &&
		isset( $mlsimport->admin->mls_env_data ) &&
		is_object( $mlsimport->admin->mls_env_data )
	) {
		$mls_class    = get_class( $mlsimport->admin->mls_env_data );
		$mls_provider = ( 'stdClass' !== $mls_class ) ? $mls_class : '';
	}

	// Theme label.
	$theme_label = '';
	if (
		isset( $mlsimport ) &&
		isset( $mlsimport->admin ) &&
		isset( $mlsimport->admin->env_data ) &&
		is_object( $mlsimport->admin->env_data )
	) {
		$env_class   = get_class( $mlsimport->admin->env_data );
		$theme_label = ( 'stdClass' !== $env_class ) ? $env_class : '';
	}

	// The real plugin stores the account name under 'mlsimport_username'.
	// The unit test bootstrap seeds it under 'account' (legacy key).
	// Read both; prefer 'mlsimport_username' (canonical).
	if ( ! empty( $opts['mlsimport_username'] ) ) {
		$account = (string) $opts['mlsimport_username'];
	} elseif ( ! empty( $opts['account'] ) ) {
		$account = (string) $opts['account'];
	} else {
		$account = '';
	}

	return array(
		'event_type'    => 'daily_telemetry',
		'reported_at'   => gmdate( 'Y-m-d\TH:i:s\Z' ),
		'install'       => array(
			'install_id' => (string) $opts['mlsimport_install_uuid'],
			'account'    => $account,
			'site_url'   => (string) home_url(),
		),
		'sync_health'   => array(
			'last_successful_sync'              => mlsimport_telemetry_iso( $last_sync_success ),
			'last_failed_sync'                  => mlsimport_telemetry_iso( $last_sync_failed ),
			'last_failure_code'                 => $last_sync_failed_code,
			'syncs_last_7_days'                 => (int) $sums['syncs'],
			'token_refresh_failures_last_7_days' => (int) $sums['token_failures'],
			'wp_cron_working'                   => (bool) $wp_cron_working,
		),
		'feed'          => array(
			'listings_found_in_feed' => $last_feed_found,
		),
		'output'        => array(
			'imported_last_7_days'   => (int) $sums['imported'],
			'updated_last_7_days'    => (int) $sums['updated'],
			'deleted_last_7_days'    => (int) $sums['deleted'],
			'active_listings_on_site' => $active_listings,
			'data_completeness'      => array(
				'with_photos_percent'      => (int) $completeness['with_photos_percent'],
				'with_price_percent'       => (int) $completeness['with_price_percent'],
				'with_address_percent'     => (int) $completeness['with_address_percent'],
				'with_coordinates_percent' => (int) $completeness['with_coordinates_percent'],
			),
		),
		'import_performance' => $import_performance,
		'engagement'    => array(
			'last_admin_page_view'       => mlsimport_telemetry_iso( $last_admin_load ),
			'last_import_task_page_view' => mlsimport_telemetry_iso( $last_import_task_load ),
		),
		'configuration' => array(
			'mls_provider'        => $mls_provider,
			'mls_id'              => $mls_id,
			'import_tasks'        => $import_tasks,
			'import_tasks_count'  => count( $import_tasks ),
			'auto_update_enabled' => (bool) $auto_update_any,
		),
		'environment'   => array(
			'plugin_version'    => defined( 'MLSIMPORT_VERSION' ) ? MLSIMPORT_VERSION : '',
			'php_version'       => PHP_VERSION,
			'wordpress_version' => get_bloginfo( 'version' ),
			'theme'             => $theme_label,
		),
		'lifecycle'     => array(
			'installed_at'          => mlsimport_telemetry_iso( $installed_at ),
			'account_connected_at'  => mlsimport_telemetry_iso( $account_connected_at ),
			'mls_connected_at'      => mlsimport_telemetry_iso( $mls_connected_at ),
			'last_field_management' => mlsimport_telemetry_iso( $last_field_mgmt ),
			'onboarding_steps'      => (object) $onboarding_steps,
		),
	);
}

// ---------------------------------------------------------------------------
// §1 Daily cron handler
// ---------------------------------------------------------------------------

/**
 * Daily cron handler. No-op if already sent today. Builds the payload and calls
 * ThemeImport::globalApiRequestSaasFireAndForget('user-activity', $payload) inside
 * try/catch(\Throwable). Sets mlsimport_telemetry_last_sent on a non-false return.
 * Hooked to 'mlsimport_daily_telemetry_event'.
 *
 * @return void
 */
function mlsimport_telemetry_run_daily(): void {
	$last_sent = (string) get_option( 'mlsimport_telemetry_last_sent', '' );
	$today     = gmdate( 'Y-m-d' );

	if ( mlsimport_telemetry_already_sent_today( $last_sent, $today ) ) {
		return;
	}

	$payload = mlsimport_telemetry_collect_payload();

	try {
		$result = ThemeImport::globalApiRequestSaasFireAndForget( 'user-activity', $payload );
	} catch ( \Throwable $e ) {
		// Fire-and-forget: transport errors are silently discarded.
		return;
	}

	if ( false !== $result ) {
		update_option( 'mlsimport_telemetry_last_sent', $today, false );
	}
}

// ---------------------------------------------------------------------------
// §1 Admin engagement tracker (registered on 'admin_init')
// ---------------------------------------------------------------------------

/**
 * Record admin-page engagement timestamps. Updates last_admin_load (and
 * last_import_task_load on the Import Task editor) only when the stored value is
 * older than 10 minutes. Hooked to 'admin_init'.
 *
 * Throttle logic (Pre-mortem Scenario 4):
 * - A missing stored timestamp is treated as epoch 0 (far in the past), so the
 *   very first admin page view writes once.
 * - Subsequent views within the 10-minute window do not write again.
 *
 * @return void
 */
function mlsimport_telemetry_track_admin_load(): void {
	// Only run on genuine admin requests — skip AJAX, CLI, cron.
	if ( ! is_admin() || wp_doing_ajax() || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
		return;
	}

	// Detect the screen from $pagenow + request vars. get_current_screen() is
	// not yet populated on 'admin_init', so a screen-object lookup misses every
	// real page load — $pagenow and $_GET are reliably set this early.
	global $pagenow;
	$page      = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
	$post_type = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : '';

	// Import Task list / editor — edit.php, post-new.php, or post.php for the
	// mlsimport_item CPT.
	$is_import_task_screen = (
		( ( 'edit.php' === $pagenow || 'post-new.php' === $pagenow ) && 'mlsimport_item' === $post_type ) ||
		( 'post.php' === $pagenow && isset( $_GET['post'] ) && 'mlsimport_item' === get_post_type( (int) $_GET['post'] ) )
	);

	// Any MLSImport admin screen — a plugin menu page or the import-task editor.
	$is_mlsimport_screen = ( $is_import_task_screen || 0 === strpos( $page, 'mlsimport' ) );

	if ( ! $is_mlsimport_screen ) {
		return;
	}

	$state = get_option( 'mlsimport_telemetry_state', array() );
	if ( ! is_array( $state ) ) {
		$state = array();
	}

	$now        = time();
	$threshold  = 10 * MINUTE_IN_SECONDS; // 600 seconds.
	$did_write  = false;

	// Throttle: only update last_admin_load when stored value is older than 10 min.
	// A missing key defaults to 0, which is always older than 10 min — writes once.
	$stored_admin = isset( $state['last_admin_load'] ) ? (int) $state['last_admin_load'] : 0;
	if ( ( $now - $stored_admin ) >= $threshold ) {
		$state['last_admin_load'] = $now;
		$did_write                = true;
	}

	if ( $is_import_task_screen ) {
		$stored_task = isset( $state['last_import_task_load'] ) ? (int) $state['last_import_task_load'] : 0;
		if ( ( $now - $stored_task ) >= $threshold ) {
			$state['last_import_task_load'] = $now;
			$did_write                      = true;
		}
	}

	if ( $did_write ) {
		update_option( 'mlsimport_telemetry_state', $state, false );
	}
}

add_action( 'admin_init', 'mlsimport_telemetry_track_admin_load' );

/**
 * Record import-field management activity. Fires on the field-selector
 * progressive-save AJAX actions; throttled to one write per 10 minutes so a
 * burst of chunked field saves causes a single option write. autoload = 'no'.
 *
 * @return void
 */
function mlsimport_telemetry_track_field_management(): void {
	// Load the persisted state; coerce a non-array back to an array.
	$state = get_option( 'mlsimport_telemetry_state', array() );
	if ( ! is_array( $state ) ) {
		$state = array();
	}
	// Current time and the last-recorded field-management stamp (missing = 0).
	$now    = time();
	$stored = isset( $state['last_field_management'] ) ? (int) $state['last_field_management'] : 0;
	// Throttle: skip if the last write was under 10 minutes ago.
	if ( ( $now - $stored ) < 10 * MINUTE_IN_SECONDS ) {
		return;
	}
	// Record the activity and persist (non-autoloaded).
	$state['last_field_management'] = $now;
	update_option( 'mlsimport_telemetry_state', $state, false );
}

// The single compact mutation endpoint is the Field Configuration activity
// seam. Priority 1 records activity before validation/persistence runs.
add_action( 'wp_ajax_mlsimport_change_field_configuration', 'mlsimport_telemetry_track_field_management', 1 );

```
