feat: added StallProbeCollector to detect stalled sessions and identify ESP failure better

added Bootstrapper script version
This commit is contained in:
Oliver Kieselbach
2026-04-09 19:31:54 +02:00
parent f33aadbe0e
commit 3f7fcbc684
31 changed files with 1581 additions and 37 deletions
@@ -38,6 +38,8 @@
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
2026-04-09 v1.1 Introduced explicit script version and log it on startup
(all entries above were v1.0)
2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard:
registry deployment marker, WMI/filesystem user profile detection,
lastloggedonUser set, and 12h bootstrap window.
@@ -61,6 +63,9 @@ param(
[int]$MaxBootstrapWindowHours = 12
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "1.1"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
@@ -105,6 +110,7 @@ function Get-FileMd5Base64 {
try {
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
Write-Log "Bootstrap script version: v$ScriptVersion"
# -- Pre-flight: Multi-signal guard to prevent installation on non-provisioning devices --
# Layered approach: each guard catches a different scenario.
@@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Partial: ModernDeployment-Diagnostics-Provider event log handling (Ebene 1 — live capture).
///
/// 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 only**: 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. 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";
private System.Diagnostics.Eventing.Reader.EventLogWatcher _modernDeploymentAutopilotWatcher;
private System.Diagnostics.Eventing.Reader.EventLogWatcher _modernDeploymentManagementWatcher;
private void StartModernDeploymentEventLogWatchers()
{
_modernDeploymentAutopilotWatcher = TryStartModernDeploymentWatcher(ModernDeploymentAutopilotChannel, "Autopilot");
_modernDeploymentManagementWatcher = TryStartModernDeploymentWatcher(ModernDeploymentManagementChannel, "ManagementService");
}
private System.Diagnostics.Eventing.Reader.EventLogWatcher TryStartModernDeploymentWatcher(string channelName, string shortName)
{
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.
var xpath = $"*[System[Level=0 or (Level >= 1 and Level <= {levelMax})]]";
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})");
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;
}
}
private void OnModernDeploymentEventRecordWritten(EventRecordWrittenEventArgs e, string shortName, string channelName)
{
if (e.EventRecord == null)
return;
try
{
var record = e.EventRecord;
// 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;
}
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 }
};
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = eventType,
Severity = severity,
Source = "ModernDeploymentWatcher",
Phase = EnrollmentPhase.Unknown,
Message = $"[{shortName}] EventID {record.Id}: {truncated}",
Data = data
});
}
catch (Exception ex)
{
_logger.Error($"Error processing ModernDeployment event from {shortName}", ex);
}
}
}
}
@@ -64,6 +64,57 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
private const int DeviceSetupFallbackDelaySeconds = 30;
private const int ProvisioningDebounceMilliseconds = 1000;
/// <summary>
/// Snapshot of current provisioning-category state for consumers like the signal-correlated
/// WhiteGlove detection. Thread-safe: copies the underlying dictionary under the state lock.
/// </summary>
public Dictionary<string, bool?> GetProvisioningCategorySnapshot()
{
lock (_stateLock)
{
return _lastCategorySucceeded == null
? new Dictionary<string, bool?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, bool?>(_lastCategorySucceeded, StringComparer.OrdinalIgnoreCase);
}
}
/// <summary>
/// True when DeviceSetupCategory.Status has resolved categorySucceeded=true OR when the
/// fallback (all subcategories succeeded, categorySucceeded null) has been confirmed.
/// </summary>
public bool DeviceSetupCategorySucceeded
{
get
{
lock (_stateLock)
{
if (_deviceSetupProvisioningCompleteFired)
return true;
return _lastCategorySucceeded != null
&& _lastCategorySucceeded.TryGetValue("DeviceSetupCategory.Status", out var v)
&& v == true;
}
}
}
/// <summary>
/// True when any AccountSetup subcategory has been tracked (resolved or in progress).
/// </summary>
public bool HasAccountSetupActivity
{
get
{
lock (_stateLock)
{
if (_lastSubcategoryStates == null)
return false;
return _lastSubcategoryStates.TryGetValue("AccountSetupCategory.Status", out var subs)
&& subs != null
&& subs.Count > 0;
}
}
}
private void StartProvisioningStatusWatcher()
{
_lastProvisioningJson = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -56,6 +56,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
private readonly object _stateLock = new object();
private readonly int _helloWaitTimeoutSeconds;
private readonly bool _modernDeploymentWatcherEnabled;
private readonly int _modernDeploymentLogLevelMax;
private const int HelloCompletionTimeoutSeconds = 300;
private const int BackfillLookbackMinutes = 5;
@@ -145,13 +147,22 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
EventId_HelloForBusiness_ProcessingStopped
};
public EspAndHelloTracker(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected, AgentLogger logger, int helloWaitTimeoutSeconds = 30)
public EspAndHelloTracker(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger,
int helloWaitTimeoutSeconds = 30,
bool modernDeploymentWatcherEnabled = true,
int modernDeploymentLogLevelMax = 3)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_helloWaitTimeoutSeconds = helloWaitTimeoutSeconds;
_modernDeploymentWatcherEnabled = modernDeploymentWatcherEnabled;
_modernDeploymentLogLevelMax = modernDeploymentLogLevelMax;
}
/// <summary>
@@ -210,6 +221,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
// Subscribe to HelloForBusiness/Operational event log for Hello processing signals
StartHelloForBusinessEventLogWatcher();
// Subscribe to ModernDeployment-Diagnostics-Provider channels (Autopilot + ManagementService)
// for live capture of ESP/Autopilot events that Shell-Core/Registry watchers may miss.
if (_modernDeploymentWatcherEnabled)
{
StartModernDeploymentEventLogWatchers();
}
// Safety net: backfill recent terminal events in case watcher started late or event delivery lagged.
BackfillRecentTerminalHelloEvents();
BackfillRecentHelloForBusinessEvents();
@@ -315,6 +333,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
_logger.Error("Error stopping Shell-Core event watcher", ex);
}
}
// Stop ModernDeployment-Diagnostics-Provider watchers
StopModernDeploymentEventLogWatchers();
}
public void Dispose()
@@ -0,0 +1,520 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Ebene 2 — Defensive deep probes that run only when enrollment has been silent for a while.
///
/// The collector is NOT a timer of its own. Instead, it is invoked from
/// <see cref="Core.MonitoringService.IdleCheckCallback"/> (which runs every 60 s for the
/// entire agent lifetime). On each tick the collector inspects the idle duration and fires
/// a full scan at the configured thresholds (default: 2, 15, 30, 60, 180 min).
///
/// Each probe scans up to four read-only sources (all wrapped in try/catch + hard timeout):
/// 1. Provisioning registry (HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings)
/// 2. Diagnostics registry (HKLM\SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot)
/// 3. Windows event logs (ModernDeployment + Shell-Core + Application)
/// 4. AppWorkload.log tail (IME ESP app install state)
///
/// The probe classifies the outcome as:
/// - anomaly → <c>stall_probe_result</c> (Warning) + optional <c>EspFailureDetected</c>
/// - activeInstalls → positive signal (healthy-in-progress), no alarm
/// - quiet → nothing found in any source
///
/// Output policy (configurable via StallProbeTraceIndices, default = [2]):
/// - Trace-indexed probes: emit a <c>stall_probe_check</c> Trace event on quiet/activeInstalls.
/// - Non-trace probes: emit nothing when no anomaly is found (agent log only).
/// - Anomaly found: always emit <c>stall_probe_result</c> Warning (plus EspFailureDetected if terminal).
/// - Probe index configured as "session-stalled": emit fire-once <c>session_stalled</c> Warning.
/// </summary>
public class StallProbeCollector
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly AgentLogger _logger;
private readonly int[] _thresholdsMinutes;
private readonly HashSet<int> _traceIndices;
private readonly HashSet<string> _sources;
private readonly int _sessionStalledAfterProbeIndex;
private readonly object _stateLock = new object();
private readonly HashSet<int> _firedProbeIndices = new HashSet<int>();
private bool _sessionStalledFired;
public event EventHandler<string> EspFailureDetected;
// Per-source hard timeouts (ms)
private const int RegistryScanTimeoutMs = 500;
private const int DiagnosticsRegistryScanTimeoutMs = 500;
private const int EventLogScanTimeoutMs = 2000;
private const int AppWorkloadTailTimeoutMs = 1000;
// Terminal ESP failure EventIDs in ModernDeployment-Diagnostics-Provider channels.
// Conservative list — we prefer to NOT auto-terminate on unknown IDs and let the user decide.
//
// TODO(stall-probe-classification): This is stellschraube #1 for promoting stall-probe findings
// to terminal failures. Currently empty by design: the ModernDeployment watcher (Ebene 1) runs
// in live-capture mode and forwards raw events so we can gather real production EventIDs first.
// Once we have 1–2 weeks of production data showing which EventIDs reliably correspond to
// terminal ESP failures (e.g. "app install failed terminally", "ESP category Error"), add them
// here. From that point on, Probe 1 (2 min) will detect them and fire EspFailureDetected →
// the session goes terminal-Failed within ~2 min instead of waiting for the 5h backend sweep.
// Cross-reference: the matching terminalReason should go into result.TerminalReason when you
// add entries (see ScanEventLogs around the TerminalModernDeploymentEventIds.Contains check).
private static readonly HashSet<int> TerminalModernDeploymentEventIds = new HashSet<int>
{
// Populated as real failure EventIDs are observed in production.
// Kept empty intentionally — live-capture mode (Etappe 3) collects the raw events first.
};
// AppWorkload.log active-install markers (positive signals)
private static readonly Regex ActiveInstallRegex = new Regex(
@"EnforcementState\s*[:=]\s*(Downloading|Installing|InProgress)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
// AppWorkload.log failure markers (negative signals — but scanner only reports, no terminal decision).
//
// TODO(stall-probe-classification): This is stellschraube #2 for promoting stall-probe findings
// to terminal failures. Today any match of this regex produces a Warning-severity
// stall_probe_result event but does NOT set result.IsTerminal or fire EspFailureDetected —
// on purpose, because IME writes transient "Failed" lines that recover (retry, re-detect) and
// we do not want false-terminal verdicts during the live-capture phase. Once we can
// distinguish "retryable IME hiccup" from "terminal app install failure" based on real
// production data, tighten the regex (e.g. require a specific error code range or a second
// confirming line) and set result.IsTerminal = true + result.TerminalReason in ScanAppWorkloadTail.
// Ideas for stronger terminal signals to add here once validated:
// - EnforcementState: Failed + specific HRESULT ranges (0x87D1... Win32 app failures)
// - DetectionState: Error that persists across multiple tail samples
// - Explicit "Required app failed" lines from the ESP required-app enforcer
private static readonly Regex FailureRegex = new Regex(
@"EnforcementState\s*[:=]\s*Failed|Error\s+0x[0-9A-Fa-f]+|DetectionState\s*[:=]\s*Error",
RegexOptions.Compiled);
public StallProbeCollector(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger,
int[] thresholdsMinutes,
int[] traceIndices,
string[] sources,
int sessionStalledAfterProbeIndex)
{
_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));
_thresholdsMinutes = thresholdsMinutes ?? throw new ArgumentNullException(nameof(thresholdsMinutes));
_traceIndices = new HashSet<int>(traceIndices ?? new int[0]);
_sources = new HashSet<string>(sources ?? new string[0], StringComparer.OrdinalIgnoreCase);
_sessionStalledAfterProbeIndex = sessionStalledAfterProbeIndex;
}
/// <summary>
/// Resets all per-probe fire-once state. Called when a new real (non-periodic) event arrives.
/// session_stalled stays fire-once across the whole session — we don't re-warn if the
/// session stalls a second time; the backend maintenance sweep handles late stalls.
/// </summary>
public void ResetProbes()
{
lock (_stateLock)
{
if (_firedProbeIndices.Count > 0)
{
_logger.Trace($"StallProbeCollector: resetting {_firedProbeIndices.Count} fired probes");
_firedProbeIndices.Clear();
}
}
}
/// <summary>
/// Called from the 60-s idle check tick. Decides whether any probe threshold has been
/// newly crossed and runs the corresponding probe.
/// </summary>
public void CheckAndRunProbes(double idleMinutes)
{
// Iterate thresholds from lowest to highest so probes fire in order.
for (int i = 0; i < _thresholdsMinutes.Length; i++)
{
var thresholdMinutes = _thresholdsMinutes[i];
if (idleMinutes < thresholdMinutes)
return;
int probeIndex = i + 1; // 1-based for user-facing output
bool alreadyFired;
lock (_stateLock)
{
alreadyFired = _firedProbeIndices.Contains(probeIndex);
}
if (alreadyFired)
continue;
RunProbe(probeIndex, thresholdMinutes, idleMinutes);
lock (_stateLock)
{
_firedProbeIndices.Add(probeIndex);
}
}
}
private void RunProbe(int probeIndex, int thresholdMinutes, double idleMinutes)
{
var started = DateTime.UtcNow;
var result = new ProbeResult { ProbeIndex = probeIndex, IdleMinutes = Math.Round(idleMinutes, 1) };
// Run each source best-effort; one failure does not skip the others.
if (_sources.Contains("provisioning_registry"))
RunWithTimeout(() => ScanProvisioningRegistry(result), RegistryScanTimeoutMs, "provisioning_registry", result);
if (_sources.Contains("diagnostics_registry"))
RunWithTimeout(() => ScanDiagnosticsRegistry(result), DiagnosticsRegistryScanTimeoutMs, "diagnostics_registry", result);
if (_sources.Contains("eventlog"))
RunWithTimeout(() => ScanEventLogs(result), EventLogScanTimeoutMs, "eventlog", result);
if (_sources.Contains("appworkload_log"))
RunWithTimeout(() => ScanAppWorkloadTail(result), AppWorkloadTailTimeoutMs, "appworkload_log", result);
result.DurationMs = (int)(DateTime.UtcNow - started).TotalMilliseconds;
// Always log locally for diagnostics — the agent_YYYYMMDD.log is the always-on observability.
var anomaliesStr = result.Anomalies.Count > 0
? $"anomalies=[{string.Join(",", result.Anomalies)}]"
: "quiet";
var activeStr = result.ActiveInstalls.Count > 0
? $" activeInstalls=[{string.Join(",", result.ActiveInstalls)}]"
: string.Empty;
var failedStr = result.SourcesFailed.Count > 0
? $" sources_failed=[{string.Join(",", result.SourcesFailed)}]"
: string.Empty;
_logger.Info($"StallProbeCollector: Probe {probeIndex}/{_thresholdsMinutes.Length} ran at idle={result.IdleMinutes}min, {anomaliesStr}{activeStr}{failedStr} (duration={result.DurationMs}ms)");
bool hasAnomaly = result.Anomalies.Count > 0;
bool hasActiveInstalls = result.ActiveInstalls.Count > 0;
// Emit stall_probe_result Warning on anomaly — always, regardless of probe index.
if (hasAnomaly)
{
EmitProbeResultEvent(result, severity: EventSeverity.Warning);
if (result.IsTerminal)
{
_logger.Warning($"StallProbeCollector: terminal ESP failure detected in Probe {probeIndex} — firing EspFailureDetected");
try { EspFailureDetected?.Invoke(this, result.TerminalReason ?? "stall_probe_terminal"); }
catch (Exception ex) { _logger.Error("EspFailureDetected handler threw", ex); }
}
}
else if (_traceIndices.Contains(probeIndex))
{
// Quiet or activeInstalls — emit a Trace heartbeat at trace-indexed probes.
EmitProbeCheckTraceEvent(result);
}
// else: non-trace probe without anomaly → silent except for agent log.
// session_stalled is fire-once across the whole session. It fires at the configured
// probe index UNLESS activeInstalls is observed (activeInstalls counts as progress).
if (probeIndex == _sessionStalledAfterProbeIndex && !hasActiveInstalls)
{
lock (_stateLock)
{
if (_sessionStalledFired)
return;
_sessionStalledFired = true;
}
EmitSessionStalledEvent(result);
}
}
private void RunWithTimeout(Action action, int timeoutMs, string sourceName, ProbeResult result)
{
try
{
// Run on the calling thread but guard against exceptions. We intentionally do not
// spawn a new thread per source — the IdleCheckCallback already runs on a worker
// thread, and spinning extra threads would be heavier than the work itself.
// The timeoutMs is enforced via source-internal reads (FileStream, Registry handles
// are using-scoped and the reads themselves are bounded).
var sw = System.Diagnostics.Stopwatch.StartNew();
action();
sw.Stop();
if (sw.ElapsedMilliseconds > timeoutMs)
{
_logger.Warning($"StallProbeCollector: {sourceName} scan exceeded soft timeout ({sw.ElapsedMilliseconds}ms > {timeoutMs}ms)");
}
}
catch (Exception ex)
{
_logger.Warning($"StallProbeCollector: {sourceName} scan failed: {ex.Message}");
result.SourcesFailed.Add(sourceName);
}
}
private void ScanProvisioningRegistry(ProbeResult result)
{
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings", writable: false))
{
if (key == null)
return;
foreach (var valueName in new[] { "DevicePreparationCategory.Status", "DeviceSetupCategory.Status", "AccountSetupCategory.Status" })
{
var json = key.GetValue(valueName)?.ToString();
if (string.IsNullOrEmpty(json))
continue;
// Rudimentary scan — only flag patterns that are definitively bad.
if (json.IndexOf("\"categorySucceeded\":false", StringComparison.OrdinalIgnoreCase) >= 0 ||
json.IndexOf("\"subcategoryState\":\"failed\"", StringComparison.OrdinalIgnoreCase) >= 0)
{
result.Anomalies.Add($"provisioning_registry:{valueName}");
if (result.RawSamples.Count < 5)
result.RawSamples.Add($"{valueName}: {Truncate(json, 400)}");
}
}
}
}
private void ScanDiagnosticsRegistry(ProbeResult result)
{
using (var diagRoot = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot", writable: false))
{
if (diagRoot == null)
return;
object deployErrorCode = diagRoot.GetValue("DeploymentErrorCode");
object deployErrorReason = diagRoot.GetValue("DeploymentErrorReason");
if (deployErrorCode != null)
{
// DeploymentErrorCode is typically a DWORD. 0 = no error.
var asString = deployErrorCode.ToString();
if (!string.IsNullOrEmpty(asString) && asString != "0" && asString != "0x0")
{
result.Anomalies.Add("diagnostics_registry:DeploymentErrorCode");
result.RawSamples.Add($"DeploymentErrorCode={asString}; DeploymentErrorReason={deployErrorReason ?? "(null)"}");
result.IsTerminal = true;
result.TerminalReason = $"DeploymentErrorCode={asString}";
}
}
}
}
private void ScanEventLogs(ProbeResult result)
{
// Scan a small window (last 15 min) of Autopilot/ESP-related channels at Level ≤ 3.
var since = DateTime.UtcNow.AddMinutes(-15);
var sinceStr = since.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
var xpath = $"*[System[(Level >= 1 and Level <= 3) and TimeCreated[@SystemTime >= '{sinceStr}']]]";
var channels = new[]
{
"Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot",
"Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService",
"Microsoft-Windows-Shell-Core/Operational"
};
int foundCount = 0;
foreach (var channel in channels)
{
try
{
var query = new EventLogQuery(channel, PathType.LogName, xpath) { ReverseDirection = true };
using (var reader = new EventLogReader(query))
{
int perChannelCap = 20;
EventRecord record;
while ((record = reader.ReadEvent()) != null && perChannelCap-- > 0)
{
using (record)
{
foundCount++;
if (result.RawSamples.Count < 5)
{
string desc = null;
try { desc = record.FormatDescription(); }
catch { }
desc = Truncate(desc ?? $"EventID {record.Id}", 400);
result.RawSamples.Add($"[{channel.Split('/').Last()}] ID={record.Id} Level={record.Level}: {desc}");
}
// Check for known terminal ModernDeployment failure event IDs.
if (TerminalModernDeploymentEventIds.Contains(record.Id))
{
result.IsTerminal = true;
result.TerminalReason = $"ModernDeployment EventID {record.Id}";
}
}
}
}
}
catch (EventLogNotFoundException)
{
// Channel not available on this OS — ignore silently.
}
catch (Exception ex)
{
_logger.Trace($"StallProbeCollector: eventlog scan of {channel} failed: {ex.Message}");
}
}
if (foundCount > 0)
{
result.Anomalies.Add($"eventlog:{foundCount}_events");
}
}
private void ScanAppWorkloadTail(ProbeResult result)
{
var path = @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\AppWorkload.log";
if (!File.Exists(path))
return;
// Read the last ~200 KB. AppWorkload.log can be huge; we never read the whole file.
const int tailBytes = 200 * 1024;
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
{
long start = Math.Max(0, fs.Length - tailBytes);
fs.Seek(start, SeekOrigin.Begin);
using (var reader = new StreamReader(fs, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true))
{
string content = reader.ReadToEnd();
// Cut at the first newline to avoid starting mid-line.
var firstNewline = content.IndexOf('\n');
if (firstNewline > 0 && firstNewline < content.Length - 1)
content = content.Substring(firstNewline + 1);
// Positive-signal scan: active installations recently logged.
foreach (Match m in ActiveInstallRegex.Matches(content))
{
if (result.ActiveInstalls.Count < 10 && !result.ActiveInstalls.Contains(m.Value))
result.ActiveInstalls.Add(m.Value);
}
// Negative-signal scan: failures.
var failureMatches = FailureRegex.Matches(content);
if (failureMatches.Count > 0)
{
result.Anomalies.Add($"appworkload_log:{failureMatches.Count}_errors");
// Sample up to 3 failure lines for context.
int sampled = 0;
foreach (Match m in failureMatches)
{
if (sampled >= 3 || result.RawSamples.Count >= 5)
break;
// Extract the enclosing line for context.
int lineStart = content.LastIndexOf('\n', Math.Max(0, m.Index));
int lineEnd = content.IndexOf('\n', m.Index);
if (lineStart < 0) lineStart = 0;
if (lineEnd < 0) lineEnd = content.Length;
var line = content.Substring(lineStart, Math.Min(400, lineEnd - lineStart)).Trim();
result.RawSamples.Add($"AppWorkload: {line}");
sampled++;
}
}
}
}
}
private void EmitProbeResultEvent(ProbeResult result, EventSeverity severity)
{
var data = BuildDataJson(result);
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.StallProbeResult,
Severity = severity,
Source = "StallProbeCollector",
Phase = EnrollmentPhase.Unknown,
Message = $"Stall probe {result.ProbeIndex}/{_thresholdsMinutes.Length}: {result.Anomalies.Count} anomaly/ies after {result.IdleMinutes}min idle",
Data = data
});
}
private void EmitProbeCheckTraceEvent(ProbeResult result)
{
var data = BuildDataJson(result);
var summary = result.ActiveInstalls.Count > 0
? $"activeInstalls={result.ActiveInstalls.Count}"
: "quiet";
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.StallProbeCheck,
Severity = EventSeverity.Trace,
Source = "StallProbeCollector",
Phase = EnrollmentPhase.Unknown,
Message = $"Stall probe {result.ProbeIndex}/{_thresholdsMinutes.Length} heartbeat: {summary} after {result.IdleMinutes}min idle",
Data = data
});
}
private void EmitSessionStalledEvent(ProbeResult result)
{
var data = BuildDataJson(result);
data["probeIndexTriggered"] = result.ProbeIndex;
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.SessionStalled,
Severity = EventSeverity.Warning,
Source = "StallProbeCollector",
Phase = EnrollmentPhase.Unknown,
Message = $"Session stalled: no enrollment progress for {result.IdleMinutes}min (stall probe {result.ProbeIndex})",
Data = data
});
}
private static Dictionary<string, object> BuildDataJson(ProbeResult r)
{
return new Dictionary<string, object>
{
{ "probeIndex", r.ProbeIndex },
{ "idleMinutes", r.IdleMinutes },
{ "probeDurationMs", r.DurationMs },
{ "anomalyTypes", r.Anomalies.ToArray() },
{ "activeInstalls", r.ActiveInstalls.ToArray() },
{ "sources_failed", r.SourcesFailed.ToArray() },
{ "rawSamples", r.RawSamples.ToArray() },
{ "isTerminal", r.IsTerminal },
{ "terminalReason", r.TerminalReason ?? string.Empty }
};
}
private static string Truncate(string s, int max)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
return s.Length <= max ? s : s.Substring(0, max) + "…";
}
private class ProbeResult
{
public int ProbeIndex;
public double IdleMinutes;
public int DurationMs;
public readonly List<string> Anomalies = new List<string>();
public readonly List<string> ActiveInstalls = new List<string>();
public readonly List<string> SourcesFailed = new List<string>();
public readonly List<string> RawSamples = new List<string>();
public bool IsTerminal;
public string TerminalReason;
}
}
}
@@ -66,21 +66,46 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
_collectorIdleTimeoutMinutes = collectors?.CollectorIdleTimeoutMinutes ?? 15;
_lastRealEventTime = DateTime.UtcNow;
_collectorsIdleStopped = false;
if (_collectorIdleTimeoutMinutes > 0)
// Stall probe collector (Ebene 2) — event-driven deep probes at 2/15/30/60/180 min idle
if (collectors?.StallProbeEnabled != false)
{
_idleCheckTimer = new Timer(
IdleCheckCallback,
null,
TimeSpan.FromSeconds(60),
TimeSpan.FromSeconds(60)
);
_logger.Info($"Collector idle timeout enabled: {_collectorIdleTimeoutMinutes} min");
var probeThresholds = collectors?.StallProbeThresholdsMinutes ?? new[] { 2, 15, 30, 60, 180 };
var probeTraceIndices = collectors?.StallProbeTraceIndices ?? new[] { 2 };
var probeSources = collectors?.StallProbeSources ?? new[]
{
"provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log"
};
var stalledAfterIndex = collectors?.SessionStalledAfterProbeIndex ?? 4;
_stallProbeCollector = new StallProbeCollector(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger,
probeThresholds,
probeTraceIndices,
probeSources,
stalledAfterIndex);
_logger.Info($"StallProbeCollector enabled: thresholds=[{string.Join(",", probeThresholds)}]min, traces=[{string.Join(",", probeTraceIndices)}], sources={probeSources.Length}");
}
else
{
_logger.Info("Collector idle timeout disabled (0)");
_logger.Info("StallProbeCollector disabled via config");
}
// The idle check timer drives BOTH the legacy collector idle timeout AND the stall probes.
// It runs every 60s for the entire agent lifetime (not stopped when collectors pause).
_idleCheckTimer = new Timer(
IdleCheckCallback,
null,
TimeSpan.FromSeconds(60),
TimeSpan.FromSeconds(60)
);
if (_collectorIdleTimeoutMinutes > 0)
_logger.Info($"Collector idle timeout enabled: {_collectorIdleTimeoutMinutes} min");
else
_logger.Info("Collector idle timeout disabled (0)");
// EnrollmentTracker: smart enrollment tracking with IME log parsing
// (replaces DownloadProgressCollector + EspUiStateCollector)
// Skip when log replay is active - LogReplayService starts its own EnrollmentTracker
@@ -105,6 +130,32 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
);
_enrollmentTracker.Start();
_logger.Info("EnrollmentTracker started — listening for IME patterns");
// Forward the previously-detected system reboot signal into the tracker so
// the signal-correlated WhiteGlove heuristic can see it. (The reboot event
// was already emitted from EmitStartupEvents before the tracker existed.)
if (_previousExitType == "reboot_kill" && _lastBootTimeUtc.HasValue)
{
_enrollmentTracker.NotifySystemRebootDetected();
}
// Wire up the stall probe's terminal-failure signal into the existing
// EnrollmentTracker failure pathway so downstream logic (grace period,
// enrollment_failed emission) stays unified.
if (_stallProbeCollector != null)
{
_stallProbeCollector.EspFailureDetected += (sender, failureType) =>
{
try
{
_enrollmentTracker?.ReportEspFailureFromExternalSource(failureType);
}
catch (Exception ex)
{
_logger.Error("Failed to forward stall probe failure to EnrollmentTracker", ex);
}
};
}
}
// Delivery Optimization collector — polls OS-level DO status for peer caching telemetry
@@ -293,27 +344,48 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
}
/// <summary>
/// Returns true for events generated by periodic timers (not real enrollment activity)
/// Returns true for events generated by periodic timers (not real enrollment activity).
/// These events do NOT reset _lastRealEventTime and do NOT restart periodic collectors —
/// the enrollment is considered "still idle" even if one of these events was emitted.
/// Stall-probe self-reports are classified periodic so the probe cannot unmark itself
/// as stalled (only a genuine enrollment signal can).
/// </summary>
private static bool IsPeriodicEvent(string eventType)
{
return eventType == "performance_snapshot" ||
eventType == "agent_metrics_snapshot" ||
eventType == "performance_collector_stopped" ||
eventType == "agent_metrics_collector_stopped";
eventType == "agent_metrics_collector_stopped" ||
eventType == Constants.EventTypes.StallProbeCheck ||
eventType == Constants.EventTypes.StallProbeResult ||
eventType == Constants.EventTypes.SessionStalled;
}
/// <summary>
/// Periodic check (every 60s) whether enrollment has gone idle.
/// When no real event has arrived within the configured timeout window,
/// periodic collectors are stopped to prevent session bloat.
/// Two responsibilities:
/// 1. Stop telemetry collectors after the configured idle window to prevent session bloat.
/// 2. Invoke the StallProbeCollector to check probe thresholds (2/15/30/60/180 min) and
/// scan for stuck ESP/IME state when silence persists. The timer keeps running even
/// after collectors are stopped so probe thresholds beyond the idle window are reachable.
/// </summary>
private void IdleCheckCallback(object state)
{
var idleMinutes = (DateTime.UtcNow - _lastRealEventTime).TotalMinutes;
// Delegate to stall probe first — it reads _lastRealEventTime and decides which probe to run.
try
{
_stallProbeCollector?.CheckAndRunProbes(idleMinutes);
}
catch (Exception ex)
{
_logger.Error("StallProbeCollector.CheckAndRunProbes threw", ex);
}
if (_collectorsIdleStopped)
return;
var idleMinutes = (DateTime.UtcNow - _lastRealEventTime).TotalMinutes;
if (idleMinutes < _collectorIdleTimeoutMinutes)
return;
@@ -373,8 +445,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
_collectorsIdleStopped = true;
// Stop the idle check timer — it will be restarted if collectors resume
_idleCheckTimer?.Change(Timeout.Infinite, Timeout.Infinite);
// NOTE: The idle check timer keeps running (previously stopped here) so the
// StallProbeCollector can still reach the higher probe thresholds (60/180 min).
// The early-return guard above prevents re-entry of the collector-stop logic.
}
/// <summary>
@@ -38,6 +38,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
{
_lastRealEventTime = DateTime.UtcNow;
// Reset stall probes — a real event proves the enrollment is still making progress,
// so the probe window starts fresh from 0.
try { _stallProbeCollector?.ResetProbes(); }
catch (Exception ex) { _logger.Verbose($"StallProbeCollector.ResetProbes failed: {ex.Message}"); }
// Restart periodic collectors if they were stopped due to idle
if (_collectorsIdleStopped)
{
@@ -44,6 +44,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
private PerformanceCollector _performanceCollector;
private AgentSelfMetricsCollector _agentSelfMetricsCollector;
private DeliveryOptimizationCollector _deliveryOptimizationCollector;
private StallProbeCollector _stallProbeCollector;
// Smart enrollment tracking (replaces DownloadProgressCollector + EspUiStateCollector)
private EnrollmentTracker _enrollmentTracker;
@@ -525,12 +526,16 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
// Start ESP and Hello tracker (ESP exit, WhiteGlove, and WHfB provisioning tracking)
var helloTimeout = _remoteConfigService?.CurrentConfig?.Collectors?.HelloWaitTimeoutSeconds
?? _configuration.HelloWaitTimeoutSeconds;
var modernDeploymentEnabled = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentWatcherEnabled ?? true;
var modernDeploymentLevelMax = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentLogLevelMax ?? 3;
_espAndHelloTracker = new EspAndHelloTracker(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger,
helloTimeout
helloTimeout,
modernDeploymentWatcherEnabled: modernDeploymentEnabled,
modernDeploymentLogLevelMax: modernDeploymentLevelMax
);
_espAndHelloTracker.Start();
@@ -168,10 +168,22 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
/// Called when WhiteGlove (Pre-Provisioning) completes successfully.
/// Emits the whiteglove_complete event. The MonitoringService handles
/// the actual agent shutdown upon seeing this event type.
/// Idempotent: fire-once per session instance. A second invocation (e.g. Shell-Core
/// watcher fires after the signal-correlated detector already matched) is a no-op.
/// </summary>
private void OnWhiteGloveCompleted(object sender, EventArgs e)
{
_logger.Info("EnrollmentTracker: WhiteGlove pre-provisioning completed");
lock (_stateLock)
{
if (_signalCorrelatedWhiteGloveTriggered)
{
_logger.Debug("EnrollmentTracker: whiteglove_complete already emitted, skipping duplicate");
return;
}
_signalCorrelatedWhiteGloveTriggered = true;
}
_logger.Info($"EnrollmentTracker: WhiteGlove pre-provisioning completed (source: {sender?.GetType().Name ?? "unknown"})");
// Stop summary timer — no more app tracking needed
_summaryTimer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
@@ -195,11 +207,116 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
// Der State wird fuer Part 2 benoetigt falls der Tracker weiterlaufen muss.
}
/// <summary>
/// Signal-correlated WhiteGlove detection (Ebene 2.6). Called periodically from the
/// summary timer (every 30 s). Evaluates the sealing pattern and, on a stable match
/// lasting ≥10 min, triggers the existing OnWhiteGloveCompleted pipeline — which emits
/// whiteglove_complete, persists the Part 1 marker, and starts graceful shutdown.
///
/// Pattern (ALL required):
/// - All device-targeted apps installed, no errors (via PackageStates)
/// - DeviceSetupCategory.Status is succeeded
/// - A system reboot has been observed since session start
/// - AAD join status shows Not Joined
/// - AccountSetup category has no activity (no resolved subcategories)
/// - No desktop arrival event has been seen
/// - No Hello signals (Hello not completed, wizard not started)
/// </summary>
public void CheckSignalCorrelatedWhiteGlove()
{
// Fast-exit: guards that don't require I/O.
lock (_stateLock)
{
if (_signalCorrelatedWhiteGloveTriggered)
return;
if (_desktopArrived || _enrollmentCompleteEmitted)
return;
// Reboot observation is required — without it we are still in Part 1 pre-reboot.
if (!_systemRebootObserved)
return;
// AAD Not Joined is required — if the user already signed in, this is not sealing.
if (_aadJoinedWithUser)
return;
}
// EspAndHelloTracker provides the authoritative provisioning signals.
if (_espAndHelloTracker == null)
return;
bool deviceSetupOk = _espAndHelloTracker.DeviceSetupCategorySucceeded;
bool hasAccountSetupActivity = _espAndHelloTracker.HasAccountSetupActivity;
bool helloInFlight = _espAndHelloTracker.IsHelloCompleted; // true once Hello resolved (either success or skip)
// ImeLogTracker provides the app-tracking state.
var packageStates = _imeLogTracker?.PackageStates;
bool appsDone = packageStates != null
&& packageStates.CountAll > 0
&& packageStates.CountCompleted >= packageStates.CountAll
&& packageStates.ErrorCount == 0;
bool allConditionsMet = deviceSetupOk && appsDone && !hasAccountSetupActivity && !helloInFlight;
DateTime? triggerAt = null;
bool shouldTrigger = false;
lock (_stateLock)
{
if (!allConditionsMet)
{
// Stability window broken — reset.
if (_signalCorrelatedWhiteGloveStableSince != null)
{
_logger.Debug("EnrollmentTracker: signal-correlated WhiteGlove conditions no longer stable, resetting stability window");
_signalCorrelatedWhiteGloveStableSince = null;
}
return;
}
if (_signalCorrelatedWhiteGloveStableSince == null)
{
_signalCorrelatedWhiteGloveStableSince = DateTime.UtcNow;
_logger.Info($"EnrollmentTracker: signal-correlated WhiteGlove conditions met — starting {SignalCorrelatedWhiteGloveStabilityMinutes}min stability window");
return;
}
var stableDuration = DateTime.UtcNow - _signalCorrelatedWhiteGloveStableSince.Value;
if (stableDuration.TotalMinutes >= SignalCorrelatedWhiteGloveStabilityMinutes)
{
shouldTrigger = true;
triggerAt = _signalCorrelatedWhiteGloveStableSince.Value;
}
}
if (shouldTrigger)
{
_logger.Info($"EnrollmentTracker: signal-correlated WhiteGlove stable since {triggerAt:HH:mm:ss} UTC — invoking OnWhiteGloveCompleted");
try
{
OnWhiteGloveCompleted(this, EventArgs.Empty);
}
catch (Exception ex)
{
_logger.Error("Signal-correlated WhiteGlove invocation failed", ex);
}
}
}
/// <summary>
/// Called when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort, WhiteGlove_Failed, etc.).
/// Terminal failures emit enrollment_failed immediately.
/// Recoverable failures (e.g. ESPProgress_Timeout) get a grace period before failure.
/// </summary>
/// <summary>
/// Public entry point for secondary ESP-failure detectors (StallProbeCollector) to report
/// a terminal failure finding. Reuses the same OnEspFailureDetected pathway as the
/// Shell-Core watcher so the downstream logic (grace period, enrollment_failed emission)
/// stays unified.
/// </summary>
public void ReportEspFailureFromExternalSource(string failureType)
{
OnEspFailureDetected(this, failureType);
}
private void OnEspFailureDetected(object sender, string failureType)
{
_logger.Info($"EnrollmentTracker: ESP failure detected: {failureType}");
@@ -69,6 +69,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
// Only emit if state-breakdown counters changed since last emission (backstop for missed events)
EmitAppTrackingSummaryIfChanged();
// Signal-correlated WhiteGlove detection (Ebene 2.6) — event-driven in the sense
// that it only triggers when the observed signal set matches; zero-cost when the
// session does not look like a sealing candidate.
try { CheckSignalCorrelatedWhiteGlove(); }
catch (Exception ex) { _logger.Error("CheckSignalCorrelatedWhiteGlove failed", ex); }
// Periodic state save (piggybacks on the existing 30s timer)
bool dirty;
EnrollmentStateData stateSnapshot;
@@ -96,6 +96,29 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
// Detected via CollectAadJoinStatus() in DeviceInfoCollector.
private bool _aadJoinedWithUser;
// Signal-correlated WhiteGlove detection (Ebene 2.6):
// Feeds the bestehenden OnWhiteGloveCompleted() callback when the Shell-Core detector
// does not match but the session shows the WhiteGlove sealing pattern (all Device-apps done,
// DeviceSetup category succeeded, system reboot observed, AAD Not Joined, no AccountSetup
// progress, no desktop arrival). Requires ≥10 min stability to avoid false-positives.
private DateTime? _signalCorrelatedWhiteGloveStableSince;
private bool _signalCorrelatedWhiteGloveTriggered;
private bool _systemRebootObserved;
private const int SignalCorrelatedWhiteGloveStabilityMinutes = 10;
/// <summary>
/// Called by MonitoringService when a system_reboot_detected event is emitted
/// (agent restart after an unclean exit / machine reboot). Used as an input signal
/// for the signal-correlated WhiteGlove detection heuristic.
/// </summary>
public void NotifySystemRebootDetected()
{
lock (_stateLock)
{
_systemRebootObserved = true;
}
}
// True when no interactive user session is expected.
// Self-Deploying (AutopilotMode=1) is always device-only.
// SkipUserStatusPage=true alone does NOT mean device-only — admins commonly skip
@@ -1 +1 @@
894
901
@@ -41,6 +41,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<SessionSummary>> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime)
=> _storage.GetStalledSessionsAsync(tenantId, cutoffTime);
public Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff)
=> _storage.GetAgentSilentSessionsAsync(tenantId, silenceCutoff, hardCutoff);
public Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours)
=> _storage.GetExcessiveDataSendersAsync(tenantId, windowCutoff, maxSessionWindowHours);
@@ -47,10 +47,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
EnrollmentPhase? currentPhase = null, string? failureReason = null,
DateTime? completedAt = null, DateTime? earliestEventTimestamp = null,
DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null,
bool? isUserDriven = null, DateTime? resumedAt = null)
bool? isUserDriven = null, DateTime? resumedAt = null,
DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false)
=> _storage.UpdateSessionStatusAsync(tenantId, sessionId, status,
currentPhase, failureReason, completedAt, earliestEventTimestamp,
latestEventTimestamp, isPreProvisioned, isUserDriven, resumedAt);
latestEventTimestamp, isPreProvisioned, isUserDriven, resumedAt,
stalledAt, clearStalledAt, clearFailureReason);
public Task IncrementSessionEventCountAsync(
string tenantId, string sessionId, int increment,
@@ -567,14 +567,42 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
case "device_location":
classification.DeviceLocationEvent = evt;
break;
case "session_stalled":
classification.SessionStalledEvent = evt;
break;
}
// Track whether this batch contains any non-periodic real event (used for
// Stalled→InProgress healing). Exclude telemetry snapshots and stall-probe
// self-reports which should not count as "agent is productively working".
if (!IsPeriodicOrStallEvent(evt.EventType))
classification.HasNonPeriodicRealEvent = true;
AggregateAppInstallEvent(evt, storedEvents[0].TenantId!, storedEvents[0].SessionId!, classification.AppInstallUpdates);
}
return classification;
}
/// <summary>
/// Classifies whether an event type should count as "real enrollment activity" for
/// Stalled→InProgress healing. Periodic telemetry and stall-probe self-reports are
/// excluded so that a silent agent that only emits heartbeats cannot unmark itself
/// as stalled without real progress.
/// </summary>
private static bool IsPeriodicOrStallEvent(string? eventType) => eventType switch
{
"performance_snapshot" => true,
"agent_metrics_snapshot" => true,
"performance_collector_stopped" => true,
"agent_metrics_collector_stopped" => true,
"stall_probe_check" => true,
"stall_probe_result" => true,
"session_stalled" => true,
"modern_deployment_log" => true,
_ => false
};
/// <summary>
/// Updates session status based on classified events.
/// Returns (statusTransitioned, whiteGloveStatusTransitioned, failureReason).
@@ -665,6 +693,36 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
}
}
// Stalled-status transitions are independent of Succeeded/Failed/Pending/WhiteGlove paths.
// They can coexist with normal processing: if a batch contains session_stalled, mark Stalled;
// if a batch contains any real (non-periodic) event while the session is currently Stalled,
// heal it back to InProgress.
if (c.SessionStalledEvent != null)
{
var stalledReason = "Agent reported stall after 60min without progress (stall_probe)";
var stalledTransitioned = await _sessionRepo.UpdateSessionStatusAsync(
request.TenantId, request.SessionId, SessionStatus.Stalled,
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp,
stalledAt: c.SessionStalledEvent.Timestamp, failureReason: stalledReason);
if (stalledTransitioned)
_logger.LogWarning("{SessionPrefix} Status: Stalled (agent-reported via session_stalled event)", sessionPrefix);
}
else if (c.HasNonPeriodicRealEvent && !statusTransitioned && !whiteGloveStatusTransitioned)
{
// Only attempt healing if the current session is actually Stalled (one read).
// The repository guard will no-op this call when the session is in any other state.
var currentSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId);
if (currentSession?.Status == SessionStatus.Stalled)
{
var healed = await _sessionRepo.UpdateSessionStatusAsync(
request.TenantId, request.SessionId, SessionStatus.InProgress,
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp,
clearStalledAt: true, clearFailureReason: true);
if (healed)
_logger.LogInformation("{SessionPrefix} Status: InProgress (healed from Stalled by new real event)", sessionPrefix);
}
}
return (statusTransitioned, whiteGloveStatusTransitioned, failureReason);
}
@@ -930,6 +988,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
public EnrollmentEvent? WhiteGloveEvent { get; set; }
public EnrollmentEvent? WhiteGloveResumedEvent { get; set; }
public EnrollmentEvent? EspFailureEvent { get; set; }
public EnrollmentEvent? SessionStalledEvent { get; set; }
public bool HasNonPeriodicRealEvent { get; set; }
public EnrollmentEvent? DeviceLocationEvent { get; set; }
public DateTime? EarliestEventTimestamp { get; set; }
public DateTime? LatestEventTimestamp { get; set; }
@@ -189,7 +189,14 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Marks sessions that have been in "InProgress" status for too long as "Failed - Timed Out"
/// Two-stage sweep for stuck sessions:
/// 1. **Agent-silent Stalled marker** (2h fixed): Sessions still InProgress but with no events
/// for more than 2h are marked as Stalled (non-terminal). Picks up agents that cannot emit
/// session_stalled themselves (bluescreen, network loss, power off). Excludes WhiteGlove.
/// 2. **Session timeout Failed** (5h default): Sessions that exceed the full SessionTimeoutHours
/// window (InProgress or Stalled) graduate to terminal Failed state.
/// Both stages run in the same 2h maintenance pass so no new timers are introduced
/// (preserving Container App scale-to-zero).
/// </summary>
private async Task MarkStalledSessionsAsTimedOutAsync()
{
@@ -200,6 +207,7 @@ namespace AutopilotMonitor.Functions.Services
{
var tenantIds = await _maintenanceRepo.GetAllTenantIdsAsync();
int totalSessionsTimedOut = 0;
int totalSessionsMarkedStalled = 0;
foreach (var tenantId in tenantIds)
{
@@ -207,8 +215,50 @@ namespace AutopilotMonitor.Functions.Services
{
var config = await _tenantConfigService.GetConfigurationAsync(tenantId);
var timeoutHours = config?.SessionTimeoutHours ?? 5;
var cutoffTime = DateTime.UtcNow.AddHours(-timeoutHours);
const int agentSilenceHours = 2; // fixed policy: 2h silence → Stalled intermediate
var now = DateTime.UtcNow;
var cutoffTime = now.AddHours(-timeoutHours);
var silenceCutoff = now.AddHours(-agentSilenceHours);
// -------- Stage 1: Agent-silent Stalled marker --------
// InProgress sessions with no events in > 2h but not yet older than 5h
// (otherwise Stage 2 picks them up directly as Failed).
var silentSessions = await _maintenanceRepo.GetAgentSilentSessionsAsync(
tenantId, silenceCutoff, hardCutoff: cutoffTime);
int silentMarked = 0;
foreach (var silent in silentSessions)
{
var lastEventAt = silent.LastEventAt ?? silent.StartedAt;
var silentMinutes = (int)(now - lastEventAt).TotalMinutes;
await _sessionRepo.UpdateSessionStatusAsync(
silent.TenantId,
silent.SessionId,
SessionStatus.Stalled,
stalledAt: now,
failureReason: $"Agent silent for {silentMinutes}min (detected by maintenance sweep)");
silentMarked++;
}
if (silentMarked > 0)
{
totalSessionsMarkedStalled += silentMarked;
_logger.LogInformation($"Tenant {tenantId}: Marked {silentMarked} agent-silent sessions as Stalled (silence threshold: {agentSilenceHours}h)");
await _maintenanceRepo.LogAuditEntryAsync(
tenantId,
"SessionStalled",
"Session",
$"{silentMarked} sessions",
"System.Maintenance",
new Dictionary<string, string>
{
{ "SessionsMarkedStalled", silentMarked.ToString() },
{ "AgentSilenceHours", agentSilenceHours.ToString() },
{ "SilenceCutoff", silenceCutoff.ToString("yyyy-MM-ddTHH:mm:ss") }
});
}
// -------- Stage 2: Terminal timeout (5h default) --------
var stalledSessions = await _maintenanceRepo.GetStalledSessionsAsync(tenantId, cutoffTime);
if (stalledSessions.Count == 0)
@@ -255,7 +305,7 @@ namespace AutopilotMonitor.Functions.Services
}
stalledStart.Stop();
_logger.LogInformation($"Stalled session check completed: {totalSessionsTimedOut} sessions timed out in {stalledStart.ElapsedMilliseconds}ms");
_logger.LogInformation($"Stalled session check completed: {totalSessionsMarkedStalled} marked Stalled, {totalSessionsTimedOut} timed out in {stalledStart.ElapsedMilliseconds}ms");
}
catch (Exception ex)
{
@@ -202,7 +202,15 @@ namespace AutopilotMonitor.Functions.Services
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var filter = $"PartitionKey eq '{tenantId}' and Status eq 'InProgress' and StartedAt lt datetime'{cutoffTime:yyyy-MM-ddTHH:mm:ss}Z'";
// Eligible for the 5h-timeout sweep: InProgress or Stalled sessions (Stalled is a
// non-terminal intermediate state set earlier by either the agent or the 2h sweep;
// when the 5h mark is reached without healing, the session graduates to Failed).
// IsPreProvisioned ne true → WhiteGlove sessions are intentionally long-lived and
// must never be timed out, even after months in storage.
var filter = $"PartitionKey eq '{tenantId}' " +
$"and (Status eq 'InProgress' or Status eq 'Stalled') " +
$"and StartedAt lt datetime'{cutoffTime:yyyy-MM-ddTHH:mm:ss}Z' " +
$"and IsPreProvisioned ne true";
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
var sessions = new List<SessionSummary>();
@@ -220,6 +228,51 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Gets sessions where the agent has gone completely silent for longer than the configured
/// agent-silence window (default 2h). These are candidates for the intermediate Stalled status.
/// Used by the 2h maintenance sweep as a backstop for agents that cannot send session_stalled
/// themselves (bluescreen, network loss, power off).
///
/// Filter criteria:
/// - Status eq 'InProgress' — do not re-mark already-Stalled sessions
/// - LastEventAt &lt; silenceCutoff — at least the configured window of agent silence
/// - StartedAt ge hardCutoff — do not catch sessions that will be picked up by the 5h timeout sweep
/// - IsPreProvisioned ne true — exclude sealed WhiteGlove sessions (intentionally long-lived)
/// </summary>
public async Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var silenceCutoffStr = silenceCutoff.ToString("yyyy-MM-ddTHH:mm:ss");
var hardCutoffStr = hardCutoff.ToString("yyyy-MM-ddTHH:mm:ss");
var filter = $"PartitionKey eq '{tenantId}' " +
$"and Status eq 'InProgress' " +
$"and LastEventAt lt datetime'{silenceCutoffStr}Z' " +
$"and StartedAt ge datetime'{hardCutoffStr}Z' " +
$"and IsPreProvisioned ne true";
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
var sessions = new List<SessionSummary>();
await foreach (var entity in query)
{
sessions.Add(MapToSessionSummary(entity));
}
return sessions;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get agent-silent sessions for tenant {tenantId}");
return new List<SessionSummary>();
}
}
/// <summary>
/// Gets sessions where the device has been actively sending data for longer than
/// <paramref name="maxSessionWindowHours"/>.
@@ -219,6 +219,7 @@ namespace AutopilotMonitor.Functions.Services
IsPreProvisioned = entity.GetBoolean("IsPreProvisioned") ?? false,
IsHybridJoin = entity.GetBoolean("IsHybridJoin") ?? false,
ResumedAt = SafeGetDateTime(entity, "ResumedAt"),
StalledAt = SafeGetDateTime(entity, "StalledAt"),
OsName = entity.GetString("OsName") ?? string.Empty,
OsBuild = entity.GetString("OsBuild") ?? string.Empty,
OsDisplayVersion = entity.GetString("OsDisplayVersion") ?? string.Empty,
@@ -291,6 +292,7 @@ namespace AutopilotMonitor.Functions.Services
int? durationSeconds = null;
string? diagnosticsBlobName = null;
DateTime? resumedAt = null;
DateTime? stalledAt = null;
string geoCountry = string.Empty;
string geoRegion = string.Empty;
string geoCity = string.Empty;
@@ -321,6 +323,7 @@ namespace AutopilotMonitor.Functions.Services
durationSeconds = existingEntity.GetInt32("DurationSeconds");
diagnosticsBlobName = existingEntity.GetString("DiagnosticsBlobName");
resumedAt = existingEntity.GetDateTimeOffset("ResumedAt")?.UtcDateTime;
stalledAt = existingEntity.GetDateTimeOffset("StalledAt")?.UtcDateTime;
geoCountry = existingEntity.GetString("GeoCountry") ?? string.Empty;
geoRegion = existingEntity.GetString("GeoRegion") ?? string.Empty;
geoCity = existingEntity.GetString("GeoCity") ?? string.Empty;
@@ -401,6 +404,9 @@ namespace AutopilotMonitor.Functions.Services
if (resumedAt.HasValue)
entity["ResumedAt"] = EnsureUtc(resumedAt.Value);
if (stalledAt.HasValue)
entity["StalledAt"] = EnsureUtc(stalledAt.Value);
if (!string.IsNullOrEmpty(geoCountry))
entity["GeoCountry"] = geoCountry;
if (!string.IsNullOrEmpty(geoRegion))
@@ -901,7 +907,7 @@ namespace AutopilotMonitor.Functions.Services
/// Event count is maintained atomically by IncrementSessionEventCountAsync and is not
/// recounted here — avoiding an expensive full-partition scan on every status change.
/// </summary>
public async Task<bool> UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, bool? isUserDriven = null, DateTime? resumedAt = null)
public async Task<bool> UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, bool? isUserDriven = null, DateTime? resumedAt = null, DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
@@ -934,11 +940,24 @@ namespace AutopilotMonitor.Functions.Services
// Build a Merge update with only the fields that actually change
var update = new TableEntity(tenantId, sessionId);
// InProgress is never passed here from IngestEventsFunction — status transitions
// are limited to Succeeded, Failed, Pending. The Pending→InProgress regression is
// structurally impossible because phase_changed events use IncrementSessionEventCountAsync
// (which never touches Status). Only StoreSessionAsync (re-registration) transitions
// Pending→InProgress for WhiteGlove Part 2.
// Status transitions from IngestEventsFunction: Succeeded, Failed, Pending, Stalled,
// and InProgress (the last one only for healing a Stalled session back to active).
// Guard: never regress a Pending (WhiteGlove) session to Stalled via the ingest
// path — WhiteGlove sessions are deliberately long-lived and handled via re-registration.
if (status == SessionStatus.Stalled && existingStatusStr == SessionStatus.Pending.ToString())
{
_logger.LogInformation($"Session {sessionId} is in Pending (WhiteGlove) state, skipping Stalled transition");
return false;
}
// Guard: only allow InProgress transition if the current state is Stalled (healing).
// Other callers must not regress terminal or Pending state via this path.
if (status == SessionStatus.InProgress && existingStatusStr != SessionStatus.Stalled.ToString())
{
_logger.LogInformation($"Session {sessionId} is in state '{existingStatusStr}', not Stalled — InProgress heal no-op");
return false;
}
update["Status"] = status.ToString();
// Update current phase if provided
@@ -1039,6 +1058,26 @@ namespace AutopilotMonitor.Functions.Services
update["ResumedAt"] = EnsureUtc(resumedAt.Value);
}
// Stalled-state bookkeeping: the agent emits session_stalled after 60 min idle,
// or the 2h maintenance sweep sets the state for completely silent agents.
if (stalledAt.HasValue)
{
update["StalledAt"] = EnsureUtc(stalledAt.Value);
if (!string.IsNullOrEmpty(failureReason))
update["FailureReason"] = failureReason;
}
// Heal: clear StalledAt + Stalled-phase FailureReason when the session goes
// back to InProgress (a new real event proved the agent is alive again).
if (clearStalledAt)
{
update["StalledAt"] = (DateTime?)null;
}
if (clearFailureReason)
{
update["FailureReason"] = string.Empty;
}
// Merge mode: only the fields set above are written; all other fields remain untouched.
// This drastically reduces ETag conflicts when concurrent requests update different fields.
await tableClient.UpdateEntityAsync(update, session.ETag, TableUpdateMode.Merge);
@@ -1646,6 +1685,7 @@ namespace AutopilotMonitor.Functions.Services
IsPreProvisioned = entity.GetBoolean("IsPreProvisioned") ?? false,
IsHybridJoin = entity.GetBoolean("IsHybridJoin") ?? false,
ResumedAt = SafeGetDateTime(entity, "ResumedAt"),
StalledAt = SafeGetDateTime(entity, "StalledAt"),
OsName = entity.GetString("OsName") ?? string.Empty,
OsBuild = entity.GetString("OsBuild") ?? string.Empty,
OsDisplayVersion = entity.GetString("OsDisplayVersion") ?? string.Empty,
@@ -177,6 +177,16 @@ namespace AutopilotMonitor.Shared
public const string VulnerabilityReport = "vulnerability_report";
public const string AgentSelfUpdated = "agent_self_updated";
public const string AgentSelfUpdateSkipped = "agent_self_update_skipped";
// Stall detection (Ebene 2 — StallProbeCollector)
public const string StallProbeCheck = "stall_probe_check"; // Trace heartbeat from Probe 2 (15 min) when no anomaly found
public const string StallProbeResult = "stall_probe_result"; // Warning when a probe found an anomaly
public const string SessionStalled = "session_stalled"; // Fire-once after Probe 4 (60 min) — triggers backend Stalled status
// ModernDeployment EventLog Watcher (Ebene 1 — live capture)
public const string ModernDeploymentLog = "modern_deployment_log"; // Info-level live capture
public const string ModernDeploymentWarning = "modern_deployment_warning"; // Level 3 (Warning)
public const string ModernDeploymentError = "modern_deployment_error"; // Level 1-2 (Critical/Error)
}
// -----------------------------------------------------------------------
@@ -21,6 +21,7 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<List<SessionSummary>> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate);
Task<List<SessionSummary>> GetSessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId = null);
Task<List<SessionSummary>> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime);
Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff);
Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours);
// --- Tenant Discovery ---
@@ -25,7 +25,8 @@ namespace AutopilotMonitor.Shared.DataAccess
EnrollmentPhase? currentPhase = null, string? failureReason = null,
DateTime? completedAt = null, DateTime? earliestEventTimestamp = null,
DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null,
bool? isUserDriven = null, DateTime? resumedAt = null);
bool? isUserDriven = null, DateTime? resumedAt = null,
DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false);
Task IncrementSessionEventCountAsync(
string tenantId, string sessionId, int increment,
DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null,
@@ -254,6 +254,75 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public int DeliveryOptimizationIntervalSeconds { get; set; } = 3;
// -----------------------------------------------------------------------
// Stall detection (Ebene 2 — StallProbeCollector)
// -----------------------------------------------------------------------
/// <summary>
/// Master switch for the stall probe mechanism. When enabled, probes run at
/// idle-time thresholds (see StallProbeThresholdsMinutes) to detect stuck
/// enrollments via registry, EventLog and AppWorkload log scans.
/// Default: true
/// </summary>
public bool StallProbeEnabled { get; set; } = true;
/// <summary>
/// Idle-time thresholds in minutes at which probes run. Each probe is fire-once
/// per idle window; the counter resets on any real (non-periodic) event.
/// Default: [2, 15, 30, 60, 180]
/// Probe 1 (2 min): early quick-response, silent unless anomaly found.
/// Probe 2 (15 min): main gate with guaranteed trace heartbeat.
/// Probe 3 (30 min): silent unless found.
/// Probe 4 (60 min): fires session_stalled event → backend sets status to Stalled.
/// Probe 5 (180 min): last-chance scan before 5h backend timeout, silent unless found.
/// </summary>
public int[] StallProbeThresholdsMinutes { get; set; } = new[] { 2, 15, 30, 60, 180 };
/// <summary>
/// Probe indices (1-based) that emit a stall_probe_check trace event even when
/// no anomaly is found. Default: [2] — only Probe 2 (15 min) sends a heartbeat.
/// Set to [1, 2] for more observability at the cost of extra trace events.
/// </summary>
public int[] StallProbeTraceIndices { get; set; } = new[] { 2 };
/// <summary>
/// Which sources to scan per probe. Any source can be removed from the list
/// to disable it individually without affecting the others.
/// Default: all four sources enabled.
/// </summary>
public string[] StallProbeSources { get; set; } = new[]
{
"provisioning_registry",
"diagnostics_registry",
"eventlog",
"appworkload_log"
};
/// <summary>
/// After which probe index (1-based) the fire-once session_stalled event is emitted.
/// Default: 4 → after Probe 4 (60 min idle).
/// </summary>
public int SessionStalledAfterProbeIndex { get; set; } = 4;
// -----------------------------------------------------------------------
// ModernDeployment EventLog Watcher (Ebene 1 — live capture)
// -----------------------------------------------------------------------
/// <summary>
/// Master switch for the ModernDeployment EventLog watcher. When enabled, the agent
/// subscribes to two Windows event channels and forwards matching events as
/// modern_deployment_log events to the backend.
/// Default: true
/// </summary>
public bool ModernDeploymentWatcherEnabled { get; set; } = true;
/// <summary>
/// Maximum Windows event level to capture from ModernDeployment channels.
/// Windows levels: 1=Critical, 2=Error, 3=Warning, 4=Information, 5=Verbose.
/// Default: 3 → captures Critical, Error and Warning.
/// </summary>
public int ModernDeploymentLogLevelMax { get; set; } = 3;
/// <summary>
/// Creates default collector configuration
/// </summary>
@@ -191,6 +191,14 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public DateTime? ResumedAt { get; set; }
/// <summary>
/// Timestamp when the session was marked as Stalled.
/// Set when the agent sends a session_stalled event (after 60 min without progress)
/// or when the backend 2h maintenance sweep detects agent silence.
/// Cleared (null) when the session heals back to InProgress via a new real event.
/// </summary>
public DateTime? StalledAt { get; set; }
/// <summary>
/// Whether the Autopilot profile indicates Hybrid Azure AD Join.
/// Derived from CloudAssignedDomainJoinMethod == 1 in the Autopilot profile.
@@ -246,6 +254,7 @@ namespace AutopilotMonitor.Shared.Models
{
InProgress,
Pending, // WhiteGlove pre-provisioning complete, awaiting user enrollment
Stalled, // Agent reported no progress for >60 min, or backend sweep detected >2h silence. Non-terminal, can heal back to InProgress.
Succeeded,
Failed,
Unknown
@@ -528,6 +528,7 @@ export function SessionTable({
Failed: "text-red-600",
InProgress: "text-blue-600",
Pending: "text-amber-600",
Stalled: "text-orange-600",
};
return (
<button
@@ -596,11 +597,12 @@ export function SessionTable({
{/* Status Filter Badges */}
<div className="mb-4 flex items-center gap-2 flex-wrap">
{(["Succeeded", "InProgress", "Pending", "Failed"] as const).map((status) => {
{(["Succeeded", "InProgress", "Pending", "Stalled", "Failed"] as const).map((status) => {
const config: Record<string, { bg: string; bgActive: string; text: string; label: string }> = {
Succeeded: { bg: "bg-green-50 text-green-700 border-green-200 hover:bg-green-100", bgActive: "bg-green-600 text-white border-green-600", text: "text-green-600", label: "Succeeded" },
InProgress: { bg: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100", bgActive: "bg-blue-600 text-white border-blue-600", text: "text-blue-600", label: "In Progress" },
Pending: { bg: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100", bgActive: "bg-amber-500 text-white border-amber-500", text: "text-amber-600", label: "Pending" },
Stalled: { bg: "bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100", bgActive: "bg-orange-600 text-white border-orange-600", text: "text-orange-600", label: "Stalled" },
Failed: { bg: "bg-red-50 text-red-700 border-red-200 hover:bg-red-100", bgActive: "bg-red-600 text-white border-red-600", text: "text-red-600", label: "Failed" },
};
const c = config[status];
@@ -1129,6 +1131,7 @@ function StatusBadge({ status, failureReason }: { status: string; failureReason?
const statusConfig = {
InProgress: { color: "bg-blue-100 text-blue-800", text: "In Progress" },
Pending: { color: "bg-amber-100 text-amber-800", text: "Pending" },
Stalled: { color: "bg-orange-100 text-orange-800", text: "Stalled" },
Succeeded: { color: "bg-green-100 text-green-800", text: "Succeeded" },
Failed: { color: "bg-red-100 text-red-800", text: "Failed" },
Unknown: { color: "bg-gray-100 text-gray-800", text: "Unknown" },
@@ -12,6 +12,7 @@ import {
TARGET_HINTS,
formatTrigger,
} from "../types";
import { KNOWN_EVENT_TYPES, findEventType } from "../eventTypes";
import { validateGatherRuleTarget } from "@/lib/guardValidation";
import { ValidationIndicator } from "@/components/ValidationIndicator";
@@ -438,15 +439,36 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
{form.trigger === "on_event" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Trigger Event Type</label>
<label className="block text-sm font-medium text-gray-700 mb-1">
Trigger Event Type
<span className="ml-2 text-xs text-gray-500 font-normal">
({KNOWN_EVENT_TYPES.length} known event types — start typing to filter)
</span>
</label>
<input
type="text"
list="gather-rule-event-types"
value={form.triggerEventType}
onChange={(e) => setForm({ ...form, triggerEventType: e.target.value })}
placeholder="e.g., enrollment_complete, enrollment_failed, app_install_failed"
placeholder="e.g., session_stalled, enrollment_failed, modern_deployment_error"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
<datalist id="gather-rule-event-types">
{KNOWN_EVENT_TYPES.map((et) => (
<option key={et.value} value={et.value}>{et.description}</option>
))}
</datalist>
{form.triggerEventType && findEventType(form.triggerEventType) && (
<p className="text-xs text-gray-500 mt-1">
{findEventType(form.triggerEventType)!.description}
</p>
)}
{form.triggerEventType && !findEventType(form.triggerEventType) && (
<p className="text-xs text-amber-600 mt-1">
Custom event type — make sure the agent actually emits this value.
</p>
)}
</div>
)}
@@ -0,0 +1,122 @@
/**
* Curated list of known agent event types for the Gather Rule "on_event" trigger autocomplete.
*
* This list is a TypeScript mirror of `AutopilotMonitor.Shared.Constants.EventTypes`.
* When adding a new event type on the agent/backend side, add it here as well so tenant admins
* can discover it in the rule builder.
*
* A unit test in lib/__tests__/eventTypes.test.ts verifies that the new stall-detection events
* are present so refactoring the Shared constants cannot silently drop them.
*/
export interface EventTypeEntry {
/** Canonical event type string as emitted by the agent. */
value: string;
/** Short human-readable label shown in the autocomplete list. */
label: string;
/** One-line description explaining when the agent emits this event. */
description: string;
/** Category used for grouping in the autocomplete. */
category: "enrollment" | "app" | "esp" | "stall" | "script" | "diagnostics" | "misc";
}
export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [
// -------- Enrollment lifecycle --------
{ value: "enrollment_complete", label: "enrollment_complete", category: "enrollment",
description: "Enrollment finished successfully (terminal)." },
{ value: "enrollment_failed", label: "enrollment_failed", category: "enrollment",
description: "Enrollment failed (terminal)." },
{ value: "whiteglove_complete", label: "whiteglove_complete", category: "enrollment",
description: "Pre-Provisioning Part 1 complete, session enters Pending waiting for user." },
{ value: "whiteglove_resumed", label: "whiteglove_resumed", category: "enrollment",
description: "Pre-Provisioning Part 2 resumed after the user powered on the device." },
{ value: "desktop_arrived", label: "desktop_arrived", category: "enrollment",
description: "User desktop detected (explorer.exe under real user)." },
{ value: "completion_check", label: "completion_check", category: "enrollment",
description: "Throttled state-machine snapshot during completion evaluation." },
{ value: "agent_started", label: "agent_started", category: "enrollment",
description: "Agent process started." },
{ value: "agent_shutdown", label: "agent_shutdown", category: "enrollment",
description: "Agent process is shutting down." },
{ value: "phase_transition", label: "phase_transition", category: "enrollment",
description: "Enrollment phase transition detected." },
// -------- Stall detection (Ebene 2) --------
{ value: "session_stalled", label: "session_stalled", category: "stall",
description: "Agent reported 60+ minutes without progress. Fires once per session and triggers the backend Stalled status." },
{ value: "stall_probe_result", label: "stall_probe_result", category: "stall",
description: "Stall probe found an anomaly (ModernDeployment error, DeploymentErrorCode≠0, IME EnforcementState Failed)." },
{ value: "stall_probe_check", label: "stall_probe_check", category: "stall",
description: "Trace-level heartbeat from a stall probe — proves the logic ran (default: only the 15 min probe)." },
// -------- ModernDeployment live capture (Ebene 1) --------
{ value: "modern_deployment_error", label: "modern_deployment_error", category: "esp",
description: "Windows ModernDeployment-Diagnostics-Provider Critical/Error event forwarded live." },
{ value: "modern_deployment_warning", label: "modern_deployment_warning", category: "esp",
description: "Windows ModernDeployment-Diagnostics-Provider Warning event forwarded live." },
{ value: "modern_deployment_log", label: "modern_deployment_log", category: "esp",
description: "Windows ModernDeployment-Diagnostics-Provider informational event." },
// -------- ESP signals --------
{ value: "esp_phase_changed", label: "esp_phase_changed", category: "esp",
description: "ESP phase transition detected via IME log patterns." },
{ value: "esp_provisioning_status", label: "esp_provisioning_status", category: "esp",
description: "ESP category status update from Windows Provisioning registry." },
{ value: "esp_state_change", label: "esp_state_change", category: "esp",
description: "Generic ESP state change event." },
// -------- App installs --------
{ value: "app_install_started", label: "app_install_started", category: "app",
description: "An application installation has started." },
{ value: "app_install_completed", label: "app_install_completed", category: "app",
description: "An application installation finished successfully." },
{ value: "app_install_failed", label: "app_install_failed", category: "app",
description: "An application installation failed." },
{ value: "app_install_skipped", label: "app_install_skipped", category: "app",
description: "An application installation was skipped." },
{ value: "app_download_started", label: "app_download_started", category: "app",
description: "An application download has started." },
{ value: "download_progress", label: "download_progress", category: "app",
description: "Download progress update (throttled)." },
{ value: "do_telemetry", label: "do_telemetry", category: "app",
description: "Delivery Optimization peer/mode telemetry for an application download." },
// -------- Scripts --------
{ value: "script_completed", label: "script_completed", category: "script",
description: "A platform or remediation script completed successfully." },
{ value: "script_failed", label: "script_failed", category: "script",
description: "A platform or remediation script failed." },
// -------- Diagnostics / misc --------
{ value: "error_detected", label: "error_detected", category: "diagnostics",
description: "Generic error detected by the agent." },
{ value: "cert_validation", label: "cert_validation", category: "diagnostics",
description: "Certificate validation result." },
{ value: "network_state_change", label: "network_state_change", category: "diagnostics",
description: "Network state changed (online/offline/interface)." },
{ value: "vulnerability_report", label: "vulnerability_report", category: "diagnostics",
description: "Aggregated vulnerability scan finding." },
{ value: "software_inventory_analysis", label: "software_inventory_analysis", category: "diagnostics",
description: "Software inventory snapshot with high-confidence findings." },
{ value: "gather_result", label: "gather_result", category: "diagnostics",
description: "Output of another gather rule (can be chained)." },
];
/** Lookup a single entry by its canonical value (case-insensitive). */
export function findEventType(value: string): EventTypeEntry | undefined {
if (!value) return undefined;
const lower = value.toLowerCase();
return KNOWN_EVENT_TYPES.find((e) => e.value.toLowerCase() === lower);
}
/** Fuzzy filter for autocomplete — matches anywhere in value/label/description. */
export function filterEventTypes(query: string): EventTypeEntry[] {
if (!query) return KNOWN_EVENT_TYPES;
const lower = query.toLowerCase();
return KNOWN_EVENT_TYPES.filter(
(e) =>
e.value.toLowerCase().includes(lower) ||
e.label.toLowerCase().includes(lower) ||
e.description.toLowerCase().includes(lower)
);
}
@@ -264,6 +264,13 @@ export default function EventTimeline({
Pre-provisioning is complete. The timeline will continue when the user powers on the device.
</p>
</div>
) : session?.status === 'Stalled' ? (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-6 text-center">
<p className="text-orange-800 font-medium mb-1">Session Stalled</p>
<p className="text-orange-600 text-sm">
{session.failureReason || 'No progress detected. The session will heal automatically when new events arrive, or expire after the timeout window.'}
</p>
</div>
) : null}
</>
) : (
@@ -133,6 +133,7 @@ function StatusBadge({ status, failureReason }: { status: string; failureReason?
const statusConfig = {
InProgress: { color: "bg-blue-100 text-blue-800", text: "In Progress" },
Pending: { color: "bg-amber-100 text-amber-800", text: "Pending" },
Stalled: { color: "bg-orange-100 text-orange-800", text: "Stalled" },
Succeeded: { color: "bg-green-100 text-green-800", text: "Succeeded" },
Failed: { color: "bg-red-100 text-red-800", text: "Failed" },
Unknown: { color: "bg-gray-100 text-gray-800", text: "Unknown" },
@@ -910,7 +910,7 @@ export default function SessionDetailPage() {
Diagnosis
</button>
)}
{adminMode && (session?.status === 'InProgress' || session?.status === 'Pending') && (
{adminMode && (session?.status === 'InProgress' || session?.status === 'Pending' || session?.status === 'Stalled') && (
<>
<button
onClick={markAsSucceeded}
@@ -151,6 +151,8 @@ export default function GlobalSearch() {
case 'Succeeded': return 'text-green-600';
case 'Failed': return 'text-red-600';
case 'InProgress': return 'text-blue-600';
case 'Pending': return 'text-amber-600';
case 'Stalled': return 'text-orange-600';
default: return 'text-gray-500';
}
};
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import {
KNOWN_EVENT_TYPES,
findEventType,
filterEventTypes,
} from "../../app/gather-rules/eventTypes";
describe("KNOWN_EVENT_TYPES", () => {
it("is not empty", () => {
expect(KNOWN_EVENT_TYPES.length).toBeGreaterThan(0);
});
it("contains all new stall-detection event types", () => {
const required = [
"session_stalled",
"stall_probe_result",
"stall_probe_check",
"modern_deployment_error",
"modern_deployment_warning",
"modern_deployment_log",
];
for (const r of required) {
expect(KNOWN_EVENT_TYPES.find((e) => e.value === r), `missing: ${r}`).toBeDefined();
}
});
it("contains core enrollment lifecycle events", () => {
const required = [
"enrollment_complete",
"enrollment_failed",
"whiteglove_complete",
"whiteglove_resumed",
"desktop_arrived",
];
for (const r of required) {
expect(KNOWN_EVENT_TYPES.find((e) => e.value === r), `missing: ${r}`).toBeDefined();
}
});
it("has no duplicate values", () => {
const seen = new Set<string>();
for (const e of KNOWN_EVENT_TYPES) {
expect(seen.has(e.value), `duplicate: ${e.value}`).toBe(false);
seen.add(e.value);
}
});
it("gives every entry a non-empty description", () => {
for (const e of KNOWN_EVENT_TYPES) {
expect(e.description.length, `empty description for ${e.value}`).toBeGreaterThan(0);
}
});
});
describe("findEventType", () => {
it("returns an entry by exact value", () => {
const e = findEventType("session_stalled");
expect(e).toBeDefined();
expect(e?.category).toBe("stall");
});
it("is case-insensitive", () => {
expect(findEventType("SESSION_STALLED")).toBeDefined();
expect(findEventType("Session_Stalled")).toBeDefined();
});
it("returns undefined for unknown values", () => {
expect(findEventType("totally_made_up_event")).toBeUndefined();
expect(findEventType("")).toBeUndefined();
});
});
describe("filterEventTypes", () => {
it("returns the full list for an empty query", () => {
expect(filterEventTypes("").length).toBe(KNOWN_EVENT_TYPES.length);
});
it("matches on the value field", () => {
const r = filterEventTypes("stall");
expect(r.find((e) => e.value === "session_stalled")).toBeDefined();
expect(r.find((e) => e.value === "stall_probe_result")).toBeDefined();
});
it("matches on the description field", () => {
const r = filterEventTypes("Pre-Provisioning");
expect(r.find((e) => e.value === "whiteglove_complete")).toBeDefined();
});
});