extracted ModernDeploymentTracker

This commit is contained in:
Oliver Kieselbach
2026-04-14 12:54:52 +02:00
parent 4fbcfb51b4
commit 97fb112c00
5 changed files with 846 additions and 491 deletions
@@ -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
{
/// <summary>
/// Tests for <see cref="ModernDeploymentTracker"/>. 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.
/// </summary>
public sealed class ModernDeploymentTrackerTests : IDisposable
{
private readonly string _tempDir;
private readonly ConcurrentBag<EnrollmentEvent> _captured;
public ModernDeploymentTrackerTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "mdt-test-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
_captured = new ConcurrentBag<EnrollmentEvent>();
}
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<EnrollmentEvent> GetCaptured() => new List<EnrollmentEvent>(_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<ModernDeploymentTracker.WhiteGloveBackfillState>(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<int> { 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));
}
}
}
@@ -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
{
/// <summary>
/// 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
/// <see cref="Constants.EventTypes.ModernDeploymentLog"/>/<see cref="Constants.EventTypes.ModernDeploymentWarning"/>/
/// <see cref="Constants.EventTypes.ModernDeploymentError"/> 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 <c>EspFailureDetected</c> 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.
/// </summary>
public partial class EspAndHelloTracker
{
private const string ModernDeploymentAutopilotChannel = "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot";
private const string ModernDeploymentManagementChannel = "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService";
/// <summary>
/// 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.
/// </summary>
private const int EventId_ManagementService_WhiteGloveStart = 509;
/// <summary>
/// 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.
/// </summary>
private static readonly HashSet<int> TargetedManagementServiceEventIds = new HashSet<int>
{
EventId_ManagementService_WhiteGloveStart // 509
};
private System.Diagnostics.Eventing.Reader.EventLogWatcher _modernDeploymentAutopilotWatcher;
private System.Diagnostics.Eventing.Reader.EventLogWatcher _modernDeploymentManagementWatcher;
/// <summary>
/// File name for persisting WhiteGlove backfill state across agent restarts.
/// Written to the stateDirectory passed via the constructor.
/// </summary>
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<int> 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)
// -----------------------------------------------------------------------
/// <summary>
/// Processes a single ModernDeployment event record. Called from both the live
/// EventLogWatcher callback and the startup backfill reader. The <paramref name="isBackfill"/>
/// flag is included in the emitted event data for trace analysis.
/// </summary>
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<string, object>
{
{ "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)
// -----------------------------------------------------------------------
/// <summary>
/// Handles ManagementService Event 509 (WhiteGlove start indicator).
/// Called from <see cref="ProcessModernDeploymentRecord"/> 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.
/// </summary>
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<string, object>
{
{ "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)
// -----------------------------------------------------------------------
/// <summary>
/// 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.
///
/// <para>Flow:</para>
/// <list type="number">
/// <item>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.</item>
/// <item>If not persisted, query the event log with a time-bounded XPath filter for the
/// configured lookback window (default: 30 minutes).</item>
/// <item>Process each matching event through <see cref="ProcessModernDeploymentRecord"/> with
/// <c>isBackfill=true</c>. The existing fire-once guard in
/// <see cref="HandleWhiteGloveStartEvent"/> prevents duplicate emissions.</item>
/// </list>
/// </summary>
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)
// -----------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
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}");
}
}
/// <summary>
/// Loads the persisted WhiteGlove backfill state from disk.
/// Returns null if no state file exists or on error.
/// </summary>
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<WhiteGloveBackfillState>(json);
}
catch (Exception ex)
{
_logger.Warning($"Failed to load WhiteGlove backfill state: {ex.Message}");
return null;
}
}
/// <summary>
/// Minimal DTO for the WhiteGlove backfill state file.
/// Persisted as <c>whiteglove-backfill.json</c> in the agent state directory.
/// </summary>
private class WhiteGloveBackfillState
{
public bool WhiteGloveStartSeen { get; set; }
public DateTime SeenUtc { get; set; }
public DateTime PersistedUtc { get; set; }
}
}
}
@@ -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()
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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";
/// <summary>
/// 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.
/// </summary>
internal const int EventId_WhiteGloveStart = 509;
internal static readonly HashSet<int> TargetedManagementServiceEventIds = new HashSet<int>
{
EventId_WhiteGloveStart
};
internal const string WhiteGloveBackfillStateFileName = "whiteglove-backfill.json";
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _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<EnrollmentEvent> 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;
}
/// <summary>
/// True once WhiteGlove start has been detected (either live Event 509 or persisted from prior run).
/// Exposed primarily for diagnostics/tests.
/// </summary>
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<int> 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;
}
}
/// <summary>
/// Builds the XPath filter for the watcher. Exposed as internal for tests.
/// </summary>
internal static string BuildXPath(int logLevelMax, HashSet<int> 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);
}
/// <summary>
/// Core event-processing logic, extracted to primitive inputs so tests can drive it
/// without needing to synthesize an EventRecord (which is abstract + Windows-only).
/// </summary>
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<string, object>
{
{ "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<string, object>
{
{ "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<WhiteGloveBackfillState>(json);
}
catch (Exception ex)
{
_logger.Warning($"Failed to load WhiteGlove backfill state: {ex.Message}");
return null;
}
}
/// <summary>
/// Test-only: force the in-memory WhiteGlove-seen guard (simulates successful backfill-dedup).
/// </summary>
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; }
}
}
}
@@ -1 +1 @@
952
953