From 97fb112c00d9724ca673cc68c243243ffe6359cc Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 14 Apr 2026 12:54:52 +0200 Subject: [PATCH] extracted ModernDeploymentTracker --- .../ModernDeploymentTrackerTests.cs | 350 +++++++++++++ ...ndHelloTracker.ModernDeploymentTracking.cs | 480 ------------------ .../Collectors/EspAndHelloTracker.cs | 34 +- .../Collectors/ModernDeploymentTracker.cs | 471 +++++++++++++++++ .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- 5 files changed, 846 insertions(+), 491 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ModernDeploymentTrackerTests.cs delete mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.ModernDeploymentTracking.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/ModernDeploymentTracker.cs diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ModernDeploymentTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ModernDeploymentTrackerTests.cs new file mode 100644 index 00000000..4f9bf995 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ModernDeploymentTrackerTests.cs @@ -0,0 +1,350 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using AutopilotMonitor.Agent.Core.Monitoring.Collectors; +using AutopilotMonitor.Agent.Core.Tests.Helpers; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; +using Xunit; + +namespace AutopilotMonitor.Agent.Core.Tests.Collectors +{ + /// + /// Tests for . Drives the ProcessEvent pipeline via + /// primitive inputs (EventRecord is abstract and Windows-only, so tests don't synthesise it). + /// Covers: level → event-type mapping, harmless-warning downgrade, WhiteGlove fire-once, + /// WhiteGlove keyword guard, cross-restart dedup via persisted state. + /// + public sealed class ModernDeploymentTrackerTests : IDisposable + { + private readonly string _tempDir; + private readonly ConcurrentBag _captured; + + public ModernDeploymentTrackerTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "mdt-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + _captured = new ConcurrentBag(); + } + + public void Dispose() + { + try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } + catch { /* best-effort */ } + } + + private ModernDeploymentTracker CreateTracker(string stateDirectory = null) + { + return new ModernDeploymentTracker( + sessionId: "sess-1", + tenantId: "tenant-1", + onEventCollected: e => _captured.Add(e), + logger: TestLogger.Instance, + logLevelMax: 3, + backfillEnabled: true, + backfillLookbackMinutes: 30, + stateDirectory: stateDirectory); + } + + private List GetCaptured() => new List(_captured); + + // ========== Level → EventType mapping ========== + + [Fact] + public void ProcessEvent_Level1_MappedToError() + { + var tracker = CreateTracker(); + + tracker.ProcessEvent( + eventId: 42, level: 1, levelDisplayName: "Critical", providerName: "prov", + timeCreatedUtc: DateTime.UtcNow, formattedDescription: "Boom", + shortName: "Autopilot", channelName: ModernDeploymentTracker.AutopilotChannel, + isBackfill: false); + + var evt = Assert.Single(GetCaptured()); + Assert.Equal(Constants.EventTypes.ModernDeploymentError, evt.EventType); + Assert.Equal(EventSeverity.Error, evt.Severity); + Assert.Equal("ModernDeploymentWatcher", evt.Source); + Assert.Equal(EnrollmentPhase.Unknown, evt.Phase); + } + + [Fact] + public void ProcessEvent_Level2_MappedToError() + { + var tracker = CreateTracker(); + + tracker.ProcessEvent(42, 2, "Error", "prov", DateTime.UtcNow, "msg", + "Autopilot", ModernDeploymentTracker.AutopilotChannel, false); + + Assert.Equal(Constants.EventTypes.ModernDeploymentError, Assert.Single(GetCaptured()).EventType); + } + + [Fact] + public void ProcessEvent_Level3_MappedToWarning() + { + var tracker = CreateTracker(); + + tracker.ProcessEvent(42, 3, "Warning", "prov", DateTime.UtcNow, "msg", + "Autopilot", ModernDeploymentTracker.AutopilotChannel, false); + + var evt = Assert.Single(GetCaptured()); + Assert.Equal(Constants.EventTypes.ModernDeploymentWarning, evt.EventType); + Assert.Equal(EventSeverity.Warning, evt.Severity); + } + + [Fact] + public void ProcessEvent_Level4_MappedToInfoLog() + { + var tracker = CreateTracker(); + + tracker.ProcessEvent(42, 4, "Information", "prov", DateTime.UtcNow, "msg", + "Autopilot", ModernDeploymentTracker.AutopilotChannel, false); + + var evt = Assert.Single(GetCaptured()); + Assert.Equal(Constants.EventTypes.ModernDeploymentLog, evt.EventType); + Assert.Equal(EventSeverity.Info, evt.Severity); + } + + [Fact] + public void ProcessEvent_NullLevel_DefaultsToInfoLog() + { + var tracker = CreateTracker(); + + tracker.ProcessEvent(42, null, null, null, DateTime.UtcNow, "msg", + "Autopilot", ModernDeploymentTracker.AutopilotChannel, false); + + Assert.Equal(Constants.EventTypes.ModernDeploymentLog, Assert.Single(GetCaptured()).EventType); + } + + // ========== Harmless warning downgrade (Event 100, Level 3) ========== + + [Fact] + public void ProcessEvent_EventId100Level3_DowngradedToDebugLog() + { + var tracker = CreateTracker(); + + tracker.ProcessEvent(100, 3, "Warning", "prov", DateTime.UtcNow, + "Autopilot policy not found", + "Autopilot", ModernDeploymentTracker.AutopilotChannel, false); + + var evt = Assert.Single(GetCaptured()); + Assert.Equal(Constants.EventTypes.ModernDeploymentLog, evt.EventType); + Assert.Equal(EventSeverity.Debug, evt.Severity); + } + + [Fact] + public void ProcessEvent_EventId100Level2_StillMappedToError() + { + var tracker = CreateTracker(); + + tracker.ProcessEvent(100, 2, "Error", "prov", DateTime.UtcNow, "msg", + "Autopilot", ModernDeploymentTracker.AutopilotChannel, false); + + Assert.Equal(Constants.EventTypes.ModernDeploymentError, Assert.Single(GetCaptured()).EventType); + } + + // ========== Message payload shape ========== + + [Fact] + public void ProcessEvent_EmptyDescription_UsesFallback() + { + var tracker = CreateTracker(); + + tracker.ProcessEvent(999, 4, "Information", "prov", DateTime.UtcNow, null, + "Autopilot", ModernDeploymentTracker.AutopilotChannel, false); + + var evt = Assert.Single(GetCaptured()); + Assert.Contains("EventID 999", evt.Message); + Assert.Contains("no formatted description", evt.Message); + } + + [Fact] + public void ProcessEvent_LongDescription_TruncatedTo1000Chars() + { + var tracker = CreateTracker(); + var longDesc = new string('x', 1500); + + tracker.ProcessEvent(42, 4, "Information", "prov", DateTime.UtcNow, longDesc, + "Autopilot", ModernDeploymentTracker.AutopilotChannel, false); + + var evt = Assert.Single(GetCaptured()); + // Message prefix "[Autopilot] EventID 42: " + 1000 chars + "…" + Assert.True(evt.Message.Length <= 2000); + Assert.EndsWith("…", evt.Message); + } + + [Fact] + public void ProcessEvent_DataDictionaryContainsAllExpectedKeys() + { + var tracker = CreateTracker(); + var ts = new DateTime(2026, 4, 14, 10, 0, 0, DateTimeKind.Utc); + + tracker.ProcessEvent(42, 3, "Warning", "MyProvider", ts, "hello", + "ManagementService", ModernDeploymentTracker.ManagementChannel, isBackfill: true); + + var evt = Assert.Single(GetCaptured()); + Assert.Equal("ManagementService", evt.Data["channel"]); + Assert.Equal(ModernDeploymentTracker.ManagementChannel, evt.Data["channelFullName"]); + Assert.Equal(42, evt.Data["eventId"]); + Assert.Equal(3, evt.Data["level"]); + Assert.Equal("Warning", evt.Data["levelName"]); + Assert.Equal("MyProvider", evt.Data["providerName"]); + Assert.Equal(true, evt.Data["backfilled"]); + } + + // ========== WhiteGlove Event 509 handling ========== + + [Fact] + public void ProcessEvent_WhiteGloveStart_FromManagementService_EmitsWhiteGloveStartedEvent() + { + var tracker = CreateTracker(_tempDir); + + tracker.ProcessEvent( + ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov", + DateTime.UtcNow, + "AutopilotManager enabled TPM requirement due to WhiteGlove policy value 1", + "ManagementService", ModernDeploymentTracker.ManagementChannel, isBackfill: false); + + var evt = Assert.Single(GetCaptured()); + Assert.Equal("whiteglove_started", evt.EventType); + Assert.Equal(EventSeverity.Info, evt.Severity); + Assert.True(evt.ImmediateUpload); + Assert.True(tracker.IsWhiteGloveStartDetected); + } + + [Fact] + public void ProcessEvent_WhiteGloveStart_FromAutopilotChannel_TreatedAsRegularLog() + { + // Event 509 is only treated as WhiteGlove when it comes from ManagementService. + // The Autopilot channel may emit event 509 for other purposes → fall through to + // the generic level-based mapping. + var tracker = CreateTracker(_tempDir); + + tracker.ProcessEvent( + ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov", + DateTime.UtcNow, "AutopilotManager WhiteGlove whatever", + "Autopilot", ModernDeploymentTracker.AutopilotChannel, isBackfill: false); + + var evt = Assert.Single(GetCaptured()); + Assert.Equal(Constants.EventTypes.ModernDeploymentLog, evt.EventType); + Assert.False(tracker.IsWhiteGloveStartDetected); + } + + [Fact] + public void ProcessEvent_WhiteGloveEvent509_WithoutWhiteGloveKeyword_Ignored() + { + // Guard against future reuse of Event 509 for unrelated purposes. + var tracker = CreateTracker(_tempDir); + + tracker.ProcessEvent( + ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov", + DateTime.UtcNow, "Some unrelated message", + "ManagementService", ModernDeploymentTracker.ManagementChannel, isBackfill: false); + + Assert.Empty(GetCaptured()); + Assert.False(tracker.IsWhiteGloveStartDetected); + } + + [Fact] + public void ProcessEvent_WhiteGloveStart_FireOnce_DuplicatesSuppressed() + { + var tracker = CreateTracker(_tempDir); + var desc = "AutopilotManager enabled TPM requirement due to WhiteGlove policy value 1"; + + tracker.ProcessEvent( + ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov", + DateTime.UtcNow, desc, + "ManagementService", ModernDeploymentTracker.ManagementChannel, false); + tracker.ProcessEvent( + ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov", + DateTime.UtcNow.AddSeconds(5), desc, + "ManagementService", ModernDeploymentTracker.ManagementChannel, false); + + Assert.Single(GetCaptured()); + } + + [Fact] + public void ProcessEvent_WhiteGloveStart_PersistsStateToDisk() + { + var tracker = CreateTracker(_tempDir); + var eventTime = new DateTime(2026, 4, 14, 9, 30, 0, DateTimeKind.Utc); + + tracker.ProcessEvent( + ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov", + eventTime, + "AutopilotManager enabled TPM requirement due to WhiteGlove policy value 1", + "ManagementService", ModernDeploymentTracker.ManagementChannel, false); + + var statePath = Path.Combine(_tempDir, ModernDeploymentTracker.WhiteGloveBackfillStateFileName); + Assert.True(File.Exists(statePath)); + var state = JsonConvert.DeserializeObject(File.ReadAllText(statePath)); + Assert.True(state.WhiteGloveStartSeen); + Assert.Equal(eventTime, state.SeenUtc); + } + + [Fact] + public void LoadWhiteGloveBackfillState_NoStateDirectory_ReturnsNull() + { + var tracker = CreateTracker(stateDirectory: null); + Assert.Null(tracker.LoadWhiteGloveBackfillState()); + } + + [Fact] + public void LoadWhiteGloveBackfillState_NoFile_ReturnsNull() + { + var tracker = CreateTracker(_tempDir); + Assert.Null(tracker.LoadWhiteGloveBackfillState()); + } + + [Fact] + public void LoadWhiteGloveBackfillState_CorruptFile_ReturnsNullAndDoesNotThrow() + { + var tracker = CreateTracker(_tempDir); + var statePath = Path.Combine(_tempDir, ModernDeploymentTracker.WhiteGloveBackfillStateFileName); + File.WriteAllText(statePath, "{not valid json"); + + Assert.Null(tracker.LoadWhiteGloveBackfillState()); + } + + [Fact] + public void LoadWhiteGloveBackfillState_RoundTrip_PreservesFlagAndTimestamps() + { + var tracker = CreateTracker(_tempDir); + var eventTime = new DateTime(2026, 4, 14, 9, 30, 0, DateTimeKind.Utc); + + tracker.PersistWhiteGloveBackfillState(eventTime); + var loaded = tracker.LoadWhiteGloveBackfillState(); + + Assert.NotNull(loaded); + Assert.True(loaded.WhiteGloveStartSeen); + Assert.Equal(eventTime, loaded.SeenUtc); + } + + // ========== XPath builder ========== + + [Fact] + public void BuildXPath_NoTargetedIds_UsesLevelFilterOnly() + { + var xpath = ModernDeploymentTracker.BuildXPath(3, null); + Assert.Contains("Level=0 or (Level >= 1 and Level <= 3)", xpath); + Assert.DoesNotContain("EventID=", xpath); + } + + [Fact] + public void BuildXPath_WithTargetedIds_IncludesOrClause() + { + var xpath = ModernDeploymentTracker.BuildXPath(3, new HashSet { 509 }); + Assert.Contains("Level=0 or (Level >= 1 and Level <= 3)", xpath); + Assert.Contains("EventID=509", xpath); + } + + [Fact] + public void BuildXPath_LevelClampedToWindowsRange() + { + Assert.Contains("Level <= 1", ModernDeploymentTracker.BuildXPath(0, null)); + Assert.Contains("Level <= 5", ModernDeploymentTracker.BuildXPath(99, null)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.ModernDeploymentTracking.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.ModernDeploymentTracking.cs deleted file mode 100644 index 73283c33..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.ModernDeploymentTracking.cs +++ /dev/null @@ -1,480 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.Eventing.Reader; -using System.IO; -using AutopilotMonitor.Shared; -using AutopilotMonitor.Shared.Models; -using Newtonsoft.Json; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors -{ - /// - /// Partial: ModernDeployment-Diagnostics-Provider event log handling (Ebene 1 — live capture + backfill). - /// - /// Subscribes to two Windows event channels that Microsoft uses to log Autopilot and ESP events: - /// - Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot - /// - Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService - /// - /// Current mode is **live-capture + targeted backfill**: every event at Level ≤ ModernDeploymentLogLevelMax - /// (default 3 = Warning, Error, Critical) is forwarded to the backend as a - /// // - /// event. Targeted events (e.g. Event 509) - /// are additionally backfilled from the event log on startup to catch events that were written - /// before the agent started (e.g. WhiteGlove initiation during OOBE before MDM enroll). - /// - /// We intentionally do NOT classify failures locally or fire EspFailureDetected from this - /// code yet — we first want to gather real EventIDs from production devices and then iterate on - /// classification rules via backend config without agent rollout. - /// - /// Timing: Watchers stay subscribed for the entire agent lifetime (not stopped by idle timeout). - /// They are push-based (kernel-driven delivery) — zero-cost when no events are written. - /// - public partial class EspAndHelloTracker - { - private const string ModernDeploymentAutopilotChannel = "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot"; - private const string ModernDeploymentManagementChannel = "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService"; - - /// - /// ManagementService Event ID 509: "AutopilotManager enabled TPM requirement due to WhiteGlove policy value 1". - /// Fires only when a technician actually initiates pre-provisioning (Win 5x → Provision), - /// NOT when the profile merely allows it (AutopilotMode=1). This is the earliest reliable - /// indicator that a session is running in WhiteGlove mode. - /// Level 4 (Informational) — bypasses the default Level≤3 filter, included via targeted EventID. - /// - private const int EventId_ManagementService_WhiteGloveStart = 509; - - /// - /// Event IDs to capture from ManagementService regardless of the level filter. - /// These are included in the XPath via "or (EventID=X or EventID=Y ...)" so they - /// bypass the general Level≤ModernDeploymentLogLevelMax threshold. - /// To observe additional events, add their IDs here — no new watcher needed. - /// - private static readonly HashSet TargetedManagementServiceEventIds = new HashSet - { - EventId_ManagementService_WhiteGloveStart // 509 - }; - - private System.Diagnostics.Eventing.Reader.EventLogWatcher _modernDeploymentAutopilotWatcher; - private System.Diagnostics.Eventing.Reader.EventLogWatcher _modernDeploymentManagementWatcher; - - /// - /// File name for persisting WhiteGlove backfill state across agent restarts. - /// Written to the stateDirectory passed via the constructor. - /// - private const string WhiteGloveBackfillStateFileName = "whiteglove-backfill.json"; - - private void StartModernDeploymentEventLogWatchers() - { - _modernDeploymentAutopilotWatcher = TryStartModernDeploymentWatcher( - ModernDeploymentAutopilotChannel, "Autopilot"); - _modernDeploymentManagementWatcher = TryStartModernDeploymentWatcher( - ModernDeploymentManagementChannel, "ManagementService", TargetedManagementServiceEventIds); - } - - private System.Diagnostics.Eventing.Reader.EventLogWatcher TryStartModernDeploymentWatcher( - string channelName, string shortName, HashSet targetedEventIds = null) - { - try - { - // Clamp the level filter into the valid Windows range [1..5]. - var levelMax = Math.Max(1, Math.Min(5, _modernDeploymentLogLevelMax)); - - // XPath: (Level >= 1 and Level <= levelMax). Level 0 ("LogAlways") is not filtered out by - // XPath comparison — Windows treats it separately — so we explicitly include it in the query. - // Targeted event IDs (e.g. Event 509 at Level 4) are added as an OR clause so they - // bypass the level filter — no dedicated watcher needed per event. - var levelFilter = $"Level=0 or (Level >= 1 and Level <= {levelMax})"; - - if (targetedEventIds != null && targetedEventIds.Count > 0) - { - var idClauses = string.Join(" or ", System.Linq.Enumerable.Select(targetedEventIds, id => $"EventID={id}")); - levelFilter += $" or ({idClauses})"; - } - - var xpath = $"*[System[{levelFilter}]]"; - - var query = new EventLogQuery(channelName, PathType.LogName, xpath); - var watcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query); - watcher.EventRecordWritten += (sender, args) => OnModernDeploymentEventRecordWritten(args, shortName, channelName); - watcher.Enabled = true; - - _logger.Info($"ModernDeployment watcher started: {channelName} (levelMax={levelMax}, targetedIds={targetedEventIds?.Count ?? 0})"); - return watcher; - } - catch (EventLogNotFoundException) - { - _logger.Warning($"ModernDeployment event log not found: {channelName} (normal on non-Windows 10/11 test environments)"); - return null; - } - catch (UnauthorizedAccessException ex) - { - _logger.Warning($"ModernDeployment watcher access denied for {channelName}: {ex.Message}"); - return null; - } - catch (Exception ex) - { - _logger.Error($"Failed to start ModernDeployment watcher for {channelName}", ex); - return null; - } - } - - private void StopModernDeploymentEventLogWatchers() - { - StopModernDeploymentWatcher(ref _modernDeploymentAutopilotWatcher, "Autopilot"); - StopModernDeploymentWatcher(ref _modernDeploymentManagementWatcher, "ManagementService"); - } - - private void StopModernDeploymentWatcher(ref System.Diagnostics.Eventing.Reader.EventLogWatcher watcher, string shortName) - { - if (watcher == null) - return; - - try - { - watcher.Enabled = false; - watcher.Dispose(); - _logger.Info($"ModernDeployment watcher stopped: {shortName}"); - } - catch (Exception ex) - { - _logger.Error($"Error stopping ModernDeployment watcher ({shortName})", ex); - } - finally - { - watcher = null; - } - } - - // ----------------------------------------------------------------------- - // Live event handler (EventLogWatcher callback) - // ----------------------------------------------------------------------- - - private void OnModernDeploymentEventRecordWritten(EventRecordWrittenEventArgs e, string shortName, string channelName) - { - if (e.EventRecord == null) - return; - - try - { - ProcessModernDeploymentRecord(e.EventRecord, shortName, channelName, isBackfill: false); - } - catch (Exception ex) - { - _logger.Error($"Error processing ModernDeployment event from {shortName}", ex); - } - } - - // ----------------------------------------------------------------------- - // Shared event processing (used by both live watcher and backfill reader) - // ----------------------------------------------------------------------- - - /// - /// Processes a single ModernDeployment event record. Called from both the live - /// EventLogWatcher callback and the startup backfill reader. The - /// flag is included in the emitted event data for trace analysis. - /// - private void ProcessModernDeploymentRecord(EventRecord record, string shortName, string channelName, bool isBackfill) - { - // Targeted event dispatch: WhiteGlove start (Event 509, Level 4). - // Captured via the combined XPath filter — handle before the generic level switch. - if (record.Id == EventId_ManagementService_WhiteGloveStart - && shortName == "ManagementService") - { - HandleWhiteGloveStartEvent(record, isBackfill); - return; - } - - // Determine severity based on Windows event level: - // 1 = Critical, 2 = Error, 3 = Warning, 4 = Information, 5 = Verbose - var level = record.Level ?? 4; - string eventType; - EventSeverity severity; - switch (level) - { - case 1: - case 2: - eventType = Constants.EventTypes.ModernDeploymentError; - severity = EventSeverity.Error; - break; - case 3: - eventType = Constants.EventTypes.ModernDeploymentWarning; - severity = EventSeverity.Warning; - break; - default: - eventType = Constants.EventTypes.ModernDeploymentLog; - severity = EventSeverity.Info; - break; - } - - // Downgrade known harmless warnings to Debug. - // EventID 100 Level 3 = "Autopilot policy not found" — fires when optional - // ESP sync policies are not configured; no real impact on enrollment. - if (level == 3 && record.Id == 100) - { - eventType = Constants.EventTypes.ModernDeploymentLog; - severity = EventSeverity.Debug; - } - - string description = null; - try { description = record.FormatDescription(); } - catch { /* some events lack formatting resources */ } - - if (string.IsNullOrEmpty(description)) - description = $"Event ID {record.Id} (no formatted description)"; - - // Trim the message body to keep the event payload small. - var truncated = description.Length > 1000 ? description.Substring(0, 1000) + "…" : description; - - var data = new Dictionary - { - { "channel", shortName }, - { "channelFullName", channelName }, - { "eventId", record.Id }, - { "level", level }, - { "levelName", record.LevelDisplayName ?? string.Empty }, - { "providerName", record.ProviderName ?? string.Empty }, - { "timeCreated", record.TimeCreated?.ToUniversalTime().ToString("o") ?? string.Empty }, - { "backfilled", isBackfill } - }; - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = eventType, - Severity = severity, - Source = "ModernDeploymentWatcher", - Phase = EnrollmentPhase.Unknown, - Message = $"[{shortName}] EventID {record.Id}: {truncated}", - Data = data - }); - } - - // ----------------------------------------------------------------------- - // WhiteGlove start detection (Event 509) - // ----------------------------------------------------------------------- - - /// - /// Handles ManagementService Event 509 (WhiteGlove start indicator). - /// Called from when a targeted - /// event ID matches. Emits a whiteglove_started event for monitoring — no actions derived. - /// Persists the detection to disk so subsequent agent restarts skip the backfill scan. - /// - private void HandleWhiteGloveStartEvent(EventRecord record, bool isBackfill) - { - string description = null; - try { description = record.FormatDescription(); } - catch { } - - if (string.IsNullOrEmpty(description)) - description = $"Event ID {record.Id} (no formatted description)"; - - // Only emit if description confirms WhiteGlove context (guard against - // future reuse of Event 509 for unrelated purposes). - if (description.IndexOf("WhiteGlove", StringComparison.OrdinalIgnoreCase) < 0) - { - _logger.Trace($"ManagementService Event 509 without WhiteGlove keyword — ignoring: {description}"); - return; - } - - // Fire-once guard: emit only the first occurrence per agent lifetime. - lock (_stateLock) - { - if (_whiteGloveStartDetected) return; - _whiteGloveStartDetected = true; - } - - _logger.Info($"WhiteGlove start detected via ManagementService Event 509 (backfill={isBackfill}): {description}"); - - // Persist across restarts — subsequent agent launches skip the backfill scan. - PersistWhiteGloveBackfillState(record.TimeCreated?.ToUniversalTime()); - - var truncated = description.Length > 500 ? description.Substring(0, 500) + "…" : description; - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "whiteglove_started", - Severity = EventSeverity.Info, - Source = "ModernDeploymentWatcher", - Phase = EnrollmentPhase.Unknown, - Message = $"WhiteGlove (Pre-Provisioning) initiated — ManagementService EventID {record.Id}", - Data = new Dictionary - { - { "channel", "ManagementService" }, - { "channelFullName", ModernDeploymentManagementChannel }, - { "eventId", record.Id }, - { "level", record.Level ?? 4 }, - { "description", truncated }, - { "timeCreated", record.TimeCreated?.ToUniversalTime().ToString("o") ?? string.Empty }, - { "backfilled", isBackfill } - }, - ImmediateUpload = true - }); - } - - // ----------------------------------------------------------------------- - // Targeted backfill (runs once on startup) - // ----------------------------------------------------------------------- - - /// - /// Scans the ManagementService event log for targeted events (e.g. Event 509) that may - /// have been written before the agent started. This covers the timing gap during OOBE where - /// WhiteGlove initiation (Event 509) fires before MDM enrollment completes and the agent - /// starts monitoring. - /// - /// Flow: - /// - /// Check persisted state — if WhiteGlove was already seen in a prior run, skip the scan - /// and set the in-memory guard to suppress live duplicates. - /// If not persisted, query the event log with a time-bounded XPath filter for the - /// configured lookback window (default: 30 minutes). - /// Process each matching event through with - /// isBackfill=true. The existing fire-once guard in - /// prevents duplicate emissions. - /// - /// - private void BackfillTargetedModernDeploymentEvents() - { - if (!_modernDeploymentBackfillEnabled) - { - _logger.Info("ModernDeployment backfill disabled by config"); - return; - } - - if (TargetedManagementServiceEventIds.Count == 0) - return; - - // Check persisted state from a prior agent run — if WhiteGlove was already detected, - // skip the event log scan entirely and set the in-memory guard. - var persistedState = LoadWhiteGloveBackfillState(); - if (persistedState != null && persistedState.WhiteGloveStartSeen) - { - _logger.Info($"WhiteGlove start already persisted from prior run (seen at {persistedState.SeenUtc:O}) — skipping backfill"); - lock (_stateLock) { _whiteGloveStartDetected = true; } - return; - } - - try - { - var lookbackMs = _modernDeploymentBackfillLookbackMinutes * 60 * 1000; - var idClauses = string.Join(" or ", - System.Linq.Enumerable.Select(TargetedManagementServiceEventIds, id => $"EventID={id}")); - var xpath = $"*[System[({idClauses}) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]"; - - _logger.Info($"ModernDeployment backfill: scanning {ModernDeploymentManagementChannel} " + - $"(lookback={_modernDeploymentBackfillLookbackMinutes}min, targetedIds={TargetedManagementServiceEventIds.Count})"); - - var query = new EventLogQuery(ModernDeploymentManagementChannel, PathType.LogName, xpath) - { - ReverseDirection = true // newest first — fire-once guard takes only the first match - }; - - int found = 0; - using (var reader = new EventLogReader(query)) - { - EventRecord record; - while ((record = reader.ReadEvent()) != null) - { - using (record) - { - found++; - _logger.Info($"Backfill found ManagementService Event {record.Id} at {record.TimeCreated:O}"); - ProcessModernDeploymentRecord(record, "ManagementService", ModernDeploymentManagementChannel, isBackfill: true); - } - } - } - - if (found == 0) - _logger.Debug($"ModernDeployment backfill: no targeted events found in last {_modernDeploymentBackfillLookbackMinutes} minutes"); - else - _logger.Info($"ModernDeployment backfill: processed {found} event(s)"); - } - catch (EventLogNotFoundException) - { - _logger.Warning($"ModernDeployment event log not found during backfill: {ModernDeploymentManagementChannel} (normal on non-Windows 10/11 test environments)"); - } - catch (UnauthorizedAccessException ex) - { - _logger.Warning($"ModernDeployment backfill access denied: {ex.Message}"); - } - catch (Exception ex) - { - _logger.Warning($"ModernDeployment backfill failed: {ex.Message}"); - } - } - - // ----------------------------------------------------------------------- - // Backfill state persistence (small JSON file for cross-restart dedup) - // ----------------------------------------------------------------------- - - /// - /// Persists the WhiteGlove detection flag to disk so the next agent start can skip - /// the backfill scan. Uses atomic write (temp file + copy) to avoid partial writes. - /// - private void PersistWhiteGloveBackfillState(DateTime? eventTimeUtc) - { - if (string.IsNullOrEmpty(_stateDirectory)) - return; - - try - { - Directory.CreateDirectory(_stateDirectory); - var filePath = Path.Combine(_stateDirectory, WhiteGloveBackfillStateFileName); - var state = new WhiteGloveBackfillState - { - WhiteGloveStartSeen = true, - SeenUtc = eventTimeUtc ?? DateTime.UtcNow, - PersistedUtc = DateTime.UtcNow - }; - var json = JsonConvert.SerializeObject(state, Formatting.Indented); - var tempPath = filePath + ".tmp"; - File.WriteAllText(tempPath, json); - File.Copy(tempPath, filePath, overwrite: true); - try { File.Delete(tempPath); } catch { } - _logger.Info($"WhiteGlove backfill state persisted to {filePath}"); - } - catch (Exception ex) - { - _logger.Warning($"Failed to persist WhiteGlove backfill state: {ex.Message}"); - } - } - - /// - /// Loads the persisted WhiteGlove backfill state from disk. - /// Returns null if no state file exists or on error. - /// - private WhiteGloveBackfillState LoadWhiteGloveBackfillState() - { - if (string.IsNullOrEmpty(_stateDirectory)) - return null; - - var filePath = Path.Combine(_stateDirectory, WhiteGloveBackfillStateFileName); - if (!File.Exists(filePath)) - return null; - - try - { - var json = File.ReadAllText(filePath); - return JsonConvert.DeserializeObject(json); - } - catch (Exception ex) - { - _logger.Warning($"Failed to load WhiteGlove backfill state: {ex.Message}"); - return null; - } - } - - /// - /// Minimal DTO for the WhiteGlove backfill state file. - /// Persisted as whiteglove-backfill.json in the agent state directory. - /// - private class WhiteGloveBackfillState - { - public bool WhiteGloveStartSeen { get; set; } - public DateTime SeenUtc { get; set; } - public DateTime PersistedUtc { get; set; } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.cs index df8dd5b9..e6c1dc30 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.cs @@ -45,6 +45,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors private RegistryWatcher _provisioningWatcher; private System.Threading.Timer _provisioningWatcherRetryTimer; private System.Threading.Timer _provisioningDebounceTimer; + private ModernDeploymentTracker _modernDeploymentTracker; private bool _isPolicyConfigured = false; private bool _isHelloPolicyEnabled = false; // true when Hello policy is explicitly detected as enabled @@ -52,7 +53,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors private bool _espExitDetected = false; private bool _helloWizardStarted = false; private bool _whiteGloveDetected = false; - private bool _whiteGloveStartDetected = false; private string _detectedEspFailureType; // set during Shell-Core event processing, consumed after event emission private readonly object _stateLock = new object(); @@ -237,13 +237,16 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors // start detection) that bypass the general Level≤3 filter via combined XPath. if (_modernDeploymentWatcherEnabled) { - StartModernDeploymentEventLogWatchers(); - - // Backfill targeted events (e.g. Event 509 / WhiteGlove start) that may have been - // written before the agent started. Uses EventLogReader to scan the event log for - // recent occurrences. Must run AFTER watcher subscription so no gap exists between - // backfill scan and live capture. Fire-once guards prevent duplicate emissions. - BackfillTargetedModernDeploymentEvents(); + _modernDeploymentTracker = new ModernDeploymentTracker( + _sessionId, + _tenantId, + _onEventCollected, + _logger, + _modernDeploymentLogLevelMax, + _modernDeploymentBackfillEnabled, + _modernDeploymentBackfillLookbackMinutes, + _stateDirectory); + _modernDeploymentTracker.Start(); } // Safety net: backfill recent terminal events in case watcher started late or event delivery lagged. @@ -352,8 +355,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors } } - // Stop ModernDeployment-Diagnostics-Provider watchers - StopModernDeploymentEventLogWatchers(); + // Stop ModernDeployment-Diagnostics-Provider tracker (composed) + if (_modernDeploymentTracker != null) + { + try + { + _modernDeploymentTracker.Stop(); + _modernDeploymentTracker = null; + } + catch (Exception ex) + { + _logger.Error("Error stopping ModernDeployment tracker", ex); + } + } } public void Dispose() diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/ModernDeploymentTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/ModernDeploymentTracker.cs new file mode 100644 index 00000000..d2edf4f4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/ModernDeploymentTracker.cs @@ -0,0 +1,471 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Eventing.Reader; +using System.IO; +using System.Linq; +using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors +{ + /// + /// Watches Microsoft-Windows-ModernDeployment-Diagnostics-Provider channels + /// (Autopilot + ManagementService) for Autopilot / ESP events and forwards them + /// to the event sink as modern_deployment_log/warning/error events. + /// + /// Additionally detects WhiteGlove (Pre-Provisioning) initiation via + /// ManagementService Event 509 with cross-restart dedup via disk persistence. + /// + internal sealed class ModernDeploymentTracker : IDisposable + { + internal const string AutopilotChannel = "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot"; + internal const string ManagementChannel = "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService"; + + /// + /// ManagementService Event ID 509: fires when a technician actually initiates + /// pre-provisioning (Win 5x → Provision). Level 4 (Informational) — bypasses + /// the default Level≤3 filter via a targeted OR clause in the XPath. + /// + internal const int EventId_WhiteGloveStart = 509; + + internal static readonly HashSet TargetedManagementServiceEventIds = new HashSet + { + EventId_WhiteGloveStart + }; + + internal const string WhiteGloveBackfillStateFileName = "whiteglove-backfill.json"; + + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private readonly int _logLevelMax; + private readonly bool _backfillEnabled; + private readonly int _backfillLookbackMinutes; + private readonly string _stateDirectory; + + private EventLogWatcher _autopilotWatcher; + private EventLogWatcher _managementWatcher; + private bool _whiteGloveStartDetected; + private readonly object _stateLock = new object(); + + public ModernDeploymentTracker( + string sessionId, + string tenantId, + Action onEventCollected, + AgentLogger logger, + int logLevelMax = 3, + bool backfillEnabled = true, + int backfillLookbackMinutes = 30, + string stateDirectory = null) + { + _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)); + _logLevelMax = logLevelMax; + _backfillEnabled = backfillEnabled; + _backfillLookbackMinutes = backfillLookbackMinutes; + _stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null; + } + + /// + /// True once WhiteGlove start has been detected (either live Event 509 or persisted from prior run). + /// Exposed primarily for diagnostics/tests. + /// + internal bool IsWhiteGloveStartDetected + { + get { lock (_stateLock) { return _whiteGloveStartDetected; } } + } + + public void Start() + { + StartWatchers(); + + if (_backfillEnabled) + { + BackfillTargetedEvents(); + } + else + { + _logger.Info("ModernDeployment backfill disabled by config"); + } + } + + public void Stop() + { + StopWatcher(ref _autopilotWatcher, "Autopilot"); + StopWatcher(ref _managementWatcher, "ManagementService"); + } + + public void Dispose() => Stop(); + + // ----------------------------------------------------------------------- + // Watcher lifecycle + // ----------------------------------------------------------------------- + + private void StartWatchers() + { + _autopilotWatcher = TryStartWatcher(AutopilotChannel, "Autopilot", null); + _managementWatcher = TryStartWatcher(ManagementChannel, "ManagementService", TargetedManagementServiceEventIds); + } + + private EventLogWatcher TryStartWatcher(string channelName, string shortName, HashSet targetedEventIds) + { + try + { + var xpath = BuildXPath(_logLevelMax, targetedEventIds); + var query = new EventLogQuery(channelName, PathType.LogName, xpath); + var watcher = new EventLogWatcher(query); + watcher.EventRecordWritten += (sender, args) => OnEventRecordWritten(args, shortName, channelName); + watcher.Enabled = true; + _logger.Info($"ModernDeployment watcher started: {channelName} (levelMax={Math.Max(1, Math.Min(5, _logLevelMax))}, targetedIds={targetedEventIds?.Count ?? 0})"); + return watcher; + } + catch (EventLogNotFoundException) + { + _logger.Warning($"ModernDeployment event log not found: {channelName} (normal on non-Windows 10/11 test environments)"); + return null; + } + catch (UnauthorizedAccessException ex) + { + _logger.Warning($"ModernDeployment watcher access denied for {channelName}: {ex.Message}"); + return null; + } + catch (Exception ex) + { + _logger.Error($"Failed to start ModernDeployment watcher for {channelName}", ex); + return null; + } + } + + /// + /// Builds the XPath filter for the watcher. Exposed as internal for tests. + /// + internal static string BuildXPath(int logLevelMax, HashSet targetedEventIds) + { + var levelMax = Math.Max(1, Math.Min(5, logLevelMax)); + var levelFilter = $"Level=0 or (Level >= 1 and Level <= {levelMax})"; + if (targetedEventIds != null && targetedEventIds.Count > 0) + { + var idClauses = string.Join(" or ", targetedEventIds.Select(id => $"EventID={id}")); + levelFilter += $" or ({idClauses})"; + } + return $"*[System[{levelFilter}]]"; + } + + private void StopWatcher(ref EventLogWatcher watcher, string shortName) + { + if (watcher == null) return; + try + { + watcher.Enabled = false; + watcher.Dispose(); + _logger.Info($"ModernDeployment watcher stopped: {shortName}"); + } + catch (Exception ex) + { + _logger.Error($"Error stopping ModernDeployment watcher ({shortName})", ex); + } + finally + { + watcher = null; + } + } + + // ----------------------------------------------------------------------- + // Event processing + // ----------------------------------------------------------------------- + + private void OnEventRecordWritten(EventRecordWrittenEventArgs e, string shortName, string channelName) + { + if (e.EventRecord == null) return; + try + { + ProcessRecord(e.EventRecord, shortName, channelName, isBackfill: false); + } + catch (Exception ex) + { + _logger.Error($"Error processing ModernDeployment event from {shortName}", ex); + } + } + + private void ProcessRecord(EventRecord record, string shortName, string channelName, bool isBackfill) + { + string description = null; + try { description = record.FormatDescription(); } + catch { /* some events lack formatting resources */ } + + ProcessEvent( + eventId: record.Id, + level: record.Level, + levelDisplayName: record.LevelDisplayName, + providerName: record.ProviderName, + timeCreatedUtc: record.TimeCreated?.ToUniversalTime(), + formattedDescription: description, + shortName: shortName, + channelName: channelName, + isBackfill: isBackfill); + } + + /// + /// Core event-processing logic, extracted to primitive inputs so tests can drive it + /// without needing to synthesize an EventRecord (which is abstract + Windows-only). + /// + internal void ProcessEvent( + int eventId, + int? level, + string levelDisplayName, + string providerName, + DateTime? timeCreatedUtc, + string formattedDescription, + string shortName, + string channelName, + bool isBackfill) + { + if (eventId == EventId_WhiteGloveStart && shortName == "ManagementService") + { + HandleWhiteGloveStart(eventId, level, timeCreatedUtc, formattedDescription, isBackfill); + return; + } + + var effectiveLevel = level ?? 4; + string eventType; + EventSeverity severity; + switch (effectiveLevel) + { + case 1: + case 2: + eventType = Constants.EventTypes.ModernDeploymentError; + severity = EventSeverity.Error; + break; + case 3: + eventType = Constants.EventTypes.ModernDeploymentWarning; + severity = EventSeverity.Warning; + break; + default: + eventType = Constants.EventTypes.ModernDeploymentLog; + severity = EventSeverity.Info; + break; + } + + // Downgrade known harmless warnings to Debug. + // EventID 100 Level 3 = "Autopilot policy not found" — fires when optional + // ESP sync policies are not configured; no real impact on enrollment. + if (effectiveLevel == 3 && eventId == 100) + { + eventType = Constants.EventTypes.ModernDeploymentLog; + severity = EventSeverity.Debug; + } + + var description = string.IsNullOrEmpty(formattedDescription) + ? $"Event ID {eventId} (no formatted description)" + : formattedDescription; + + var truncated = description.Length > 1000 ? description.Substring(0, 1000) + "…" : description; + + var data = new Dictionary + { + { "channel", shortName }, + { "channelFullName", channelName }, + { "eventId", eventId }, + { "level", effectiveLevel }, + { "levelName", levelDisplayName ?? string.Empty }, + { "providerName", providerName ?? string.Empty }, + { "timeCreated", timeCreatedUtc?.ToString("o") ?? string.Empty }, + { "backfilled", isBackfill } + }; + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = eventType, + Severity = severity, + Source = "ModernDeploymentWatcher", + Phase = EnrollmentPhase.Unknown, + Message = $"[{shortName}] EventID {eventId}: {truncated}", + Data = data + }); + } + + private void HandleWhiteGloveStart(int eventId, int? level, DateTime? timeCreatedUtc, string formattedDescription, bool isBackfill) + { + var description = string.IsNullOrEmpty(formattedDescription) + ? $"Event ID {eventId} (no formatted description)" + : formattedDescription; + + if (description.IndexOf("WhiteGlove", StringComparison.OrdinalIgnoreCase) < 0) + { + _logger.Trace($"ManagementService Event 509 without WhiteGlove keyword — ignoring: {description}"); + return; + } + + lock (_stateLock) + { + if (_whiteGloveStartDetected) return; + _whiteGloveStartDetected = true; + } + + _logger.Info($"WhiteGlove start detected via ManagementService Event 509 (backfill={isBackfill}): {description}"); + + PersistWhiteGloveBackfillState(timeCreatedUtc); + + var truncated = description.Length > 500 ? description.Substring(0, 500) + "…" : description; + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "whiteglove_started", + Severity = EventSeverity.Info, + Source = "ModernDeploymentWatcher", + Phase = EnrollmentPhase.Unknown, + Message = $"WhiteGlove (Pre-Provisioning) initiated — ManagementService EventID {eventId}", + Data = new Dictionary + { + { "channel", "ManagementService" }, + { "channelFullName", ManagementChannel }, + { "eventId", eventId }, + { "level", level ?? 4 }, + { "description", truncated }, + { "timeCreated", timeCreatedUtc?.ToString("o") ?? string.Empty }, + { "backfilled", isBackfill } + }, + ImmediateUpload = true + }); + } + + // ----------------------------------------------------------------------- + // Backfill + // ----------------------------------------------------------------------- + + private void BackfillTargetedEvents() + { + if (TargetedManagementServiceEventIds.Count == 0) return; + + var persistedState = LoadWhiteGloveBackfillState(); + if (persistedState != null && persistedState.WhiteGloveStartSeen) + { + _logger.Info($"WhiteGlove start already persisted from prior run (seen at {persistedState.SeenUtc:O}) — skipping backfill"); + lock (_stateLock) { _whiteGloveStartDetected = true; } + return; + } + + try + { + var lookbackMs = _backfillLookbackMinutes * 60 * 1000; + var idClauses = string.Join(" or ", + TargetedManagementServiceEventIds.Select(id => $"EventID={id}")); + var xpath = $"*[System[({idClauses}) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]"; + + _logger.Info($"ModernDeployment backfill: scanning {ManagementChannel} " + + $"(lookback={_backfillLookbackMinutes}min, targetedIds={TargetedManagementServiceEventIds.Count})"); + + var query = new EventLogQuery(ManagementChannel, PathType.LogName, xpath) + { + ReverseDirection = true + }; + + int found = 0; + using (var reader = new EventLogReader(query)) + { + EventRecord record; + while ((record = reader.ReadEvent()) != null) + { + using (record) + { + found++; + _logger.Info($"Backfill found ManagementService Event {record.Id} at {record.TimeCreated:O}"); + ProcessRecord(record, "ManagementService", ManagementChannel, isBackfill: true); + } + } + } + + if (found == 0) + _logger.Debug($"ModernDeployment backfill: no targeted events found in last {_backfillLookbackMinutes} minutes"); + else + _logger.Info($"ModernDeployment backfill: processed {found} event(s)"); + } + catch (EventLogNotFoundException) + { + _logger.Warning($"ModernDeployment event log not found during backfill: {ManagementChannel} (normal on non-Windows 10/11 test environments)"); + } + catch (UnauthorizedAccessException ex) + { + _logger.Warning($"ModernDeployment backfill access denied: {ex.Message}"); + } + catch (Exception ex) + { + _logger.Warning($"ModernDeployment backfill failed: {ex.Message}"); + } + } + + // ----------------------------------------------------------------------- + // Backfill state persistence + // ----------------------------------------------------------------------- + + internal void PersistWhiteGloveBackfillState(DateTime? eventTimeUtc) + { + if (string.IsNullOrEmpty(_stateDirectory)) return; + + try + { + Directory.CreateDirectory(_stateDirectory); + var filePath = Path.Combine(_stateDirectory, WhiteGloveBackfillStateFileName); + var state = new WhiteGloveBackfillState + { + WhiteGloveStartSeen = true, + SeenUtc = eventTimeUtc ?? DateTime.UtcNow, + PersistedUtc = DateTime.UtcNow + }; + var json = JsonConvert.SerializeObject(state, Formatting.Indented); + var tempPath = filePath + ".tmp"; + File.WriteAllText(tempPath, json); + File.Copy(tempPath, filePath, overwrite: true); + try { File.Delete(tempPath); } catch { } + _logger.Info($"WhiteGlove backfill state persisted to {filePath}"); + } + catch (Exception ex) + { + _logger.Warning($"Failed to persist WhiteGlove backfill state: {ex.Message}"); + } + } + + internal WhiteGloveBackfillState LoadWhiteGloveBackfillState() + { + if (string.IsNullOrEmpty(_stateDirectory)) return null; + + var filePath = Path.Combine(_stateDirectory, WhiteGloveBackfillStateFileName); + if (!File.Exists(filePath)) return null; + + try + { + var json = File.ReadAllText(filePath); + return JsonConvert.DeserializeObject(json); + } + catch (Exception ex) + { + _logger.Warning($"Failed to load WhiteGlove backfill state: {ex.Message}"); + return null; + } + } + + /// + /// Test-only: force the in-memory WhiteGlove-seen guard (simulates successful backfill-dedup). + /// + internal void MarkWhiteGloveStartDetectedForTest() + { + lock (_stateLock) { _whiteGloveStartDetected = true; } + } + + internal class WhiteGloveBackfillState + { + public bool WhiteGloveStartSeen { get; set; } + public DateTime SeenUtc { get; set; } + public DateTime PersistedUtc { get; set; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index be1c55a3..a8ed109d 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -952 +953