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