diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 index 88cd7f4e..04ebb242 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 @@ -49,7 +49,6 @@ param( $AgentBasePath = "$env:ProgramData\AutopilotMonitor" $AgentBinPath = "$AgentBasePath\Agent" $AgentConfigPath = "$AgentBasePath\Config" -$AgentRulesPath = "$AgentBasePath\Rules" $AgentSpoolPath = "$AgentBasePath\Spool" $AgentLogsPath = "$AgentBasePath\Logs" $TaskName = "AutopilotMonitor-Agent" @@ -59,7 +58,6 @@ $LogFile = "$AgentLogsPath\Bootstrap.log" New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null New-Item -Path $AgentConfigPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null -New-Item -Path $AgentRulesPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null New-Item -Path $AgentSpoolPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null New-Item -Path $AgentLogsPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs index e5a77327..8e450b47 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs @@ -129,6 +129,14 @@ namespace AutopilotMonitor.Agent.Core.Configuration /// public double SimulationSpeedFactor { get; set; } = 50; + /// + /// Wait time in seconds after ESP exit before marking Hello as skipped. + /// When ESP exits, we wait this duration for Hello wizard to start (event 62404). + /// Default: 30 seconds (reasonable for systems under load). + /// If Hello wizard starts within this window, we continue waiting for Hello completion (300/301). + /// + public int HelloWaitTimeoutSeconds { get; set; } = 30; + /// /// Validates the configuration /// diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/HelloDetector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/HelloDetector.cs index 51f5fbe6..e294997b 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/HelloDetector.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/HelloDetector.cs @@ -12,12 +12,16 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors /// Monitors the "Microsoft-Windows-User Device Registration/Admin" event log channel /// and checks registry for WHfB policy configuration. /// - /// Key Event IDs: + /// Key Event IDs (User Device Registration/Admin): /// 358 - Prerequisites passed, provisioning will be launched - /// 360 - Prerequisites failed, provisioning will NOT be launched + /// 360 - Prerequisites failed, provisioning will NOT be launched (SNAPSHOT ONLY - not terminal) /// 362 - Provisioning blocked /// 300 - NGC key registered successfully (Hello provisioned) /// 301 - NGC key registration failed + /// + /// Key Event IDs (Microsoft-Windows-Shell-Core/Operational): + /// 62404 - CloudExperienceHost Web App Activity Started (CXID: 'AADHello' or 'NGC' - Hello wizard started) + /// 62407 - CloudExperienceHost Web App Event 2 (CommercialOOBE_ESPProgress_Page_Exiting - ESP exit) /// public class HelloDetector : IDisposable { @@ -26,24 +30,38 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors private readonly string _tenantId; private readonly Action _onEventCollected; private System.Diagnostics.Eventing.Reader.EventLogWatcher _watcher; + private System.Diagnostics.Eventing.Reader.EventLogWatcher _shellCoreWatcher; private System.Threading.Timer _policyCheckTimer; + private System.Threading.Timer _helloWaitTimer; private bool _isPolicyConfigured = false; private bool _isHelloCompleted = false; + private bool _espExitDetected = false; + private bool _helloWizardStarted = false; private readonly object _stateLock = new object(); + private readonly int _helloWaitTimeoutSeconds; + /// - /// Callback invoked when Hello provisioning completes (successfully, failed, skipped, or blocked) + /// Callback invoked when Hello provisioning completes (successfully or failed) + /// Based on events 300/301 only, NOT on event 360 (which is just a snapshot) /// public event EventHandler HelloCompleted; + /// + /// Callback invoked when ESP exit or Hello wizard start is detected + /// Triggers transition to FinalizingSetup phase + /// + public event EventHandler FinalizingSetupPhaseTriggered; + private const string EventLogChannel = "Microsoft-Windows-User Device Registration/Admin"; + private const string ShellCoreEventLogChannel = "Microsoft-Windows-Shell-Core/Operational"; // WHfB policy registry paths private const string CspPolicyBasePath = @"SOFTWARE\Microsoft\Policies\PassportForWork"; private const string GpoPolicyPath = @"SOFTWARE\Policies\Microsoft\PassportForWork"; - // Tracked event IDs + // Tracked event IDs (User Device Registration) private const int EventId_NgcKeyRegistered = 300; private const int EventId_NgcKeyRegistrationFailed = 301; private const int EventId_ProvisioningWillLaunch = 358; @@ -51,6 +69,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors private const int EventId_ProvisioningBlocked = 362; private const int EventId_PinStatus = 376; + // Tracked event IDs (Shell-Core/Operational) + private const int EventId_ShellCore_WebAppStarted = 62404; + private const int EventId_ShellCore_WebAppEvent = 62407; + private static readonly HashSet TrackedEventIds = new HashSet { EventId_NgcKeyRegistered, @@ -61,12 +83,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors EventId_PinStatus }; - public HelloDetector(string sessionId, string tenantId, Action onEventCollected, AgentLogger logger) + private static readonly HashSet TrackedShellCoreEventIds = new HashSet + { + EventId_ShellCore_WebAppStarted, + EventId_ShellCore_WebAppEvent + }; + + public HelloDetector(string sessionId, string tenantId, Action onEventCollected, AgentLogger logger, int helloWaitTimeoutSeconds = 30) { _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _helloWaitTimeoutSeconds = helloWaitTimeoutSeconds; } /// @@ -92,16 +121,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors // Check if WHfB policy is configured initially CheckHelloPolicy(); - // Start periodic policy check (every 30 seconds) to detect policy arriving later via MDM + // Start periodic policy check to detect policy arriving later via MDM _policyCheckTimer = new System.Threading.Timer( _ => CheckHelloPolicy(), null, - TimeSpan.FromSeconds(30), - TimeSpan.FromSeconds(30) + TimeSpan.FromSeconds(30), // Initial delay before first check (to allow policies to apply during enrollment) + TimeSpan.FromSeconds(30) // Subsequent checks every 15 seconds ); // Subscribe to User Device Registration event log StartEventLogWatcher(); + + // Subscribe to Shell-Core/Operational event log for ESP exit and Hello wizard detection + StartShellCoreEventLogWatcher(); } public void Stop() @@ -122,6 +154,21 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors } } + // Stop Hello wait timer + if (_helloWaitTimer != null) + { + try + { + _helloWaitTimer.Dispose(); + _helloWaitTimer = null; + } + catch (Exception ex) + { + _logger.Error("Error stopping Hello wait timer", ex); + } + } + + // Stop User Device Registration watcher if (_watcher != null) { try @@ -136,6 +183,22 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors _logger.Error("Error stopping Hello detector watcher", ex); } } + + // Stop Shell-Core/Operational watcher + if (_shellCoreWatcher != null) + { + try + { + _shellCoreWatcher.Enabled = false; + _shellCoreWatcher.EventRecordWritten -= OnShellCoreEventRecordWritten; + _shellCoreWatcher.Dispose(); + _shellCoreWatcher = null; + } + catch (Exception ex) + { + _logger.Error("Error stopping Shell-Core event watcher", ex); + } + } } private void CheckHelloPolicy() @@ -333,12 +396,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors case EventId_ProvisioningWillNotLaunch: // 360 eventType = "hello_provisioning_skipped"; severity = EventSeverity.Warning; - message = "Windows Hello for Business provisioning skipped - prerequisites not met"; - // Mark Hello as completed (terminal event - skipped counts as completed) - lock (_stateLock) { _isHelloCompleted = true; } - _logger.Info("Windows Hello provisioning COMPLETED (skipped)"); - // Trigger HelloCompleted event for EnrollmentTracker - try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { } + message = "Windows Hello for Business provisioning prerequisites not met (snapshot only, not terminal)"; + // DO NOT mark Hello as completed - event 360 is just a snapshot and can change + // DO NOT trigger HelloCompleted event + // Wait for actual terminal events: 300 (success), 301 (failed), or ESP exit + timeout + _logger.Info("Windows Hello provisioning prerequisites not met (snapshot, not terminal)"); break; case EventId_ProvisioningBlocked: // 362 @@ -370,7 +432,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors EventType = eventType, Severity = severity, Source = "HelloDetector", - Phase = EnrollmentPhase.FinalizingSetup, + Phase = EnrollmentPhase.Unknown, // Phase will be determined by EnrollmentTracker based on context Message = message, Data = new Dictionary { @@ -388,6 +450,219 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors } } + private void StartShellCoreEventLogWatcher() + { + try + { + // Watch for ESP exit and Hello wizard start events in Shell-Core/Operational log + var query = new EventLogQuery( + ShellCoreEventLogChannel, + PathType.LogName, + "*[System[TimeCreated[timediff(@SystemTime) <= 1000]]]" + ); + + _shellCoreWatcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query); + _shellCoreWatcher.EventRecordWritten += OnShellCoreEventRecordWritten; + _shellCoreWatcher.Enabled = true; + + _logger.Info($"Started watching: {ShellCoreEventLogChannel}"); + } + catch (EventLogNotFoundException) + { + _logger.Warning($"Event log not found: {ShellCoreEventLogChannel} (normal if not on a real device)"); + } + catch (Exception ex) + { + _logger.Error($"Failed to start Shell-Core event log watcher", ex); + } + } + + private void OnShellCoreEventRecordWritten(object sender, EventRecordWrittenEventArgs e) + { + if (e.EventRecord == null) + return; + + try + { + var record = e.EventRecord; + var eventId = record.Id; + + if (!TrackedShellCoreEventIds.Contains(eventId)) + return; + + var description = record.FormatDescription() ?? $"Event ID {eventId}"; + + string eventType; + EventSeverity severity = EventSeverity.Info; + string message; + bool triggerFinalizingSetup = false; + string finalizingSetupReason = null; + + switch (eventId) + { + case EventId_ShellCore_WebAppStarted: // 62404 + // Check if this is AADHello or NGC (Hello wizard started) + if (description.Contains("AADHello") || description.Contains("'NGC'")) + { + eventType = "hello_wizard_started"; + message = "Windows Hello wizard started (CloudExperienceHost)"; + triggerFinalizingSetup = true; + finalizingSetupReason = "hello_wizard_started"; + + lock (_stateLock) + { + _helloWizardStarted = true; + + // Stop the hello wait timer if running (Hello wizard appeared within timeout) + if (_helloWaitTimer != null) + { + _helloWaitTimer.Dispose(); + _helloWaitTimer = null; + _logger.Info($"Hello wizard started within {_helloWaitTimeoutSeconds}s timeout - stopping wait timer"); + } + } + + _logger.Info("Windows Hello wizard started - detected via Shell-Core event 62404"); + } + else + { + // Not Hello-related, ignore + return; + } + break; + + case EventId_ShellCore_WebAppEvent: // 62407 + // Check if this is ESP exit event + // Use robust pattern: OOBE_ESP*Exit* instead of full string CommercialOOBE_ESPProgress_Page_Exiting + if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exit", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) + { + eventType = "esp_exiting"; + message = "ESP (Enrollment Status Page) is exiting - enrollment nearing completion"; + triggerFinalizingSetup = true; + finalizingSetupReason = "esp_exiting"; + + lock (_stateLock) + { + _espExitDetected = true; + + // Start timer to wait for Hello wizard to start + _logger.Info($"ESP exit detected - starting {_helloWaitTimeoutSeconds}s wait timer for Hello wizard"); + _helloWaitTimer = new System.Threading.Timer( + OnHelloWaitTimeout, + null, + TimeSpan.FromSeconds(_helloWaitTimeoutSeconds), + TimeSpan.FromMilliseconds(-1) // One-shot timer + ); + } + + _logger.Info("ESP exit detected - detected via Shell-Core event 62407"); + } + else + { + // Not ESP exit, ignore + return; + } + break; + + default: + return; + } + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + Timestamp = record.TimeCreated ?? DateTime.UtcNow, + EventType = eventType, + Severity = severity, + Source = "HelloDetector", + Phase = EnrollmentPhase.Unknown, // Let EnrollmentTracker decide phase + Message = message, + Data = new Dictionary + { + { "windowsEventId", eventId }, + { "providerName", record.ProviderName ?? "" }, + { "description", description }, + { "eventLogChannel", ShellCoreEventLogChannel } + } + }); + + _logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})"); + + // Trigger FinalizingSetup phase transition + if (triggerFinalizingSetup) + { + try + { + FinalizingSetupPhaseTriggered?.Invoke(this, finalizingSetupReason); + } + catch { } + } + } + catch (Exception ex) + { + _logger.Error("Error processing Shell-Core event record", ex); + } + } + + /// + /// Called when the Hello wait timer expires after ESP exit. + /// If Hello wizard hasn't started by now, we assume it's not configured or was skipped. + /// Mark Hello as completed so enrollment can proceed. + /// + private void OnHelloWaitTimeout(object state) + { + lock (_stateLock) + { + if (_helloWizardStarted) + { + // Hello wizard already started, this timeout is obsolete + _logger.Debug("Hello wait timeout fired but wizard already started - ignoring"); + return; + } + + if (_isHelloCompleted) + { + // Hello already completed via events 300/301 - timeout is obsolete + _logger.Debug("Hello wait timeout fired but Hello already completed - ignoring"); + return; + } + + // Timeout expired without Hello wizard starting + _logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired without Hello wizard starting"); + _logger.Info("Assuming Hello is not configured or was skipped - marking as completed"); + + _isHelloCompleted = true; + + // Emit event for tracking + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "hello_wait_timeout", + Severity = EventSeverity.Info, + Source = "HelloDetector", + Phase = EnrollmentPhase.Unknown, + Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit - assuming not configured", + Data = new Dictionary + { + { "timeoutSeconds", _helloWaitTimeoutSeconds }, + { "espExitDetected", _espExitDetected } + } + }); + + // Trigger HelloCompleted so enrollment can proceed + try + { + HelloCompleted?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + _logger.Error("Error invoking HelloCompleted from timeout", ex); + } + } + } + public void Dispose() { Stop(); diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs index 32aeb973..b80e1889 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs @@ -172,7 +172,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core _configuration.SessionId, _configuration.TenantId, EmitEvent, - _logger + _logger, + _configuration.HelloWaitTimeoutSeconds ); _helloDetector.Start(); diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs index 9a99f912..eec0de10 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs @@ -105,6 +105,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking if (_helloDetector != null) { _helloDetector.HelloCompleted += OnHelloCompleted; + _helloDetector.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered; } } @@ -781,33 +782,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking // Emit final summary EmitAppTrackingSummary(); + // Note: Phase transition to FinalizingSetup is now handled by Shell-Core events + // (ESP exit or Hello wizard start) for more robust detection. + // We no longer automatically transition here when apps complete. if (_lastEspPhase != null) { _logger.Info($"EnrollmentTracker: All apps completed while in phase '{_lastEspPhase}'"); - - // When all user apps complete during AccountSetup, transition to FinalizingSetup. - // This signals the progress portal that app installation is done and - // we're now waiting for final ESP steps (e.g. Windows Hello provisioning). - if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) - { - _logger.Info("EnrollmentTracker: User apps completed - transitioning to FinalizingSetup phase"); - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "esp_phase_changed", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.FinalizingSetup, - Message = "ESP phase: FinalizingSetup (all user apps completed, waiting for final steps)", - Data = new Dictionary - { - { "espPhase", "FinalizingSetup" }, - { "autoDetected", true }, - { "previousPhase", "AccountSetup" } - } - }); - } + _logger.Info("EnrollmentTracker: Waiting for ESP exit or Hello wizard events to transition to FinalizingSetup"); } } @@ -919,6 +900,33 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking } } + /// + /// Called when ESP exit or Hello wizard start is detected (via HelloDetector Shell-Core events) + /// Triggers transition to FinalizingSetup phase + /// + private void OnFinalizingSetupPhaseTriggered(object sender, string reason) + { + _logger.Info($"EnrollmentTracker: FinalizingSetup phase triggered - reason: {reason}"); + + // Emit phase change event to FinalizingSetup + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "esp_phase_changed", + Severity = EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.FinalizingSetup, + Message = $"ESP phase: FinalizingSetup (triggered by {reason})", + Data = new Dictionary + { + { "espPhase", "FinalizingSetup" }, + { "autoDetected", true }, + { "triggeredBy", reason } + } + }); + } + private void SummaryTimerCallback(object state) { if (_imeLogTracker?.PackageStates?.CountAll > 0) @@ -950,10 +958,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking { Stop(); - // Unsubscribe from HelloDetector event + // Unsubscribe from HelloDetector events if (_helloDetector != null) { _helloDetector.HelloCompleted -= OnHelloCompleted; + _helloDetector.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered; } _summaryTimer?.Dispose(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs index e64bedff..d8a670ce 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs @@ -205,7 +205,8 @@ namespace AutopilotMonitor.Functions.Functions await _storageService.IncrementSessionEventCountAsync( request.TenantId, request.SessionId, - processedCount + processedCount, + earliestEventTimestamp ); } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs index d8dce317..09799c6e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs @@ -434,7 +434,7 @@ namespace AutopilotMonitor.Functions.Services /// Increments the session event count without touching status or phase fields. /// Uses Merge mode to safely handle concurrent updates. /// - public async Task IncrementSessionEventCountAsync(string tenantId, string sessionId, int increment) + public async Task IncrementSessionEventCountAsync(string tenantId, string sessionId, int increment, DateTime? earliestEventTimestamp = null) { SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); @@ -454,6 +454,17 @@ namespace AutopilotMonitor.Functions.Services { ["EventCount"] = currentCount + increment }; + + // Update StartedAt if an earlier event timestamp is provided + if (earliestEventTimestamp.HasValue) + { + var currentStartedAt = entity.Value.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue; + if (earliestEventTimestamp.Value < currentStartedAt) + { + update["StartedAt"] = earliestEventTimestamp.Value; + } + } + await tableClient.UpdateEntityAsync(update, entity.Value.ETag, TableUpdateMode.Merge); return; } diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx index 04ea3024..3c6b9b6a 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -1264,8 +1264,11 @@ function DeviceDetailsCard({ events }: { events: EnrollmentEvent[] }) { // Extract device detail events const getEventData = (eventType: string): Record | null => { - const evt = events.find(e => e.eventType === eventType); - return evt?.data ?? null; + // Find the NEWEST event of this type (last occurrence) to get the most recent state + const matchingEvents = events.filter(e => e.eventType === eventType); + if (matchingEvents.length === 0) return null; + const latestEvent = matchingEvents[matchingEvents.length - 1]; + return latestEvent?.data ?? null; }; // Helper to check if IP is IPv6