mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08455cc5f9 | |||
| 87c5bf018c | |||
| 0d6c03d64b | |||
| b14866c9a6 | |||
| 25b133d3d0 | |||
| 684167292f | |||
| 6474860a57 | |||
| d23f2eae3e | |||
| fae6aa0b92 | |||
| 43dfb731f1 | |||
| dd5534b0c9 | |||
| 54334de378 | |||
| 9671191eef | |||
| c5a4feb762 | |||
| 2253fca533 | |||
| ff9e79dcc7 | |||
| e54b4585b8 | |||
| 7894d61e0b | |||
| 090f47d4fb | |||
| c2d68b6537 | |||
| 5b0dfaad99 | |||
| 50d29e61e8 | |||
| 4b03ba4e19 | |||
| f0aec031f3 | |||
| 66208b3767 | |||
| c60e07dfd4 | |||
| f564ee6a06 |
+1
-1
Submodule .claude/commands updated: 0d25a07655...d48bf40136
@@ -114,7 +114,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -125,6 +125,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -78,6 +78,23 @@ jobs:
|
||||
working-directory: ${{ env.WEB_DIR }}
|
||||
run: npm ci
|
||||
|
||||
# Star counter for the GitHub star link (utils/githubStars.ts). Baked into the static HTML
|
||||
# so visitors' browsers never call GitHub; stale until the next web deploy. The token stays
|
||||
# in this step, never in the build. Fail-soft: without a number the link has no counter.
|
||||
- name: Read GitHub star count (fail-soft)
|
||||
id: stars
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
count=$(gh api "repos/$REPO" --jq .stargazers_count 2>/dev/null || true)
|
||||
if [[ "$count" =~ ^[0-9]+$ ]]; then
|
||||
echo "count=$count" >> "$GITHUB_OUTPUT"
|
||||
echo "GitHub stars: $count"
|
||||
else
|
||||
echo "::warning::GitHub star count unavailable - the star link renders without a counter"
|
||||
fi
|
||||
|
||||
# next.config output:'export' -- `next build` writes the static site to out/
|
||||
# (prebuild: sync-error-codes, generate-lastmod, generate-version, whats-new).
|
||||
- name: Build
|
||||
@@ -98,6 +115,8 @@ jobs:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
# Docs checkout for public/whats-new.json; the prebuild fails in CI without it.
|
||||
WHATS_NEW_DOCS_DIR: ${{ github.workspace }}/whats-new-docs
|
||||
# Star counter from the step above; empty when the lookup failed.
|
||||
NEXT_PUBLIC_GITHUB_STARS: ${{ steps.stars.outputs.count }}
|
||||
run: |
|
||||
npm run build
|
||||
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
|
||||
|
||||
+1
-1
Submodule internal updated: 997079976b...d73e9478c3
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-004",
|
||||
"title": "Windows Update Failed During Enrollment",
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install or download during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20 from the System log, 31 from the Operational channel). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -18,10 +18,14 @@
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "wu_install_failed",
|
||||
"source": "event_type",
|
||||
"source": "event_data",
|
||||
"eventType": "windows_update_failed",
|
||||
"dataField": "wuEventId",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
@@ -31,6 +35,9 @@
|
||||
"dataField": "updateTitle",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -40,6 +47,9 @@
|
||||
"dataField": "hresult",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -49,6 +59,9 @@
|
||||
"dataField": "hresultSymbol",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
|
||||
+16
-3
@@ -1385,10 +1385,10 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-DEV-004",
|
||||
"title": "Windows Update Failed During Enrollment",
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install or download during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20 from the System log, 31 from the Operational channel). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -1401,10 +1401,14 @@
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "wu_install_failed",
|
||||
"source": "event_type",
|
||||
"source": "event_data",
|
||||
"eventType": "windows_update_failed",
|
||||
"dataField": "wuEventId",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
@@ -1414,6 +1418,9 @@
|
||||
"dataField": "updateTitle",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -1423,6 +1430,9 @@
|
||||
"dataField": "hresult",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -1432,6 +1442,9 @@
|
||||
"dataField": "hresultSymbol",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"filterField": "wuPhase",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "install,download",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -203,7 +203,7 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
|
||||
"action": "updateDoTelemetry",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent in 1.104.102.0 and 1.105.103.0 decompiles, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent from the 1.104.102.0 and 1.105.103.0 builds, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -584,7 +584,7 @@
|
||||
"category": "always",
|
||||
"pattern": "^Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
|
||||
"action": "updateDoTelemetry",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent in 1.104.102.0 and 1.105.103.0 decompiles, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
|
||||
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent from the 1.104.102.0 and 1.105.103.0 builds, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "always",
|
||||
"pattern": "^Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
+6
-3
@@ -41,15 +41,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
}
|
||||
|
||||
private GatherRuleContext Context(bool unrestricted = false, string? imeLogPathOverride = null)
|
||||
=> new GatherRuleContext(
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
return new GatherRuleContext(
|
||||
logger,
|
||||
"sess", "tenant",
|
||||
evt => _events.Add(evt),
|
||||
imeLogPathOverride,
|
||||
new LogFilePositionTracker())
|
||||
new GatherRuleSessionState("sess", null, logger))
|
||||
{
|
||||
UnrestrictedMode = unrestricted
|
||||
};
|
||||
}
|
||||
|
||||
private static GatherRule Rule(string collectorType, string target,
|
||||
Dictionary<string, string>? parameters = null) => new GatherRule
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
public void Context_DebugLog_is_noop_without_writer()
|
||||
{
|
||||
var context = new GatherRuleContext(
|
||||
_logger, "sess", "tenant", _ => { }, null, new LogFilePositionTracker());
|
||||
_logger, "sess", "tenant", _ => { }, null, new GatherRuleSessionState("sess", null, _logger));
|
||||
|
||||
context.DebugLog("RULE-1", GatherRuleDebugLog.StageScope, "should not throw, should not write");
|
||||
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// An agent restart inside one enrollment session — a new <see cref="GatherRuleExecutor"/> on the
|
||||
/// same state directory — continues where the previous run stopped: logparser rules do not
|
||||
/// replay their log, on_change rules do not re-emit an unchanged result. Regression anchor:
|
||||
/// session c8106021, where every restart replayed the whole HP Image Assistant log (70 events).
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // startup rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorRestartTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly TempDirectory _stateDir = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly object _eventsGate = new object();
|
||||
private readonly List<GatherRuleExecutor> _executors = new List<GatherRuleExecutor>();
|
||||
|
||||
// %TEMP% lives under C:\Users (hard-blocked) — log targets sit beside the test assembly.
|
||||
private readonly string _logDir = Path.Combine(
|
||||
AppContext.BaseDirectory, "gather-restart-tests-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public GatherRuleExecutorRestartTests()
|
||||
{
|
||||
Directory.CreateDirectory(_logDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var executor in _executors)
|
||||
executor.Dispose();
|
||||
_tmp.Dispose();
|
||||
_stateDir.Dispose();
|
||||
try { Directory.Delete(_logDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private string StateFile => Path.Combine(_stateDir.Path, GatherRuleStatePersistence.FileName);
|
||||
|
||||
private List<EnrollmentEvent> Events
|
||||
{
|
||||
get { lock (_eventsGate) return _events.ToList(); }
|
||||
}
|
||||
|
||||
/// <summary>One agent run: a fresh executor whose startup rules run once. Not disposed — a hard kill.</summary>
|
||||
private GatherRuleExecutor AgentRun(GatherRule rule, string sessionId = "session-a", string? stateDirectory = "default",
|
||||
Action<EnrollmentEvent>? onEvent = null, string? debugLogPath = null)
|
||||
{
|
||||
var executor = new GatherRuleExecutor(
|
||||
sessionId, "tenant",
|
||||
evt =>
|
||||
{
|
||||
onEvent?.Invoke(evt);
|
||||
lock (_eventsGate) _events.Add(evt);
|
||||
},
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
debugLogPath: debugLogPath,
|
||||
stateDirectory: stateDirectory == "default" ? _stateDir.Path : stateDirectory)
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
_executors.Add(executor);
|
||||
executor.UpdateRules(new List<GatherRule> { rule });
|
||||
Assert.True(executor.WaitForStartupRules(30));
|
||||
return executor;
|
||||
}
|
||||
|
||||
private static GatherRule LogRule(string path, string id = "hpia-log-collect") => new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
Title = id,
|
||||
CollectorType = "logparser",
|
||||
Target = path,
|
||||
Parameters = new Dictionary<string, string> { ["pattern"] = "^(?<status>Update .+)$", ["format"] = "text" },
|
||||
Trigger = "startup",
|
||||
OutputEventType = "HPiA-UpdateStatus",
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
private static GatherRule OnChangeFileRule(string path) => new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-OC-FILE",
|
||||
Title = "file presence",
|
||||
CollectorType = "file",
|
||||
Target = path,
|
||||
Trigger = "startup",
|
||||
OutputEventType = "gather_file_presence",
|
||||
Enabled = true,
|
||||
EmitMode = "on_change",
|
||||
};
|
||||
|
||||
private string WriteLog(string name, string content)
|
||||
{
|
||||
var path = Path.Combine(_logDir, name);
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
private int StatusEvents => Events.Count(e => e.EventType == "HPiA-UpdateStatus");
|
||||
|
||||
[Fact]
|
||||
public void Restart_does_not_replay_the_log_and_new_lines_arrive_once()
|
||||
{
|
||||
var path = WriteLog("HP Image Assistant.log", "Update one\nUpdate two\nnoise\n");
|
||||
|
||||
AgentRun(LogRule(path));
|
||||
Assert.Equal(2, StatusEvents);
|
||||
|
||||
AgentRun(LogRule(path)); // reboot, nothing new
|
||||
Assert.Equal(2, StatusEvents);
|
||||
|
||||
File.AppendAllText(path, "Update three\n");
|
||||
AgentRun(LogRule(path)); // next restart picks up only the new line
|
||||
Assert.Equal(3, StatusEvents);
|
||||
Assert.Equal("Update three", Events.Last(e => e.EventType == "HPiA-UpdateStatus").Data["status"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_is_on_disk_right_after_the_run_without_a_clean_shutdown()
|
||||
{
|
||||
var path = WriteLog("hardkill.log", "Update one\n");
|
||||
|
||||
AgentRun(LogRule(path)); // never disposed
|
||||
|
||||
var saved = JsonConvert.DeserializeObject<GatherRuleStateData>(File.ReadAllText(StateFile))!;
|
||||
var position = Assert.Single(saved.LogPositions!);
|
||||
Assert.Equal(new FileInfo(path).Length, position.Position);
|
||||
Assert.Equal("session-a", saved.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_is_saved_only_after_the_events_went_out()
|
||||
{
|
||||
var path = WriteLog("ordering.log", "Update one\n");
|
||||
var savedDuringEmit = new List<bool>();
|
||||
|
||||
AgentRun(LogRule(path), onEvent: _ => savedDuringEmit.Add(File.Exists(StateFile)));
|
||||
|
||||
Assert.Equal(new[] { false }, savedDuringEmit);
|
||||
Assert.True(File.Exists(StateFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_session_reads_the_log_from_the_beginning()
|
||||
{
|
||||
var path = WriteLog("rotated.log", "Update one\nUpdate two\n");
|
||||
AgentRun(LogRule(path), sessionId: "session-a");
|
||||
|
||||
AgentRun(LogRule(path), sessionId: "session-b"); // session rotated on the same device
|
||||
|
||||
Assert.Equal(4, StatusEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_replaced_during_the_reboot_is_read_from_the_beginning()
|
||||
{
|
||||
var path = WriteLog("replaced.log", "Update one\n");
|
||||
AgentRun(LogRule(path, "replaced"));
|
||||
|
||||
// The tool writes a new log after the reboot; it is already longer than the old position.
|
||||
File.WriteAllText(path, "Update fresh A\nUpdate fresh B\nUpdate fresh C\n");
|
||||
AgentRun(LogRule(path, "replaced"));
|
||||
|
||||
Assert.Equal(new[] { "Update one", "Update fresh A", "Update fresh B", "Update fresh C" },
|
||||
Events.Where(e => e.EventType == "HPiA-UpdateStatus").Select(e => (string)e.Data["status"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_on_change_result_stays_silent_after_a_restart()
|
||||
{
|
||||
var watched = Path.Combine(_logDir, "marker.txt");
|
||||
|
||||
AgentRun(OnChangeFileRule(watched)); // absent → first result emits
|
||||
AgentRun(OnChangeFileRule(watched)); // reboot, still absent → silent
|
||||
Assert.Single(Events, e => e.EventType == "gather_file_presence");
|
||||
|
||||
File.WriteAllText(watched, "present");
|
||||
AgentRun(OnChangeFileRule(watched)); // changed → emits
|
||||
|
||||
Assert.Equal(2, Events.Count(e => e.EventType == "gather_file_presence"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_a_state_directory_every_run_starts_over_and_writes_nothing()
|
||||
{
|
||||
var path = WriteLog("diagnostic.log", "Update one\n");
|
||||
|
||||
AgentRun(LogRule(path), stateDirectory: null);
|
||||
AgentRun(LogRule(path), stateDirectory: null);
|
||||
|
||||
Assert.Equal(2, StatusEvents);
|
||||
Assert.False(File.Exists(StateFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Debug_trace_says_what_was_restored_and_where_reading_continues()
|
||||
{
|
||||
var path = WriteLog("traced.log", "Update one\n");
|
||||
var trace = Path.Combine(_tmp.Path, "gather-rules-debug.log");
|
||||
AgentRun(LogRule(path));
|
||||
File.AppendAllText(path, "Update two\n");
|
||||
|
||||
AgentRun(LogRule(path), debugLogPath: trace);
|
||||
|
||||
var text = File.ReadAllText(trace);
|
||||
Assert.Contains("gather state: restored 1 logparser position(s) for 1 rule(s)", text);
|
||||
Assert.Contains("hpia-log-collect | config | logparser position restored: traced.log", text);
|
||||
Assert.Contains("traced.log: continuing at position", text);
|
||||
}
|
||||
}
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="GatherRuleSessionState"/> and <see cref="GatherRuleStatePersistence"/>: what is
|
||||
/// written, what a restart restores, what it refuses, and when the file is (not) written.
|
||||
/// </summary>
|
||||
public sealed class GatherRuleSessionStateTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly TempDirectory _stateDir = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public GatherRuleSessionStateTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tmp.Dispose();
|
||||
_stateDir.Dispose();
|
||||
}
|
||||
|
||||
private string StateFile => Path.Combine(_stateDir.Path, GatherRuleStatePersistence.FileName);
|
||||
|
||||
private GatherRuleSessionState NewState(string sessionId = "session-a") =>
|
||||
new GatherRuleSessionState(sessionId, new GatherRuleStatePersistence(_stateDir.Path, _logger), _logger);
|
||||
|
||||
private static LogFileHead Head(string text) => LogFileHead.FromBytes(Encoding.UTF8.GetBytes(text));
|
||||
|
||||
// ── restore ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Positions_and_emitted_hashes_survive_a_restart_of_the_same_session()
|
||||
{
|
||||
var first = NewState();
|
||||
first.CommitLogPosition("RULE-1", @"C:\Logs\a.log", 120, 7, Head("2026-09-28 a.log start"), heldTailStart: -1);
|
||||
first.CommitEmittedHash("RULE-2", "hash-value");
|
||||
|
||||
var second = NewState();
|
||||
|
||||
var position = second.PeekLogPosition("RULE-1", @"C:\Logs\a.log");
|
||||
Assert.NotNull(position);
|
||||
Assert.Equal(120, position!.Position);
|
||||
Assert.Equal(7, position.LineNumber);
|
||||
Assert.True(position.FromPreviousRun);
|
||||
Assert.Equal("hash-value", second.GetEmittedHashes()["RULE-2"]);
|
||||
Assert.StartsWith("restored 1 logparser position(s) for 1 rule(s) and 1 on_change value(s)", second.RestoreSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_of_another_session_is_ignored()
|
||||
{
|
||||
NewState("session-a").CommitLogPosition("RULE-1", @"C:\Logs\a.log", 120, 7, Head("start"), -1);
|
||||
|
||||
var rotated = NewState("session-b");
|
||||
|
||||
Assert.Null(rotated.PeekLogPosition("RULE-1", @"C:\Logs\a.log"));
|
||||
Assert.Contains("saved for session session-a, current session is session-b", rotated.RestoreSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_file_is_a_first_run_and_corrupt_file_a_fresh_start()
|
||||
{
|
||||
Assert.StartsWith("no saved state", NewState().RestoreSummary);
|
||||
|
||||
File.WriteAllText(StateFile, "not json {{{");
|
||||
var afterCorruption = NewState();
|
||||
|
||||
Assert.StartsWith("saved state discarded", afterCorruption.RestoreSummary);
|
||||
Assert.Empty(afterCorruption.GetEmittedHashes());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tampered_entries_are_dropped_and_the_rest_restored()
|
||||
{
|
||||
var data = new GatherRuleStateData
|
||||
{
|
||||
SessionId = "session-a",
|
||||
SavedAtUtc = DateTime.UtcNow,
|
||||
LogPositions = new List<GatherLogPositionData>
|
||||
{
|
||||
new GatherLogPositionData { RuleId = "OK", Path = @"C:\a.log", Position = 10, LineNumber = 1, HeadLength = 10, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "NEG", Path = @"C:\b.log", Position = -5, HeadLength = 10, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "", Path = @"C:\c.log", Position = 1, HeadLength = 1, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "BIGHEAD", Path = @"C:\d.log", Position = 1, HeadLength = 5000, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "NOHEAD", Path = @"C:\e.log", Position = 50, HeadLength = 0 },
|
||||
},
|
||||
OnChangeHashes = new Dictionary<string, string> { ["GOOD"] = "x", ["EMPTY"] = "" },
|
||||
};
|
||||
File.WriteAllText(StateFile, JsonConvert.SerializeObject(data));
|
||||
|
||||
var state = NewState();
|
||||
|
||||
Assert.NotNull(state.PeekLogPosition("OK", @"C:\a.log"));
|
||||
Assert.Null(state.PeekLogPosition("NEG", @"C:\b.log"));
|
||||
Assert.Null(state.PeekLogPosition("BIGHEAD", @"C:\d.log"));
|
||||
Assert.Null(state.PeekLogPosition("NOHEAD", @"C:\e.log"));
|
||||
Assert.Equal(new[] { "GOOD" }, state.GetEmittedHashes().Keys.ToArray());
|
||||
Assert.Contains("5 invalid entries dropped", state.RestoreSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_path_no_real_file_can_have_is_dropped_and_never_stops_the_executor()
|
||||
{
|
||||
// Path APIs throw on these characters under .NET Framework; a throw here would keep
|
||||
// every collector host down at every restart of the session.
|
||||
var data = new GatherRuleStateData
|
||||
{
|
||||
SessionId = "session-a",
|
||||
LogPositions = new List<GatherLogPositionData>
|
||||
{
|
||||
new GatherLogPositionData { RuleId = "BAD", Path = "C:\\Logs\\x.|og", Position = 1, HeadLength = 1, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "WILD", Path = "C:\\Logs\\*.log", Position = 1, HeadLength = 1, HeadHash = "h" },
|
||||
new GatherLogPositionData { RuleId = "OK", Path = "C:\\Logs\\ok.log", Position = 1, HeadLength = 1, HeadHash = "h" },
|
||||
},
|
||||
};
|
||||
File.WriteAllText(StateFile, JsonConvert.SerializeObject(data));
|
||||
|
||||
var state = NewState();
|
||||
|
||||
Assert.NotNull(state.PeekLogPosition("OK", "C:\\Logs\\ok.log"));
|
||||
Assert.Null(state.PeekLogPosition("BAD", "C:\\Logs\\x.|og"));
|
||||
Assert.Contains("2 invalid entries dropped", state.RestoreSummary);
|
||||
}
|
||||
|
||||
// ── where reading starts ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Reading_continues_while_the_file_head_still_matches()
|
||||
{
|
||||
var state = NewState();
|
||||
state.CommitLogPosition("R", @"C:\a.log", 30, 3, Head("2026-09-28 first line\n"), -1);
|
||||
|
||||
var start = GatherRuleSessionState.ResolveStart(state.PeekLogPosition("R", @"C:\a.log"), 200,
|
||||
Head("2026-09-28 first line\nsecond line\nthird"), out var how);
|
||||
|
||||
Assert.Equal(30, start);
|
||||
Assert.Equal(LogReadStart.Continue, how);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_shrunk_file_and_a_replaced_file_are_read_from_the_beginning()
|
||||
{
|
||||
var state = NewState();
|
||||
state.CommitLogPosition("R", @"C:\a.log", 30, 3, Head("2026-09-28 first line\n"), -1);
|
||||
var known = state.PeekLogPosition("R", @"C:\a.log");
|
||||
|
||||
Assert.Equal(0, GatherRuleSessionState.ResolveStart(known, 20, Head("2026-09-28 fir"), out var shrunk));
|
||||
Assert.Equal(LogReadStart.Shrunk, shrunk);
|
||||
|
||||
// Replaced and already longer than the old position: only the head tells.
|
||||
Assert.Equal(0, GatherRuleSessionState.ResolveStart(known, 500, Head("2026-09-30 a new log entirely, much longer"), out var replaced));
|
||||
Assert.Equal(LogReadStart.Replaced, replaced);
|
||||
|
||||
// Shorter than the recorded head although not shorter than the position.
|
||||
state.CommitLogPosition("S", @"C:\b.log", 5, 1, Head("0123456789abcdef"), -1);
|
||||
Assert.Equal(0, GatherRuleSessionState.ResolveStart(state.PeekLogPosition("S", @"C:\b.log"), 8, Head("01234567"), out var cut));
|
||||
Assert.Equal(LogReadStart.Replaced, cut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Positions_are_kept_per_rule_for_the_same_file()
|
||||
{
|
||||
var state = NewState();
|
||||
state.CommitLogPosition("RULE-A", @"C:\shared.log", 100, 10, Head("x"), -1);
|
||||
|
||||
Assert.Null(state.PeekLogPosition("RULE-B", @"C:\shared.log"));
|
||||
Assert.NotNull(state.PeekLogPosition("RULE-A", @"C:\SHARED.LOG")); // paths compare case-insensitively
|
||||
}
|
||||
|
||||
// ── when the file is written ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_commit_does_not_write_the_file()
|
||||
{
|
||||
var state = NewState();
|
||||
state.CommitLogPosition("R", @"C:\a.log", 10, 1, Head("head"), -1);
|
||||
File.Delete(StateFile);
|
||||
|
||||
state.CommitLogPosition("R", @"C:\a.log", 10, 1, Head("head"), -1);
|
||||
state.CommitEmittedHash("H", "v");
|
||||
File.Delete(StateFile);
|
||||
state.CommitEmittedHash("H", "v");
|
||||
|
||||
Assert.False(File.Exists(StateFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failed_save_is_retried_at_the_next_commit()
|
||||
{
|
||||
var blocked = Path.Combine(_stateDir.Path, "blocked");
|
||||
File.WriteAllText(blocked, "a file where the state directory should be");
|
||||
var state = new GatherRuleSessionState("session-a", new GatherRuleStatePersistence(blocked, _logger), _logger);
|
||||
|
||||
state.CommitEmittedHash("H", "v"); // Directory.CreateDirectory fails
|
||||
File.Delete(blocked);
|
||||
state.CommitEmittedHash("H", "v"); // unchanged, but the earlier save is still owed
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(blocked, GatherRuleStatePersistence.FileName)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_closed_state_no_longer_writes()
|
||||
{
|
||||
var state = NewState();
|
||||
state.Close();
|
||||
|
||||
state.CommitEmittedHash("H", "v");
|
||||
|
||||
Assert.False(File.Exists(StateFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_persistence_nothing_is_written()
|
||||
{
|
||||
var state = new GatherRuleSessionState("session-a", null, _logger);
|
||||
|
||||
state.CommitLogPosition("R", @"C:\a.log", 10, 1, Head("head"), -1);
|
||||
|
||||
Assert.StartsWith("persistence off", state.RestoreSummary);
|
||||
Assert.Empty(Directory.GetFiles(_stateDir.Path));
|
||||
}
|
||||
|
||||
// ── bounds and concurrency ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void A_rule_forgets_its_least_recently_touched_file_beyond_the_cap()
|
||||
{
|
||||
var state = NewState();
|
||||
for (var i = 0; i < GatherRuleSessionState.MaxLogFilesPerRule; i++)
|
||||
state.CommitLogPosition("R", $@"C:\logs\{i}.log", 1, 1, Head("h"), -1);
|
||||
state.PeekLogPosition("R", @"C:\logs\0.log"); // touched again — survives
|
||||
|
||||
state.CommitLogPosition("R", @"C:\logs\new.log", 1, 1, Head("h"), -1);
|
||||
|
||||
Assert.NotNull(state.PeekLogPosition("R", @"C:\logs\0.log"));
|
||||
Assert.Null(state.PeekLogPosition("R", @"C:\logs\1.log"));
|
||||
Assert.NotNull(state.PeekLogPosition("R", @"C:\logs\new.log"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parallel_commits_leave_a_consistent_file()
|
||||
{
|
||||
var state = NewState();
|
||||
|
||||
Parallel.For(0, 8, worker =>
|
||||
{
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
state.CommitLogPosition($"RULE-{worker}", $@"C:\logs\{worker}.log", i + 1, i, Head("h"), -1);
|
||||
state.PeekLogPosition($"RULE-{worker}", $@"C:\logs\{worker}.log");
|
||||
state.CommitEmittedHash($"RULE-{worker}", "hash-" + i);
|
||||
}
|
||||
});
|
||||
|
||||
var restored = NewState();
|
||||
for (var worker = 0; worker < 8; worker++)
|
||||
{
|
||||
Assert.Equal(50, restored.PeekLogPosition($"RULE-{worker}", $@"C:\logs\{worker}.log")!.Position);
|
||||
Assert.Equal("hash-49", restored.GetEmittedHashes()[$"RULE-{worker}"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="LogLineReader"/>, <see cref="LogTextEncoding"/> and <see cref="LogFileHead"/>:
|
||||
/// exact byte positions, StreamReader-compatible line ends, byte order marks only at offset 0,
|
||||
/// and the code-unit view of UTF-16/UTF-32 files.
|
||||
/// </summary>
|
||||
public sealed class LogLineReaderTests
|
||||
{
|
||||
private static byte[] Bytes(Encoding encoding, string text, bool withBom = true)
|
||||
{
|
||||
var body = encoding.GetBytes(text);
|
||||
return withBom ? encoding.GetPreamble().Concat(body).ToArray() : body;
|
||||
}
|
||||
|
||||
private static (List<string> Lines, List<long> EndPositions, List<bool> Terminated) ReadAll(
|
||||
byte[] file, long start = 0, int maxLineBytes = 1024)
|
||||
{
|
||||
var encoding = LogFileHead.FromBytes(file.Take(LogFileHead.MaxBytes).ToArray()).Encoding;
|
||||
using (var stream = new MemoryStream(file))
|
||||
{
|
||||
stream.Seek(start, SeekOrigin.Begin);
|
||||
var reader = new LogLineReader(stream, encoding, maxLineBytes);
|
||||
var lines = new List<string>();
|
||||
var ends = new List<long>();
|
||||
var terminated = new List<bool>();
|
||||
string line;
|
||||
while ((line = reader.ReadLine()) != null)
|
||||
{
|
||||
lines.Add(line);
|
||||
ends.Add(reader.Position);
|
||||
terminated.Add(reader.LastLineTerminated);
|
||||
}
|
||||
return (lines, ends, terminated);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_ends_match_StreamReader_including_a_lone_carriage_return()
|
||||
{
|
||||
var file = Encoding.UTF8.GetBytes("one\ntwo\r\nthree\rfour");
|
||||
|
||||
var result = ReadAll(file);
|
||||
|
||||
Assert.Equal(new[] { "one", "two", "three", "four" }, result.Lines);
|
||||
Assert.Equal(new long[] { 4, 9, 15, 19 }, result.EndPositions);
|
||||
Assert.Equal(new[] { true, true, true, false }, result.Terminated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_after_each_line_is_the_offset_to_resume_from()
|
||||
{
|
||||
var file = Encoding.UTF8.GetBytes("alpha\nbeta\ngamma\n");
|
||||
|
||||
var first = ReadAll(file);
|
||||
var resumed = ReadAll(file, start: first.EndPositions[0]);
|
||||
|
||||
Assert.Equal(new[] { "beta", "gamma" }, resumed.Lines);
|
||||
Assert.Equal(file.Length, resumed.EndPositions.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Utf8_byte_order_mark_is_skipped_only_at_offset_0()
|
||||
{
|
||||
var file = Bytes(Encoding.UTF8, "first\nsecond\n");
|
||||
|
||||
var result = ReadAll(file);
|
||||
|
||||
Assert.Equal(new[] { "first", "second" }, result.Lines);
|
||||
Assert.Equal(LogTextEncoding.Utf8WithBom, LogFileHead.FromBytes(file).Encoding);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("utf-16le")]
|
||||
[InlineData("utf-16be")]
|
||||
[InlineData("utf-32le")]
|
||||
[InlineData("utf-32be")]
|
||||
public void Unicode_files_are_read_in_their_code_units_from_any_resume_point(string name)
|
||||
{
|
||||
var encoding = name == "utf-16le" ? Encoding.Unicode
|
||||
: name == "utf-16be" ? Encoding.BigEndianUnicode
|
||||
: name == "utf-32le" ? Encoding.UTF32
|
||||
: new UTF32Encoding(bigEndian: true, byteOrderMark: true);
|
||||
var file = Bytes(encoding, "ERROR first\r\nERROR zweite Zeile mit Umlaut äöü\r\n");
|
||||
|
||||
var whole = ReadAll(file);
|
||||
var resumed = ReadAll(file, start: whole.EndPositions[0]);
|
||||
|
||||
Assert.Equal(name, LogFileHead.FromBytes(file).Encoding.Name);
|
||||
Assert.Equal(new[] { "ERROR first", "ERROR zweite Zeile mit Umlaut äöü" }, whole.Lines);
|
||||
Assert.Equal(new[] { "ERROR zweite Zeile mit Umlaut äöü" }, resumed.Lines);
|
||||
Assert.Equal(file.Length, whole.EndPositions.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void File_without_byte_order_mark_is_utf8_and_ansi_bytes_decode_as_before()
|
||||
{
|
||||
var file = new byte[] { (byte)'M', (byte)'a', (byte)'n', 0xFC, (byte)'\n' }; // "Man" + Windows-1252 'ü'
|
||||
|
||||
var result = ReadAll(file);
|
||||
|
||||
Assert.Equal(LogTextEncoding.Utf8, LogFileHead.FromBytes(file).Encoding);
|
||||
Assert.Equal(new StreamReader(new MemoryStream(file), Encoding.UTF8).ReadLine(), result.Lines.Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Carriage_return_at_end_of_file_ends_the_line_and_its_late_line_feed_is_skipped()
|
||||
{
|
||||
var firstWrite = Encoding.UTF8.GetBytes("done\r");
|
||||
var later = Encoding.UTF8.GetBytes("done\r\nnext\n");
|
||||
|
||||
var first = ReadAll(firstWrite);
|
||||
var resumed = ReadAll(later, start: first.EndPositions.Last());
|
||||
|
||||
Assert.Equal(new[] { "done" }, first.Lines);
|
||||
Assert.True(first.Terminated.Single());
|
||||
Assert.Equal(new[] { "next" }, resumed.Lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overlong_line_is_consumed_without_being_kept_and_reading_continues_behind_it()
|
||||
{
|
||||
var file = Encoding.UTF8.GetBytes(new string('x', 50) + "\nshort\n");
|
||||
using (var stream = new MemoryStream(file))
|
||||
{
|
||||
var reader = new LogLineReader(stream, LogTextEncoding.Utf8, maxLineBytes: 10);
|
||||
|
||||
Assert.Equal(string.Empty, reader.ReadLine());
|
||||
Assert.True(reader.LastLineTruncated);
|
||||
Assert.Equal(51, reader.Position);
|
||||
Assert.Equal("short", reader.ReadLine());
|
||||
Assert.False(reader.LastLineTruncated);
|
||||
Assert.Null(reader.ReadLine());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_spanning_many_read_buffers_is_returned_whole()
|
||||
{
|
||||
var longLine = new string('y', 200_000);
|
||||
var file = Encoding.UTF8.GetBytes(longLine + "\nend\n");
|
||||
|
||||
var result = ReadAll(file, maxLineBytes: 1_000_000);
|
||||
|
||||
Assert.Equal(new[] { longLine, "end" }, result.Lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Half_written_utf16_character_at_end_of_file_stays_in_the_unterminated_line()
|
||||
{
|
||||
var full = Bytes(Encoding.Unicode, "ok\r\nhalf");
|
||||
var file = full.Concat(new byte[] { 0x41 }).ToArray(); // first byte of the next UTF-16 unit
|
||||
|
||||
var result = ReadAll(file);
|
||||
|
||||
Assert.Equal("ok", result.Lines[0]);
|
||||
Assert.False(result.Terminated.Last());
|
||||
Assert.Equal(file.Length, result.EndPositions.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlignDown_snaps_to_code_units_behind_the_byte_order_mark()
|
||||
{
|
||||
Assert.Equal(0, LogTextEncoding.Utf16LittleEndian.AlignDown(1));
|
||||
Assert.Equal(2, LogTextEncoding.Utf16LittleEndian.AlignDown(3));
|
||||
Assert.Equal(6, LogTextEncoding.Utf16LittleEndian.AlignDown(7));
|
||||
Assert.Equal(8, LogTextEncoding.Utf32LittleEndian.AlignDown(11));
|
||||
Assert.Equal(7, LogTextEncoding.Utf8.AlignDown(7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Head_hash_of_a_prefix_matches_the_hash_of_the_shorter_file()
|
||||
{
|
||||
var shortFile = LogFileHead.FromBytes(Encoding.UTF8.GetBytes("2026-09-28 start"));
|
||||
var grown = LogFileHead.FromBytes(Encoding.UTF8.GetBytes("2026-09-28 start\nmore lines"));
|
||||
|
||||
Assert.Equal(shortFile.Hash(shortFile.Length), grown.Hash(shortFile.Length));
|
||||
Assert.NotEqual(shortFile.Hash(shortFile.Length), grown.Hash(grown.Length));
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -37,7 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
logger, "sess", "tenant",
|
||||
evt => _events.Add(evt),
|
||||
null,
|
||||
new LogFilePositionTracker(),
|
||||
new GatherRuleSessionState("sess", null, logger),
|
||||
new GatherRuleDebugLog(_debugLogPath, logger))
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Reading semantics of <see cref="LogParserCollector"/> across runs on one context: every
|
||||
/// line of a growing log is matched exactly once — no line lost at a maxLines cut, no line
|
||||
/// split by a writer that is mid-line, UTF-16 logs readable after the first run, positions
|
||||
/// kept per rule, line numbers counted from the start of the file.
|
||||
/// </summary>
|
||||
public sealed class LogParserCollectorReadingTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly GatherRuleContext _context;
|
||||
|
||||
// %TEMP% lives under C:\Users (hard-blocked) — parse targets sit beside the
|
||||
// test assembly instead, admitted via UnrestrictedMode.
|
||||
private readonly string _logDir = Path.Combine(
|
||||
AppContext.BaseDirectory, "gather-reading-tests-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public LogParserCollectorReadingTests()
|
||||
{
|
||||
Directory.CreateDirectory(_logDir);
|
||||
_context = NewContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tmp.Dispose();
|
||||
try { Directory.Delete(_logDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private GatherRuleContext NewContext()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
return new GatherRuleContext(logger, "sess", "tenant",
|
||||
evt => _events.Add(evt), null, new GatherRuleSessionState("sess", null, logger))
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
private string LogPath(string name) => Path.Combine(_logDir, name);
|
||||
|
||||
private static GatherRule TextRule(string ruleId, string target, string pattern,
|
||||
string trigger = "interval", int? maxLines = null)
|
||||
{
|
||||
var parameters = new Dictionary<string, string> { ["pattern"] = pattern, ["format"] = "text" };
|
||||
if (maxLines.HasValue) parameters["maxLines"] = maxLines.Value.ToString();
|
||||
return new GatherRule
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Title = ruleId,
|
||||
CollectorType = "logparser",
|
||||
Target = target,
|
||||
Parameters = parameters,
|
||||
Trigger = trigger,
|
||||
IntervalSeconds = trigger == "interval" ? 30 : (int?)null,
|
||||
OutputEventType = "logparser_reading_test",
|
||||
};
|
||||
}
|
||||
|
||||
private List<string> Captured(string group) =>
|
||||
_events.Select(e => e.Data.TryGetValue(group, out var v) ? (string)v : "<missing>").ToList();
|
||||
|
||||
[Fact]
|
||||
public void Two_rules_on_one_file_each_read_the_whole_file()
|
||||
{
|
||||
var path = LogPath("shared.log");
|
||||
File.WriteAllText(path, "ERROR one\nWARN two\nERROR three\n");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(TextRule("RULE-ERRORS", path, "^ERROR (?<what>\\w+)"), _context);
|
||||
collector.Execute(TextRule("RULE-WARNINGS", path, "^WARN (?<what>\\w+)"), _context);
|
||||
|
||||
Assert.Equal(2, _events.Count(e => (string)e.Data["ruleId"] == "RULE-ERRORS"));
|
||||
Assert.Single(_events, e => (string)e.Data["ruleId"] == "RULE-WARNINGS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxLines_cut_loses_no_line_across_runs()
|
||||
{
|
||||
var path = LogPath("long.log");
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 1; i <= 30; i++)
|
||||
sb.Append("hit ").Append(i.ToString("D3")).Append(' ').Append('x', 90).Append('\n');
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
var rule = TextRule("RULE-LONG", path, "^hit (?<n>\\d{3})", maxLines: 10);
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
for (var run = 0; run < 6; run++)
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
var expected = Enumerable.Range(1, 30).Select(i => i.ToString("D3")).ToList();
|
||||
Assert.Equal(expected, Captured("n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_written_in_two_parts_is_matched_once_and_whole()
|
||||
{
|
||||
var path = LogPath("partial.log");
|
||||
File.WriteAllText(path, "ERR");
|
||||
var rule = TextRule("RULE-PARTIAL", path, "^ERROR (?<code>0x[0-9A-F]+)$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context); // the writer is mid-line
|
||||
File.AppendAllText(path, "OR 0x1F\n");
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
Assert.Equal(new List<string> { "0x1F" }, Captured("code"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interval_rule_reads_a_line_still_unfinished_on_its_second_run_once()
|
||||
{
|
||||
var path = LogPath("never-terminated.log");
|
||||
File.WriteAllText(path, "ERROR 0x2A");
|
||||
var rule = TextRule("RULE-UNFINISHED", path, "^ERROR (?<code>0x[0-9A-F]+)$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context); // held: the writer may still be in the line
|
||||
collector.Execute(rule, _context); // still unfinished — taken as it is
|
||||
collector.Execute(rule, _context); // nothing new
|
||||
|
||||
Assert.Equal(new List<string> { "0x2A" }, Captured("code"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_shot_rule_reads_an_unfinished_line_now_and_whole_on_its_next_run()
|
||||
{
|
||||
var path = LogPath("startup.log");
|
||||
File.WriteAllText(path, "ok\nERR");
|
||||
var rule = TextRule("RULE-STARTUP", path, "^ERR(?<rest>.*)$", trigger: "startup");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context); // agent start: the writer is mid-line
|
||||
File.AppendAllText(path, "OR 0x1F\n");
|
||||
collector.Execute(rule, _context); // next agent start
|
||||
|
||||
// Read twice, never split: the second read sees the whole line.
|
||||
Assert.Equal(new List<string> { "", "OR 0x1F" }, Captured("rest"));
|
||||
Assert.Equal(new long[] { 2, 2 }, _events.Select(e => Convert.ToInt64(e.Data["logLineNumber"])).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_feed_written_after_a_trailing_carriage_return_is_consumed_without_a_line()
|
||||
{
|
||||
var path = LogPath("cr-at-end.log");
|
||||
File.WriteAllText(path, "hit\r");
|
||||
var rule = TextRule("RULE-CR", path, "^hit$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context);
|
||||
File.AppendAllText(path, "\n");
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
Assert.Single(_events);
|
||||
var position = _context.SessionState.PeekLogPosition("RULE-CR", path);
|
||||
Assert.Equal(new FileInfo(path).Length, position!.Position);
|
||||
Assert.Equal(1, position.LineNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Utf16_log_appended_after_the_first_run_is_still_matched()
|
||||
{
|
||||
var path = LogPath("transcript.log");
|
||||
File.WriteAllText(path, "ERROR first\r\n", Encoding.Unicode); // UTF-16 LE with BOM
|
||||
var rule = TextRule("RULE-UTF16", path, "^ERROR (?<what>\\w+)$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context);
|
||||
using (var stream = new FileStream(path, FileMode.Append, FileAccess.Write))
|
||||
{
|
||||
var bytes = new UnicodeEncoding(bigEndian: false, byteOrderMark: false).GetBytes("ERROR second\r\n");
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
Assert.Equal(new List<string> { "first", "second" }, Captured("what"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("abc")]
|
||||
[InlineData("0")]
|
||||
[InlineData("-5")]
|
||||
public void MaxLines_that_is_no_positive_number_falls_back_to_the_default(string maxLines)
|
||||
{
|
||||
var path = LogPath("misconfigured.log");
|
||||
File.WriteAllText(path, "hit 1\nhit 2\n");
|
||||
var rule = TextRule("RULE-MISCONFIGURED", path, "^hit (?<n>\\d)$");
|
||||
rule.Parameters["maxLines"] = maxLines;
|
||||
|
||||
new LogParserCollector().Execute(rule, _context);
|
||||
|
||||
Assert.Equal(new List<string> { "1", "2" }, Captured("n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_numbers_count_from_the_start_of_the_file()
|
||||
{
|
||||
var path = LogPath("numbers.log");
|
||||
File.WriteAllText(path, "a\nhit\nb\n");
|
||||
var rule = TextRule("RULE-NUMBERS", path, "^hit$");
|
||||
var collector = new LogParserCollector();
|
||||
|
||||
collector.Execute(rule, _context);
|
||||
File.AppendAllText(path, "c\nhit\n");
|
||||
collector.Execute(rule, _context);
|
||||
|
||||
Assert.Equal(new long[] { 2, 5 }, _events.Select(e => Convert.ToInt64(e.Data["logLineNumber"])).ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -35,7 +35,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
tenantId: "tenant",
|
||||
onEventCollected: _ => { },
|
||||
imeLogPathOverride: null,
|
||||
filePositionTracker: new LogFilePositionTracker())
|
||||
sessionState: new GatherRuleSessionState("sess", null, logger))
|
||||
{
|
||||
// Bypass the registry allowlist guard for the synthetic test path.
|
||||
UnrestrictedMode = true
|
||||
|
||||
+1
-1
@@ -337,7 +337,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
"Launch powershell executor in machine session",
|
||||
new[] { "context=machine" }
|
||||
};
|
||||
// Wording from the IME decompile (ContentDownloaderDeliveryOptimization, identical 1.97–1.105).
|
||||
// IME wording, identical from 1.97 to 1.105.
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-DO-TIMEOUT-2",
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
/// completion markers (whiteglove.complete, clean-exit) — V1 sessions had only AgentLogs/
|
||||
/// and ImeLogs/, which left forensics blind to decision-engine state and pending uploads.
|
||||
/// </summary>
|
||||
[Collection(UserProfileResolverCollection.Name)] // replaces UserProfileResolver's static state
|
||||
public sealed class DiagnosticsPackageServiceTests
|
||||
{
|
||||
private static AgentConfiguration Cfg(string sessionId = "S1") => new AgentConfiguration
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Serialises the test classes that replace <see cref="UserProfileResolver"/>'s static state.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class UserProfileResolverCollection
|
||||
{
|
||||
public const string Name = "UserProfileResolver";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The agent starts before anyone has signed in. A lookup in that window must not pin
|
||||
/// "no user" for the rest of the process — the %LOGGED_ON_USER_PROFILE% gather rules and the
|
||||
/// RealmJoin user logs of the diagnostics package would never resolve.
|
||||
/// </summary>
|
||||
[Collection(UserProfileResolverCollection.Name)]
|
||||
public sealed class UserProfileResolverTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime Start = new DateTime(2026, 9, 28, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public void Dispose() => UserProfileResolver.Reset();
|
||||
|
||||
[Fact]
|
||||
public void User_who_signs_in_after_the_first_lookup_is_found_later()
|
||||
{
|
||||
string? signedIn = null;
|
||||
var now = Start;
|
||||
UserProfileResolver.SetDetectorForTesting(() => signedIn, () => now);
|
||||
|
||||
Assert.Null(UserProfileResolver.GetLoggedOnUserProfilePath());
|
||||
|
||||
signedIn = @"C:\Users\enduser";
|
||||
now += UserProfileResolver.RetryInterval;
|
||||
|
||||
Assert.Equal(@"C:\Users\enduser", UserProfileResolver.GetLoggedOnUserProfilePath());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_user_is_detected_again_only_after_the_retry_interval()
|
||||
{
|
||||
var detections = 0;
|
||||
var now = Start;
|
||||
UserProfileResolver.SetDetectorForTesting(() => { detections++; return null; }, () => now);
|
||||
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
now += TimeSpan.FromSeconds(10);
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
Assert.Equal(1, detections);
|
||||
|
||||
now += UserProfileResolver.RetryInterval;
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
Assert.Equal(2, detections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_clock_stepping_backwards_does_not_delay_the_next_detection()
|
||||
{
|
||||
var detections = 0;
|
||||
var now = Start;
|
||||
UserProfileResolver.SetDetectorForTesting(() => { detections++; return null; }, () => now);
|
||||
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
now -= TimeSpan.FromHours(1); // OOBE time sync
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
|
||||
Assert.Equal(2, detections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_found_user_is_kept_without_detecting_again()
|
||||
{
|
||||
var detections = 0;
|
||||
UserProfileResolver.SetDetectorForTesting(() => { detections++; return @"C:\Users\enduser"; }, () => Start);
|
||||
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
UserProfileResolver.GetLoggedOnUserProfilePath();
|
||||
|
||||
Assert.Equal(1, detections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pinned_no_user_stays_null_without_real_detection()
|
||||
{
|
||||
UserProfileResolver.SetForTesting(null!);
|
||||
|
||||
Assert.Null(UserProfileResolver.GetLoggedOnUserProfilePath());
|
||||
Assert.Null(UserProfileResolver.ExpandCustomTokens(@"%LOGGED_ON_USER_PROFILE%\AppData\Local\x.log"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// CloudExperienceHost navigation (D-310): one agent.log line per page start/stop and per
|
||||
/// update-page event name, and for the OOBE update page itself (<c>OobeNDUP</c>,
|
||||
/// <c>RebootNDUP</c>) the <c>oobe_update_page</c> telemetry plus the registry state at its live
|
||||
/// start and stop. Descriptions follow the Shell-Core manifest templates; a 62407 value
|
||||
/// (URLs with tenant ids) leaves the tracker only as a boolean, an integer or an HRESULT.
|
||||
/// </summary>
|
||||
public sealed class ShellCoreTrackerBreadcrumbTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 10, 2, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string NdupStarted = "CloudExperienceHost Web App Activity started. CXID: 'OobeNDUP'.";
|
||||
private const string EspPageStarted = "CloudExperienceHost Web App Activity started. CXID: 'MDMProgressAfterAADRefactored'.";
|
||||
private const string PageStopped = "CloudExperienceHost Web App Activity stopped. Result: 'success'.";
|
||||
private const string ScanStartedName = "CloudExperienceHost Web App Event 1. Name: 'ExpeditedUpdate_startWUScanStarted'.";
|
||||
private const string NavigateWithTenantUrl =
|
||||
"CloudExperienceHost Web App Event 2. Name: 'Navigate', Value: '{\"httpMethod\":\"GET\",\"url\":\"https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/authorize\"}'.";
|
||||
private const string DownloadSucceededWithValue =
|
||||
"CloudExperienceHost Web App Event 2. Name: 'ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded', Value: '{\"url\":\"https://login.microsoftonline.com/x\"}'.";
|
||||
private const string CspGateTrue =
|
||||
"CloudExperienceHost Web App Event 2. Name: 'ExpeditedUpdate_isNDUPAllowedByCSPSucceeded', Value: 'true'.";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState> _registry = Array.Empty<OobeUpdateRegistrySnapshot.KeyState>();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private ShellCoreTracker Build() =>
|
||||
new ShellCoreTracker("S1", "T1",
|
||||
new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
helloTracker: null,
|
||||
oobeUpdateRegistryReader: () => _registry);
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal posted) =>
|
||||
(IReadOnlyDictionary<string, object>)posted.TypedPayload!;
|
||||
|
||||
private static OobeUpdateRegistrySnapshot.KeyState NdupKey(string line) =>
|
||||
new OobeUpdateRegistrySnapshot.KeyState("setupOobeNdup", @"SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE\NDUP",
|
||||
exists: true, hasValues: true, line: line);
|
||||
|
||||
// ------------------------------------------------------------- breadcrumbs ----
|
||||
|
||||
[Fact]
|
||||
public void PageStart_NamesTheCxid()
|
||||
{
|
||||
Assert.Equal("CXH page started: OobeNDUP",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PageStop_IsAttributedToTheLastStartedPage()
|
||||
{
|
||||
// 62405 names no page — it ends the one started last.
|
||||
Assert.Equal("CXH page stopped: OobeNDUP (result=success)",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, lastPage: "OobeNDUP"));
|
||||
Assert.Equal("CXH page stopped: ? (result=success)",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePageEventNames_AreLogged_FromBothEventIds()
|
||||
{
|
||||
Assert.Equal("CXH event: ExpeditedUpdate_startWUScanStarted",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, lastPage: null));
|
||||
Assert.Equal("CXH event: ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppEvent, DownloadSucceededWithValue, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsafeEventValues_NeverReachTheLog()
|
||||
{
|
||||
var crumb = ShellCoreTracker.FormatCxhBreadcrumb(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppEvent, DownloadSucceededWithValue, lastPage: null);
|
||||
|
||||
Assert.NotNull(crumb);
|
||||
Assert.DoesNotContain("microsoftonline", crumb);
|
||||
Assert.DoesNotContain("url", crumb!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeEventValues_AreLogged()
|
||||
{
|
||||
Assert.Equal("CXH event: ExpeditedUpdate_isNDUPAllowedByCSPSucceeded (value=true)",
|
||||
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppEvent, CspGateTrue, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherEventNames_AreNotLogged()
|
||||
{
|
||||
// Ordinary page traffic (Navigate, Visible, ESP progress) stays out of the log.
|
||||
Assert.Null(ShellCoreTracker.FormatCxhBreadcrumb(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppEvent, NavigateWithTenantUrl, lastPage: "OobeNDUP"));
|
||||
Assert.Null(ShellCoreTracker.FormatCxhBreadcrumb(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppEvent, "CommercialOOBE_ESPProgress_Page_Exiting", lastPage: null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("CloudExperienceHost Web App Activity started.")]
|
||||
public void PageStartWithoutCxid_IsNoBreadcrumb(string description)
|
||||
{
|
||||
Assert.Null(ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppStarted, description, lastPage: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LongTokens_AreBounded()
|
||||
{
|
||||
var longCxid = new string('X', 500);
|
||||
var crumb = ShellCoreTracker.FormatCxhBreadcrumb(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppStarted, $"CloudExperienceHost Web App Activity started. CXID: '{longCxid}'.", lastPage: null);
|
||||
|
||||
Assert.NotNull(crumb);
|
||||
Assert.True(crumb!.Length < 200, crumb);
|
||||
Assert.EndsWith("…", crumb);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("OobeNDUP", true)]
|
||||
[InlineData("RebootNDUP", true)]
|
||||
[InlineData("EarlyRebootNDUP", true)]
|
||||
[InlineData("ExpeditedUpdate_startWUScanStarted", true)]
|
||||
[InlineData("AADHello", false)]
|
||||
[InlineData("MDMProgressAfterAADRefactored", false)]
|
||||
[InlineData(null, false)]
|
||||
public void UpdatePageTokens_AreRecognized(string? token, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ShellCoreTracker.IsUpdatePageToken(token!));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ update page records ----
|
||||
|
||||
[Fact]
|
||||
public void UpdatePageRecords_ArePageStartsStopsAndNames_OfTheUpdatePageOnly()
|
||||
{
|
||||
var start = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, lastPage: null);
|
||||
Assert.Equal(OobeUpdatePageRecord.PageStarted, start!.CxhEvent);
|
||||
Assert.Equal("OobeNDUP", start.Page);
|
||||
|
||||
var stop = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, lastPage: "RebootNDUP");
|
||||
Assert.Equal(OobeUpdatePageRecord.PageStopped, stop!.CxhEvent);
|
||||
Assert.Equal("RebootNDUP", stop.Page);
|
||||
Assert.Equal("success", stop.Result);
|
||||
|
||||
var name = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, lastPage: null);
|
||||
Assert.Equal(OobeUpdatePageRecord.EventName, name!.CxhEvent);
|
||||
Assert.Equal("ExpeditedUpdate_startWUScanStarted", name.Name);
|
||||
Assert.Null(name.Value);
|
||||
|
||||
// Other pages and names are no update-page records.
|
||||
Assert.Null(ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted, EspPageStarted, lastPage: null));
|
||||
Assert.Null(ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, lastPage: "MDMProgressAfterAADRefactored"));
|
||||
Assert.Null(ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEvent, NavigateWithTenantUrl, lastPage: "OobeNDUP"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePageRecord_CarriesOnlyASafeValue()
|
||||
{
|
||||
var unsafeValue = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEvent, DownloadSucceededWithValue, lastPage: null);
|
||||
Assert.Null(unsafeValue!.Value);
|
||||
|
||||
var gate = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEvent, CspGateTrue, lastPage: null);
|
||||
Assert.Equal("true", gate!.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("true", "true")]
|
||||
[InlineData("FALSE", "FALSE")]
|
||||
[InlineData("0", "0")]
|
||||
[InlineData("-2147024891", "-2147024891")]
|
||||
[InlineData("0x80240022", "0x80240022")]
|
||||
[InlineData("12345678901", null)] // more than ten digits
|
||||
[InlineData("0x123456789", null)] // more than eight hex digits
|
||||
[InlineData("https://login.microsoftonline.com/x", null)]
|
||||
[InlineData("{\"result\":0}", null)]
|
||||
[InlineData("true and more", null)]
|
||||
[InlineData("", null)]
|
||||
public void SafeValues_AreBooleansIntegersAndHresults(string value, string? expected)
|
||||
{
|
||||
var description = $"CloudExperienceHost Web App Event 2. Name: 'ExpeditedUpdate_x', Value: '{value}'.";
|
||||
|
||||
Assert.Equal(expected, ShellCoreTracker.ExtractSafeValue(description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveUpdatePage_IsReported_WithTheRegistryState_AndRaisesNothing()
|
||||
{
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1") };
|
||||
using var tracker = Build();
|
||||
var raised = 0;
|
||||
tracker.EspExited += (_, _) => raised++;
|
||||
tracker.EspFailureDetected += (_, _) => raised++;
|
||||
tracker.FinalizingSetupPhaseTriggered += (_, _) => raised++;
|
||||
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddMinutes(1), "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1, EnableExpeditedUpdateSyncInstallCompleted=1") };
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(30), "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(new object[] { "page_started", "event_name", "page_stopped" }, pages.Select(p => Data(p)["cxhEvent"]).ToArray());
|
||||
Assert.All(pages, p => Assert.Equal(false, Data(p)["backfill"]));
|
||||
|
||||
var states = ByType(Constants.EventTypes.OobeUpdateState);
|
||||
Assert.Equal(new object[] { "update_page_started", "update_page_stopped" }, states.Select(s => Data(s)["moment"]).ToArray());
|
||||
Assert.All(states, s => Assert.Equal("OobeNDUP", Data(s)["page"]));
|
||||
|
||||
// Telemetry only — no ESP signal, no other event.
|
||||
Assert.Equal(0, raised);
|
||||
Assert.False(tracker.IsEspExitedForTest);
|
||||
Assert.Equal(pages.Count + states.Count, _sink.Posted.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherPages_AreNotReported()
|
||||
{
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1") };
|
||||
using var tracker = Build();
|
||||
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStarted, EspPageStarted, At, "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(1), "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfill_ReportsTheUpdatePage_WithItsEventTimes_ButReadsNoRegistry()
|
||||
{
|
||||
// The registry shows the state now, not at the record's time.
|
||||
var reads = 0;
|
||||
using var tracker = new ShellCoreTracker("S1", "T1",
|
||||
new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
helloTracker: null,
|
||||
oobeUpdateRegistryReader: () => { reads++; return new[] { NdupKey("EnableExpeditedUpdate=1") }; });
|
||||
|
||||
tracker.ReplayBackfillRecords(new[]
|
||||
{
|
||||
(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(-4)),
|
||||
(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddMinutes(-3)),
|
||||
(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(-2)),
|
||||
});
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(3, pages.Count);
|
||||
Assert.All(pages, p => Assert.Equal(true, Data(p)["backfill"]));
|
||||
Assert.Equal(At.AddMinutes(-4), pages[0].OccurredAtUtc);
|
||||
Assert.Equal(0, reads);
|
||||
// Nothing else: no ESP exit or failure was in the window, so no skipped-records trace.
|
||||
Assert.Equal(3, _sink.Posted.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentStartAndStop_ReportTheRegistryState_StopOnce()
|
||||
{
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1") };
|
||||
var tracker = Build();
|
||||
|
||||
tracker.Start();
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1, ExpeditedUpdateStatus=2") };
|
||||
tracker.Stop();
|
||||
tracker.Stop();
|
||||
tracker.Dispose();
|
||||
|
||||
var states = ByType(Constants.EventTypes.OobeUpdateState);
|
||||
Assert.Equal(new object[] { "agent_start", "agent_stop" }, states.Select(s => Data(s)["moment"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackerThatNeverStarted_ReportsNothingAtStop()
|
||||
{
|
||||
_registry = new[] { NdupKey("EnableExpeditedUpdate=1") };
|
||||
var tracker = Build();
|
||||
|
||||
tracker.Dispose();
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -61,7 +61,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
|
||||
// IME AppInstallStatus values (StatusServiceReports\..\Status), stable IME 1.50 .. 1.106.
|
||||
// 1001/1003 sit inside the 1000 band but are NOT terminal — IME maps every InProgress
|
||||
// enforcement state to 1001 (verified in the decompiled 1.106 StatusServiceHelpers).
|
||||
// enforcement state to 1001 (verified against IME 1.106).
|
||||
[Theory]
|
||||
[InlineData(1000, "installed")]
|
||||
[InlineData(1002, "installed")]
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// The OOBE update registry snapshot (D-310, S4/S5): one readable, bounded line per key, for
|
||||
/// agent.log and the <c>oobe_update_state</c> event. The registry read itself is a thin wrapper.
|
||||
/// </summary>
|
||||
public sealed class OobeUpdateRegistrySnapshotTests
|
||||
{
|
||||
private static KeyValuePair<string, object> Entry(string name, object value) => new KeyValuePair<string, object>(name, value);
|
||||
|
||||
[Fact]
|
||||
public void Values_AreFormattedByType()
|
||||
{
|
||||
Assert.Equal("1", OobeUpdateRegistrySnapshot.FormatValue(1));
|
||||
Assert.Equal("5000000000", OobeUpdateRegistrySnapshot.FormatValue(5000000000L));
|
||||
Assert.Equal("Succeeded", OobeUpdateRegistrySnapshot.FormatValue("Succeeded"));
|
||||
Assert.Equal("a|b", OobeUpdateRegistrySnapshot.FormatValue(new[] { "a", "b" }));
|
||||
Assert.Equal("(binary 3 bytes)", OobeUpdateRegistrySnapshot.FormatValue(new byte[] { 1, 2, 3 }));
|
||||
Assert.Equal("(null)", OobeUpdateRegistrySnapshot.FormatValue(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LongValues_AreTruncated()
|
||||
{
|
||||
var formatted = OobeUpdateRegistrySnapshot.FormatValue(new string('x', 500));
|
||||
Assert.Equal(OobeUpdateRegistrySnapshot.MaxValueLength + 1, formatted.Length);
|
||||
Assert.EndsWith("…", formatted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Entries_AreOrderedByName()
|
||||
{
|
||||
var line = OobeUpdateRegistrySnapshot.FormatEntries(new[]
|
||||
{
|
||||
Entry("ExpeditedUpdateStatus", 2),
|
||||
Entry("EnableExpeditedUpdate", 1),
|
||||
Entry("", "default"),
|
||||
}, oobeOnly: false);
|
||||
|
||||
Assert.Equal("(default)=default, EnableExpeditedUpdate=1, ExpeditedUpdateStatus=2", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyManagerSystemKey_KeepsOnlyOobeValues()
|
||||
{
|
||||
// That key holds every System policy of the device; only the OOBE ones matter here.
|
||||
var line = OobeUpdateRegistrySnapshot.FormatEntries(new[]
|
||||
{
|
||||
Entry("AllowTelemetry", 1),
|
||||
Entry("AllowOOBEUpdates", 1),
|
||||
Entry("AllowOOBEUpdates_ProviderSet", 1),
|
||||
}, oobeOnly: true);
|
||||
|
||||
Assert.Equal("AllowOOBEUpdates=1, AllowOOBEUpdates_ProviderSet=1", line);
|
||||
Assert.Equal("(no OOBE values)", OobeUpdateRegistrySnapshot.FormatEntries(new[] { Entry("AllowTelemetry", 1) }, oobeOnly: true));
|
||||
Assert.Equal("(no values)", OobeUpdateRegistrySnapshot.FormatEntries(new KeyValuePair<string, object>[0], oobeOnly: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManyEntries_AreCapped_AndSaySo()
|
||||
{
|
||||
var entries = Enumerable.Range(0, OobeUpdateRegistrySnapshot.MaxEntries + 5)
|
||||
.Select(i => Entry($"Value{i:000}", i));
|
||||
|
||||
var line = OobeUpdateRegistrySnapshot.FormatEntries(entries, oobeOnly: false);
|
||||
|
||||
Assert.EndsWith("… (+5 more)", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_CountsOnlyOobeValues_OfThePolicyManagerSystemKey()
|
||||
{
|
||||
var policy = OobeUpdateRegistrySnapshot.Describe("policyManagerSystem", OobeUpdateRegistrySnapshot.PolicyManagerSystemKey,
|
||||
new[] { Entry("AllowTelemetry", 1) }, new string[0]);
|
||||
Assert.True(policy.Exists);
|
||||
Assert.False(policy.HasValues);
|
||||
|
||||
var oobe = OobeUpdateRegistrySnapshot.Describe("policyManagerSystem", OobeUpdateRegistrySnapshot.PolicyManagerSystemKey,
|
||||
new[] { Entry("AllowTelemetry", 1), Entry("AllowOOBEUpdates", 1) }, new string[0]);
|
||||
Assert.True(oobe.HasValues);
|
||||
Assert.Equal("AllowOOBEUpdates=1", oobe.Line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_SubkeysAlone_AreNoValues()
|
||||
{
|
||||
var state = OobeUpdateRegistrySnapshot.Describe("ndup", @"SOFTWARE\Microsoft\Windows\CurrentVersion\NDUP",
|
||||
new KeyValuePair<string, object>[0], new[] { "Updates" });
|
||||
|
||||
Assert.False(state.HasValues);
|
||||
Assert.Equal("(no values) | subkeys: Updates", state.Line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keys_HaveDistinctAliases()
|
||||
{
|
||||
// The aliases are the data keys of oobe_update_state.
|
||||
Assert.Equal(OobeUpdateRegistrySnapshot.Keys.Length,
|
||||
OobeUpdateRegistrySnapshot.Keys.Select(k => k.Alias).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_ReturnsEveryKey_FromTheRealRegistry()
|
||||
{
|
||||
var states = OobeUpdateRegistrySnapshot.Read();
|
||||
|
||||
Assert.Equal(OobeUpdateRegistrySnapshot.Keys.Select(k => k.Path), states.Select(s => s.Path));
|
||||
Assert.All(states.Where(s => !s.Exists), s => Assert.False(s.HasValues));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_ToleratesMissingLoggerOrStates()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
OobeUpdateRegistrySnapshot.Log(new AgentLogger(tmp.Path, AgentLogLevel.Info), "test", OobeUpdateRegistrySnapshot.Read());
|
||||
OobeUpdateRegistrySnapshot.Log(null!, "test", OobeUpdateRegistrySnapshot.Read());
|
||||
OobeUpdateRegistrySnapshot.Log(new AgentLogger(tmp.Path, AgentLogLevel.Info), "test", null!);
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Measurement telemetry of the OOBE update page (D-310): <c>oobe_update_page</c> per
|
||||
/// update-page record and <c>oobe_update_state</c> for the registry behind it — Debug, bounded
|
||||
/// per run, the state only when a key holds values and the content changed.
|
||||
/// </summary>
|
||||
public sealed class OobeUpdateTelemetryTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 10, 2, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState> _registry = Array.Empty<OobeUpdateRegistrySnapshot.KeyState>();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private OobeUpdateTelemetry Build(Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>>? reader = null) =>
|
||||
new OobeUpdateTelemetry("sess-oobe", "tenant-oobe",
|
||||
new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
reader ?? (() => _registry));
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal posted) =>
|
||||
(IReadOnlyDictionary<string, object>)posted.TypedPayload!;
|
||||
|
||||
private static OobeUpdateRegistrySnapshot.KeyState Key(string alias, string? line) =>
|
||||
line == null
|
||||
? new OobeUpdateRegistrySnapshot.KeyState(alias, $@"SOFTWARE\{alias}", exists: false, hasValues: false, line: null)
|
||||
: new OobeUpdateRegistrySnapshot.KeyState(alias, $@"SOFTWARE\{alias}", exists: true, hasValues: true, line: line);
|
||||
|
||||
// ------------------------------------------------------------- oobe_update_page ----
|
||||
|
||||
[Fact]
|
||||
public void Page_IsReported_WithItsEventTime_AndFields()
|
||||
{
|
||||
Build().ReportPage(
|
||||
new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "OobeNDUP", result: "success"),
|
||||
At.AddMinutes(-3), isBackfill: true);
|
||||
|
||||
var page = Assert.Single(ByType(Constants.EventTypes.OobeUpdatePage));
|
||||
Assert.Equal(At.AddMinutes(-3), page.OccurredAtUtc);
|
||||
Assert.Equal("Debug", page.Payload![SignalPayloadKeys.Severity]);
|
||||
var data = Data(page);
|
||||
Assert.Equal("page_stopped", data["cxhEvent"]);
|
||||
Assert.Equal("OobeNDUP", data["page"]);
|
||||
Assert.Equal("success", data["result"]);
|
||||
Assert.Equal(true, data["backfill"]);
|
||||
Assert.Equal(1, data["occurrence"]);
|
||||
Assert.False(data.ContainsKey("value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventName_CarriesItsSafeValue()
|
||||
{
|
||||
Build().ReportPage(
|
||||
new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62407, name: "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", value: "true"),
|
||||
At, isBackfill: false);
|
||||
|
||||
var data = Data(Assert.Single(ByType(Constants.EventTypes.OobeUpdatePage)));
|
||||
Assert.Equal("ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", data["name"]);
|
||||
Assert.Equal("true", data["value"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SamePageOrName_IsReportedAtMostThreeTimes()
|
||||
{
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: "ExpeditedUpdate_progress"), At.AddSeconds(i), isBackfill: false);
|
||||
}
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "OobeNDUP", result: "success"), At.AddMinutes(1), isBackfill: false);
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerKey + 1, pages.Count);
|
||||
Assert.Equal(new object[] { 1, 2, 3 }, pages.Take(3).Select(p => Data(p)["occurrence"]).ToArray());
|
||||
// The repeats do not crowd out the page end.
|
||||
Assert.Equal("page_stopped", Data(pages.Last())["cxhEvent"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PageEvents_AreCappedPerRun()
|
||||
{
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < OobeUpdateTelemetry.MaxPageEventsPerRun + 20; i++)
|
||||
{
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At, isBackfill: false);
|
||||
}
|
||||
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun, ByType(Constants.EventTypes.OobeUpdatePage).Count);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ oobe_update_state ----
|
||||
|
||||
[Fact]
|
||||
public void State_WithoutValues_IsNotReported()
|
||||
{
|
||||
_registry = new[] { Key("setupOobeNdup", null), Key("policiesOobe", null) };
|
||||
|
||||
Build().ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
|
||||
Assert.Empty(ByType(Constants.EventTypes.OobeUpdateState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_ReportsOnlyKeysWithValues_UnderTheirAlias()
|
||||
{
|
||||
_registry = new[]
|
||||
{
|
||||
Key("setupOobeNdup", "EnableExpeditedUpdate=1, EnableExpeditedUpdateSyncDownloadCompleted=1"),
|
||||
Key("ndup", null),
|
||||
new OobeUpdateRegistrySnapshot.KeyState("policyManagerSystem", "x", exists: true, hasValues: false, line: "(no OOBE values)"),
|
||||
Key("espSetupPolicy", "InstallQualityUpdates=1"),
|
||||
};
|
||||
|
||||
Build().ReportState(OobeUpdateTelemetry.Moments.UpdatePageStopped, "OobeNDUP");
|
||||
|
||||
var state = Assert.Single(ByType(Constants.EventTypes.OobeUpdateState));
|
||||
Assert.Equal("Debug", state.Payload![SignalPayloadKeys.Severity]);
|
||||
var data = Data(state);
|
||||
Assert.Equal("update_page_stopped", data["moment"]);
|
||||
Assert.Equal("OobeNDUP", data["page"]);
|
||||
Assert.Equal(2, data["keysWithValues"]);
|
||||
Assert.Equal("EnableExpeditedUpdate=1, EnableExpeditedUpdateSyncDownloadCompleted=1", data["setupOobeNdup"]);
|
||||
Assert.Equal("InstallQualityUpdates=1", data["espSetupPolicy"]);
|
||||
Assert.False(data.ContainsKey("ndup"));
|
||||
Assert.False(data.ContainsKey("policyManagerSystem"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnchangedState_IsReportedOnce_AChangeAgain()
|
||||
{
|
||||
var telemetry = Build();
|
||||
_registry = new[] { Key("espSetupPolicy", "InstallQualityUpdates=1") };
|
||||
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.UpdatePageStarted, "OobeNDUP");
|
||||
_registry = new[] { Key("espSetupPolicy", "InstallQualityUpdates=1"), Key("setupOobeNdup", "EnableExpeditedUpdate=1") };
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.UpdatePageStopped, "OobeNDUP");
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.AgentStop);
|
||||
|
||||
var states = ByType(Constants.EventTypes.OobeUpdateState);
|
||||
Assert.Equal(new object[] { "agent_start", "update_page_stopped" }, states.Select(s => Data(s)["moment"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateEvents_AreCappedPerRun()
|
||||
{
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < OobeUpdateTelemetry.MaxStateEventsPerRun + 4; i++)
|
||||
{
|
||||
_registry = new[] { Key("setupOobeNdup", $"ExpeditedUpdateStatus={i}") };
|
||||
telemetry.ReportState(OobeUpdateTelemetry.Moments.UpdatePageStarted, "OobeNDUP");
|
||||
}
|
||||
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxStateEventsPerRun, ByType(Constants.EventTypes.OobeUpdateState).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LongLines_AreTruncated()
|
||||
{
|
||||
_registry = new[] { Key("ndupUpdates", new string('x', 3000)) };
|
||||
|
||||
Build().ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
|
||||
var line = (string)Data(Assert.Single(ByType(Constants.EventTypes.OobeUpdateState)))["ndupUpdates"];
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxStateLineLength + 1, line.Length);
|
||||
Assert.EndsWith("…", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailingRegistryRead_ReportsNothing_AndDoesNotThrow()
|
||||
{
|
||||
Build(() => throw new UnauthorizedAccessException("denied")).ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
}
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// ServicingTracker (D-310) — the CBS servicing steps of OS updates from the Setup log. The
|
||||
/// sequence and XML shapes are those Windows 11 writes for a cumulative update installed by
|
||||
/// <c>UpdateAgentLCU</c>: initiate Absent → Staged, Staged reached, initiate Staged → Installed,
|
||||
/// restart required, Installed reached after the restart.
|
||||
/// </summary>
|
||||
public sealed class ServicingTrackerTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 9, 25, 18, 52, 37, DateTimeKind.Utc);
|
||||
private const string Kb = "KB5099999";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly UpdateActivityTally _tally = new UpdateActivityTally();
|
||||
private readonly List<(string Step, string Update, DateTime At)> _osActivity = new List<(string, string, DateTime)>();
|
||||
private readonly ServicingTracker _tracker;
|
||||
private long _nextRecordId = 1200;
|
||||
|
||||
public ServicingTrackerTests()
|
||||
{
|
||||
_tracker = NewTracker(stateDirectory: null);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private ServicingTracker NewTracker(string? stateDirectory) =>
|
||||
new ServicingTracker(
|
||||
sessionId: "sess-cbs",
|
||||
tenantId: "tenant-cbs",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(T0)),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
stateDirectory: stateDirectory,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: (step, update, at) => _osActivity.Add((step, update, at)));
|
||||
|
||||
private void Process(
|
||||
int eventId,
|
||||
DateTime at,
|
||||
string? initialState = null,
|
||||
string? targetState = "Installed",
|
||||
string? errorCode = null,
|
||||
string? client = "UpdateAgentLCU",
|
||||
string? package = Kb,
|
||||
long? recordId = null,
|
||||
ServicingTracker? tracker = null,
|
||||
bool isBackfill = false)
|
||||
{
|
||||
(tracker ?? _tracker).ProcessEvent(
|
||||
eventId: eventId,
|
||||
recordId: recordId ?? _nextRecordId++,
|
||||
timeCreatedUtc: at,
|
||||
package: package,
|
||||
initialState: initialState,
|
||||
targetState: targetState,
|
||||
errorCode: errorCode,
|
||||
client: client,
|
||||
isBackfill: isBackfill);
|
||||
}
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> Servicing() =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == Constants.EventTypes.WindowsUpdateServicing).ToList();
|
||||
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
|
||||
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
|
||||
|
||||
[Fact]
|
||||
public void CumulativeUpdateSequence_ListsEveryStep_AndReportsTheEngineSteps()
|
||||
{
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, T0, initialState: "Absent", targetState: "Staged");
|
||||
Process(ServicingTracker.EventId_StateReached, T0.AddMinutes(4), targetState: "Staged", errorCode: "0x0");
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, T0.AddMinutes(29), initialState: "Staged", targetState: "Installed");
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0.AddMinutes(33), targetState: "Installed", errorCode: "0x0");
|
||||
// Written after the restart.
|
||||
Process(ServicingTracker.EventId_StateReached, T0.AddMinutes(41), targetState: "Installed", errorCode: "0x0");
|
||||
|
||||
var rows = Servicing();
|
||||
Assert.Equal(
|
||||
new[] { "initiating", "state_reached", "initiating", "reboot_required", "state_reached" },
|
||||
rows.Select(s => (string)Data(s)["step"]).ToArray());
|
||||
Assert.All(rows, s => Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.All(rows, s => Assert.Equal("ServicingWatcher", s.Payload![SignalPayloadKeys.Source]));
|
||||
Assert.All(rows, s => Assert.Equal(Kb, Data(s)["package"]));
|
||||
Assert.All(rows, s => Assert.Equal("UpdateAgentLCU", Data(s)["client"]));
|
||||
// ErrorCode 0x0 is success, not a decoded HRESULT.
|
||||
Assert.All(rows, s => Assert.False(Data(s).ContainsKey("hresult")));
|
||||
Assert.Equal("Absent", Data(rows[0])["initialState"]);
|
||||
Assert.Equal("Staged", Data(rows[0])["targetState"]);
|
||||
Assert.Equal(T0.AddMinutes(33), rows[3].OccurredAtUtc);
|
||||
Assert.Contains("needs a restart", rows[3].Payload![SignalPayloadKeys.Message]);
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
OsUpdateSteps.StagingStarted, OsUpdateSteps.Staged, OsUpdateSteps.InstallStarted,
|
||||
OsUpdateSteps.RebootRequired, OsUpdateSteps.Installed,
|
||||
},
|
||||
_osActivity.Select(a => a.Step).ToArray());
|
||||
Assert.All(_osActivity, a => Assert.Equal(Kb, a.Update));
|
||||
Assert.Equal(T0.AddMinutes(29), _osActivity[2].At);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CbsTask")]
|
||||
[InlineData("DISM Package Manager Provider")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void NonOsClients_AreCountedNotListed(string? client)
|
||||
{
|
||||
// CBS maintenance writes hundreds of these in seconds; language packs and features on
|
||||
// demand installed by apps are not the OOBE update either.
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, T0, initialState: "Staged", targetState: "Installed",
|
||||
client: client, package: "Microsoft-Windows-Telnet-Client-FOD-en-US");
|
||||
Process(ServicingTracker.EventId_StateReached, T0, targetState: "Installed", errorCode: "0x0",
|
||||
client: client, package: "Microsoft-Windows-Telnet-Client-FOD-en-US");
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
Assert.Empty(_osActivity);
|
||||
var counts = _tally.Snapshot().ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
Assert.Equal(1, counts["servicing_other_1"]);
|
||||
Assert.Equal(1, counts["servicing_other_2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsUpdateAgentClient_IsAnOsClient()
|
||||
{
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, client: "WindowsUpdateAgent", errorCode: "0x0");
|
||||
|
||||
Assert.Single(Servicing());
|
||||
Assert.Equal(OsUpdateSteps.RebootRequired, Assert.Single(_osActivity).Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeFailed_EmitsWarning_WithDecodedHResult()
|
||||
{
|
||||
Process(ServicingTracker.EventId_ChangeFailed, T0, targetState: "Installed", errorCode: "0x800f0922");
|
||||
|
||||
var s = Assert.Single(Servicing());
|
||||
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
|
||||
var data = Data(s);
|
||||
Assert.Equal("failed", data["step"]);
|
||||
Assert.Equal("0x800F0922", data["hresult"]);
|
||||
Assert.Equal("CBS_E_INSTALLERS_FAILED", data["hresultSymbol"]);
|
||||
Assert.Contains("FAILED", s.Payload![SignalPayloadKeys.Message]);
|
||||
Assert.Equal(OsUpdateSteps.Failed, Assert.Single(_osActivity).Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartiallyInstalled_IsAFailure_AndSaysSo()
|
||||
{
|
||||
Process(ServicingTracker.EventId_PartiallyInstalled, T0, targetState: "Installed", errorCode: "0x800f0922");
|
||||
|
||||
var s = Assert.Single(Servicing());
|
||||
Assert.Equal("failed", Data(s)["step"]);
|
||||
Assert.Contains("partially installed", s.Payload![SignalPayloadKeys.Message]);
|
||||
Assert.Equal(OsUpdateSteps.Failed, Assert.Single(_osActivity).Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeTowardsAnotherState_IsListed_ButIsNoEngineStep()
|
||||
{
|
||||
// Removing a superseded package is servicing, not progress of the update install.
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, T0, initialState: "Installed", targetState: "Absent");
|
||||
|
||||
Assert.Single(Servicing());
|
||||
Assert.Empty(_osActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledRecord_KeepsItsEventTime()
|
||||
{
|
||||
var earlier = T0.AddMinutes(-20);
|
||||
Process(ServicingTracker.EventId_InitiatingChanges, earlier, initialState: "Absent", targetState: "Staged", isBackfill: true);
|
||||
|
||||
var s = Assert.Single(Servicing());
|
||||
Assert.Equal(earlier, s.OccurredAtUtc);
|
||||
Assert.Equal(true, Data(s)["backfilled"]);
|
||||
Assert.Equal(earlier, Assert.Single(_osActivity).At);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_SkipsEmittedRecordsAfterRestart()
|
||||
{
|
||||
var first = NewTracker(_tmp.Path);
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", recordId: 1205, tracker: first);
|
||||
|
||||
var second = NewTracker(_tmp.Path);
|
||||
second.LoadWatermark();
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", recordId: 1205, tracker: second, isBackfill: true);
|
||||
Process(ServicingTracker.EventId_StateReached, T0.AddMinutes(8), errorCode: "0x0", recordId: 1207, tracker: second);
|
||||
|
||||
Assert.Equal(new[] { "reboot_required", "state_reached" }, Servicing().Select(s => (string)Data(s)["step"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameRecordDeliveredTwice_IsListedOnce()
|
||||
{
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", recordId: 1205);
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", recordId: 1205);
|
||||
|
||||
Assert.Single(Servicing());
|
||||
Assert.Single(_osActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseUserData_ReadsBothCbsPayloadShapes()
|
||||
{
|
||||
const string initiate =
|
||||
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='Microsoft-Windows-Servicing' Guid='{bd12f3b8-fc40-4a61-a307-b7a013a069c1}'/>" +
|
||||
"<EventID>1</EventID><Version>0</Version><Level>0</Level><Task>1</Task><Opcode>0</Opcode><Keywords>0x8000000000000000</Keywords>" +
|
||||
"<TimeCreated SystemTime='2026-09-25T19:21:42.4486782Z'/><EventRecordID>1203</EventRecordID><Channel>Setup</Channel><Computer>DESKTOP-TEST</Computer>" +
|
||||
"<Security UserID='S-1-5-18'/></System><UserData><CbsPackageInitiateChanges xmlns='http://manifests.microsoft.com/win/2004/08/windows/setup_provider'>" +
|
||||
"<PackageIdentifier>KB5099999</PackageIdentifier><InitialPackageState>5064</InitialPackageState><InitialPackageStateTextized>Staged</InitialPackageStateTextized>" +
|
||||
"<IntendedPackageState>5112</IntendedPackageState><IntendedPackageStateTextized>Installed</IntendedPackageStateTextized><Client>UpdateAgentLCU</Client>" +
|
||||
"</CbsPackageInitiateChanges></UserData></Event>";
|
||||
const string changeState =
|
||||
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='Microsoft-Windows-Servicing' Guid='{bd12f3b8-fc40-4a61-a307-b7a013a069c1}'/>" +
|
||||
"<EventID>4</EventID><TimeCreated SystemTime='2026-09-25T19:26:07.1381462Z'/><EventRecordID>1205</EventRecordID><Channel>Setup</Channel></System>" +
|
||||
"<UserData><CbsPackageChangeState xmlns='http://manifests.microsoft.com/win/2004/08/windows/setup_provider'><PackageIdentifier>KB5099999</PackageIdentifier>" +
|
||||
"<IntendedPackageState>5112</IntendedPackageState><IntendedPackageStateTextized>Installed</IntendedPackageStateTextized><ErrorCode>0x0</ErrorCode>" +
|
||||
"<Client>UpdateAgentLCU</Client></CbsPackageChangeState></UserData></Event>";
|
||||
|
||||
var a = ServicingTracker.ParseUserData(initiate);
|
||||
Assert.Equal("KB5099999", a["PackageIdentifier"]);
|
||||
Assert.Equal("Staged", a["InitialPackageStateTextized"]);
|
||||
Assert.Equal("Installed", a["IntendedPackageStateTextized"]);
|
||||
Assert.Equal("UpdateAgentLCU", a["client"]); // case-insensitive
|
||||
Assert.False(a.ContainsKey("ErrorCode"));
|
||||
|
||||
var b = ServicingTracker.ParseUserData(changeState);
|
||||
Assert.Equal("Installed", b["IntendedPackageStateTextized"]);
|
||||
Assert.Equal("0x0", b["ErrorCode"]);
|
||||
Assert.False(b.ContainsKey("InitialPackageStateTextized"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseUserData_MalformedOrWithoutUserData_ReturnsEmpty_NeverThrows()
|
||||
{
|
||||
Assert.Empty(ServicingTracker.ParseUserData(null));
|
||||
Assert.Empty(ServicingTracker.ParseUserData(""));
|
||||
Assert.Empty(ServicingTracker.ParseUserData("<not-xml"));
|
||||
Assert.Empty(ServicingTracker.ParseUserData("<Event><System><EventID>1</EventID></System></Event>"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistsOnlyAfterTheEngineSignalWasHandedOver()
|
||||
{
|
||||
// "Reboot required" is written right before the update's restart: its engine signal
|
||||
// must be on its way before the watermark tells the next run's backfill to skip it.
|
||||
var path = System.IO.Path.Combine(_tmp.Path, ServicingTracker.WatermarkStateFileName);
|
||||
bool? watermarkOnDiskAtSignal = null;
|
||||
var tracker = new ServicingTracker(
|
||||
sessionId: "sess-cbs",
|
||||
tenantId: "tenant-cbs",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(T0)),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
stateDirectory: _tmp.Path,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: (step, update, at) => watermarkOnDiskAtSignal = System.IO.File.Exists(path));
|
||||
|
||||
Process(ServicingTracker.EventId_RebootRequired, T0, errorCode: "0x0", tracker: tracker);
|
||||
|
||||
Assert.False(watermarkOnDiskAtSignal);
|
||||
Assert.True(System.IO.File.Exists(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
#nullable enable
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// WindowsUpdateClassifier (D-310) — decides which Windows Update client events are listed one
|
||||
/// by one (OS updates) and which are only counted (Store, Defender, everything else). The
|
||||
/// titles are the shapes the WU client writes on Windows 11 GA and Insider builds.
|
||||
/// </summary>
|
||||
public sealed class WindowsUpdateClassifierTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("2026-09 Cumulative Update for Windows 11, version 25H2 for x64-based Systems (KB5099999)")]
|
||||
[InlineData("2026-09 Cumulative Update Preview for Windows 11, version 24H2 for x64-based Systems (KB5099998)")]
|
||||
[InlineData("2026-09 Dynamic Update for Windows 11, version 25H2 for x64-based Systems (KB5099997)")]
|
||||
[InlineData("2026-09 Servicing Stack Update for Windows 11, version 25H2 for x64-based Systems (KB5099996)")]
|
||||
[InlineData("Windows 11 Insider Preview Quality Update (26220.9568)")]
|
||||
[InlineData("Windows 11 Insider Preview Feature Update (26220.9022)")]
|
||||
[InlineData("Windows 11, version 25H2 Upgrade")]
|
||||
[InlineData("2026-09 .NET Framework Security Update (KB5099995)")]
|
||||
[InlineData("2026-08 .NET Framework Preview Update (KB5099994)")]
|
||||
[InlineData("Windows Malicious Software Removal Tool x64 - v5.145 (KB890830)")]
|
||||
public void WindowsAndDotNetFrameworkUpdates_AreOs(string title)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Os, WindowsUpdateClassifier.Classify(title, serviceGuid: null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.459.505.0) - Current Channel (Broad)")]
|
||||
[InlineData("Update for Microsoft Defender Antivirus antimalware platform - KB4052623 (Version 4.18.26080.4) - Current Channel (Staged)")]
|
||||
// Names "Windows" and "Update" — Defender must still win.
|
||||
[InlineData("Windows 10/11 - Update for Microsoft Defender for Endpoint - KB5005292 (Version 10.8838.26060.15013) - Current Channel (Staged)")]
|
||||
public void DefenderUpdates_AreDefender(string title)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Defender, WindowsUpdateClassifier.Classify(title, serviceGuid: null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("9NSTH9KHZDLQ-Microsoft.UI.Xaml.2.8")]
|
||||
[InlineData("9NBLGGH4NNS1-Microsoft.DesktopAppInstaller")]
|
||||
// Upper-case "WINDOWS" inside the package name must not make it an OS update.
|
||||
[InlineData("9WZDNCRFHVN5-MICROSOFT.WINDOWSCALCULATOR")]
|
||||
[InlineData("9WZDNCRFHWKN-MICROSOFT.WINDOWSSOUNDRECORDER")]
|
||||
[InlineData("ApplicationSet-9WZDNCRFJ3Q2-Microsoft.BingWeather")]
|
||||
public void StoreProductTitles_AreStore(string title)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Store, WindowsUpdateClassifier.Classify(title, serviceGuid: null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{855e8a7c-ecb4-4ca3-b045-1dfa50104289}")]
|
||||
[InlineData("{855E8A7C-ECB4-4CA3-B045-1DFA50104289}")]
|
||||
[InlineData("855e8a7c-ecb4-4ca3-b045-1dfa50104289")]
|
||||
public void StoreServiceGuid_IsStore_WhateverTheTitle(string serviceGuid)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Store,
|
||||
WindowsUpdateClassifier.Classify("2026-09 Cumulative Update for Windows 11 (KB5099999)", serviceGuid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherServiceGuid_LeavesTheTitleInCharge()
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Os,
|
||||
WindowsUpdateClassifier.Classify("2026-09 Cumulative Update for Windows 11 (KB5099999)", "{7971f918-a847-4430-9279-4a52d1efe18d}"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Intel net Driver Update (24.50.0.4)")]
|
||||
[InlineData("PowerShell v7.6.6 (x64)")]
|
||||
[InlineData("Remote help 5.2.1040.0")]
|
||||
[InlineData("Microsoft Edge-Stable Channel Version 140 Update for x64 based Edge-Chromium (Build 140.0.3485.54)")]
|
||||
[InlineData("Microsoft .NET 8.0.20 Security Update for x64 Client (KB5099993)")]
|
||||
[InlineData("Windows Feature Experience Pack 1000.26100.234.0")]
|
||||
[InlineData("KB5099999")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void EverythingElse_IsOther(string? title)
|
||||
{
|
||||
Assert.Equal(WindowsUpdateClassifier.Other, WindowsUpdateClassifier.Classify(title!, serviceGuid: null));
|
||||
}
|
||||
}
|
||||
}
|
||||
+409
-61
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
@@ -9,55 +10,81 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// WindowsUpdateTracker — surfaces quality/cumulative update activity during OOBE from the
|
||||
/// WindowsUpdateClient/Operational channel. Drives the primitive <see cref="WindowsUpdateTracker.ProcessEvent"/>
|
||||
/// WindowsUpdateClient provider: install events from the System log, scan/download events from
|
||||
/// the Operational channel (D-310). Drives the primitive <see cref="WindowsUpdateTracker.ProcessEvent"/>
|
||||
/// test seam (no real EventRecord, which is abstract + Windows-only), mirroring the
|
||||
/// ModernDeploymentTracker test pattern.
|
||||
/// ModernDeploymentTracker test pattern. Update titles are the shapes the provider writes.
|
||||
/// </summary>
|
||||
public sealed class WindowsUpdateTrackerTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 7, 8, 10, 15, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string Lcu = "2026-09 Cumulative Update for Windows 11, version 25H2 for x64-based Systems (KB5099999)";
|
||||
private const string StoreApp = "9WZDNCRFHVN5-MICROSOFT.WINDOWSCALCULATOR";
|
||||
private const string DefenderIntelligence = "Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.459.505.0) - Current Channel (Broad)";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink;
|
||||
private readonly WindowsUpdateTracker _tracker;
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly UpdateActivityTally _tally = new UpdateActivityTally();
|
||||
private readonly List<(string Step, string Update, DateTime At)> _osActivity = new List<(string, string, DateTime)>();
|
||||
private readonly WindowsUpdateTracker _system;
|
||||
private readonly WindowsUpdateTracker _operational;
|
||||
private long _nextRecordId = 1;
|
||||
|
||||
public WindowsUpdateTrackerTests()
|
||||
{
|
||||
_sink = new FakeSignalIngressSink();
|
||||
var post = new InformationalEventPost(_sink, new VirtualClock(At));
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_tracker = new WindowsUpdateTracker(
|
||||
sessionId: "sess-wu",
|
||||
tenantId: "tenant-wu",
|
||||
post: post,
|
||||
logger: logger,
|
||||
backfillEnabled: false,
|
||||
stateDirectory: null); // in-memory dedup for most tests
|
||||
// In-memory dedup for most tests.
|
||||
_system = NewTracker(WindowsUpdateTracker.SystemChannel, stateDirectory: null);
|
||||
_operational = NewTracker(channel: null, stateDirectory: null);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private void Emit(int eventId, string? errorCode = null, string? updateTitle = "Cumulative Update KB5099999", long? recordId = null)
|
||||
private WindowsUpdateTracker NewTracker(string? channel, string? stateDirectory) =>
|
||||
new WindowsUpdateTracker(
|
||||
sessionId: "sess-wu",
|
||||
tenantId: "tenant-wu",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
stateDirectory: stateDirectory,
|
||||
channel: channel,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: (step, update, at) => _osActivity.Add((step, update, at)));
|
||||
|
||||
private void Emit(
|
||||
WindowsUpdateTracker tracker,
|
||||
int eventId,
|
||||
string? updateTitle = Lcu,
|
||||
string? errorCode = null,
|
||||
long? recordId = null,
|
||||
string? serviceGuid = null,
|
||||
string? updateCount = null,
|
||||
DateTime? at = null,
|
||||
bool isBackfill = false)
|
||||
{
|
||||
_tracker.ProcessEvent(
|
||||
tracker.ProcessEvent(
|
||||
eventId: eventId,
|
||||
level: 4,
|
||||
recordId: recordId ?? _nextRecordId++,
|
||||
timeCreatedUtc: At,
|
||||
timeCreatedUtc: at ?? At,
|
||||
updateTitle: updateTitle,
|
||||
updateGuid: "{8b1c8726-1111-2222-3333-444455556666}",
|
||||
updateRevisionNumber: "200",
|
||||
errorCode: errorCode,
|
||||
formattedDescription: null,
|
||||
isBackfill: false);
|
||||
isBackfill: isBackfill,
|
||||
serviceGuid: serviceGuid,
|
||||
updateCount: updateCount);
|
||||
}
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
|
||||
@@ -70,10 +97,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
|
||||
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
|
||||
|
||||
private int Counted(string key) =>
|
||||
_tally.Snapshot().Where(kv => kv.Key == key).Select(kv => kv.Value).SingleOrDefault();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install events (System log)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void FailureEvent_EmitsWindowsUpdateFailed_WithDecodedHResult_AndImmediateUpload()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallFailure, errorCode: "0x80240022");
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallFailure, errorCode: "0x80240022");
|
||||
|
||||
var failed = ByType(Constants.EventTypes.WindowsUpdateFailed);
|
||||
var s = Assert.Single(failed);
|
||||
@@ -83,37 +117,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
var data = Data(s);
|
||||
Assert.Equal("0x80240022", data["hresult"]);
|
||||
Assert.Equal("WU_E_ALL_UPDATES_FAILED", data["hresultSymbol"]);
|
||||
Assert.Equal("Cumulative Update KB5099999", data["updateTitle"]);
|
||||
Assert.Equal(Lcu, data["updateTitle"]);
|
||||
Assert.Equal(20, data["wuEventId"]);
|
||||
Assert.Equal("system", data["wuChannel"]);
|
||||
Assert.Equal("install", data["wuPhase"]);
|
||||
Assert.Equal(WindowsUpdateClassifier.Os, data["updateClass"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessEvent_EmitsWindowsUpdateSucceeded_InfoSeverity()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess);
|
||||
|
||||
var ok = ByType(Constants.EventTypes.WindowsUpdateSucceeded);
|
||||
var s = Assert.Single(ok);
|
||||
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("false", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Equal("installed", Data(s)["wuPhase"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartedAndDownloadEvents_EmitDebugStarted()
|
||||
public void StartedAndDownloadEvents_EmitInfoStarted_WithTheirPhase()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallStarted);
|
||||
Emit(WindowsUpdateTracker.EventId_DownloadStarted);
|
||||
// Info, not Debug: with only OS updates listed they are the visible progress of a
|
||||
// long OOBE update.
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallStarted);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_DownloadStarted);
|
||||
|
||||
var started = ByType(Constants.EventTypes.WindowsUpdateStarted);
|
||||
Assert.Equal(2, started.Count);
|
||||
Assert.All(started, s => Assert.Equal("Debug", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.All(started, s => Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.Equal(new[] { "install", "download" }, started.Select(s => (string)Data(s)["wuPhase"]).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameRecordId_ProcessedTwice_EmitsOnce()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: 42);
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: 42);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 42);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 42);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
}
|
||||
@@ -125,8 +166,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
// with a higher RecordId can be processed first. An older, never-emitted backfill event
|
||||
// (lower RecordId) must NOT be suppressed as "already processed" — these early pre-agent
|
||||
// OOBE updates are the whole point of the feature. A high-water-mark dedup dropped them.
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: 100); // live, high RecordId
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: 50); // backfill, older, never seen
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 100); // live, high RecordId
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 50); // backfill, older, never seen
|
||||
|
||||
Assert.Equal(2, ByType(Constants.EventTypes.WindowsUpdateSucceeded).Count);
|
||||
}
|
||||
@@ -138,53 +179,212 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
// time, not at agent start (the ctor's default UtcNow). InformationalEventPost forwards
|
||||
// EnrollmentEvent.Timestamp as the signal's OccurredAtUtc, which drives the timeline entry.
|
||||
var eventTime = new DateTime(2026, 7, 8, 7, 5, 0, DateTimeKind.Utc); // well before `At`
|
||||
_tracker.ProcessEvent(
|
||||
eventId: WindowsUpdateTracker.EventId_InstallFailure,
|
||||
level: 4,
|
||||
recordId: 7,
|
||||
timeCreatedUtc: eventTime,
|
||||
updateTitle: "KB5099999",
|
||||
updateGuid: null,
|
||||
updateRevisionNumber: null,
|
||||
errorCode: "0x80240022",
|
||||
formattedDescription: null,
|
||||
isBackfill: true);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallFailure, errorCode: "0x80240022",
|
||||
recordId: 7, at: eventTime, isBackfill: true);
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateFailed));
|
||||
Assert.Equal(eventTime, s.OccurredAtUtc);
|
||||
Assert.Equal(true, Data(s)["backfilled"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeRecordId_IsNeverDeduped()
|
||||
{
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: -1);
|
||||
Emit(WindowsUpdateTracker.EventId_InstallSuccess, recordId: -1);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: -1);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: -1);
|
||||
|
||||
Assert.Equal(2, ByType(Constants.EventTypes.WindowsUpdateSucceeded).Count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan/download events (Operational channel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistsAcrossTrackerInstances()
|
||||
public void Downloaded_EmitsInfoStarted_WithDownloadedPhase()
|
||||
{
|
||||
var post = new InformationalEventPost(_sink, new VirtualClock(At));
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_Downloaded);
|
||||
|
||||
var first = new WindowsUpdateTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
|
||||
first.ProcessEvent(WindowsUpdateTracker.EventId_InstallSuccess, 4, recordId: 500, timeCreatedUtc: At,
|
||||
updateTitle: "KB1", updateGuid: null, updateRevisionNumber: null, errorCode: null,
|
||||
formattedDescription: null, isBackfill: false);
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateStarted));
|
||||
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("downloaded", Data(s)["wuPhase"]);
|
||||
Assert.Equal("operational", Data(s)["wuChannel"]);
|
||||
}
|
||||
|
||||
// A fresh tracker (simulating an agent restart) must load the watermark and skip the
|
||||
// already-emitted record when the OOBE backfill re-reads it.
|
||||
var second = new WindowsUpdateTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
|
||||
second.LoadWatermark();
|
||||
second.ProcessEvent(WindowsUpdateTracker.EventId_InstallSuccess, 4, recordId: 500, timeCreatedUtc: At,
|
||||
updateTitle: "KB1", updateGuid: null, updateRevisionNumber: null, errorCode: null,
|
||||
formattedDescription: null, isBackfill: true);
|
||||
[Fact]
|
||||
public void DownloadFailure_OfAnOsUpdate_EmitsWarning_WithDownloadPhase()
|
||||
{
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_DownloadFailed, errorCode: "0x80248007");
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateFailed));
|
||||
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("false", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
var data = Data(s);
|
||||
Assert.Equal("download", data["wuPhase"]);
|
||||
Assert.Equal("0x80248007", data["hresult"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFound_EmitsOnlyChangedCounts_AndCountsTheRepeats()
|
||||
{
|
||||
foreach (var count in new[] { "3", "3", "5", "5", "0" })
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFound, updateTitle: null, updateCount: count);
|
||||
|
||||
var found = ByType(Constants.EventTypes.WindowsUpdateStarted);
|
||||
Assert.Equal(new[] { "3", "5", "0" }, found.Select(s => (string)Data(s)["updateCount"]).ToArray());
|
||||
Assert.All(found, s => Assert.Equal("Debug", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.All(found, s => Assert.Equal("scan", Data(s)["wuPhase"]));
|
||||
Assert.All(found, s => Assert.False(Data(s).ContainsKey("updateClass")));
|
||||
Assert.Contains("scan found 3 update(s)", found[0].Payload![SignalPayloadKeys.Message]);
|
||||
Assert.Equal(2, Counted("scan_found_repeat"));
|
||||
Assert.Empty(_osActivity); // a scan result is not an update step
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFound_IsCappedPerRun()
|
||||
{
|
||||
for (var i = 0; i < WindowsUpdateTracker.MaxScanFoundEmissions + 2; i++)
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFound, updateTitle: null, updateCount: i.ToString());
|
||||
|
||||
Assert.Equal(WindowsUpdateTracker.MaxScanFoundEmissions, ByType(Constants.EventTypes.WindowsUpdateStarted).Count);
|
||||
Assert.Equal(2, Counted("scan_found_repeat"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFailed_EmitsEachErrorCodeOnce()
|
||||
{
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: "0x8024401c");
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: "0x8024401c");
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: "0x80072EE2");
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: "0x8024401C");
|
||||
|
||||
var failed = ByType(Constants.EventTypes.WindowsUpdateFailed);
|
||||
Assert.Equal(2, failed.Count);
|
||||
Assert.All(failed, s => Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]));
|
||||
Assert.All(failed, s => Assert.Equal("scan", Data(s)["wuPhase"]));
|
||||
Assert.Equal(2, Counted("scan_failed_repeat"));
|
||||
Assert.Empty(_osActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFailed_IsCappedPerRun()
|
||||
{
|
||||
for (var i = 0; i < WindowsUpdateTracker.MaxScanFailedEmissions + 2; i++)
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_ScanFailed, updateTitle: null, errorCode: $"0x8024000{i}");
|
||||
|
||||
Assert.Equal(WindowsUpdateTracker.MaxScanFailedEmissions, ByType(Constants.EventTypes.WindowsUpdateFailed).Count);
|
||||
Assert.Equal(2, Counted("scan_failed_repeat"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Classification — only OS updates are listed, the rest is counted
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(StoreApp, null, WindowsUpdateTracker.EventId_InstallSuccess, "store_19")]
|
||||
[InlineData("Some App", "{855e8a7c-ecb4-4ca3-b045-1dfa50104289}", WindowsUpdateTracker.EventId_Downloaded, "store_41")]
|
||||
[InlineData(DefenderIntelligence, null, WindowsUpdateTracker.EventId_InstallStarted, "defender_43")]
|
||||
[InlineData("Intel net Driver Update (24.50.0.4)", null, WindowsUpdateTracker.EventId_DownloadStarted, "other_44")]
|
||||
// A real download failure names no update at all; it must not reach ANALYZE-DEV-004 as
|
||||
// a failed OS update.
|
||||
[InlineData("", null, WindowsUpdateTracker.EventId_DownloadFailed, "other_31")]
|
||||
[InlineData("Some App", "{855e8a7c-ecb4-4ca3-b045-1dfa50104289}", WindowsUpdateTracker.EventId_InstallFailure, "store_20")]
|
||||
public void NonOsUpdates_AreCountedNotEmitted(string title, string? serviceGuid, int eventId, string expectedKey)
|
||||
{
|
||||
var tracker = WindowsUpdateTracker.IsSystemChannelEventId(eventId) ? _system : _operational;
|
||||
Emit(tracker, eventId, updateTitle: title, serviceGuid: serviceGuid, errorCode: "0x80248007");
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
Assert.Empty(_osActivity);
|
||||
Assert.Equal(1, Counted(expectedKey));
|
||||
// The census still sees the channel working.
|
||||
Assert.Equal(1, tracker.EmittedThisRun);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountedRecord_DeliveredTwice_IsCountedOnce()
|
||||
{
|
||||
// Live watcher and backfill overlap: the seen-set dedups counted records too.
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, updateTitle: StoreApp, recordId: 7);
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, updateTitle: StoreApp, recordId: 7);
|
||||
|
||||
Assert.Equal(1, Counted("store_19"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OsUpdateSteps_AreReportedToTheEngineCallback()
|
||||
{
|
||||
var t0 = At;
|
||||
Emit(_system, WindowsUpdateTracker.EventId_DownloadStarted, at: t0);
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_Downloaded, at: t0.AddMinutes(4));
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallStarted, at: t0.AddMinutes(5));
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallSuccess, at: t0.AddMinutes(31));
|
||||
Emit(_system, WindowsUpdateTracker.EventId_InstallFailure, errorCode: "0x800F0922", at: t0.AddMinutes(32));
|
||||
Emit(_operational, WindowsUpdateTracker.EventId_DownloadFailed, errorCode: "0x80248007", at: t0.AddMinutes(33));
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
OsUpdateSteps.DownloadStarted, OsUpdateSteps.Downloaded, OsUpdateSteps.InstallStarted,
|
||||
OsUpdateSteps.Installed, OsUpdateSteps.Failed, OsUpdateSteps.Failed,
|
||||
},
|
||||
_osActivity.Select(a => a.Step).ToArray());
|
||||
Assert.All(_osActivity, a => Assert.Equal(Lcu, a.Update));
|
||||
Assert.Equal(t0.AddMinutes(31), _osActivity[3].At);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowingCallback_NeverCostsTheEvent()
|
||||
{
|
||||
var tracker = new WindowsUpdateTracker(
|
||||
"s", "t", new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
channel: WindowsUpdateTracker.SystemChannel,
|
||||
onOsUpdateActivity: (step, update, at) => throw new InvalidOperationException("boom"));
|
||||
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallSuccess);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channels and XPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DefaultEventIds_FollowTheProviderManifest()
|
||||
{
|
||||
Assert.Equal(WindowsUpdateTracker.SystemChannel, _system.WatchedChannel);
|
||||
Assert.Equal(new[] { 19, 20, 43, 44 }, _system.TargetedEventIds.OrderBy(id => id).ToArray());
|
||||
Assert.Equal(WindowsUpdateTracker.Channel, _operational.WatchedChannel);
|
||||
Assert.Equal(new[] { 25, 26, 31, 41 }, _operational.TargetedEventIds.OrderBy(id => id).ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(16, true)]
|
||||
[InlineData(19, true)]
|
||||
[InlineData(20, true)]
|
||||
[InlineData(24, true)]
|
||||
[InlineData(27, true)]
|
||||
[InlineData(28, true)]
|
||||
[InlineData(32, true)]
|
||||
[InlineData(33, true)]
|
||||
[InlineData(43, true)]
|
||||
[InlineData(44, true)]
|
||||
[InlineData(212, true)]
|
||||
[InlineData(218, true)]
|
||||
[InlineData(25, false)]
|
||||
[InlineData(26, false)]
|
||||
[InlineData(31, false)]
|
||||
[InlineData(41, false)]
|
||||
[InlineData(15, false)]
|
||||
[InlineData(219, false)]
|
||||
public void IsSystemChannelEventId_MatchesTheManifest(int eventId, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, WindowsUpdateTracker.IsSystemChannelEventId(eventId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildXPath_ContainsAllTargetedIds_Ordered()
|
||||
{
|
||||
@@ -192,6 +392,113 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.Equal("*[System[(EventID=19 or EventID=20 or EventID=43 or EventID=44)]]", xpath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildXPath_SharedSystemLog_FiltersTheProvider_AndTheLookback()
|
||||
{
|
||||
// Verified against a real System log: the provider clause keeps the other providers'
|
||||
// EventIDs 19/20/43/44 out of the shared channel.
|
||||
var ids = new HashSet<int> { 44, 19, 20, 43 };
|
||||
|
||||
Assert.Equal(
|
||||
"*[System[Provider[@Name='Microsoft-Windows-WindowsUpdateClient'] and (EventID=19 or EventID=20 or EventID=43 or EventID=44)]]",
|
||||
WindowsUpdateTracker.BuildXPath(ids, WindowsUpdateTracker.ProviderName));
|
||||
Assert.Equal(
|
||||
"*[System[Provider[@Name='Microsoft-Windows-WindowsUpdateClient'] and (EventID=19 or EventID=20 or EventID=43 or EventID=44) and TimeCreated[timediff(@SystemTime) <= 3600000]]]",
|
||||
WindowsUpdateTracker.BuildXPath(ids, WindowsUpdateTracker.ProviderName, lookbackMs: 3600000));
|
||||
Assert.Equal(
|
||||
"*[System[(EventID=25 or EventID=26) and TimeCreated[timediff(@SystemTime) <= 60000]]]",
|
||||
WindowsUpdateTracker.BuildXPath(new HashSet<int> { 26, 25 }, providerName: null, lookbackMs: 60000));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watermark
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistsAcrossTrackerInstances()
|
||||
{
|
||||
var first = NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path);
|
||||
Emit(first, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 500);
|
||||
|
||||
// A fresh tracker (simulating an agent restart) must load the watermark and skip the
|
||||
// already-emitted record when the OOBE backfill re-reads it.
|
||||
var second = NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path);
|
||||
second.LoadWatermark();
|
||||
Emit(second, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 500, isBackfill: true);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_IsPerChannel()
|
||||
{
|
||||
// RecordIds are per channel: System record 500 and Operational record 500 are two
|
||||
// different events and must not dedup each other — before or after a restart.
|
||||
Emit(NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path), WindowsUpdateTracker.EventId_InstallSuccess, recordId: 500);
|
||||
Emit(NewTracker(null, _tmp.Path), WindowsUpdateTracker.EventId_Downloaded, recordId: 500);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateStarted));
|
||||
Assert.Equal(500L, ReadWatermark(WindowsUpdateTracker.SystemWatermarkStateFileName));
|
||||
Assert.Equal(500L, ReadWatermark(WindowsUpdateTracker.WatermarkStateFileName));
|
||||
|
||||
var system = NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path);
|
||||
var operational = NewTracker(null, _tmp.Path);
|
||||
system.LoadWatermark();
|
||||
operational.LoadWatermark();
|
||||
Emit(system, WindowsUpdateTracker.EventId_InstallSuccess, recordId: 500, isBackfill: true);
|
||||
Emit(operational, WindowsUpdateTracker.EventId_Downloaded, recordId: 500, isBackfill: true);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateSucceeded));
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateStarted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountedRecords_WriteNoStateFile()
|
||||
{
|
||||
// A Store/Defender burst must not cost a state-file write per record; only emitted
|
||||
// records move the persisted watermark.
|
||||
var tracker = NewTracker(WindowsUpdateTracker.SystemChannel, _tmp.Path);
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallSuccess, updateTitle: StoreApp, recordId: 600);
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallStarted, updateTitle: DefenderIntelligence, recordId: 601);
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(_tmp.Path, WindowsUpdateTracker.SystemWatermarkStateFileName)));
|
||||
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallStarted, recordId: 602);
|
||||
Assert.Equal(602L, ReadWatermark(WindowsUpdateTracker.SystemWatermarkStateFileName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistsOnlyAfterTheEngineSignalWasHandedOver()
|
||||
{
|
||||
// A restart's backfill skips a persisted record, so the record's engine signal must be
|
||||
// on its way before the watermark reaches the disk — never the other way round.
|
||||
var path = Path.Combine(_tmp.Path, WindowsUpdateTracker.SystemWatermarkStateFileName);
|
||||
bool? watermarkOnDiskAtSignal = null;
|
||||
var tracker = new WindowsUpdateTracker(
|
||||
sessionId: "sess-wu",
|
||||
tenantId: "tenant-wu",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
backfillEnabled: false,
|
||||
stateDirectory: _tmp.Path,
|
||||
channel: WindowsUpdateTracker.SystemChannel,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: (step, update, at) => watermarkOnDiskAtSignal = File.Exists(path));
|
||||
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_InstallStarted, recordId: 700);
|
||||
|
||||
Assert.False(watermarkOnDiskAtSignal);
|
||||
Assert.Equal(700L, ReadWatermark(WindowsUpdateTracker.SystemWatermarkStateFileName));
|
||||
}
|
||||
|
||||
private long ReadWatermark(string fileName) =>
|
||||
JObject.Parse(File.ReadAllText(Path.Combine(_tmp.Path, fileName)))["LastRecordId"]!.Value<long>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing and HRESULT decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ParseEventData_ExtractsNamedFields_NamespaceAgnostic()
|
||||
{
|
||||
@@ -212,6 +519,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.True(data.ContainsKey("UPDATETITLE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEventData_ReadsTheScanFields()
|
||||
{
|
||||
// Event 26 (scan found N) and 25 (scan failed) carry no update title.
|
||||
const string xml =
|
||||
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'>" +
|
||||
"<System><EventID>26</EventID></System>" +
|
||||
"<EventData>" +
|
||||
"<Data Name='updateCount'>3</Data>" +
|
||||
"<Data Name='serviceGuid'>{8b24b027-1dee-babb-9a95-3517dfb9c552}</Data>" +
|
||||
"</EventData></Event>";
|
||||
|
||||
var data = WindowsUpdateTracker.ParseEventData(xml);
|
||||
Assert.Equal("3", data["updateCount"]);
|
||||
Assert.Equal("{8b24b027-1dee-babb-9a95-3517dfb9c552}", data["serviceGuid"]);
|
||||
Assert.False(data.ContainsKey("updateTitle"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEventData_MalformedXml_ReturnsEmpty_NeverThrows()
|
||||
{
|
||||
@@ -303,10 +628,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
var data = Data(s);
|
||||
Assert.Equal("21=1,25=3", data["wuClientCensus"]);
|
||||
Assert.Equal("200=2", data["updateOrchestratorCensus"]);
|
||||
Assert.Equal("19,20,43,44", data["targetedEventIds"]);
|
||||
Assert.Equal("25,26,31,41", data["targetedEventIds"]);
|
||||
Assert.False(data.ContainsKey("censusTruncated"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_ListsTheTargetedIdsOfEveryChannel()
|
||||
{
|
||||
// The host runs the census on the Operational instance; the evidence must still say
|
||||
// the System-log install IDs were watched too.
|
||||
var tracker = BuildCensusTracker(censusEnabled: true, osBuildChanged: true);
|
||||
|
||||
tracker.EmitChannelCensusIfBlind(otherChannelsEmitted: 0, otherTargetedEventIds: new[] { 44, 19, 20, 43 });
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateChannelCensus));
|
||||
Assert.Equal("19,20,25,26,31,41,43,44", Data(s)["targetedEventIds"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_BuildUnchanged_EmitsNothing()
|
||||
{
|
||||
@@ -320,17 +658,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
[Fact]
|
||||
public void Census_TargetedEventsWereEmittedThisRun_EmitsNothing()
|
||||
{
|
||||
// The watcher captured the update through its normal path — no blind spot to report.
|
||||
// The watcher captured activity through its normal path — no blind spot to report.
|
||||
// Any class counts: a counted Store update proves the channel is read as well.
|
||||
var tracker = BuildCensusTracker(censusEnabled: true, osBuildChanged: true);
|
||||
tracker.ProcessEvent(WindowsUpdateTracker.EventId_InstallSuccess, 4, recordId: 9, timeCreatedUtc: At,
|
||||
updateTitle: "KB1", updateGuid: null, updateRevisionNumber: null, errorCode: null,
|
||||
formattedDescription: null, isBackfill: false);
|
||||
Emit(tracker, WindowsUpdateTracker.EventId_Downloaded, updateTitle: StoreApp, recordId: 9);
|
||||
|
||||
tracker.EmitChannelCensusIfBlind();
|
||||
|
||||
Assert.Empty(ByType(Constants.EventTypes.WindowsUpdateChannelCensus));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_AnotherChannelEmitted_EmitsNothing()
|
||||
{
|
||||
// The System-log watcher saw the install — the update was not missed.
|
||||
var tracker = BuildCensusTracker(censusEnabled: true, osBuildChanged: true);
|
||||
|
||||
tracker.EmitChannelCensusIfBlind(otherChannelsEmitted: 1);
|
||||
|
||||
Assert.Empty(ByType(Constants.EventTypes.WindowsUpdateChannelCensus));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_DisabledByConfig_EmitsNothing()
|
||||
{
|
||||
|
||||
+56
@@ -153,6 +153,62 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Assert.Equal(0, callCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopped_waiting_fires_callback_once_with_the_stage_still_non_terminal()
|
||||
{
|
||||
// D-310: the engine stopped waiting for the user without a verdict. The stage stays
|
||||
// non-terminal, yet the agent must end the session — the callback carries the state.
|
||||
using var rig = new Rig();
|
||||
DecisionState? captured = null;
|
||||
var callCount = 0;
|
||||
var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"),
|
||||
onTerminal: state =>
|
||||
{
|
||||
Interlocked.Increment(ref callCount);
|
||||
captured = state;
|
||||
});
|
||||
|
||||
var builder = new DecisionStateBuilder(sut.CurrentState)
|
||||
{
|
||||
Stage = SessionStage.EspAccountSetup,
|
||||
StepIndex = sut.CurrentState.StepIndex + 1,
|
||||
StoppedWaitingReason = new SignalFact<string>(StoppedWaitingReasons.OobeUpdateNoSignIn, 1),
|
||||
};
|
||||
var stopped = builder.Build();
|
||||
var step = new DecisionStep(stopped, new DecisionTransition(
|
||||
stepIndex: stopped.StepIndex, sessionTraceOrdinal: 1, signalOrdinalRef: 1, occurredAtUtc: At,
|
||||
trigger: "DeadlineFired:advisory_completion:OobeUpdateSignInWaitExpired",
|
||||
fromStage: SessionStage.SessionStarted, toStage: SessionStage.EspAccountSetup,
|
||||
taken: true, deadEndReason: null, reducerVersion: "2.0.0.0"), Array.Empty<DecisionEffect>());
|
||||
sut.ApplyStep(step, Sig(1, At));
|
||||
|
||||
// A follow-up step must not fire it again.
|
||||
sut.ApplyStep(StepToStage(sut.CurrentState, SessionStage.EspAccountSetup, signalOrdinal: 2, at: At.AddSeconds(1)),
|
||||
Sig(2, At.AddSeconds(1)));
|
||||
|
||||
Assert.Equal(1, callCount);
|
||||
Assert.Equal(SessionStage.EspAccountSetup, captured!.Stage);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, captured.StoppedWaitingReason!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopped_waiting_on_load_does_not_refire_the_callback()
|
||||
{
|
||||
// The orchestrator ends a recovered stopped-waiting session itself at start.
|
||||
using var rig = new Rig();
|
||||
var callCount = 0;
|
||||
var recovered = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1"))
|
||||
{
|
||||
Stage = SessionStage.EspAccountSetup,
|
||||
StoppedWaitingReason = new SignalFact<string>(StoppedWaitingReasons.OobeUpdateNoSignIn, 7),
|
||||
}.Build();
|
||||
|
||||
var sut = rig.Build(recovered, onTerminal: _ => Interlocked.Increment(ref callCount));
|
||||
sut.ApplyStep(StepToStage(sut.CurrentState, SessionStage.EspAccountSetup, signalOrdinal: 8, at: At), Sig(8, At));
|
||||
|
||||
Assert.Equal(0, callCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_callback_null_is_supported()
|
||||
{
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// The production factory hands its state directory to the gather executor. Without it every
|
||||
/// unit test of the gather state passes while the agent keeps replaying logs after each reboot —
|
||||
/// the one line in <see cref="DefaultComponentFactory"/> is the whole feature.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // the startup rule executes on the shared ThreadPool
|
||||
public sealed class DefaultComponentFactoryGatherStateTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly TempDirectory _stateDir = new TempDirectory();
|
||||
|
||||
// %TEMP% lives under C:\Users (hard-blocked) — the log sits beside the test assembly.
|
||||
private readonly string _logDir = Path.Combine(
|
||||
AppContext.BaseDirectory, "factory-gather-state-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public DefaultComponentFactoryGatherStateTests() => Directory.CreateDirectory(_logDir);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tmp.Dispose();
|
||||
_stateDir.Dispose();
|
||||
try { Directory.Delete(_logDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gather_host_built_by_the_factory_persists_logparser_positions_in_the_state_directory()
|
||||
{
|
||||
var log = Path.Combine(_logDir, "vendor.log");
|
||||
File.WriteAllText(log, "Update one\n");
|
||||
|
||||
var factory = new DefaultComponentFactory(
|
||||
agentConfig: new AgentConfiguration
|
||||
{
|
||||
ApiBaseUrl = "https://example",
|
||||
TenantId = "t",
|
||||
SessionId = "s",
|
||||
UnrestrictedMode = true,
|
||||
},
|
||||
remoteConfig: new AgentConfigResponse
|
||||
{
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
GatherRules = new List<GatherRule>
|
||||
{
|
||||
new GatherRule
|
||||
{
|
||||
RuleId = "vendor-log",
|
||||
Title = "vendor log",
|
||||
CollectorType = "logparser",
|
||||
Target = log,
|
||||
Parameters = new Dictionary<string, string> { ["pattern"] = "^Update", ["format"] = "text" },
|
||||
Trigger = "startup",
|
||||
OutputEventType = "vendor_update",
|
||||
Enabled = true,
|
||||
},
|
||||
},
|
||||
},
|
||||
networkMetrics: null,
|
||||
agentVersion: "test",
|
||||
stateDirectory: _stateDir.Path,
|
||||
startupEventGate: null);
|
||||
|
||||
var hosts = factory.CreateCollectorHosts(
|
||||
sessionId: "s",
|
||||
tenantId: "t",
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
whiteGloveSealingPatternIds: Array.Empty<string>(),
|
||||
ingress: new FakeSignalIngressSink(),
|
||||
clock: new VirtualClock(new DateTime(2026, 9, 28, 12, 0, 0, DateTimeKind.Utc)),
|
||||
telemetrySpool: null,
|
||||
timelineEvents: null).Hosts;
|
||||
try
|
||||
{
|
||||
hosts.OfType<GatherRuleExecutorHost>().Single().Start();
|
||||
|
||||
var stateFile = Path.Combine(_stateDir.Path, GatherRuleStatePersistence.FileName);
|
||||
Assert.True(SpinWait.SpinUntil(() => File.Exists(stateFile), TimeSpan.FromSeconds(10)),
|
||||
"the gather host did not write its state into the factory's state directory");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
/// <summary>
|
||||
/// GatherRuleExecutorHost feeds its phase_change / phase_exit / on_event triggers from the
|
||||
/// POST-REDUCE <see cref="TimelineEventStream"/> (emitted timeline events), not from raw
|
||||
/// signals. Regression anchor: session 32312a32 (rsneuffen.de) — a phase_change rule on
|
||||
/// signals. Regression anchor: session 32312a32 — a phase_change rule on
|
||||
/// FinalizingSetup fired at the raw EspPhaseChanged(FinalizingSetup) signal (ESP exit),
|
||||
/// 7 minutes before the engine's RealmJoin-gated phase_transition(FinalizingSetup), and read
|
||||
/// a registry key before the RealmJoin package wrote it. With the timeline feed the rule
|
||||
|
||||
@@ -174,6 +174,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
sut.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recovered_stopped_waiting_state_ends_the_session_at_start()
|
||||
{
|
||||
// D-310: a crash between "the engine stopped waiting" and the termination must not
|
||||
// leave the session running — the restart ends it the same way, with the engine's reason.
|
||||
using var rig = new Rig();
|
||||
Directory.CreateDirectory(rig.StateDir);
|
||||
|
||||
var stoppedState = DecisionState.CreateInitial("S1", "T1")
|
||||
.ToBuilder()
|
||||
.WithStage(SessionStage.EspAccountSetup)
|
||||
.WithStepIndex(9)
|
||||
.Build()
|
||||
.ToBuilder();
|
||||
stoppedState.StoppedWaitingReason = new SignalFact<string>(StoppedWaitingReasons.OobeUpdateNoSignIn, 42);
|
||||
new SnapshotPersistence(Path.Combine(rig.StateDir, "snapshot.json")).Save(stoppedState.Build());
|
||||
|
||||
var sut = rig.Build();
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
EnrollmentTerminatedEventArgs? captured = null;
|
||||
sut.Terminated += (_, e) => { captured = e; fired.Set(); };
|
||||
|
||||
sut.Start();
|
||||
Assert.True(fired.Wait(5000), "Terminated did not fire for the recovered stopped-waiting state.");
|
||||
|
||||
Assert.Equal(EnrollmentTerminationReason.StoppedWaiting, captured!.Reason);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.TimedOut, captured.Outcome);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, captured.StopReason);
|
||||
Assert.Equal(SessionStage.EspAccountSetup.ToString(), captured.StageName);
|
||||
Assert.Contains("60 minutes", captured.Details);
|
||||
|
||||
sut.Stop();
|
||||
}
|
||||
|
||||
// ================================================================= Sonderfall 2: Segment-Quarantine
|
||||
|
||||
[Fact]
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// WindowsUpdateWatcherHost (D-310) — routes every configured WU EventID to the channel the
|
||||
/// provider manifest writes it to, reports counted activity once, and feeds Windows/.NET update
|
||||
/// steps to the decision engine unless <c>OobeUpdatePhaseMode</c> is Off. The event-log
|
||||
/// watchers themselves are not armed here; the trackers' ProcessEvent seams drive the host.
|
||||
/// </summary>
|
||||
public sealed class WindowsUpdateWatcherHostTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 10, 1, 9, 30, 0, DateTimeKind.Utc);
|
||||
private const string Lcu = "2026-09 Cumulative Update for Windows 11, version 25H2 for x64-based Systems (KB5099999)";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private long _nextRecordId = 1;
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private WindowsUpdateWatcherHost Build(bool osUpdateSignalsEnabled, bool servicingWatcherEnabled = true) =>
|
||||
new WindowsUpdateWatcherHost(
|
||||
sessionId: "sess-wuhost",
|
||||
tenantId: "tenant-wuhost",
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
ingress: _sink,
|
||||
clock: new VirtualClock(At),
|
||||
targetedEventIds: new[] { 19, 20, 43, 44 },
|
||||
backfillLookbackMinutes: 0,
|
||||
stateDirectory: _tmp.Path,
|
||||
operationalEventIds: new[] { 25, 26, 31, 41 },
|
||||
servicingWatcherEnabled: servicingWatcherEnabled,
|
||||
osUpdateSignalsEnabled: osUpdateSignalsEnabled);
|
||||
|
||||
private void Wu(WindowsUpdateTracker tracker, int eventId, string title, DateTime at) =>
|
||||
tracker.ProcessEvent(eventId, 4, _nextRecordId++, at, title, updateGuid: null, updateRevisionNumber: null,
|
||||
errorCode: null, formattedDescription: null, isBackfill: false);
|
||||
|
||||
private void Cbs(ServicingTracker tracker, int eventId, string client, DateTime at) =>
|
||||
tracker.ProcessEvent(eventId, _nextRecordId++, at, "KB5099999", initialState: null, targetState: "Installed",
|
||||
errorCode: "0x0", client: client, isBackfill: false);
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> OsUpdateActivity() =>
|
||||
_sink.Posted.Where(p => p.Kind == DecisionSignalKind.OsUpdateActivity).ToList();
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
|
||||
// ---------------------------------------------------------------- routing ----
|
||||
|
||||
[Fact]
|
||||
public void RouteEventIds_Defaults_SplitByManifest()
|
||||
{
|
||||
var (system, operational) = WindowsUpdateWatcherHost.RouteEventIds(new[] { 19, 20, 43, 44 }, new[] { 25, 26, 31, 41 });
|
||||
|
||||
Assert.Equal(new[] { 19, 20, 43, 44 }, system);
|
||||
Assert.Equal(new[] { 25, 26, 31, 41 }, operational);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteEventIds_MisplacedIds_EndUpOnTheirRealChannel()
|
||||
{
|
||||
// An old config that lists 41 under the targeted IDs, or 20 under the operational
|
||||
// ones, must still watch the channel that carries them.
|
||||
var (system, operational) = WindowsUpdateWatcherHost.RouteEventIds(new[] { 41, 19 }, new[] { 20, 41 });
|
||||
|
||||
Assert.Equal(new[] { 19, 20 }, system);
|
||||
Assert.Equal(new[] { 41 }, operational);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteEventIds_NothingConfigured_WatchesNothing()
|
||||
{
|
||||
var (system, operational) = WindowsUpdateWatcherHost.RouteEventIds(null, Array.Empty<int>());
|
||||
|
||||
Assert.Empty(system);
|
||||
Assert.Empty(operational);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_BuildsOneTrackerPerChannel_AndTheServicingTracker()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: false);
|
||||
|
||||
Assert.Equal(WindowsUpdateTracker.SystemChannel, host.SystemTrackerForTest!.WatchedChannel);
|
||||
Assert.Equal(new[] { 19, 20, 43, 44 }, host.SystemTrackerForTest.TargetedEventIds.OrderBy(id => id).ToArray());
|
||||
Assert.Equal(WindowsUpdateTracker.Channel, host.OperationalTrackerForTest!.WatchedChannel);
|
||||
Assert.Equal(new[] { 25, 26, 31, 41 }, host.OperationalTrackerForTest.TargetedEventIds.OrderBy(id => id).ToArray());
|
||||
Assert.NotNull(host.ServicingTrackerForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServicingWatcherDisabled_BuildsNoServicingTracker()
|
||||
{
|
||||
Assert.Null(Build(osUpdateSignalsEnabled: false, servicingWatcherEnabled: false).ServicingTrackerForTest);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- engine signal behind flag ----
|
||||
|
||||
[Fact]
|
||||
public void OsUpdateSignalsDisabled_ListsTheUpdate_ButPostsNoEngineSignal()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: false);
|
||||
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallStarted, Lcu, At);
|
||||
Cbs(host.ServicingTrackerForTest!, ServicingTracker.EventId_RebootRequired, "UpdateAgentLCU", At.AddMinutes(20));
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateStarted));
|
||||
Assert.Single(ByType(Constants.EventTypes.WindowsUpdateServicing));
|
||||
Assert.Empty(OsUpdateActivity());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OsUpdateSignalsEnabled_PostsOsUpdateActivity_FromBothSources()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: true);
|
||||
var wuAt = DateTime.SpecifyKind(At, DateTimeKind.Unspecified);
|
||||
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallStarted, Lcu, wuAt);
|
||||
Cbs(host.ServicingTrackerForTest!, ServicingTracker.EventId_RebootRequired, "UpdateAgentLCU", At.AddMinutes(20));
|
||||
|
||||
var signals = OsUpdateActivity();
|
||||
Assert.Equal(2, signals.Count);
|
||||
|
||||
var wu = signals[0];
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, wu.Payload![OsUpdatePayloadKeys.Step]);
|
||||
Assert.Equal("wu", wu.Payload![OsUpdatePayloadKeys.Source]);
|
||||
Assert.Equal(Lcu, wu.Payload![OsUpdatePayloadKeys.Update]);
|
||||
Assert.Equal("WindowsUpdateWatcher", wu.SourceOrigin);
|
||||
Assert.Equal(EvidenceKind.Raw, wu.Evidence.Kind);
|
||||
Assert.Equal("os-update-activity:wu", wu.Evidence.Identifier);
|
||||
Assert.Equal(At, wu.OccurredAtUtc);
|
||||
Assert.Equal(DateTimeKind.Utc, wu.OccurredAtUtc.Kind);
|
||||
Assert.Equal(1, wu.KindSchemaVersion);
|
||||
|
||||
var cbs = signals[1];
|
||||
Assert.Equal(OsUpdateSteps.RebootRequired, cbs.Payload![OsUpdatePayloadKeys.Step]);
|
||||
Assert.Equal("servicing", cbs.Payload![OsUpdatePayloadKeys.Source]);
|
||||
Assert.Equal("KB5099999", cbs.Payload![OsUpdatePayloadKeys.Update]);
|
||||
Assert.Equal("ServicingWatcher", cbs.SourceOrigin);
|
||||
Assert.Equal("os-update-activity:servicing", cbs.Evidence.Identifier);
|
||||
Assert.Equal(At.AddMinutes(20), cbs.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OsUpdateSignalsEnabled_NonOsActivity_NeverReachesTheEngine()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: true);
|
||||
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallSuccess, "9NSTH9KHZDLQ-Microsoft.UI.Xaml.2.8", At);
|
||||
Wu(host.OperationalTrackerForTest!, WindowsUpdateTracker.EventId_Downloaded,
|
||||
"Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.459.505.0) - Current Channel (Broad)", At);
|
||||
Cbs(host.ServicingTrackerForTest!, ServicingTracker.EventId_StateReached, "CbsTask", At);
|
||||
|
||||
Assert.Empty(OsUpdateActivity());
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- activity summary ----
|
||||
|
||||
[Fact]
|
||||
public void ActivitySummary_IsEmittedOnce_WhenTheHostStops()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: false);
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallSuccess, "9NSTH9KHZDLQ-Microsoft.UI.Xaml.2.8", At);
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallSuccess, "9NBLGGH4NNS1-Microsoft.DesktopAppInstaller", At);
|
||||
Cbs(host.ServicingTrackerForTest!, ServicingTracker.EventId_InitiatingChanges, "CbsTask", At);
|
||||
|
||||
// The termination path stops the host explicitly and disposes it afterwards.
|
||||
host.Stop();
|
||||
host.Dispose();
|
||||
|
||||
var summary = Assert.Single(ByType(Constants.EventTypes.WindowsUpdateActivitySummary));
|
||||
Assert.Equal("Debug", summary.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Contains("3 event(s)", summary.Payload![SignalPayloadKeys.Message]);
|
||||
var data = (IReadOnlyDictionary<string, object>)summary.TypedPayload!;
|
||||
Assert.Equal(2, data["store_19"]);
|
||||
Assert.Equal(1, data["servicing_other_1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivitySummary_NothingCounted_EmitsNothing()
|
||||
{
|
||||
var host = Build(osUpdateSignalsEnabled: false);
|
||||
Wu(host.SystemTrackerForTest!, WindowsUpdateTracker.EventId_InstallSuccess, Lcu, At);
|
||||
|
||||
host.Stop();
|
||||
|
||||
Assert.Empty(ByType(Constants.EventTypes.WindowsUpdateActivitySummary));
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -102,6 +102,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.Equal(expected, sink.Posted[0].Payload![SignalPayloadKeys.HelloWaitTimeoutSeconds]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "Shadow")] // no config value — the default mode
|
||||
[InlineData("Shadow", "Shadow")]
|
||||
[InlineData("active", "Active")] // case-insensitive
|
||||
[InlineData("Off", "Off")]
|
||||
[InlineData("Enforce", "Shadow")] // unknown — changes no decision, still measures
|
||||
public void Oobe_update_phase_mode_is_stamped_at_every_start_normalized(string? configured, string expected)
|
||||
{
|
||||
// D-310: stamped every time, so a later Off/Shadow can take Active away mid-session.
|
||||
using var tmp = new TempDirectory();
|
||||
var sink = new FakeSignalIngressSink();
|
||||
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(
|
||||
sink, HelloWaitTimeout.DefaultSeconds, NewLogger(tmp.Path), configured);
|
||||
|
||||
Assert.Equal(expected, sink.Posted[0].Payload![SignalPayloadKeys.OobeUpdatePhaseMode]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Swallows_sink_exceptions()
|
||||
{
|
||||
|
||||
+37
@@ -933,6 +933,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed.ToString(), (string)data["outcome"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_propagates_stopped_waiting_reason_and_cause_in_agent_shutting_down_data()
|
||||
{
|
||||
// D-310: the engine stopped waiting for the user (no sign-in after the OOBE update).
|
||||
// The backend classifies this shutdown honestly — it keys on the reason tag.
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.EspAccountSetup }.Build();
|
||||
|
||||
rig.Build().Handle(sender: null!, new EnrollmentTerminatedEventArgs(
|
||||
EnrollmentTerminationReason.StoppedWaiting,
|
||||
EnrollmentTerminationOutcome.TimedOut,
|
||||
SessionStage.EspAccountSetup.ToString(),
|
||||
EndUtc,
|
||||
details: StoppedWaitingReasons.Describe(StoppedWaitingReasons.OobeUpdateNoSignIn),
|
||||
stopReason: StoppedWaitingReasons.OobeUpdateNoSignIn));
|
||||
|
||||
var data = rig.DataOf(Constants.EventTypes.AgentShuttingDown);
|
||||
Assert.NotNull(data);
|
||||
Assert.Equal("stopped_waiting", (string)data!["reason"]);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, (string)data["stopReason"]);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.TimedOut.ToString(), (string)data["outcome"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_omits_stop_reason_for_other_shutdowns()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.AwaitingHello }.Build();
|
||||
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.MaxLifetimeExceeded, EnrollmentTerminationOutcome.TimedOut, SessionStage.AwaitingHello));
|
||||
|
||||
var data = rig.DataOf(Constants.EventTypes.AgentShuttingDown);
|
||||
Assert.NotNull(data);
|
||||
Assert.False(data!.ContainsKey("stopReason"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_includes_uptime_and_agent_version_in_agent_shutting_down()
|
||||
{
|
||||
|
||||
+2
-2
@@ -58,8 +58,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// (IME service family and AgentExecutor.log: eras are byte ranges).</description></item>
|
||||
/// <item><description><see cref="TryResolveByLocalTime"/> — transfer for files written by the
|
||||
/// SAME process as the IME service log but without their own era marker (AppWorkload.log,
|
||||
/// AppActionProcessor.log: plugins hosted in IntuneManagementExtension.exe, verified in the
|
||||
/// decompiled build). Within one process every file shares the zone belief, so a local
|
||||
/// AppActionProcessor.log: plugins hosted in IntuneManagementExtension.exe, verified against
|
||||
/// the IME build). Within one process every file shares the zone belief, so a local
|
||||
/// timestamp places the line in the service era whose local range contains it. Disabled
|
||||
/// entirely when the service eras' local ranges are not monotonic (a westward zone change
|
||||
/// between two service starts makes the ranges overlap) — ambiguity must never anchor.</description></item>
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@ using System.Collections.Generic;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks file read positions for incremental log file reading.
|
||||
/// In-memory only - resets on agent restart, which is acceptable since
|
||||
/// we start fresh per enrollment session.
|
||||
/// Tracks the IME log tracker's file read positions for incremental log file reading.
|
||||
/// The bookmarks survive agent restarts through the tracker's own state file
|
||||
/// (<see cref="GetAllPositions"/> / <see cref="RestorePosition"/>); the freshness timestamps do not.
|
||||
/// </summary>
|
||||
public class LogFilePositionTracker
|
||||
{
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Cross-restart dedup for event-log watchers that backfill a channel on start. RecordIds are
|
||||
/// per channel, so every watched channel owns one instance with its own state file.
|
||||
/// <para>
|
||||
/// Two boundaries, deliberately separate: the restart watermark is the highest RecordId a
|
||||
/// PRIOR run emitted (loaded once, immutable for this run); the seen-set holds what THIS run
|
||||
/// processed. A single high-water mark would be wrong because the live watcher is armed before
|
||||
/// the backfill runs: a live record with a higher RecordId can arrive first and would then
|
||||
/// make the backfill skip every older, never-emitted record.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class EventRecordWatermark
|
||||
{
|
||||
private readonly string _stateFilePath;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _label;
|
||||
private readonly object _lock = new object();
|
||||
private readonly HashSet<long> _seenThisRun = new HashSet<long>();
|
||||
private long _restartWatermark = -1;
|
||||
private long _maxEmittedRecordId = -1;
|
||||
|
||||
/// <param name="stateDirectory">Directory for the state file; null keeps the dedup in memory only.</param>
|
||||
/// <param name="stateFileName">File name inside <paramref name="stateDirectory"/>.</param>
|
||||
/// <param name="label">Watcher name for log lines.</param>
|
||||
public EventRecordWatermark(string stateDirectory, string stateFileName, AgentLogger logger, string label)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_label = label ?? "EventLog";
|
||||
_stateFilePath = string.IsNullOrEmpty(stateDirectory) || string.IsNullOrEmpty(stateFileName)
|
||||
? null
|
||||
: Path.Combine(Environment.ExpandEnvironmentVariables(stateDirectory), stateFileName);
|
||||
}
|
||||
|
||||
/// <summary>Highest RecordId emitted by a prior run; -1 when none was persisted.</summary>
|
||||
public long RestartWatermark
|
||||
{
|
||||
get { lock (_lock) return _restartWatermark; }
|
||||
}
|
||||
|
||||
/// <summary>True when the record was already emitted, by a prior run or by this one.</summary>
|
||||
public bool IsAlreadyProcessed(long recordId)
|
||||
{
|
||||
if (recordId < 0) return false; // no RecordId → cannot dedup, let it through
|
||||
lock (_lock)
|
||||
{
|
||||
return recordId <= _restartWatermark || _seenThisRun.Contains(recordId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically claims <paramref name="recordId"/> for this run. True on first sight (the
|
||||
/// caller processes it), false when it was already processed. Records without a RecordId
|
||||
/// (-1) are always claimed, never tracked. Claiming persists nothing — only
|
||||
/// <see cref="MarkEmitted"/> does, so a record that is counted instead of emitted costs no
|
||||
/// state-file write (CBS maintenance writes hundreds of servicing records in seconds).
|
||||
/// </summary>
|
||||
public bool TryClaim(long recordId)
|
||||
{
|
||||
if (recordId < 0) return true;
|
||||
lock (_lock)
|
||||
{
|
||||
return recordId > _restartWatermark && _seenThisRun.Add(recordId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists <paramref name="recordId"/> when it raises the emitted maximum, so a restarted
|
||||
/// agent's backfill skips it. Called after the record's event and engine signal were
|
||||
/// handed to the ingress: a crash before that re-emits the record instead of losing it.
|
||||
/// The ingress makes them durable only when its worker appends them to the SignalLog —
|
||||
/// normally milliseconds later; a process killed inside that window (e.g. by a reboot)
|
||||
/// loses the record, and the backfill does not bring it back. Counted records above the
|
||||
/// persisted maximum are counted again after a restart, which only touches the activity
|
||||
/// summary.
|
||||
/// </summary>
|
||||
public void MarkEmitted(long recordId)
|
||||
{
|
||||
if (recordId < 0) return;
|
||||
|
||||
long toPersist = -1;
|
||||
lock (_lock)
|
||||
{
|
||||
if (recordId > _maxEmittedRecordId)
|
||||
{
|
||||
_maxEmittedRecordId = recordId;
|
||||
toPersist = recordId;
|
||||
}
|
||||
}
|
||||
if (toPersist >= 0) Persist(toPersist);
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
if (_stateFilePath == null || !File.Exists(_stateFilePath)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var state = JsonConvert.DeserializeObject<WatermarkState>(File.ReadAllText(_stateFilePath));
|
||||
if (state == null) return;
|
||||
lock (_lock)
|
||||
{
|
||||
_restartWatermark = state.LastRecordId;
|
||||
_maxEmittedRecordId = state.LastRecordId;
|
||||
}
|
||||
_logger.Info($"{_label} watermark loaded: lastRecordId={state.LastRecordId} (persisted {state.PersistedUtc:O})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to load {_label} watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void Persist(long recordId)
|
||||
{
|
||||
if (_stateFilePath == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_stateFilePath));
|
||||
var json = JsonConvert.SerializeObject(
|
||||
new WatermarkState { LastRecordId = recordId, PersistedUtc = DateTime.UtcNow },
|
||||
Formatting.Indented);
|
||||
var tempPath = _stateFilePath + ".tmp";
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Copy(tempPath, _stateFilePath, overwrite: true);
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to persist {_label} watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WatermarkState
|
||||
{
|
||||
public long LastRecordId { get; set; }
|
||||
public DateTime PersistedUtc { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -360,7 +360,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
: (DateTime?)null;
|
||||
}
|
||||
|
||||
/// <summary>StateMessageEnforcementState bands (verified against decompiled IME 1.97/1.104).</summary>
|
||||
/// <summary>StateMessageEnforcementState bands (verified against IME 1.97/1.104).</summary>
|
||||
internal static string ClassifyEnforcementState(int state)
|
||||
{
|
||||
if (state >= 1000 && state < 2000) return "success";
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the registry state behind the OOBE quality update (D-310, S4/S5): the NDUP state
|
||||
/// Windows keeps while the update page runs, and both policy paths of the ESP setting "Install
|
||||
/// Windows quality updates". <see cref="OobeUpdateTelemetry"/> logs it and reports it as
|
||||
/// <c>oobe_update_state</c>. Read-only; the values are update-state flags and policy values,
|
||||
/// no user data.
|
||||
/// </summary>
|
||||
internal static class OobeUpdateRegistrySnapshot
|
||||
{
|
||||
/// <summary>The snapshot's keys, each with the alias it carries in <c>oobe_update_state</c>.</summary>
|
||||
internal static readonly (string Alias, string Path)[] Keys =
|
||||
{
|
||||
("setupOobeNdup", @"SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE\NDUP"),
|
||||
("setupOobeNdupUpdates", @"SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE\NDUP\Updates"),
|
||||
("ndup", @"SOFTWARE\Microsoft\Windows\CurrentVersion\NDUP"),
|
||||
("ndupUpdates", @"SOFTWARE\Microsoft\Windows\CurrentVersion\NDUP\Updates"),
|
||||
("espSetupPolicy", @"SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking\Device\Setup\Policy"),
|
||||
("policyManagerSystem", PolicyManagerSystemKey),
|
||||
("policiesOobe", @"SOFTWARE\Policies\Microsoft\Windows\OOBE"),
|
||||
};
|
||||
|
||||
// Carries every System policy of the device; only the OOBE ones belong in this snapshot.
|
||||
internal const string PolicyManagerSystemKey = @"SOFTWARE\Microsoft\PolicyManager\current\device\System";
|
||||
|
||||
internal const int MaxEntries = 30;
|
||||
internal const int MaxValueLength = 200;
|
||||
|
||||
/// <summary>One key of the snapshot: whether it exists, whether it holds values, and its line.</summary>
|
||||
internal sealed class KeyState
|
||||
{
|
||||
public KeyState(string alias, string path, bool exists, bool hasValues, string line)
|
||||
{
|
||||
Alias = alias;
|
||||
Path = path;
|
||||
Exists = exists;
|
||||
HasValues = hasValues;
|
||||
Line = line;
|
||||
}
|
||||
|
||||
public string Alias { get; }
|
||||
public string Path { get; }
|
||||
public bool Exists { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the key holds at least one value — for <see cref="PolicyManagerSystemKey"/>
|
||||
/// one with OOBE in its name. Subkeys alone are no values.
|
||||
/// </summary>
|
||||
public bool HasValues { get; }
|
||||
|
||||
/// <summary><see cref="FormatEntries"/> plus the subkey names; null when the key is absent.</summary>
|
||||
public string Line { get; }
|
||||
}
|
||||
|
||||
/// <summary>Reads every key from the 64-bit view. Throws on a registry failure; the caller is fail-soft.</summary>
|
||||
public static IReadOnlyList<KeyState> Read()
|
||||
{
|
||||
var states = new List<KeyState>(Keys.Length);
|
||||
using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
|
||||
{
|
||||
foreach (var (alias, path) in Keys)
|
||||
{
|
||||
using (var key = hklm.OpenSubKey(path))
|
||||
{
|
||||
if (key == null)
|
||||
{
|
||||
states.Add(new KeyState(alias, path, exists: false, hasValues: false, line: null));
|
||||
continue;
|
||||
}
|
||||
|
||||
var entries = key.GetValueNames()
|
||||
.Select(name => new KeyValuePair<string, object>(name, key.GetValue(name)))
|
||||
.ToList();
|
||||
states.Add(Describe(alias, path, entries, key.GetSubKeyNames()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/// <summary>The state of a present key from its values and subkey names.</summary>
|
||||
internal static KeyState Describe(string alias, string path, IReadOnlyList<KeyValuePair<string, object>> entries, string[] subKeys)
|
||||
{
|
||||
var oobeOnly = string.Equals(path, PolicyManagerSystemKey, StringComparison.OrdinalIgnoreCase);
|
||||
var line = FormatEntries(entries, oobeOnly);
|
||||
if (subKeys != null && subKeys.Length > 0)
|
||||
line += $" | subkeys: {string.Join(", ", subKeys.Take(MaxEntries))}";
|
||||
return new KeyState(alias, path, exists: true,
|
||||
hasValues: entries.Any(e => !oobeOnly || IsOobeName(e.Key)), line: line);
|
||||
}
|
||||
|
||||
/// <summary>One agent.log line per present key, one naming the absent keys.</summary>
|
||||
public static void Log(AgentLogger logger, string moment, IReadOnlyList<KeyState> states)
|
||||
{
|
||||
if (logger == null || states == null) return;
|
||||
|
||||
var absent = new List<string>();
|
||||
foreach (var state in states)
|
||||
{
|
||||
if (!state.Exists)
|
||||
{
|
||||
absent.Add(state.Path);
|
||||
continue;
|
||||
}
|
||||
logger.Info($"OOBE update registry ({moment}): HKLM\\{state.Path}: {state.Line}");
|
||||
}
|
||||
|
||||
if (absent.Count > 0)
|
||||
logger.Info($"OOBE update registry ({moment}): absent: {string.Join(", ", absent)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>name=value</c> pairs ordered by name, bounded in count and length.
|
||||
/// <paramref name="oobeOnly"/> keeps only names that mention OOBE.
|
||||
/// </summary>
|
||||
internal static string FormatEntries(IEnumerable<KeyValuePair<string, object>> entries, bool oobeOnly)
|
||||
{
|
||||
var selected = entries
|
||||
.Where(e => !oobeOnly || IsOobeName(e.Key))
|
||||
.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (selected.Count == 0) return oobeOnly ? "(no OOBE values)" : "(no values)";
|
||||
|
||||
var shown = selected.Take(MaxEntries)
|
||||
.Select(e => $"{(e.Key.Length == 0 ? "(default)" : e.Key)}={FormatValue(e.Value)}");
|
||||
var line = string.Join(", ", shown);
|
||||
return selected.Count > MaxEntries ? $"{line}, … (+{selected.Count - MaxEntries} more)" : line;
|
||||
}
|
||||
|
||||
internal static string FormatValue(object value)
|
||||
{
|
||||
string text;
|
||||
switch (value)
|
||||
{
|
||||
case null: text = "(null)"; break;
|
||||
case int i: text = i.ToString(CultureInfo.InvariantCulture); break;
|
||||
case long l: text = l.ToString(CultureInfo.InvariantCulture); break;
|
||||
case string s: text = s; break;
|
||||
case string[] multi: text = string.Join("|", multi); break;
|
||||
case byte[] bytes: text = $"(binary {bytes.Length} bytes)"; break;
|
||||
default: text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; break;
|
||||
}
|
||||
return text.Length > MaxValueLength ? text.Substring(0, MaxValueLength) + "…" : text;
|
||||
}
|
||||
|
||||
private static bool IsOobeName(string name) =>
|
||||
name != null && name.IndexOf("OOBE", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// One CloudExperienceHost record of the OOBE update page, parsed by
|
||||
/// <see cref="ShellCoreTracker.ParseUpdatePageRecord"/>.
|
||||
/// </summary>
|
||||
internal sealed class OobeUpdatePageRecord
|
||||
{
|
||||
public const string PageStarted = "page_started";
|
||||
public const string PageStopped = "page_stopped";
|
||||
public const string EventName = "event_name";
|
||||
|
||||
public OobeUpdatePageRecord(string cxhEvent, int windowsEventId, string page = null, string result = null, string name = null, string value = null)
|
||||
{
|
||||
CxhEvent = cxhEvent;
|
||||
WindowsEventId = windowsEventId;
|
||||
Page = page;
|
||||
Result = result;
|
||||
Name = name;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
/// <summary><see cref="PageStarted"/>, <see cref="PageStopped"/> or <see cref="EventName"/>.</summary>
|
||||
public string CxhEvent { get; }
|
||||
public int WindowsEventId { get; }
|
||||
|
||||
/// <summary>The page's CXID (start), or the page a stop is attributed to.</summary>
|
||||
public string Page { get; }
|
||||
|
||||
/// <summary>The page result of a stop.</summary>
|
||||
public string Result { get; }
|
||||
|
||||
/// <summary>The event name of a 62406/62407 record.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>A 62407 value of a safe form (boolean, integer, HRESULT); null otherwise.</summary>
|
||||
public string Value { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measurement telemetry of the OOBE quality update (D-310): the update page's
|
||||
/// CloudExperienceHost records as <c>oobe_update_page</c> and the registry state behind it as
|
||||
/// <c>oobe_update_state</c>. Debug, bounded per agent run, no decisions. Owned by
|
||||
/// <see cref="ShellCoreTracker"/>, which observes the page.
|
||||
/// </summary>
|
||||
internal sealed class OobeUpdateTelemetry
|
||||
{
|
||||
internal const int MaxPageEventsPerRun = 50;
|
||||
internal const int MaxPageEventsPerKey = 3;
|
||||
internal const int MaxStateEventsPerRun = 6;
|
||||
internal const int MaxStateLineLength = 1000;
|
||||
|
||||
/// <summary>When a registry snapshot is taken (<c>moment</c> of <c>oobe_update_state</c>).</summary>
|
||||
internal static class Moments
|
||||
{
|
||||
public const string AgentStart = "agent_start";
|
||||
public const string UpdatePageStarted = "update_page_started";
|
||||
public const string UpdatePageStopped = "update_page_stopped";
|
||||
public const string AgentStop = "agent_stop";
|
||||
}
|
||||
|
||||
private const string Source = "ShellCoreTracker";
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>> _readRegistry;
|
||||
|
||||
private readonly object _lock = new object();
|
||||
private readonly Dictionary<string, int> _pageEventsPerKey = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
private int _pageEvents;
|
||||
private bool _pageCapLogged;
|
||||
private int _stateEvents;
|
||||
private string _lastStateContent;
|
||||
|
||||
public OobeUpdateTelemetry(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>> readRegistry)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_readRegistry = readRegistry ?? throw new ArgumentNullException(nameof(readRegistry));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>oobe_update_page</c> per update-page record, at most
|
||||
/// <see cref="MaxPageEventsPerKey"/> per page/name and <see cref="MaxPageEventsPerRun"/>
|
||||
/// per run — a page that logs the same name in a loop cannot flood the session.
|
||||
/// </summary>
|
||||
public void ReportPage(OobeUpdatePageRecord record, DateTime occurredAtUtc, bool isBackfill)
|
||||
{
|
||||
if (record == null) return;
|
||||
|
||||
var key = $"{record.CxhEvent}:{record.Page ?? record.Name}";
|
||||
int occurrence;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_pageEvents >= MaxPageEventsPerRun)
|
||||
{
|
||||
if (!_pageCapLogged)
|
||||
{
|
||||
_pageCapLogged = true;
|
||||
_logger.Info($"OOBE update page telemetry: {MaxPageEventsPerRun} events reached, the rest of this run stays in agent.log");
|
||||
}
|
||||
return;
|
||||
}
|
||||
_pageEventsPerKey.TryGetValue(key, out occurrence);
|
||||
if (occurrence >= MaxPageEventsPerKey) return;
|
||||
occurrence++;
|
||||
_pageEventsPerKey[key] = occurrence;
|
||||
_pageEvents++;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "cxhEvent", record.CxhEvent },
|
||||
{ "windowsEventId", record.WindowsEventId },
|
||||
{ "eventTime", occurredAtUtc.ToString("o") },
|
||||
{ "backfill", isBackfill },
|
||||
{ "occurrence", occurrence },
|
||||
};
|
||||
if (record.Page != null) data["page"] = record.Page;
|
||||
if (record.Result != null) data["result"] = record.Result;
|
||||
if (record.Name != null) data["name"] = record.Name;
|
||||
if (record.Value != null) data["value"] = record.Value;
|
||||
|
||||
try
|
||||
{
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = occurredAtUtc,
|
||||
EventType = Constants.EventTypes.OobeUpdatePage,
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = Source,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = DescribePage(record),
|
||||
Data = data,
|
||||
ImmediateUpload = false,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"OOBE update page telemetry failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the OOBE update registry state, logs it and reports it as <c>oobe_update_state</c>
|
||||
/// — only when at least one key holds values and the content changed since the last report
|
||||
/// of this run, at most <see cref="MaxStateEventsPerRun"/> times. Fail-soft.
|
||||
/// </summary>
|
||||
public void ReportState(string moment, string page = null)
|
||||
{
|
||||
var label = page == null ? moment : $"{moment} {page}";
|
||||
IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState> states;
|
||||
try
|
||||
{
|
||||
states = _readRegistry() ?? Array.Empty<OobeUpdateRegistrySnapshot.KeyState>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"OOBE update registry ({label}): snapshot failed: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
OobeUpdateRegistrySnapshot.Log(_logger, label, states);
|
||||
|
||||
var withValues = states.Where(s => s.HasValues).ToList();
|
||||
if (withValues.Count == 0) return;
|
||||
|
||||
var content = string.Join("\n", withValues.Select(s => $"{s.Alias}={s.Line}"));
|
||||
lock (_lock)
|
||||
{
|
||||
if (string.Equals(content, _lastStateContent, StringComparison.Ordinal)) return;
|
||||
if (_stateEvents >= MaxStateEventsPerRun) return;
|
||||
_lastStateContent = content;
|
||||
_stateEvents++;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "moment", moment },
|
||||
{ "keysWithValues", withValues.Count },
|
||||
};
|
||||
if (page != null) data["page"] = page;
|
||||
foreach (var state in withValues)
|
||||
{
|
||||
data[state.Alias] = state.Line.Length > MaxStateLineLength
|
||||
? state.Line.Substring(0, MaxStateLineLength) + "…"
|
||||
: state.Line;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.OobeUpdateState,
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = Source,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"OOBE update registry state ({label}): {withValues.Count} key(s) with values",
|
||||
Data = data,
|
||||
ImmediateUpload = false,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"OOBE update state telemetry failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribePage(OobeUpdatePageRecord record)
|
||||
{
|
||||
switch (record.CxhEvent)
|
||||
{
|
||||
case OobeUpdatePageRecord.PageStarted:
|
||||
return $"OOBE update page started: {record.Page}";
|
||||
case OobeUpdatePageRecord.PageStopped:
|
||||
return $"OOBE update page stopped: {record.Page} (result: {record.Result})";
|
||||
default:
|
||||
return record.Value == null
|
||||
? $"OOBE update page event: {record.Name}"
|
||||
: $"OOBE update page event: {record.Name} = {record.Value}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Watches the CBS servicing steps of OS updates in the <c>Setup</c> log
|
||||
/// (<c>Microsoft-Windows-Servicing</c>, D-310): 1 initiating changes (current → target state),
|
||||
/// 2 target state reached, 3 failed, 4 a restart is needed to finish, 6 failed and partially
|
||||
/// installed. The Windows Update client logs only the start of an installation; these steps
|
||||
/// show the long part of it — staging, installing, "reboot required" right before the restart.
|
||||
/// <para>
|
||||
/// Only packages of the OS update clients (<c>UpdateAgentLCU</c> for the cumulative update,
|
||||
/// <c>WindowsUpdateAgent</c>) are emitted as <c>windows_update_servicing</c> and reported to
|
||||
/// the decision engine; language packs, features on demand and DISM operations started by
|
||||
/// apps are counted into the host's <see cref="UpdateActivityTally"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class ServicingTracker : IDisposable
|
||||
{
|
||||
internal const string Channel = "Setup";
|
||||
internal const string ProviderName = "Microsoft-Windows-Servicing";
|
||||
|
||||
internal const int EventId_InitiatingChanges = 1;
|
||||
internal const int EventId_StateReached = 2;
|
||||
internal const int EventId_ChangeFailed = 3;
|
||||
internal const int EventId_RebootRequired = 4;
|
||||
internal const int EventId_PartiallyInstalled = 6;
|
||||
|
||||
internal static readonly int[] TargetedEventIds =
|
||||
{
|
||||
EventId_InitiatingChanges, EventId_StateReached, EventId_ChangeFailed, EventId_RebootRequired, EventId_PartiallyInstalled,
|
||||
};
|
||||
|
||||
internal const string WatermarkStateFileName = "servicing-watermark.json";
|
||||
|
||||
// CBS client ids of the Windows Update installers (seen on 24H2/25H2: UpdateAgentLCU).
|
||||
private static readonly HashSet<string> OsUpdateClients = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"UpdateAgentLCU",
|
||||
"WindowsUpdateAgent",
|
||||
};
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly bool _backfillEnabled;
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly UpdateActivityTally _tally;
|
||||
private readonly Action<string, string, DateTime> _onOsUpdateActivity;
|
||||
private readonly EventRecordWatermark _watermark;
|
||||
private EventLogWatcher _watcher;
|
||||
|
||||
public ServicingTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
bool backfillEnabled = true,
|
||||
int backfillLookbackMinutes = 60,
|
||||
string stateDirectory = null,
|
||||
UpdateActivityTally tally = null,
|
||||
Action<string, string, DateTime> onOsUpdateActivity = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_backfillEnabled = backfillEnabled;
|
||||
_backfillLookbackMinutes = backfillLookbackMinutes;
|
||||
_tally = tally;
|
||||
_onOsUpdateActivity = onOsUpdateActivity;
|
||||
_watermark = new EventRecordWatermark(stateDirectory, WatermarkStateFileName, logger, "Servicing");
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_watermark.Load();
|
||||
StartWatcher();
|
||||
|
||||
if (_backfillEnabled && _backfillLookbackMinutes > 0)
|
||||
BackfillRecentEvents();
|
||||
else
|
||||
_logger.Info("Servicing backfill disabled by config");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_watcher == null) return;
|
||||
try
|
||||
{
|
||||
_watcher.Enabled = false;
|
||||
_watcher.Dispose();
|
||||
_logger.Info("Servicing watcher stopped");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error stopping Servicing watcher", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_watcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
internal void LoadWatermark() => _watermark.Load();
|
||||
|
||||
private static HashSet<int> TargetedSet => new HashSet<int>(TargetedEventIds);
|
||||
|
||||
private void StartWatcher()
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery(Channel, PathType.LogName,
|
||||
WindowsUpdateTracker.BuildXPath(TargetedSet, ProviderName));
|
||||
_watcher = new EventLogWatcher(query);
|
||||
_watcher.EventRecordWritten += OnEventRecordWritten;
|
||||
_watcher.Enabled = true;
|
||||
_logger.Info($"Servicing watcher started: {Channel} ({ProviderName}, targetedIds={string.Join(",", TargetedEventIds)})");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"Servicing event log not found: {Channel}");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"Servicing watcher access denied for {Channel}: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to start Servicing watcher for {Channel}", ex);
|
||||
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId,
|
||||
collectorName: "ServicingTracker", reason: $"watcher_arm_failed:{Channel}", ex: ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
|
||||
{
|
||||
if (e.EventRecord == null) return;
|
||||
try
|
||||
{
|
||||
ProcessRecord(e.EventRecord, isBackfill: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error processing Servicing event", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessRecord(EventRecord record, bool isBackfill)
|
||||
{
|
||||
var recordId = record.RecordId ?? -1;
|
||||
if (_watermark.IsAlreadyProcessed(recordId)) return;
|
||||
|
||||
string xml = null;
|
||||
try { xml = record.ToXml(); }
|
||||
catch { }
|
||||
|
||||
var fields = ParseUserData(xml);
|
||||
fields.TryGetValue("PackageIdentifier", out var package);
|
||||
fields.TryGetValue("InitialPackageStateTextized", out var initialState);
|
||||
fields.TryGetValue("IntendedPackageStateTextized", out var targetState);
|
||||
fields.TryGetValue("ErrorCode", out var errorCode);
|
||||
fields.TryGetValue("Client", out var client);
|
||||
|
||||
ProcessEvent(
|
||||
eventId: record.Id,
|
||||
recordId: recordId,
|
||||
timeCreatedUtc: record.TimeCreated?.ToUniversalTime(),
|
||||
package: package,
|
||||
initialState: initialState,
|
||||
targetState: targetState,
|
||||
errorCode: errorCode,
|
||||
client: client,
|
||||
isBackfill: isBackfill);
|
||||
}
|
||||
|
||||
/// <summary>Test seam — drives the processing without a Windows-only <see cref="EventRecord"/>.</summary>
|
||||
internal void ProcessEvent(
|
||||
int eventId,
|
||||
long recordId,
|
||||
DateTime? timeCreatedUtc,
|
||||
string package,
|
||||
string initialState,
|
||||
string targetState,
|
||||
string errorCode,
|
||||
string client,
|
||||
bool isBackfill)
|
||||
{
|
||||
if (!_watermark.TryClaim(recordId)) return;
|
||||
|
||||
if (string.IsNullOrEmpty(client) || !OsUpdateClients.Contains(client))
|
||||
{
|
||||
_tally?.Increment($"servicing_other_{eventId}");
|
||||
return;
|
||||
}
|
||||
|
||||
var pkg = string.IsNullOrEmpty(package) ? "(unknown package)" : package;
|
||||
var target = string.IsNullOrEmpty(targetState) ? "?" : targetState;
|
||||
|
||||
string step;
|
||||
string osStep;
|
||||
string message;
|
||||
var severity = EventSeverity.Info;
|
||||
string hresultHex = null;
|
||||
string hresultSymbol = null;
|
||||
if (WindowsUpdateTracker.TryNormalizeHResult(errorCode, out var hresultValue, out var hex) && hresultValue != 0)
|
||||
{
|
||||
hresultHex = hex;
|
||||
hresultSymbol = WindowsUpdateTracker.ResolveHResultSymbol(hresultValue);
|
||||
}
|
||||
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_InitiatingChanges:
|
||||
step = "initiating";
|
||||
osStep = IsState(targetState, "Installed") ? OsUpdateSteps.InstallStarted
|
||||
: IsState(targetState, "Staged") ? OsUpdateSteps.StagingStarted
|
||||
: null;
|
||||
message = $"Servicing started for {pkg}: {(string.IsNullOrEmpty(initialState) ? "?" : initialState)} → {target}";
|
||||
break;
|
||||
case EventId_StateReached:
|
||||
step = "state_reached";
|
||||
osStep = IsState(targetState, "Installed") ? OsUpdateSteps.Installed
|
||||
: IsState(targetState, "Staged") ? OsUpdateSteps.Staged
|
||||
: null;
|
||||
message = $"Servicing finished for {pkg}: now {target}";
|
||||
break;
|
||||
case EventId_RebootRequired:
|
||||
step = "reboot_required";
|
||||
osStep = OsUpdateSteps.RebootRequired;
|
||||
message = $"Servicing needs a restart to finish {pkg} → {target}";
|
||||
break;
|
||||
case EventId_ChangeFailed:
|
||||
case EventId_PartiallyInstalled:
|
||||
step = "failed";
|
||||
osStep = OsUpdateSteps.Failed;
|
||||
severity = EventSeverity.Warning;
|
||||
message = $"Servicing FAILED for {pkg} → {target}" +
|
||||
(hresultHex != null ? $" ({hresultHex} {hresultSymbol})" : string.Empty) +
|
||||
(eventId == EventId_PartiallyInstalled ? ", package is partially installed" : string.Empty);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "servicingEventId", eventId },
|
||||
{ "step", step },
|
||||
{ "package", pkg },
|
||||
{ "client", client },
|
||||
{ "backfilled", isBackfill },
|
||||
};
|
||||
if (!string.IsNullOrEmpty(initialState)) data["initialState"] = initialState;
|
||||
if (!string.IsNullOrEmpty(targetState)) data["targetState"] = targetState;
|
||||
if (hresultHex != null)
|
||||
{
|
||||
data["hresult"] = hresultHex;
|
||||
data["hresultSymbol"] = hresultSymbol;
|
||||
}
|
||||
if (recordId >= 0) data["recordId"] = recordId;
|
||||
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
|
||||
EventType = Constants.EventTypes.WindowsUpdateServicing,
|
||||
Severity = severity,
|
||||
Source = "ServicingWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data,
|
||||
ImmediateUpload = false,
|
||||
});
|
||||
|
||||
if (osStep != null && _onOsUpdateActivity != null)
|
||||
{
|
||||
try { _onOsUpdateActivity(osStep, pkg, timeCreatedUtc ?? DateTime.UtcNow); }
|
||||
catch (Exception ex) { _logger.Warning($"Servicing: OS update activity callback failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Last: everything this record produced has been handed to the ingress first.
|
||||
_watermark.MarkEmitted(recordId);
|
||||
}
|
||||
|
||||
private static bool IsState(string state, string expected) =>
|
||||
string.Equals(state, expected, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Child elements of the first element under <c>UserData</c>
|
||||
/// (<c>CbsPackageInitiateChanges</c> / <c>CbsPackageChangeState</c>), keyed by local name,
|
||||
/// case-insensitively. Empty on null/malformed XML — never throws.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, string> ParseUserData(string xml)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrEmpty(xml)) return result;
|
||||
|
||||
try
|
||||
{
|
||||
var doc = XDocument.Parse(xml);
|
||||
var userData = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "UserData");
|
||||
var payload = userData?.Elements().FirstOrDefault();
|
||||
if (payload == null) return result;
|
||||
|
||||
foreach (var el in payload.Elements())
|
||||
{
|
||||
if (!result.ContainsKey(el.Name.LocalName))
|
||||
result[el.Name.LocalName] = el.Value;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed / unexpected XML — best effort.
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void BackfillRecentEvents()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lookbackMs = (long)_backfillLookbackMinutes * 60 * 1000;
|
||||
var xpath = WindowsUpdateTracker.BuildXPath(TargetedSet, ProviderName, lookbackMs);
|
||||
_logger.Info($"Servicing backfill: scanning {Channel} (lookback={_backfillLookbackMinutes}min, restartWatermark={_watermark.RestartWatermark})");
|
||||
|
||||
int processed = 0;
|
||||
using (var reader = new EventLogReader(new EventLogQuery(Channel, PathType.LogName, xpath)))
|
||||
{
|
||||
EventRecord record;
|
||||
while ((record = reader.ReadEvent()) != null)
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
ProcessRecord(record, isBackfill: true);
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (processed > 0)
|
||||
_logger.Info($"Servicing backfill: scanned {processed} event(s)");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"Servicing event log not found during backfill: {Channel}");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"Servicing backfill access denied: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Servicing backfill failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+207
-3
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -31,9 +32,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
internal const string ShellCoreEventLogChannel = "Microsoft-Windows-Shell-Core/Operational";
|
||||
internal const int EventId_ShellCore_WebAppStarted = 62404;
|
||||
internal const int EventId_ShellCore_WebAppStopped = 62405;
|
||||
internal const int EventId_ShellCore_WebAppEventName = 62406;
|
||||
internal const int EventId_ShellCore_WebAppEvent = 62407;
|
||||
internal const int BackfillLookbackMinutes = 5;
|
||||
|
||||
// 62405 (page stopped) and 62406 (event with a name only) feed the CloudExperienceHost
|
||||
// navigation breadcrumbs in agent.log and the OOBE update page telemetry (D-310), nothing else.
|
||||
private const string WatchedEventIdsXPath =
|
||||
"(EventID=62404 or EventID=62405 or EventID=62406 or EventID=62407)";
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound for the caller-supplied backfill lookback. Matches the agent's max
|
||||
/// lifetime (360 min) — a window wider than the agent can ever live would only widen
|
||||
@@ -45,22 +53,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private static readonly HashSet<int> TrackedShellCoreEventIds = new HashSet<int>
|
||||
{
|
||||
EventId_ShellCore_WebAppStarted,
|
||||
EventId_ShellCore_WebAppStopped,
|
||||
EventId_ShellCore_WebAppEventName,
|
||||
EventId_ShellCore_WebAppEvent
|
||||
};
|
||||
|
||||
private static readonly Regex EspExitingPattern = new Regex(
|
||||
@"OOBE_ESP.*Exiting", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// Manifest templates: 62404 "... started. CXID: '%1'.", 62405 "... stopped. Result: '%1'.",
|
||||
// 62406 "... Event 1. Name: '%1'.", 62407 "... Event 2. Name: '%1', Value: '%2'.". A 62407
|
||||
// value can carry URLs with tenant ids — a value leaves the tracker only in a form that
|
||||
// cannot (SafeValuePattern: a boolean, an integer, an HRESULT).
|
||||
private static readonly Regex CxidPattern = new Regex(
|
||||
@"CXID:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private static readonly Regex ResultPattern = new Regex(
|
||||
@"Result:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private static readonly Regex NamePattern = new Regex(
|
||||
@"Name:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private static readonly Regex ValuePattern = new Regex(
|
||||
@"Value:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private static readonly Regex SafeValuePattern = new Regex(
|
||||
@"^(?:true|false|-?[0-9]{1,10}|0x[0-9a-f]{1,8})$",
|
||||
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
private const int MaxBreadcrumbTokenLength = 128;
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly HelloTracker _helloTracker;
|
||||
private readonly OobeUpdateTelemetry _oobeUpdate;
|
||||
|
||||
private EventLogWatcher _watcher;
|
||||
private int _started;
|
||||
private int _stopReported;
|
||||
private bool _espExitDetected;
|
||||
private bool _whiteGloveDetected;
|
||||
private bool _helloWizardStartDetected;
|
||||
private string _lastCxhPage; // 62405 names no page, it ends the last one started
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
/// <summary>
|
||||
@@ -99,13 +130,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
HelloTracker helloTracker)
|
||||
HelloTracker helloTracker,
|
||||
Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>> oobeUpdateRegistryReader = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_helloTracker = helloTracker; // nullable — HelloTracker may be unavailable in some test setups
|
||||
// Test seam: a fake registry reader; production reads the real registry.
|
||||
_oobeUpdate = new OobeUpdateTelemetry(sessionId, tenantId, post, logger,
|
||||
oobeUpdateRegistryReader ?? OobeUpdateRegistrySnapshot.Read);
|
||||
}
|
||||
|
||||
internal bool IsEspExitedForTest { get { lock (_stateLock) { return _espExitDetected; } } }
|
||||
@@ -113,12 +148,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
public void Start()
|
||||
{
|
||||
// The OOBE update registry state at every agent start — so also right after the
|
||||
// update's restart (D-310).
|
||||
if (Interlocked.Exchange(ref _started, 1) == 0)
|
||||
_oobeUpdate.ReportState(OobeUpdateTelemetry.Moments.AgentStart);
|
||||
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery(
|
||||
ShellCoreEventLogChannel,
|
||||
PathType.LogName,
|
||||
"*[System[(EventID=62404 or EventID=62407)]]");
|
||||
$"*[System[{WatchedEventIdsXPath}]]");
|
||||
|
||||
_watcher = new EventLogWatcher(query);
|
||||
_watcher.EventRecordWritten += OnEventRecordWritten;
|
||||
@@ -143,6 +183,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
// Stop runs again from Dispose — one snapshot, and only for a tracker that started.
|
||||
if (Volatile.Read(ref _started) == 1 && Interlocked.Exchange(ref _stopReported, 1) == 0)
|
||||
_oobeUpdate.ReportState(OobeUpdateTelemetry.Moments.AgentStop);
|
||||
|
||||
if (_watcher == null) return;
|
||||
try
|
||||
{
|
||||
@@ -186,6 +230,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// </summary>
|
||||
internal void ProcessEvent(int eventId, string description, DateTime timestamp, string providerName, bool isBackfill)
|
||||
{
|
||||
ObserveCxhRecord(eventId, description, timestamp, isBackfill);
|
||||
if (eventId == EventId_ShellCore_WebAppStopped || eventId == EventId_ShellCore_WebAppEventName) return;
|
||||
|
||||
string eventType;
|
||||
EventSeverity severity = EventSeverity.Info;
|
||||
string message;
|
||||
@@ -388,7 +435,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var query = new EventLogQuery(
|
||||
ShellCoreEventLogChannel,
|
||||
PathType.LogName,
|
||||
$"*[System[(EventID=62404 or EventID=62407) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
|
||||
$"*[System[{WatchedEventIdsXPath} and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
|
||||
|
||||
var records = new List<(int Id, string Description, DateTime OccurredAtUtc)>();
|
||||
using (var reader = new EventLogReader(query))
|
||||
@@ -466,6 +513,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
foreach (var record in records)
|
||||
{
|
||||
var description = record.Description ?? string.Empty;
|
||||
ObserveCxhRecord(record.Id, description, record.OccurredAtUtc, isBackfill: true);
|
||||
|
||||
if (record.Id == EventId_ShellCore_WebAppStarted)
|
||||
{
|
||||
@@ -473,6 +521,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
continue;
|
||||
}
|
||||
|
||||
// 62405/62406 only feed the breadcrumbs and the update page telemetry above.
|
||||
if (record.Id != EventId_ShellCore_WebAppEvent) continue;
|
||||
|
||||
var isFailure = HasEspFailurePattern(description);
|
||||
var isExit = !isFailure && EspExitingPattern.IsMatch(description);
|
||||
if (!isFailure && !isExit) continue;
|
||||
@@ -569,6 +620,159 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// CloudExperienceHost navigation: breadcrumbs and OOBE update page telemetry (D-310)
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Writes one agent.log line per CloudExperienceHost page start/stop and per update-page
|
||||
/// event name, so a diagnostics package shows the OOBE navigation. Records of the update
|
||||
/// page itself (<c>OobeNDUP</c>, <c>RebootNDUP</c> and its <c>ExpeditedUpdate_*</c> names)
|
||||
/// are also reported as <c>oobe_update_page</c>, and at the page's live start and stop the
|
||||
/// registry state behind it as <c>oobe_update_state</c>. No decision.
|
||||
/// </summary>
|
||||
private void ObserveCxhRecord(int eventId, string description, DateTime occurredAtUtc, bool isBackfill)
|
||||
{
|
||||
try
|
||||
{
|
||||
string page;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (eventId == EventId_ShellCore_WebAppStarted)
|
||||
_lastCxhPage = ExtractToken(CxidPattern, description) ?? _lastCxhPage;
|
||||
page = _lastCxhPage;
|
||||
}
|
||||
|
||||
var crumb = FormatCxhBreadcrumb(eventId, description, page);
|
||||
if (crumb == null) return;
|
||||
_logger.Info(isBackfill ? $"{crumb} (backfill, at {occurredAtUtc:o})" : crumb);
|
||||
|
||||
var record = ParseUpdatePageRecord(eventId, description, page);
|
||||
if (record == null) return;
|
||||
_oobeUpdate.ReportPage(record, occurredAtUtc, isBackfill);
|
||||
|
||||
// The registry is read now — only a live page boundary is the page's moment.
|
||||
if (!isBackfill && record.CxhEvent != OobeUpdatePageRecord.EventName)
|
||||
{
|
||||
_oobeUpdate.ReportState(
|
||||
record.CxhEvent == OobeUpdatePageRecord.PageStarted
|
||||
? OobeUpdateTelemetry.Moments.UpdatePageStarted
|
||||
: OobeUpdateTelemetry.Moments.UpdatePageStopped,
|
||||
record.Page);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ShellCoreTracker: CXH record observation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The breadcrumb for one Shell-Core record, or null when it is not one: a page start
|
||||
/// (62404, its CXID), a page stop (62405, its result, attributed to the last started
|
||||
/// page), or an update-page event name (62406/62407 — a 62407 value only in a safe form).
|
||||
/// </summary>
|
||||
internal static string FormatCxhBreadcrumb(int eventId, string description, string lastPage)
|
||||
{
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_ShellCore_WebAppStarted:
|
||||
{
|
||||
var cxid = ExtractToken(CxidPattern, description);
|
||||
return cxid == null ? null : $"CXH page started: {cxid}";
|
||||
}
|
||||
case EventId_ShellCore_WebAppStopped:
|
||||
{
|
||||
var result = ExtractToken(ResultPattern, description) ?? "?";
|
||||
return $"CXH page stopped: {lastPage ?? "?"} (result={result})";
|
||||
}
|
||||
case EventId_ShellCore_WebAppEventName:
|
||||
case EventId_ShellCore_WebAppEvent:
|
||||
{
|
||||
var name = ExtractToken(NamePattern, description);
|
||||
if (!IsUpdatePageToken(name)) return null;
|
||||
var value = eventId == EventId_ShellCore_WebAppEvent ? ExtractSafeValue(description) : null;
|
||||
return value == null ? $"CXH event: {name}" : $"CXH event: {name} (value={value})";
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The record of the OOBE update page, or null for any other CloudExperienceHost record:
|
||||
/// the start of a page whose CXID names NDUP, the stop of such a page (62405, attributed
|
||||
/// to the last started page), or an update-page event name from 62406/62407.
|
||||
/// </summary>
|
||||
internal static OobeUpdatePageRecord ParseUpdatePageRecord(int eventId, string description, string lastPage)
|
||||
{
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_ShellCore_WebAppStarted:
|
||||
{
|
||||
var cxid = ExtractToken(CxidPattern, description);
|
||||
return IsUpdatePageToken(cxid)
|
||||
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, eventId, page: cxid)
|
||||
: null;
|
||||
}
|
||||
case EventId_ShellCore_WebAppStopped:
|
||||
return IsUpdatePageToken(lastPage)
|
||||
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, eventId,
|
||||
page: lastPage, result: ExtractToken(ResultPattern, description) ?? "?")
|
||||
: null;
|
||||
case EventId_ShellCore_WebAppEventName:
|
||||
case EventId_ShellCore_WebAppEvent:
|
||||
{
|
||||
var name = ExtractToken(NamePattern, description);
|
||||
if (!IsUpdatePageToken(name)) return null;
|
||||
var value = eventId == EventId_ShellCore_WebAppEvent ? ExtractSafeValue(description) : null;
|
||||
return new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, eventId, name: name, value: value);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsUpdatePageToken(string token) =>
|
||||
token != null
|
||||
&& (token.IndexOf("NDUP", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| token.IndexOf("ExpeditedUpdate", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
|
||||
/// <summary>
|
||||
/// The 62407 value when it is a boolean, an integer of up to ten digits or an HRESULT —
|
||||
/// forms that cannot carry a URL or a tenant id (the CSP gate, error codes). Null for
|
||||
/// anything else.
|
||||
/// </summary>
|
||||
internal static string ExtractSafeValue(string description)
|
||||
{
|
||||
var value = ExtractToken(ValuePattern, description);
|
||||
if (string.IsNullOrEmpty(value)) return null;
|
||||
try
|
||||
{
|
||||
return SafeValuePattern.IsMatch(value) ? value : null;
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractToken(Regex pattern, string description)
|
||||
{
|
||||
if (string.IsNullOrEmpty(description)) return null;
|
||||
try
|
||||
{
|
||||
var match = pattern.Match(description);
|
||||
if (!match.Success) return null;
|
||||
var token = match.Groups[1].Value;
|
||||
return token.Length > MaxBreadcrumbTokenLength ? token.Substring(0, MaxBreadcrumbTokenLength) + "…" : token;
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Pattern helpers
|
||||
// =====================================================================
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Thread-safe counters for update activity that is counted instead of emitted (D-310): Store,
|
||||
/// Defender and other non-OS update events, repeated scan results, servicing steps of non-OS
|
||||
/// packages. Shared by the update watchers of one host, which reports the totals once as
|
||||
/// <c>windows_update_activity_summary</c> when it stops.
|
||||
/// </summary>
|
||||
internal sealed class UpdateActivityTally
|
||||
{
|
||||
private readonly object _lock = new object();
|
||||
private readonly Dictionary<string, int> _counts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
public void Increment(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key)) return;
|
||||
lock (_lock)
|
||||
{
|
||||
_counts.TryGetValue(key, out var current);
|
||||
_counts[key] = current + 1;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsEmpty
|
||||
{
|
||||
get { lock (_lock) return _counts.Count == 0; }
|
||||
}
|
||||
|
||||
/// <summary>Counts ordered by key, for a stable event payload.</summary>
|
||||
public IReadOnlyList<KeyValuePair<string, int>> Snapshot()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _counts.OrderBy(kv => kv.Key, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Sorts a Windows Update client event into the update class that decides how it is reported
|
||||
/// (D-310). Only <see cref="Os"/> updates — Windows quality/cumulative/servicing-stack/feature
|
||||
/// updates, .NET Framework and the Malicious Software Removal Tool — are enrollment-relevant and
|
||||
/// emitted one by one. Defender intelligence and Store app updates arrive every few minutes on a
|
||||
/// fresh device (up to 171 downloads per hour in the field census) and are only counted.
|
||||
/// </summary>
|
||||
internal static class WindowsUpdateClassifier
|
||||
{
|
||||
public const string Os = "os";
|
||||
public const string Defender = "defender";
|
||||
public const string Store = "store";
|
||||
public const string Other = "other";
|
||||
|
||||
/// <summary>The Microsoft Store service; WU events carry it as <c>serviceGuid</c>.</summary>
|
||||
internal const string StoreServiceGuid = "855e8a7c-ecb4-4ca3-b045-1dfa50104289";
|
||||
|
||||
// Store packages are titled "<12-char product id>-<Publisher.App>", e.g. "9NBLGGH4NNS1-Microsoft.DesktopAppInstaller";
|
||||
// bundles add an "ApplicationSet-" prefix.
|
||||
private static readonly Regex StoreProductTitle =
|
||||
new Regex(@"^(ApplicationSet-)?[0-9A-Z]{12}-\S", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private static readonly Regex DefenderTitle =
|
||||
new Regex(@"Microsoft Defender|Windows Defender", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
// "Windows" plus "Update"/"Upgrade" covers cumulative, quality, security, servicing-stack,
|
||||
// dynamic and feature updates in both the GA and the Insider title forms.
|
||||
private static readonly Regex WindowsUpdateTitle =
|
||||
new Regex(@"\bWindows\b.*\b(Update|Upgrade)\b|\b(Update|Upgrade)\b.*\bWindows\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private static readonly Regex OsTitleExtras =
|
||||
new Regex(@"\.NET Framework|Servicing Stack|Malicious Software Removal Tool",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
public static string Classify(string updateTitle, string serviceGuid)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(serviceGuid)
|
||||
&& serviceGuid.Trim('{', '}', ' ').Equals(StoreServiceGuid, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Store;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updateTitle)) return Other;
|
||||
|
||||
try
|
||||
{
|
||||
if (StoreProductTitle.IsMatch(updateTitle)) return Store;
|
||||
if (DefenderTitle.IsMatch(updateTitle)) return Defender;
|
||||
if (OsTitleExtras.IsMatch(updateTitle) || WindowsUpdateTitle.IsMatch(updateTitle)) return Os;
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
// A pathological title must never cost the event — it is reported as "other".
|
||||
}
|
||||
|
||||
return Other;
|
||||
}
|
||||
}
|
||||
}
|
||||
+267
-250
@@ -2,100 +2,106 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Xml.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Services;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Watches <c>Microsoft-Windows-WindowsUpdateClient/Operational</c> for quality/cumulative
|
||||
/// update install activity that happens DURING Autopilot OOBE / ESP and forwards it as
|
||||
/// structured <c>windows_update_succeeded</c> / <c>windows_update_failed</c> /
|
||||
/// <c>windows_update_started</c> events.
|
||||
/// Watches ONE channel of the <c>Microsoft-Windows-WindowsUpdateClient</c> provider for update
|
||||
/// activity during OOBE / ESP and forwards it as <c>windows_update_started</c> /
|
||||
/// <c>windows_update_succeeded</c> / <c>windows_update_failed</c> events.
|
||||
/// <para>
|
||||
/// This is a blind spot no other tool surfaces well: a cumulative update installing mid-OOBE
|
||||
/// can break the enrollment (r/Intune KB5095189), and the ESP "Install Windows quality updates
|
||||
/// during OOBE" feature makes it increasingly common. The Intune console shows none of it — it
|
||||
/// is otherwise deep manual digging on the device.
|
||||
/// <b>Two channels (D-310).</b> The provider manifest writes the install events — 19 success,
|
||||
/// 20 failure (HRESULT), 43 install started, 44 download started — to the <b>System</b> log and
|
||||
/// the scan/download events — 25 scan failed, 26 scan found N, 31 download failed, 41
|
||||
/// downloaded — to the Operational channel. The host runs one instance per channel; RecordIds
|
||||
/// are per channel, so each instance keeps its own watermark. The System log is shared with
|
||||
/// every other provider, hence the provider filter in its XPath.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Backfill.</b> OOBE updates run at the early "Getting updates" screen, frequently BEFORE the
|
||||
/// agent starts. The WindowsUpdateClient/Operational .evtx persists across the mid-OOBE reboot,
|
||||
/// so a startup backfill with a generous lookback catches those pre-agent events.
|
||||
/// <b>Classification.</b> Only Windows/.NET updates (<see cref="WindowsUpdateClassifier.Os"/>)
|
||||
/// are emitted one by one and reported to the decision engine. Store, Defender and other
|
||||
/// updates arrive every few minutes on a fresh device and are only counted into the host's
|
||||
/// <see cref="UpdateActivityTally"/>. Repeated scan results (26) and scan failures (25) are
|
||||
/// deduplicated; their repeats are counted the same way.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cross-restart dedup.</b> Unlike the single-latch WhiteGlove backfill, WU events are many
|
||||
/// distinct records, so an agent restart would re-read (and re-emit) events already reported in
|
||||
/// the prior run. We persist the highest processed <see cref="EventRecord.RecordId"/> as a
|
||||
/// watermark and skip records at/below it. Live records always carry a higher RecordId and pass.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Verified EventIDs (build against these):</b> 19=install success, 20=install failure
|
||||
/// (carries HRESULT in <c>errorCode</c>), 43=install started, 44=download started. IDs whose
|
||||
/// semantics are unverified across sources (25/26/31, restart-required 21 vs 41) are excluded by
|
||||
/// default and can be added via <c>CollectorConfiguration.WindowsUpdateTargetedEventIds</c>
|
||||
/// after validation on a real device trace — no redeploy needed.
|
||||
/// <b>Backfill.</b> Updates can start before the agent does; the .evtx survives the reboot, so
|
||||
/// a startup backfill with a generous lookback catches them. Backfilled events keep their own
|
||||
/// event time on the timeline.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class WindowsUpdateTracker : IDisposable
|
||||
{
|
||||
internal const string Channel = "Microsoft-Windows-WindowsUpdateClient/Operational";
|
||||
internal const string SystemChannel = "System";
|
||||
internal const string ProviderName = "Microsoft-Windows-WindowsUpdateClient";
|
||||
internal const string OrchestratorChannel = "Microsoft-Windows-UpdateOrchestrator/Operational";
|
||||
|
||||
internal const int EventId_InstallSuccess = 19;
|
||||
internal const int EventId_InstallFailure = 20;
|
||||
// System channel (provider manifest).
|
||||
internal const int EventId_InstallSuccess = 19;
|
||||
internal const int EventId_InstallFailure = 20;
|
||||
internal const int EventId_InstallStarted = 43;
|
||||
internal const int EventId_DownloadStarted = 44;
|
||||
// Operational channel.
|
||||
internal const int EventId_ScanFailed = 25;
|
||||
internal const int EventId_ScanFound = 26;
|
||||
internal const int EventId_DownloadFailed = 31;
|
||||
internal const int EventId_Downloaded = 41;
|
||||
|
||||
internal const string WatermarkStateFileName = "windows-update-watermark.json";
|
||||
internal const string SystemWatermarkStateFileName = "windows-update-system-watermark.json";
|
||||
|
||||
// Hard bound for the unfiltered census scan — during OOBE both channels carry at most a
|
||||
// few hundred records in the lookback window; the cap only guards against a pathological
|
||||
// log. A truncated census says so in its payload (no silent caps).
|
||||
internal const int CensusRecordCap = 5000;
|
||||
|
||||
// Scan results repeat every few minutes; only changes are worth a timeline row.
|
||||
internal const int MaxScanFoundEmissions = 10;
|
||||
internal const int MaxScanFailedEmissions = 5;
|
||||
|
||||
private static readonly int[] DefaultSystemEventIds = { EventId_InstallSuccess, EventId_InstallFailure, EventId_InstallStarted, EventId_DownloadStarted };
|
||||
private static readonly int[] DefaultOperationalEventIds = { EventId_ScanFailed, EventId_ScanFound, EventId_DownloadFailed, EventId_Downloaded };
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly string _channel;
|
||||
private readonly bool _isSystemChannel;
|
||||
private readonly HashSet<int> _targetedEventIds;
|
||||
private readonly bool _backfillEnabled;
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly bool _channelCensusEnabled;
|
||||
private readonly Func<bool> _osBuildChangedProvider;
|
||||
private readonly UpdateActivityTally _tally;
|
||||
private readonly Action<string, string, DateTime> _onOsUpdateActivity;
|
||||
private readonly EventRecordWatermark _watermark;
|
||||
|
||||
// Count of targeted WU events emitted THIS run (live + backfill). Zero after the backfill
|
||||
// while the OS build provably changed = the watcher is blind to the update's channel/IDs.
|
||||
// Targeted events processed THIS run (any class). Zero after the backfill while the OS
|
||||
// build provably changed = the watcher is blind to the update's channel/IDs.
|
||||
private int _emittedThisRun;
|
||||
|
||||
private readonly object _throttleLock = new object();
|
||||
private readonly HashSet<string> _scanFailureCodesReported = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
private string _lastScanFoundCount;
|
||||
private int _scanFoundEmitted;
|
||||
|
||||
private EventLogWatcher _watcher;
|
||||
|
||||
// Dedup. Guarded because EventRecordWritten callbacks run concurrently with the backfill scan.
|
||||
//
|
||||
// Cross-restart boundary loaded from disk: the highest RecordId emitted in a PRIOR run.
|
||||
// Immutable during this run — events at/below it were already emitted before this process
|
||||
// started and must not be re-emitted by the backfill scan.
|
||||
private long _restartWatermark = -1;
|
||||
// RecordIds emitted in THIS run — the intra-run dedup set. Deliberately a HashSet, NOT a
|
||||
// high-water mark: the live watcher is armed before the backfill runs, so a live event with a
|
||||
// HIGHER RecordId can be processed before/during backfill. A high-water mark would then make
|
||||
// the dedup skip every older, never-emitted backfill event (recordId <= max) — silently
|
||||
// dropping exactly the early pre-agent OOBE updates that are this feature's whole point.
|
||||
// WU install events are low-volume (a handful per enrollment), so the set stays tiny.
|
||||
private readonly HashSet<long> _seenThisRun = new HashSet<long>();
|
||||
// Highest RecordId emitted so far (max of restart watermark + this run). Persisted so the next
|
||||
// run's restart watermark is correct.
|
||||
private long _maxEmittedRecordId = -1;
|
||||
private readonly object _watermarkLock = new object();
|
||||
|
||||
/// <param name="channel">Event log channel; defaults to the Operational channel.</param>
|
||||
/// <param name="targetedEventIds">IDs to watch on <paramref name="channel"/>; defaults to the manifest set of that channel.</param>
|
||||
/// <param name="tally">Counter for activity that is counted instead of emitted; null drops the counts.</param>
|
||||
/// <param name="onOsUpdateActivity">Called for every Windows/.NET update step with (step, update title, event time).</param>
|
||||
public WindowsUpdateTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
@@ -106,25 +112,60 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
int backfillLookbackMinutes = 60,
|
||||
string stateDirectory = null,
|
||||
bool channelCensusEnabled = true,
|
||||
Func<bool> osBuildChangedProvider = null)
|
||||
Func<bool> osBuildChangedProvider = null,
|
||||
string channel = null,
|
||||
UpdateActivityTally tally = null,
|
||||
Action<string, string, DateTime> onOsUpdateActivity = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_channel = string.IsNullOrEmpty(channel) ? Channel : channel;
|
||||
_isSystemChannel = string.Equals(_channel, SystemChannel, StringComparison.OrdinalIgnoreCase);
|
||||
_targetedEventIds = targetedEventIds != null && targetedEventIds.Length > 0
|
||||
? new HashSet<int>(targetedEventIds)
|
||||
: new HashSet<int> { EventId_InstallSuccess, EventId_InstallFailure, EventId_InstallStarted, EventId_DownloadStarted };
|
||||
: new HashSet<int>(_isSystemChannel ? DefaultSystemEventIds : DefaultOperationalEventIds);
|
||||
_backfillEnabled = backfillEnabled;
|
||||
_backfillLookbackMinutes = backfillLookbackMinutes;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
_channelCensusEnabled = channelCensusEnabled;
|
||||
_osBuildChangedProvider = osBuildChangedProvider;
|
||||
_tally = tally;
|
||||
_onOsUpdateActivity = onOsUpdateActivity;
|
||||
_watermark = new EventRecordWatermark(
|
||||
stateDirectory,
|
||||
_isSystemChannel ? SystemWatermarkStateFileName : WatermarkStateFileName,
|
||||
logger,
|
||||
_isSystemChannel ? "WindowsUpdate (System)" : "WindowsUpdate");
|
||||
}
|
||||
|
||||
/// <summary>Channel this instance watches.</summary>
|
||||
internal string WatchedChannel => _channel;
|
||||
|
||||
/// <summary>Targeted events processed this run, any class.</summary>
|
||||
internal int EmittedThisRun => Volatile.Read(ref _emittedThisRun);
|
||||
|
||||
/// <summary>EventIDs this instance watches on <see cref="WatchedChannel"/>.</summary>
|
||||
internal IReadOnlyCollection<int> TargetedEventIds => _targetedEventIds;
|
||||
|
||||
/// <summary>
|
||||
/// True when the provider manifest writes <paramref name="eventId"/> to the System log
|
||||
/// (16–24, 27, 28, 32, 33, 43, 44, 212–218); all other IDs live in the Operational channel.
|
||||
/// </summary>
|
||||
internal static bool IsSystemChannelEventId(int eventId) =>
|
||||
(eventId >= 16 && eventId <= 24)
|
||||
|| eventId == 27 || eventId == 28
|
||||
|| eventId == 32 || eventId == 33
|
||||
|| eventId == 43 || eventId == 44
|
||||
|| (eventId >= 212 && eventId <= 218);
|
||||
|
||||
/// <summary>
|
||||
/// Watches the channel and backfills it. The blind-spot census is NOT run here: it needs
|
||||
/// the emissions of every channel, so the host runs it after all its trackers started.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
LoadWatermark();
|
||||
_watermark.Load();
|
||||
StartWatcher();
|
||||
|
||||
if (_backfillEnabled && _backfillLookbackMinutes > 0)
|
||||
@@ -133,10 +174,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("WindowsUpdate backfill disabled by config");
|
||||
_logger.Info($"WindowsUpdate backfill disabled by config ({_channel})");
|
||||
}
|
||||
|
||||
EmitChannelCensusIfBlind();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
@@ -146,11 +185,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
_watcher.Enabled = false;
|
||||
_watcher.Dispose();
|
||||
_logger.Info("WindowsUpdate watcher stopped");
|
||||
_logger.Info($"WindowsUpdate watcher stopped ({_channel})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error stopping WindowsUpdate watcher", ex);
|
||||
_logger.Error($"Error stopping WindowsUpdate watcher ({_channel})", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -160,52 +199,64 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
internal void LoadWatermark() => _watermark.Load();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Watcher lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private string ProviderFilter => _isSystemChannel ? ProviderName : null;
|
||||
|
||||
private void StartWatcher()
|
||||
{
|
||||
if (_targetedEventIds.Count == 0)
|
||||
{
|
||||
_logger.Warning("WindowsUpdate watcher not started: no targeted EventIDs configured");
|
||||
_logger.Warning($"WindowsUpdate watcher not started: no targeted EventIDs configured ({_channel})");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var xpath = BuildXPath(_targetedEventIds);
|
||||
var query = new EventLogQuery(Channel, PathType.LogName, xpath);
|
||||
var query = new EventLogQuery(_channel, PathType.LogName, BuildXPath(_targetedEventIds, ProviderFilter));
|
||||
_watcher = new EventLogWatcher(query);
|
||||
_watcher.EventRecordWritten += OnEventRecordWritten;
|
||||
_watcher.Enabled = true;
|
||||
_logger.Info($"WindowsUpdate watcher started: {Channel} (targetedIds={_targetedEventIds.Count})");
|
||||
_logger.Info($"WindowsUpdate watcher started: {_channel} (targetedIds={string.Join(",", _targetedEventIds.OrderBy(id => id))})");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate event log not found: {Channel} (normal on non-Windows 10/11 test environments)");
|
||||
_logger.Warning($"WindowsUpdate event log not found: {_channel} (normal on non-Windows 10/11 test environments)");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate watcher access denied for {Channel}: {ex.Message}");
|
||||
_logger.Warning($"WindowsUpdate watcher access denied for {_channel}: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to start WindowsUpdate watcher for {Channel}", ex);
|
||||
_logger.Error($"Failed to start WindowsUpdate watcher for {_channel}", ex);
|
||||
// MON-D1: surface a dead watcher as one-shot telemetry, not just a local log line.
|
||||
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId,
|
||||
collectorName: "WindowsUpdateTracker", reason: $"watcher_arm_failed:{Channel}", ex: ex);
|
||||
collectorName: "WindowsUpdateTracker", reason: $"watcher_arm_failed:{_channel}", ex: ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Targeted-EventID XPath filter without a provider clause. Exposed for tests.</summary>
|
||||
internal static string BuildXPath(HashSet<int> targetedEventIds) =>
|
||||
BuildXPath(targetedEventIds, providerName: null);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the targeted-EventID XPath filter. Exposed as internal for tests.
|
||||
/// Targeted-EventID XPath filter, optionally restricted to <paramref name="providerName"/>
|
||||
/// (mandatory on the shared System log) and to the last <paramref name="lookbackMs"/>.
|
||||
/// </summary>
|
||||
internal static string BuildXPath(HashSet<int> targetedEventIds)
|
||||
internal static string BuildXPath(HashSet<int> targetedEventIds, string providerName, long? lookbackMs = null)
|
||||
{
|
||||
var idClauses = string.Join(" or ",
|
||||
targetedEventIds.OrderBy(id => id).Select(id => $"EventID={id}"));
|
||||
return $"*[System[({idClauses})]]";
|
||||
var clauses = new List<string>(3);
|
||||
if (!string.IsNullOrEmpty(providerName))
|
||||
clauses.Add($"Provider[@Name='{providerName}']");
|
||||
clauses.Add("(" + string.Join(" or ", targetedEventIds.OrderBy(id => id).Select(id => $"EventID={id}")) + ")");
|
||||
if (lookbackMs.HasValue)
|
||||
clauses.Add($"TimeCreated[timediff(@SystemTime) <= {lookbackMs.Value}]");
|
||||
return $"*[System[{string.Join(" and ", clauses)}]]";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -230,7 +281,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var recordId = record.RecordId ?? -1;
|
||||
|
||||
// Cheap dedup gate BEFORE the expensive XML/FormatDescription work.
|
||||
if (IsAlreadyProcessed(recordId))
|
||||
if (_watermark.IsAlreadyProcessed(recordId))
|
||||
return;
|
||||
|
||||
string xml = null;
|
||||
@@ -242,6 +293,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
eventData.TryGetValue("updateGuid", out var updateGuid);
|
||||
eventData.TryGetValue("updateRevisionNumber", out var updateRevision);
|
||||
eventData.TryGetValue("errorCode", out var errorCode);
|
||||
eventData.TryGetValue("serviceGuid", out var serviceGuid);
|
||||
eventData.TryGetValue("updateCount", out var updateCount);
|
||||
|
||||
string description = null;
|
||||
try { description = record.FormatDescription(); }
|
||||
@@ -257,7 +310,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
updateRevisionNumber: updateRevision,
|
||||
errorCode: errorCode,
|
||||
formattedDescription: description,
|
||||
isBackfill: isBackfill);
|
||||
isBackfill: isBackfill,
|
||||
serviceGuid: serviceGuid,
|
||||
updateCount: updateCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -275,66 +330,68 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string updateRevisionNumber,
|
||||
string errorCode,
|
||||
string formattedDescription,
|
||||
bool isBackfill)
|
||||
bool isBackfill,
|
||||
string serviceGuid = null,
|
||||
string updateCount = null)
|
||||
{
|
||||
if (!MarkProcessed(recordId))
|
||||
if (!_watermark.TryClaim(recordId))
|
||||
return; // already emitted (cross-restart or duplicate delivery)
|
||||
|
||||
System.Threading.Interlocked.Increment(ref _emittedThisRun);
|
||||
Interlocked.Increment(ref _emittedThisRun);
|
||||
|
||||
string eventType;
|
||||
EventSeverity severity;
|
||||
bool immediateUpload;
|
||||
ClassifyEventId(eventId, out eventType, out severity, out immediateUpload);
|
||||
var shape = ShapeFor(eventId);
|
||||
var title = string.IsNullOrEmpty(updateTitle) ? "(unknown update)" : updateTitle;
|
||||
|
||||
// Scan-level events name no update: deduplicate instead of classifying. Every other
|
||||
// event is classified; only Windows/.NET updates are listed, the rest is counted —
|
||||
// so windows_update_succeeded/failed always mean an OS update (ANALYZE-DEV-004/005).
|
||||
string updateClass = null;
|
||||
if (eventId == EventId_ScanFound)
|
||||
{
|
||||
if (!ClaimScanFoundEmission(updateCount)) { _tally?.Increment("scan_found_repeat"); return; }
|
||||
}
|
||||
else if (eventId == EventId_ScanFailed)
|
||||
{
|
||||
if (!ClaimScanFailedEmission(errorCode)) { _tally?.Increment("scan_failed_repeat"); return; }
|
||||
}
|
||||
else
|
||||
{
|
||||
updateClass = WindowsUpdateClassifier.Classify(updateTitle, serviceGuid);
|
||||
if (updateClass != WindowsUpdateClassifier.Os)
|
||||
{
|
||||
_tally?.Increment($"{updateClass}_{eventId}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "wuEventId", eventId },
|
||||
{ "wuChannel", _isSystemChannel ? "system" : "operational" },
|
||||
{ "wuPhase", shape.Phase },
|
||||
{ "backfilled", isBackfill },
|
||||
};
|
||||
if (updateClass != null) data["updateClass"] = updateClass;
|
||||
if (recordId >= 0) data["recordId"] = recordId;
|
||||
if (level.HasValue) data["level"] = level.Value;
|
||||
if (!string.IsNullOrEmpty(updateTitle)) data["updateTitle"] = updateTitle;
|
||||
if (!string.IsNullOrEmpty(updateGuid)) data["updateGuid"] = updateGuid;
|
||||
if (!string.IsNullOrEmpty(updateRevisionNumber)) data["updateRevisionNumber"] = updateRevisionNumber;
|
||||
if (!string.IsNullOrEmpty(updateCount)) data["updateCount"] = updateCount;
|
||||
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
|
||||
|
||||
// Decode the HRESULT for failure events (and any event that carries a non-empty errorCode).
|
||||
string hresultHex;
|
||||
if (TryNormalizeHResult(errorCode, out var hresultValue, out hresultHex))
|
||||
if (TryNormalizeHResult(errorCode, out var hresultValue, out var hresultHex))
|
||||
{
|
||||
data["hresult"] = hresultHex;
|
||||
data["hresultSymbol"] = ResolveHResultSymbol(hresultValue);
|
||||
}
|
||||
|
||||
var title = string.IsNullOrEmpty(updateTitle) ? "(unknown update)" : updateTitle;
|
||||
string message;
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_InstallSuccess:
|
||||
message = $"Windows Update installed during enrollment: {title}";
|
||||
break;
|
||||
case EventId_InstallFailure:
|
||||
message = data.TryGetValue("hresultSymbol", out var sym)
|
||||
? $"Windows Update FAILED during enrollment: {title} ({data["hresult"]} {sym})"
|
||||
: $"Windows Update FAILED during enrollment: {title}";
|
||||
break;
|
||||
case EventId_DownloadStarted:
|
||||
message = $"Windows Update download started during enrollment: {title}";
|
||||
break;
|
||||
case EventId_InstallStarted:
|
||||
message = $"Windows Update install started during enrollment: {title}";
|
||||
break;
|
||||
default:
|
||||
message = $"Windows Update activity (EventID {eventId}) during enrollment: {title}";
|
||||
break;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(formattedDescription))
|
||||
{
|
||||
var truncated = formattedDescription.Length > 1000
|
||||
data["description"] = formattedDescription.Length > 1000
|
||||
? formattedDescription.Substring(0, 1000) + "…"
|
||||
: formattedDescription;
|
||||
data["description"] = truncated;
|
||||
}
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
@@ -343,46 +400,107 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
TenantId = _tenantId,
|
||||
// Use the WU event's own time, not the default UtcNow the ctor stamps: backfilled
|
||||
// pre-agent OOBE updates must land on the timeline at when the update actually
|
||||
// installed, not at agent start. InformationalEventPost forwards this as occurredAtUtc,
|
||||
// happened, not at agent start. InformationalEventPost forwards this as occurredAtUtc,
|
||||
// which drives the timeline entry's timestamp (EventTimelineEmitter).
|
||||
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
EventType = shape.EventType,
|
||||
Severity = shape.Severity,
|
||||
Source = "WindowsUpdateWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Message = BuildMessage(eventId, title, updateCount, data),
|
||||
Data = data,
|
||||
ImmediateUpload = immediateUpload,
|
||||
ImmediateUpload = shape.Immediate,
|
||||
});
|
||||
|
||||
if (updateClass == WindowsUpdateClassifier.Os && shape.OsStep != null && _onOsUpdateActivity != null)
|
||||
{
|
||||
try { _onOsUpdateActivity(shape.OsStep, updateTitle, timeCreatedUtc ?? DateTime.UtcNow); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdate: OS update activity callback failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Last: everything this record produced has been handed to the ingress first.
|
||||
_watermark.MarkEmitted(recordId);
|
||||
}
|
||||
|
||||
private static void ClassifyEventId(int eventId, out string eventType, out EventSeverity severity, out bool immediateUpload)
|
||||
private struct EventShape
|
||||
{
|
||||
public string EventType;
|
||||
public EventSeverity Severity;
|
||||
public bool Immediate;
|
||||
public string Phase;
|
||||
/// <summary>Step reported to the decision engine for OS updates; null = not a step.</summary>
|
||||
public string OsStep;
|
||||
}
|
||||
|
||||
private static EventShape ShapeFor(int eventId)
|
||||
{
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_InstallFailure:
|
||||
eventType = Constants.EventTypes.WindowsUpdateFailed;
|
||||
severity = EventSeverity.Error;
|
||||
immediateUpload = true; // a mid-OOBE update failure is high-signal — surface it fast
|
||||
break;
|
||||
case EventId_InstallSuccess:
|
||||
eventType = Constants.EventTypes.WindowsUpdateSucceeded;
|
||||
severity = EventSeverity.Info;
|
||||
immediateUpload = false;
|
||||
break;
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateSucceeded, Severity = EventSeverity.Info, Phase = "installed", OsStep = OsUpdateSteps.Installed };
|
||||
case EventId_InstallFailure:
|
||||
// A mid-OOBE install failure is high-signal — surface it fast.
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateFailed, Severity = EventSeverity.Error, Immediate = true, Phase = "install", OsStep = OsUpdateSteps.Failed };
|
||||
case EventId_InstallStarted:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Info, Phase = "install", OsStep = OsUpdateSteps.InstallStarted };
|
||||
case EventId_DownloadStarted:
|
||||
eventType = Constants.EventTypes.WindowsUpdateStarted;
|
||||
severity = EventSeverity.Debug; // timeline context only
|
||||
immediateUpload = false;
|
||||
break;
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Info, Phase = "download", OsStep = OsUpdateSteps.DownloadStarted };
|
||||
case EventId_Downloaded:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Info, Phase = "downloaded", OsStep = OsUpdateSteps.Downloaded };
|
||||
case EventId_DownloadFailed:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateFailed, Severity = EventSeverity.Warning, Phase = "download", OsStep = OsUpdateSteps.Failed };
|
||||
case EventId_ScanFailed:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateFailed, Severity = EventSeverity.Warning, Phase = "scan" };
|
||||
case EventId_ScanFound:
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Debug, Phase = "scan" };
|
||||
default:
|
||||
// A newly-enabled (still-being-validated) EventID: keep it visible (Info, not Debug)
|
||||
// so it can be assessed on real traces.
|
||||
eventType = Constants.EventTypes.WindowsUpdateStarted;
|
||||
severity = EventSeverity.Info;
|
||||
immediateUpload = false;
|
||||
break;
|
||||
// An EventID enabled via config whose meaning has not been mapped: keep it
|
||||
// visible (Info, not Debug) so it can be assessed on real traces.
|
||||
return new EventShape { EventType = Constants.EventTypes.WindowsUpdateStarted, Severity = EventSeverity.Info, Phase = "activity" };
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildMessage(int eventId, string title, string updateCount, Dictionary<string, object> data)
|
||||
{
|
||||
var hresultSuffix = data.TryGetValue("hresultSymbol", out var sym)
|
||||
? $" ({data["hresult"]} {sym})"
|
||||
: string.Empty;
|
||||
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_InstallSuccess: return $"Windows Update installed during enrollment: {title}";
|
||||
case EventId_InstallFailure: return $"Windows Update FAILED during enrollment: {title}{hresultSuffix}";
|
||||
case EventId_InstallStarted: return $"Windows Update install started during enrollment: {title}";
|
||||
case EventId_DownloadStarted: return $"Windows Update download started during enrollment: {title}";
|
||||
case EventId_Downloaded: return $"Windows Update downloaded during enrollment: {title}";
|
||||
case EventId_DownloadFailed: return $"Windows Update download FAILED during enrollment: {title}{hresultSuffix}";
|
||||
case EventId_ScanFailed: return $"Windows Update scan failed during enrollment{hresultSuffix}";
|
||||
case EventId_ScanFound:
|
||||
return string.IsNullOrEmpty(updateCount)
|
||||
? "Windows Update scan completed during enrollment"
|
||||
: $"Windows Update scan found {updateCount} update(s) during enrollment";
|
||||
default: return $"Windows Update activity (EventID {eventId}) during enrollment: {title}";
|
||||
}
|
||||
}
|
||||
|
||||
private bool ClaimScanFoundEmission(string updateCount)
|
||||
{
|
||||
lock (_throttleLock)
|
||||
{
|
||||
if (_scanFoundEmitted >= MaxScanFoundEmissions) return false;
|
||||
if (string.Equals(_lastScanFoundCount, updateCount ?? string.Empty, StringComparison.Ordinal)) return false;
|
||||
_lastScanFoundCount = updateCount ?? string.Empty;
|
||||
_scanFoundEmitted++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool ClaimScanFailedEmission(string errorCode)
|
||||
{
|
||||
lock (_throttleLock)
|
||||
{
|
||||
if (_scanFailureCodesReported.Count >= MaxScanFailedEmissions) return false;
|
||||
return _scanFailureCodesReported.Add(errorCode ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,14 +614,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
try
|
||||
{
|
||||
var lookbackMs = (long)_backfillLookbackMinutes * 60 * 1000;
|
||||
var idClauses = string.Join(" or ",
|
||||
_targetedEventIds.OrderBy(id => id).Select(id => $"EventID={id}"));
|
||||
var xpath = $"*[System[({idClauses}) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]";
|
||||
var xpath = BuildXPath(_targetedEventIds, ProviderFilter, lookbackMs);
|
||||
|
||||
_logger.Info($"WindowsUpdate backfill: scanning {Channel} " +
|
||||
$"(lookback={_backfillLookbackMinutes}min, targetedIds={_targetedEventIds.Count}, restartWatermark={_restartWatermark})");
|
||||
_logger.Info($"WindowsUpdate backfill: scanning {_channel} " +
|
||||
$"(lookback={_backfillLookbackMinutes}min, targetedIds={_targetedEventIds.Count}, restartWatermark={_watermark.RestartWatermark})");
|
||||
|
||||
var query = new EventLogQuery(Channel, PathType.LogName, xpath);
|
||||
var query = new EventLogQuery(_channel, PathType.LogName, xpath);
|
||||
|
||||
int processed = 0;
|
||||
using (var reader = new EventLogReader(query))
|
||||
@@ -520,21 +636,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
|
||||
if (processed == 0)
|
||||
_logger.Debug($"WindowsUpdate backfill: no targeted events in last {_backfillLookbackMinutes} minutes");
|
||||
_logger.Debug($"WindowsUpdate backfill: no targeted events in last {_backfillLookbackMinutes} minutes ({_channel})");
|
||||
else
|
||||
_logger.Info($"WindowsUpdate backfill: scanned {processed} event(s)");
|
||||
_logger.Info($"WindowsUpdate backfill: scanned {processed} event(s) ({_channel})");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate event log not found during backfill: {Channel} (normal on non-Windows 10/11 test environments)");
|
||||
_logger.Warning($"WindowsUpdate event log not found during backfill: {_channel} (normal on non-Windows 10/11 test environments)");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate backfill access denied: {ex.Message}");
|
||||
_logger.Warning($"WindowsUpdate backfill access denied ({_channel}): {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdate backfill failed: {ex.Message}");
|
||||
_logger.Warning($"WindowsUpdate backfill failed ({_channel}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,17 +660,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
/// <summary>
|
||||
/// Self-evidencing blind spot diagnosis. Session 7443317c: an OOBE quality update
|
||||
/// provably installed (OS build changed across the mid-enrollment reboot) yet neither
|
||||
/// the live watcher nor two 60-min backfills captured a single targeted event — the
|
||||
/// update logged via a channel/EventID set the watcher is blind to. With diagnostics
|
||||
/// upload tenant-disabled there is no client-log path to find out which one; so when
|
||||
/// the blind spot provably occurred (build changed AND zero targeted events emitted
|
||||
/// this run), emit a one-shot unfiltered EventID histogram of both update channels
|
||||
/// over the lookback window. The histogram arrives via normal event upload and tells
|
||||
/// us which IDs to add to <c>WindowsUpdateTargetedEventIds</c> via remote config —
|
||||
/// no agent redeploy, no diagnostics access needed.
|
||||
/// provably installed (OS build changed across the mid-enrollment reboot) yet the watcher
|
||||
/// captured nothing — it read the install IDs from the Operational channel, while the
|
||||
/// manifest writes them to the System log (D-310). When the blind spot occurs again
|
||||
/// (build changed AND zero targeted events on every channel this run), emit a one-shot
|
||||
/// unfiltered EventID histogram of the Operational and UpdateOrchestrator channels.
|
||||
/// </summary>
|
||||
internal void EmitChannelCensusIfBlind()
|
||||
/// <param name="otherChannelsEmitted">Targeted events the host's other update watchers processed this run.</param>
|
||||
/// <param name="otherTargetedEventIds">EventIDs the host's other update watchers target, listed with this instance's own.</param>
|
||||
internal void EmitChannelCensusIfBlind(int otherChannelsEmitted = 0, IEnumerable<int> otherTargetedEventIds = null)
|
||||
{
|
||||
if (!_channelCensusEnabled) return;
|
||||
|
||||
@@ -562,7 +676,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
if (_osBuildChangedProvider == null || !_osBuildChangedProvider())
|
||||
return;
|
||||
if (System.Threading.Volatile.Read(ref _emittedThisRun) > 0)
|
||||
if (EmittedThisRun + otherChannelsEmitted > 0)
|
||||
return;
|
||||
|
||||
var lookback = _backfillLookbackMinutes > 0 ? _backfillLookbackMinutes : 60;
|
||||
@@ -581,8 +695,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{ "wuClientCensus", wuClientHistogram },
|
||||
{ "updateOrchestratorCensus", orchestratorHistogram },
|
||||
{ "lookbackMinutes", lookback },
|
||||
{ "restartWatermark", _restartWatermark },
|
||||
{ "targetedEventIds", string.Join(",", _targetedEventIds.OrderBy(id => id)) },
|
||||
{ "restartWatermark", _watermark.RestartWatermark },
|
||||
{ "targetedEventIds", string.Join(",", _targetedEventIds.Concat(otherTargetedEventIds ?? Enumerable.Empty<int>()).Distinct().OrderBy(id => id)) },
|
||||
};
|
||||
if (wuClient.Truncated || orchestrator.Truncated)
|
||||
data["censusTruncated"] = true;
|
||||
@@ -666,102 +780,5 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// <summary>Compact "id=count" form ordered by EventID, e.g. "19=2,25=1,43=2".</summary>
|
||||
internal static string FormatHistogram(Dictionary<int, int> histogram) =>
|
||||
string.Join(",", histogram.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}={kv.Value}"));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Watermark dedup (cross-restart)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private bool IsAlreadyProcessed(long recordId)
|
||||
{
|
||||
if (recordId < 0) return false; // no RecordId → cannot dedup, let it through
|
||||
lock (_watermarkLock)
|
||||
{
|
||||
return recordId <= _restartWatermark || _seenThisRun.Contains(recordId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically claims <paramref name="recordId"/>. Returns true if this is the first time we
|
||||
/// see it (caller should emit), false if it was already processed — either in a PRIOR run
|
||||
/// (at/below the restart watermark) or already this run (in the seen-set). Only advances +
|
||||
/// persists the max on a genuinely-new, higher RecordId. Records without a RecordId (-1) are
|
||||
/// always emitted, never tracked or persisted.
|
||||
/// </summary>
|
||||
private bool MarkProcessed(long recordId)
|
||||
{
|
||||
if (recordId < 0) return true;
|
||||
|
||||
long toPersist = -1;
|
||||
lock (_watermarkLock)
|
||||
{
|
||||
// Prior run, or already emitted this run. Note: a lower RecordId that was never
|
||||
// emitted this run (e.g. a backfill record read after a higher live record) is NOT
|
||||
// in _seenThisRun and is above the restart watermark, so it is correctly emitted.
|
||||
if (recordId <= _restartWatermark || !_seenThisRun.Add(recordId))
|
||||
return false;
|
||||
|
||||
if (recordId > _maxEmittedRecordId)
|
||||
{
|
||||
_maxEmittedRecordId = recordId;
|
||||
toPersist = _maxEmittedRecordId;
|
||||
}
|
||||
}
|
||||
if (toPersist >= 0) PersistWatermark(toPersist);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void LoadWatermark()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_stateDirectory)) return;
|
||||
|
||||
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
|
||||
if (!File.Exists(filePath)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var state = JsonConvert.DeserializeObject<WatermarkState>(json);
|
||||
if (state != null)
|
||||
{
|
||||
lock (_watermarkLock)
|
||||
{
|
||||
_restartWatermark = state.LastRecordId;
|
||||
_maxEmittedRecordId = state.LastRecordId;
|
||||
}
|
||||
_logger.Info($"WindowsUpdate watermark loaded: lastRecordId={state.LastRecordId} (persisted {state.PersistedUtc:O})");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to load WindowsUpdate watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistWatermark(long recordId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_stateDirectory)) return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
|
||||
var state = new WatermarkState { LastRecordId = recordId, 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 { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to persist WindowsUpdate watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WatermarkState
|
||||
{
|
||||
public long LastRecordId { get; set; }
|
||||
public DateTime PersistedUtc { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -472,7 +472,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
// Expand BEFORE the guard: the guard must judge the path that is read,
|
||||
// and an unresolved token is not a path it can judge.
|
||||
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path);
|
||||
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path, userProfilePath);
|
||||
if (expandedPath == null)
|
||||
{
|
||||
_logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}");
|
||||
|
||||
+58
-18
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
@@ -14,7 +15,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// The agent runs as SYSTEM, so standard environment variables like %USERPROFILE% or
|
||||
/// %LOCALAPPDATA% resolve to the SYSTEM profile — not the logged-on user. This class
|
||||
/// detects the real user via explorer.exe ownership (WTS session query, WMI fallback —
|
||||
/// see <see cref="Interop.ProcessOwnerLookup"/>) and caches the result for the agent's lifetime.
|
||||
/// see <see cref="Interop.ProcessOwnerLookup"/>) and caches the first user it finds for the
|
||||
/// agent's lifetime. "No user yet" is not cached: the agent usually starts before anyone has
|
||||
/// signed in, so detection is retried — at most every <see cref="RetryInterval"/>.
|
||||
///
|
||||
/// Usage in paths: %LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log
|
||||
/// </summary>
|
||||
@@ -23,9 +26,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
// Spelled once, in Shared: the built-in diagnostics catalog uses the same token.
|
||||
public const string Token = Shared.Models.DiagnosticsBuiltInSections.UserProfileToken;
|
||||
|
||||
/// <summary>Minimum time between two detections while no user has been found.</summary>
|
||||
internal static readonly TimeSpan RetryInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static readonly object Lock = new object();
|
||||
private static bool _resolved;
|
||||
private static string _userProfilePath; // e.g. C:\Users\JohnDoe — null if no user detected
|
||||
private static string _userProfilePath; // e.g. C:\Users\JohnDoe — null until a user is detected
|
||||
private static DateTime _lastFailedDetectionUtc = DateTime.MinValue;
|
||||
|
||||
// Seams for tests; production uses the real detection and clock.
|
||||
private static Func<string> _detector = DetectLoggedOnUserProfile;
|
||||
private static Func<DateTime> _utcNow = () => DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the logged-on user's profile path (e.g. C:\Users\JohnDoe) or null
|
||||
@@ -34,17 +44,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// </summary>
|
||||
public static string GetLoggedOnUserProfilePath()
|
||||
{
|
||||
if (_resolved)
|
||||
return _userProfilePath;
|
||||
var cached = Volatile.Read(ref _userProfilePath);
|
||||
if (cached != null)
|
||||
return cached;
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (_resolved)
|
||||
if (_userProfilePath != null)
|
||||
return _userProfilePath;
|
||||
|
||||
_userProfilePath = DetectLoggedOnUserProfile();
|
||||
_resolved = true;
|
||||
return _userProfilePath;
|
||||
// A clock that stepped backwards (common during OOBE) never delays the retry.
|
||||
var now = _utcNow();
|
||||
var sinceLastFailure = now - _lastFailedDetectionUtc;
|
||||
if (_lastFailedDetectionUtc != DateTime.MinValue
|
||||
&& sinceLastFailure >= TimeSpan.Zero && sinceLastFailure < RetryInterval)
|
||||
return null;
|
||||
|
||||
var detected = _detector();
|
||||
if (detected != null)
|
||||
Volatile.Write(ref _userProfilePath, detected);
|
||||
else
|
||||
_lastFailedDetectionUtc = now;
|
||||
return detected;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,18 +74,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// Environment.ExpandEnvironmentVariables for standard tokens.
|
||||
/// Returns null if the path contains the token but no user is logged on.
|
||||
/// </summary>
|
||||
public static string ExpandCustomTokens(string rawPath)
|
||||
public static string ExpandCustomTokens(string rawPath) =>
|
||||
ExpandCustomTokens(rawPath, ContainsUserProfileToken(rawPath) ? GetLoggedOnUserProfilePath() : null);
|
||||
|
||||
/// <summary>
|
||||
/// Same, with the profile path the caller already resolved for its guard. Callers that
|
||||
/// guard the expanded path must use this overload: while detection is still retrying, a
|
||||
/// second lookup can find the user the first one missed, and the guard would then judge a
|
||||
/// C:\Users path without the profile that admits it.
|
||||
/// </summary>
|
||||
public static string ExpandCustomTokens(string rawPath, string userProfilePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rawPath))
|
||||
return rawPath;
|
||||
|
||||
if (rawPath.IndexOf(Token, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
var profilePath = GetLoggedOnUserProfilePath();
|
||||
if (profilePath == null)
|
||||
if (userProfilePath == null)
|
||||
return null; // No user detected — caller should skip this path
|
||||
|
||||
rawPath = ReplaceCaseInsensitive(rawPath, Token, profilePath);
|
||||
rawPath = ReplaceCaseInsensitive(rawPath, Token, userProfilePath);
|
||||
}
|
||||
|
||||
return Environment.ExpandEnvironmentVariables(rawPath);
|
||||
@@ -80,26 +109,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the cached state. Only used for testing.
|
||||
/// Resets the cached state and the seams. Only used for testing.
|
||||
/// </summary>
|
||||
internal static void Reset()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_resolved = false;
|
||||
_userProfilePath = null;
|
||||
_lastFailedDetectionUtc = DateTime.MinValue;
|
||||
_detector = DetectLoggedOnUserProfile;
|
||||
_utcNow = () => DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allows tests to inject a specific profile path without WMI.
|
||||
/// Allows tests to inject a specific profile path without WMI. Null pins "no user signed in"
|
||||
/// — detection keeps answering null instead of finding the developer's own session.
|
||||
/// </summary>
|
||||
internal static void SetForTesting(string profilePath)
|
||||
{
|
||||
SetDetectorForTesting(() => profilePath, () => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Replaces detection and clock, clearing what was cached. Only used for testing.</summary>
|
||||
internal static void SetDetectorForTesting(Func<string> detector, Func<DateTime> utcNow)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_userProfilePath = profilePath;
|
||||
_resolved = true;
|
||||
_userProfilePath = null;
|
||||
_lastFailedDetectionUtc = DateTime.MinValue;
|
||||
_detector = detector ?? throw new ArgumentNullException(nameof(detector));
|
||||
_utcNow = utcNow ?? throw new ArgumentNullException(nameof(utcNow));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
|
||||
if (filePath == null)
|
||||
return data; // Token present but no user logged on — skip silently
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
|
||||
if (filePath == null)
|
||||
return data; // Token present but no user logged on — skip silently
|
||||
|
||||
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Text encoding of a log file as the logparser reads it. Chosen from the byte order mark at
|
||||
/// the start of the file in <see cref="StreamReader"/>'s detection order; without one the file
|
||||
/// is UTF-8, which is also how ANSI logs were always read (invalid bytes decode to U+FFFD).
|
||||
/// </summary>
|
||||
internal sealed class LogTextEncoding
|
||||
{
|
||||
public static readonly LogTextEncoding Utf8 = new LogTextEncoding("utf-8", new UTF8Encoding(false), 1, 0);
|
||||
public static readonly LogTextEncoding Utf8WithBom = new LogTextEncoding("utf-8 with bom", new UTF8Encoding(false), 1, 3);
|
||||
public static readonly LogTextEncoding Utf16LittleEndian = new LogTextEncoding("utf-16le", new UnicodeEncoding(false, false), 2, 2);
|
||||
public static readonly LogTextEncoding Utf16BigEndian = new LogTextEncoding("utf-16be", new UnicodeEncoding(true, false), 2, 2);
|
||||
public static readonly LogTextEncoding Utf32LittleEndian = new LogTextEncoding("utf-32le", new UTF32Encoding(false, false), 4, 4);
|
||||
public static readonly LogTextEncoding Utf32BigEndian = new LogTextEncoding("utf-32be", new UTF32Encoding(true, false), 4, 4);
|
||||
|
||||
private LogTextEncoding(string name, Encoding encoding, int unitSize, int byteOrderMarkLength)
|
||||
{
|
||||
Name = name;
|
||||
Encoding = encoding;
|
||||
UnitSize = unitSize;
|
||||
ByteOrderMarkLength = byteOrderMarkLength;
|
||||
LineFeed = encoding.GetBytes("\n");
|
||||
CarriageReturn = encoding.GetBytes("\r");
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public Encoding Encoding { get; }
|
||||
|
||||
/// <summary>Bytes per code unit; every line boundary lies on a unit boundary.</summary>
|
||||
public int UnitSize { get; }
|
||||
|
||||
/// <summary>Length of the byte order mark at offset 0 — skipped, never part of a line.</summary>
|
||||
public int ByteOrderMarkLength { get; }
|
||||
|
||||
public byte[] LineFeed { get; }
|
||||
public byte[] CarriageReturn { get; }
|
||||
|
||||
public static LogTextEncoding Detect(byte[] head)
|
||||
{
|
||||
if (StartsWith(head, 0xEF, 0xBB, 0xBF)) return Utf8WithBom;
|
||||
if (StartsWith(head, 0xFF, 0xFE, 0x00, 0x00)) return Utf32LittleEndian;
|
||||
if (StartsWith(head, 0xFF, 0xFE)) return Utf16LittleEndian;
|
||||
if (StartsWith(head, 0xFE, 0xFF)) return Utf16BigEndian;
|
||||
if (StartsWith(head, 0x00, 0x00, 0xFE, 0xFF)) return Utf32BigEndian;
|
||||
return Utf8;
|
||||
}
|
||||
|
||||
/// <summary>The last code-unit boundary at or before <paramref name="position"/>.</summary>
|
||||
public long AlignDown(long position)
|
||||
{
|
||||
if (position <= ByteOrderMarkLength) return position < ByteOrderMarkLength ? 0 : position;
|
||||
return position - (position - ByteOrderMarkLength) % UnitSize;
|
||||
}
|
||||
|
||||
private static bool StartsWith(byte[] bytes, params byte[] prefix)
|
||||
{
|
||||
if (bytes.Length < prefix.Length) return false;
|
||||
for (var i = 0; i < prefix.Length; i++)
|
||||
if (bytes[i] != prefix[i]) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The first bytes of a log file: they carry its byte order mark and identify the file, so a
|
||||
/// file replaced under the same name is recognised even when it is already longer than the
|
||||
/// position read so far.
|
||||
/// </summary>
|
||||
internal sealed class LogFileHead
|
||||
{
|
||||
internal const int MaxBytes = 4096;
|
||||
|
||||
private readonly byte[] _bytes;
|
||||
|
||||
private LogFileHead(byte[] bytes)
|
||||
{
|
||||
_bytes = bytes;
|
||||
}
|
||||
|
||||
/// <summary>Up to <see cref="MaxBytes"/> from offset 0. Leaves the stream at an undefined position.</summary>
|
||||
public static LogFileHead Read(Stream stream)
|
||||
{
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
var buffer = new byte[(int)Math.Min(MaxBytes, stream.Length)];
|
||||
var read = 0;
|
||||
while (read < buffer.Length)
|
||||
{
|
||||
var n = stream.Read(buffer, read, buffer.Length - read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
if (read < buffer.Length) Array.Resize(ref buffer, read);
|
||||
return new LogFileHead(buffer);
|
||||
}
|
||||
|
||||
internal static LogFileHead FromBytes(byte[] bytes) => new LogFileHead(bytes);
|
||||
|
||||
public int Length => _bytes.Length;
|
||||
|
||||
public LogTextEncoding Encoding => LogTextEncoding.Detect(_bytes);
|
||||
|
||||
/// <summary>SHA-256 of the first <paramref name="length"/> bytes, Base64.</summary>
|
||||
public string Hash(int length)
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
return Convert.ToBase64String(sha.ComputeHash(_bytes, 0, length));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Byte-exact line reader for the logparser collector. <see cref="Position"/> is the file
|
||||
/// offset right behind the last line returned, so a caller that stops after any line can
|
||||
/// resume exactly there — <see cref="StreamReader"/> only exposes its read-ahead boundary.
|
||||
/// <para>
|
||||
/// Line ends are those of <see cref="StreamReader.ReadLine"/>: "\r\n", "\n" and a lone "\r",
|
||||
/// in code units of the file's <see cref="LogTextEncoding"/>. A "\n" directly after the start
|
||||
/// position is skipped when the unit before it is "\r": the previous run ended on a "\r" whose
|
||||
/// "\n" had not been written yet. A line at end of file without a line end is returned with
|
||||
/// <see cref="LastLineTerminated"/> false; the caller decides whether the writer is still in
|
||||
/// it. A line longer than the cap is consumed without being kept: <see cref="ReadLine"/>
|
||||
/// returns an empty string and sets <see cref="LastLineTruncated"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class LogLineReader
|
||||
{
|
||||
private const int ReadBufferSize = 64 * 1024;
|
||||
|
||||
private readonly Stream _stream;
|
||||
private readonly LogTextEncoding _encoding;
|
||||
private readonly int _unit;
|
||||
private readonly int _maxLineBytes;
|
||||
private readonly byte[] _buffer = new byte[ReadBufferSize];
|
||||
private int _pos;
|
||||
private int _len;
|
||||
private long _bufferOffset;
|
||||
private bool _eof;
|
||||
private bool _skipLineFeedAfterCarriageReturn;
|
||||
|
||||
private byte[] _line = new byte[4096];
|
||||
private int _lineLength;
|
||||
|
||||
/// <param name="stream">Seekable stream positioned at a code-unit boundary.</param>
|
||||
public LogLineReader(Stream stream, LogTextEncoding encoding, int maxLineBytes)
|
||||
{
|
||||
if (stream == null) throw new ArgumentNullException(nameof(stream));
|
||||
if (encoding == null) throw new ArgumentNullException(nameof(encoding));
|
||||
if (maxLineBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maxLineBytes));
|
||||
_stream = stream;
|
||||
_encoding = encoding;
|
||||
_unit = encoding.UnitSize;
|
||||
_maxLineBytes = maxLineBytes;
|
||||
_bufferOffset = stream.Position;
|
||||
_skipLineFeedAfterCarriageReturn = PrecededByCarriageReturn();
|
||||
}
|
||||
|
||||
/// <summary>File offset of the next unread byte, exact.</summary>
|
||||
public long Position => _bufferOffset + _pos;
|
||||
|
||||
/// <summary>File offset of the first byte of the line most recently returned.</summary>
|
||||
public long LastLineStart { get; private set; }
|
||||
|
||||
/// <summary>False for a line that reached end of file without a line end.</summary>
|
||||
public bool LastLineTerminated { get; private set; }
|
||||
|
||||
/// <summary>True when the last line was longer than the cap; it was consumed, not returned.</summary>
|
||||
public bool LastLineTruncated { get; private set; }
|
||||
|
||||
/// <summary>The next line without its line end, or null at end of file.</summary>
|
||||
public string ReadLine()
|
||||
{
|
||||
if (Position == 0 && _encoding.ByteOrderMarkLength > 0 && Ensure(_encoding.ByteOrderMarkLength))
|
||||
_pos += _encoding.ByteOrderMarkLength;
|
||||
|
||||
if (_skipLineFeedAfterCarriageReturn)
|
||||
{
|
||||
_skipLineFeedAfterCarriageReturn = false;
|
||||
if (Ensure(_unit) && UnitAt(_pos, _encoding.LineFeed))
|
||||
_pos += _unit;
|
||||
}
|
||||
|
||||
LastLineStart = Position;
|
||||
LastLineTerminated = false;
|
||||
LastLineTruncated = false;
|
||||
_lineLength = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (!Ensure(_unit))
|
||||
{
|
||||
// End of file. A partial code unit (a writer mid-character) belongs to the line.
|
||||
var rest = _len - _pos;
|
||||
if (rest > 0)
|
||||
{
|
||||
Append(_pos, rest);
|
||||
_pos = _len;
|
||||
}
|
||||
return Position > LastLineStart ? Decode() : null;
|
||||
}
|
||||
|
||||
// Scan the whole code units in the buffer for the next line end and take the text
|
||||
// before it in one copy.
|
||||
var end = _pos + (_len - _pos) / _unit * _unit;
|
||||
var scan = _pos;
|
||||
while (scan < end && !IsLineEnd(scan))
|
||||
scan += _unit;
|
||||
if (scan > _pos)
|
||||
{
|
||||
Append(_pos, scan - _pos);
|
||||
_pos = scan;
|
||||
}
|
||||
if (scan == end)
|
||||
continue; // no line end in the buffer yet — refill
|
||||
|
||||
var carriageReturn = !UnitAt(_pos, _encoding.LineFeed);
|
||||
_pos += _unit;
|
||||
if (carriageReturn && Ensure(_unit) && UnitAt(_pos, _encoding.LineFeed))
|
||||
_pos += _unit;
|
||||
LastLineTerminated = true;
|
||||
return Decode();
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsLineEnd(int offset)
|
||||
{
|
||||
if (_unit == 1)
|
||||
{
|
||||
var b = _buffer[offset];
|
||||
return b == (byte)'\n' || b == (byte)'\r';
|
||||
}
|
||||
return UnitAt(offset, _encoding.LineFeed) || UnitAt(offset, _encoding.CarriageReturn);
|
||||
}
|
||||
|
||||
private bool PrecededByCarriageReturn()
|
||||
{
|
||||
var start = _bufferOffset;
|
||||
if (start - _unit < _encoding.ByteOrderMarkLength) return false;
|
||||
var previous = new byte[_unit];
|
||||
_stream.Seek(start - _unit, SeekOrigin.Begin);
|
||||
var read = 0;
|
||||
while (read < _unit)
|
||||
{
|
||||
var n = _stream.Read(previous, read, _unit - read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
_stream.Seek(start, SeekOrigin.Begin);
|
||||
if (read < _unit) return false;
|
||||
for (var i = 0; i < _unit; i++)
|
||||
if (previous[i] != _encoding.CarriageReturn[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>At least <paramref name="count"/> unread bytes in the buffer, unless the file ends first.</summary>
|
||||
private bool Ensure(int count)
|
||||
{
|
||||
while (_len - _pos < count)
|
||||
{
|
||||
if (_eof) return false;
|
||||
var remaining = _len - _pos;
|
||||
if (_pos > 0)
|
||||
{
|
||||
Buffer.BlockCopy(_buffer, _pos, _buffer, 0, remaining);
|
||||
_bufferOffset += _pos;
|
||||
_pos = 0;
|
||||
_len = remaining;
|
||||
}
|
||||
var read = _stream.Read(_buffer, _len, _buffer.Length - _len);
|
||||
if (read <= 0)
|
||||
{
|
||||
_eof = true;
|
||||
return _len - _pos >= count;
|
||||
}
|
||||
_len += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool UnitAt(int offset, byte[] unit)
|
||||
{
|
||||
for (var i = 0; i < _unit; i++)
|
||||
if (_buffer[offset + i] != unit[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Append(int offset, int count)
|
||||
{
|
||||
var room = _maxLineBytes - _lineLength;
|
||||
if (count > room)
|
||||
{
|
||||
LastLineTruncated = true;
|
||||
count = room;
|
||||
if (count <= 0) return;
|
||||
}
|
||||
if (_lineLength + count > _line.Length)
|
||||
{
|
||||
var grown = new byte[Math.Max(_line.Length * 2, _lineLength + count)];
|
||||
Buffer.BlockCopy(_line, 0, grown, 0, _lineLength);
|
||||
_line = grown;
|
||||
}
|
||||
Buffer.BlockCopy(_buffer, offset, _line, _lineLength, count);
|
||||
_lineLength += count;
|
||||
}
|
||||
|
||||
private string Decode() =>
|
||||
_lineLength == 0 || LastLineTruncated ? string.Empty : _encoding.Encoding.GetString(_line, 0, _lineLength);
|
||||
}
|
||||
}
|
||||
+254
-150
@@ -18,6 +18,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
public string CollectorType => "logparser";
|
||||
|
||||
/// <summary>A wildcard target reads at most this many files per run, the newest first.</summary>
|
||||
internal const int MaxFilesPerRun = 20;
|
||||
|
||||
/// <summary>Lines one run reads from one file unless the rule sets <c>maxLines</c>.</summary>
|
||||
internal const int DefaultMaxLines = 1000;
|
||||
|
||||
/// <summary>The IME tracker's line bound: a longer physical line is skipped, never matched.</summary>
|
||||
internal const int MaxLineBytes = 32 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Executes the log parser rule. Returns null because logparser emits events directly
|
||||
/// via context.OnEventCollected rather than returning a single result dictionary.
|
||||
@@ -34,7 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
|
||||
if (filePath == null)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
@@ -84,10 +93,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
if (rule.Parameters.TryGetValue("trackPosition", out trackPositionStr))
|
||||
bool.TryParse(trackPositionStr, out trackPosition);
|
||||
|
||||
// A value that is no positive number keeps the default: a run capped at 0 lines would
|
||||
// never advance its position, and the rule would read nothing for the whole session.
|
||||
string maxLinesStr;
|
||||
int maxLines = 1000;
|
||||
int maxLines = DefaultMaxLines;
|
||||
if (rule.Parameters.TryGetValue("maxLines", out maxLinesStr))
|
||||
int.TryParse(maxLinesStr, out maxLines);
|
||||
{
|
||||
int parsedMaxLines;
|
||||
if (int.TryParse(maxLinesStr, out parsedMaxLines) && parsedMaxLines > 0)
|
||||
maxLines = parsedMaxLines;
|
||||
else
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"maxLines '{maxLinesStr}' is not a positive number — using {DefaultMaxLines}");
|
||||
}
|
||||
|
||||
// Determine format: "cmtrace" (default) or "text" for plain text logs
|
||||
string formatStr;
|
||||
@@ -144,7 +162,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
/// as the event data). Stops after <see cref="MaxTracedMatchesPerFile"/> matches.
|
||||
/// </summary>
|
||||
private static void TraceMatch(GatherRuleContext context, GatherRule rule, string fileName,
|
||||
int lineNumber, Match match, Regex pattern, int matchNumber)
|
||||
long lineNumber, Match match, Regex pattern, int matchNumber)
|
||||
{
|
||||
if (matchNumber > MaxTracedMatchesPerFile)
|
||||
{
|
||||
@@ -195,14 +213,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
try
|
||||
{
|
||||
// Return matched files sorted by last write time (newest first), capped at 20
|
||||
// Return matched files sorted by last write time (newest first), capped at MaxFilesPerRun
|
||||
var allMatches = Directory.GetFiles(directory, fileNamePart);
|
||||
if (allMatches.Length > 20)
|
||||
if (allMatches.Length > MaxFilesPerRun)
|
||||
context.DebugLog(ruleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"wildcard matched {allMatches.Length} files — capped to the 20 newest");
|
||||
$"wildcard matched {allMatches.Length} files — capped to the {MaxFilesPerRun} newest");
|
||||
return allMatches
|
||||
.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc)
|
||||
.Take(20)
|
||||
.Take(MaxFilesPerRun)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -216,183 +234,248 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
private static void ProcessLogFile(string filePath, GatherRule rule, Regex pattern,
|
||||
bool trackPosition, int maxLines, bool isTextMode, GatherRuleContext context)
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
long startPosition = trackPosition
|
||||
? context.FilePositionTracker.GetSafePosition(filePath, fileInfo.Length)
|
||||
: 0;
|
||||
var state = context.SessionState;
|
||||
var known = trackPosition ? state.PeekLogPosition(rule.RuleId, filePath) : null;
|
||||
|
||||
// Nothing new to read
|
||||
if (startPosition >= fileInfo.Length)
|
||||
// Nothing new since the last run — decided from the length alone, without opening the file.
|
||||
var length = new FileInfo(filePath).Length;
|
||||
if (known != null && known.Position == length)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{Path.GetFileName(filePath)}: no new content (position {startPosition} >= length {fileInfo.Length}) — trackPosition=true, nothing re-read");
|
||||
$"{fileName}: no new content (position {known.Position} >= length {length}) — trackPosition=true, nothing re-read" +
|
||||
(known.FromPreviousRun ? "; position saved by a previous agent run" : ""));
|
||||
return;
|
||||
}
|
||||
|
||||
// A last line without a line end is usually one its writer is still in. An interval
|
||||
// rule leaves it for its next run, once. A one-shot trigger may have no next run, so it
|
||||
// reads the line now but saves the position before it: the next run reads it again,
|
||||
// whole — at worst a match repeats, it is never split.
|
||||
var isInterval = string.Equals(rule.Trigger, "interval", StringComparison.OrdinalIgnoreCase);
|
||||
var mayHoldTail = trackPosition && isInterval;
|
||||
var tailReadAgainNextRun = false;
|
||||
|
||||
int matchCount = 0;
|
||||
int linesRead = 0;
|
||||
int committedLines = 0;
|
||||
int parseFailCount = 0;
|
||||
int timeoutCount = 0;
|
||||
long endPosition = startPosition;
|
||||
int oversizedCount = 0;
|
||||
long startPosition;
|
||||
long endPosition;
|
||||
long lineNumberBase;
|
||||
long fileLength;
|
||||
long heldTailStart = -1;
|
||||
LogFileHead head;
|
||||
LogTextEncoding encoding;
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
fileLength = stream.Length;
|
||||
head = LogFileHead.Read(stream);
|
||||
encoding = head.Encoding;
|
||||
|
||||
LogReadStart start;
|
||||
startPosition = encoding.AlignDown(GatherRuleSessionState.ResolveStart(known, fileLength, head, out start));
|
||||
lineNumberBase = start == LogReadStart.Continue ? known.LineNumber : 0;
|
||||
TraceReadStart(context, rule, fileName, known, start, fileLength);
|
||||
|
||||
stream.Seek(startPosition, SeekOrigin.Begin);
|
||||
var reader = new LogLineReader(stream, encoding, MaxLineBytes);
|
||||
endPosition = startPosition;
|
||||
|
||||
using (var reader = new StreamReader(stream, Encoding.UTF8))
|
||||
while (linesRead < maxLines)
|
||||
{
|
||||
string line;
|
||||
while ((line = reader.ReadLine()) != null && linesRead < maxLines)
|
||||
var line = reader.ReadLine();
|
||||
if (line == null)
|
||||
{
|
||||
linesRead++;
|
||||
|
||||
if (isTextMode)
|
||||
{
|
||||
// Text mode: match regex directly against the raw line
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(line);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
data[groupName] = group.Value;
|
||||
}
|
||||
|
||||
data["logLine"] = TruncateMessage(line, 500);
|
||||
data["logLineNumber"] = linesRead;
|
||||
data["logFile"] = Path.GetFileName(filePath);
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
: "logparser_match";
|
||||
var severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity);
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// CMTrace mode: parse line as CMTrace format, match regex against message
|
||||
CmTraceLogEntry entry;
|
||||
if (!CmTraceLogParser.TryParseLine(line, out entry))
|
||||
{
|
||||
parseFailCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(entry.Message);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
data[groupName] = group.Value;
|
||||
}
|
||||
|
||||
// Gather-rule logs are arbitrary files this collector does not
|
||||
// tail, so no writer offset can be measured for them; the
|
||||
// reader-zone fallback carries its known defect (see
|
||||
// ResolveUtcAssumingReaderZone). Behaviour unchanged from before
|
||||
// the parser split.
|
||||
var entryUtc = entry.TimestampUtc
|
||||
?? (entry.HasTimestamp
|
||||
? CmTraceLogParser.ResolveUtcAssumingReaderZone(entry.LocalTimestamp)
|
||||
: DateTime.UtcNow);
|
||||
data["logTimestamp"] = entryUtc.ToString("o");
|
||||
data["logComponent"] = entry.Component;
|
||||
data["logType"] = entry.Type;
|
||||
data["logMessage"] = TruncateMessage(entry.Message, 500);
|
||||
data["logFile"] = Path.GetFileName(filePath);
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
: "logparser_match";
|
||||
var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = entryUtc,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
|
||||
}
|
||||
// End of file. A "\n" the reader skipped behind the "\r" the previous run
|
||||
// ended on is consumed too — unless a kept tail must be read again.
|
||||
if (!tailReadAgainNextRun)
|
||||
endPosition = reader.Position;
|
||||
break;
|
||||
}
|
||||
|
||||
// Capture the final stream position after reading
|
||||
endPosition = stream.Position;
|
||||
var unfinished = !reader.LastLineTerminated;
|
||||
if (unfinished && mayHoldTail
|
||||
&& (known == null || known.HeldTailStart != reader.LastLineStart))
|
||||
{
|
||||
heldTailStart = reader.LastLineStart;
|
||||
break;
|
||||
}
|
||||
|
||||
linesRead++;
|
||||
var lineNumber = lineNumberBase + linesRead;
|
||||
if (unfinished && trackPosition && !isInterval)
|
||||
{
|
||||
tailReadAgainNextRun = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
endPosition = reader.Position;
|
||||
committedLines = linesRead;
|
||||
}
|
||||
|
||||
if (reader.LastLineTruncated)
|
||||
{
|
||||
oversizedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isTextMode)
|
||||
{
|
||||
// Text mode: match regex directly against the raw line
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(line);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
data[groupName] = group.Value;
|
||||
}
|
||||
|
||||
data["logLine"] = TruncateMessage(line, 500);
|
||||
data["logLineNumber"] = lineNumber;
|
||||
data["logFile"] = fileName;
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
: "logparser_match";
|
||||
var severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity);
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, fileName, lineNumber, match, pattern, matchCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// CMTrace mode: parse line as CMTrace format, match regex against message
|
||||
CmTraceLogEntry entry;
|
||||
if (!CmTraceLogParser.TryParseLine(line, out entry))
|
||||
{
|
||||
parseFailCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(entry.Message);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
data[groupName] = group.Value;
|
||||
}
|
||||
|
||||
// Gather-rule logs are arbitrary files this collector does not
|
||||
// tail, so no writer offset can be measured for them; the
|
||||
// reader-zone fallback carries its known defect (see
|
||||
// ResolveUtcAssumingReaderZone). Behaviour unchanged from before
|
||||
// the parser split.
|
||||
var entryUtc = entry.TimestampUtc
|
||||
?? (entry.HasTimestamp
|
||||
? CmTraceLogParser.ResolveUtcAssumingReaderZone(entry.LocalTimestamp)
|
||||
: DateTime.UtcNow);
|
||||
data["logTimestamp"] = entryUtc.ToString("o");
|
||||
data["logComponent"] = entry.Component;
|
||||
data["logType"] = entry.Type;
|
||||
data["logMessage"] = TruncateMessage(entry.Message, 500);
|
||||
data["logFile"] = fileName;
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
: "logparser_match";
|
||||
var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = entryUtc,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = GatherRuleExecutor.SourceName,
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, fileName, lineNumber, match, pattern, matchCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Saved only now, after every event of this file went out: a process that dies
|
||||
// before this line repeats the file's new lines after the restart, never skips them.
|
||||
if (trackPosition)
|
||||
context.FilePositionTracker.SetPosition(filePath, endPosition);
|
||||
state.CommitLogPosition(rule.RuleId, filePath, endPosition, lineNumberBase + committedLines, head, heldTailStart);
|
||||
|
||||
if (matchCount > 0)
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {fileName}");
|
||||
|
||||
// Per-file outcome — always written so a "matched 0" run is visible too.
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{Path.GetFileName(filePath)}: read {linesRead} lines from position {startPosition}->{endPosition}, " +
|
||||
$"matched {matchCount}, parseFailures={parseFailCount}, regexTimeouts={timeoutCount}, mode={(isTextMode ? "text" : "cmtrace")}");
|
||||
$"{fileName}: read {linesRead} lines from position {startPosition}->{endPosition}, " +
|
||||
$"matched {matchCount}, parseFailures={parseFailCount}, regexTimeouts={timeoutCount}, mode={(isTextMode ? "text" : "cmtrace")}, encoding={encoding.Name}" +
|
||||
(oversizedCount > 0 ? $", skipped {oversizedCount} line(s) over {MaxLineBytes / (1024 * 1024)} MB" : ""));
|
||||
|
||||
if (heldTailStart >= 0)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: last line has no line end yet — left for the next run, read then even if still unfinished");
|
||||
else if (tailReadAgainNextRun)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: last line has no line end yet — read now and again by the next run, position kept before it");
|
||||
|
||||
if (!isTextMode && linesRead > 0 && parseFailCount == linesRead)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
"every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text");
|
||||
|
||||
if (linesRead == maxLines)
|
||||
if (linesRead == maxLines && endPosition < fileLength)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"stopped at maxLines={maxLines} — remaining content is deferred to the next run");
|
||||
}
|
||||
@@ -403,6 +486,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraceReadStart(GatherRuleContext context, GatherRule rule, string fileName,
|
||||
LogPositionSnapshot known, LogReadStart start, long fileLength)
|
||||
{
|
||||
if (start == LogReadStart.Shrunk)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: file is shorter than the saved position (length {fileLength} < {known.Position}) — truncated or recreated, reading from the beginning");
|
||||
}
|
||||
else if (start == LogReadStart.Replaced)
|
||||
{
|
||||
context.Logger.Info($"LogParser rule {rule.RuleId}: {fileName} was replaced since the last read — reading it from the beginning");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: file start changed since the last read (replaced or rewritten) — saved position {known.Position} discarded, reading from the beginning");
|
||||
}
|
||||
else if (start == LogReadStart.Continue && known.FromPreviousRun)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: continuing at position {known.Position} (after line {known.LineNumber}) saved by a previous agent run");
|
||||
}
|
||||
}
|
||||
|
||||
private static EventSeverity MapCmTraceTypeToSeverity(int cmTraceType, string ruleOverride)
|
||||
{
|
||||
// If the rule specifies a severity, use it
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
|
||||
if (filePath == null)
|
||||
return data; // Token present but no user logged on — skip silently
|
||||
|
||||
|
||||
+6
-4
@@ -4,7 +4,6 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
@@ -19,7 +18,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public string TenantId { get; }
|
||||
public Action<EnrollmentEvent> OnEventCollected { get; }
|
||||
public string ImeLogPathOverride { get; }
|
||||
public LogFilePositionTracker FilePositionTracker { get; }
|
||||
|
||||
/// <summary>Logparser positions per (rule, file) and on_change values; survives agent restarts when persisted.</summary>
|
||||
public GatherRuleSessionState SessionState { get; }
|
||||
|
||||
public bool UnrestrictedMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -34,7 +36,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
string imeLogPathOverride,
|
||||
LogFilePositionTracker filePositionTracker,
|
||||
GatherRuleSessionState sessionState,
|
||||
GatherRuleDebugLog debugLog = null)
|
||||
{
|
||||
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -42,7 +44,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
OnEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
ImeLogPathOverride = imeLogPathOverride;
|
||||
FilePositionTracker = filePositionTracker ?? throw new ArgumentNullException(nameof(filePositionTracker));
|
||||
SessionState = sessionState ?? throw new ArgumentNullException(nameof(sessionState));
|
||||
DebugWriter = debugLog;
|
||||
}
|
||||
|
||||
|
||||
+56
-9
@@ -10,7 +10,6 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
@@ -38,6 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly GatherRuleDebugLog _debug; // null unless EnableGatherRuleDebugLog / --gather-debug-log
|
||||
private readonly GatherRuleSessionState _state;
|
||||
private readonly GatherRuleContext _context;
|
||||
private readonly Dictionary<string, IGatherRuleCollector> _collectors;
|
||||
|
||||
@@ -50,12 +50,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// Phase-scope + emit-mode state (ActivePhases / ActiveFromPhase / EmitMode on GatherRule).
|
||||
// Guarded by _scopeLock; collector work always runs outside the lock. The state deliberately
|
||||
// survives UpdateRules config refreshes — an agent restart causes at most one re-emit per
|
||||
// on_change rule (acceptable under the per-enrollment lifecycle).
|
||||
// survives UpdateRules config refreshes. Across agent restarts only the last emitted hash
|
||||
// survives: it is seeded from _state and committed there after each emit, so an unchanged
|
||||
// on_change result stays silent after a reboot. Phase latches, dedup sets and the
|
||||
// suppression streak are process-scoped.
|
||||
private readonly object _scopeLock = new object();
|
||||
private EnrollmentPhase _currentPhase = EnrollmentPhase.Unknown;
|
||||
private readonly HashSet<string> _fromPhaseLatched = new HashSet<string>();
|
||||
private readonly Dictionary<string, string> _lastEmittedHash = new Dictionary<string, string>();
|
||||
private readonly HashSet<string> _hashFromPreviousRun = new HashSet<string>();
|
||||
private readonly Dictionary<string, (int Count, DateTime SinceUtc)> _suppressed =
|
||||
new Dictionary<string, (int Count, DateTime SinceUtc)>();
|
||||
private readonly HashSet<string> _scopeBypassLogged = new HashSet<string>();
|
||||
@@ -77,9 +80,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
set { _context.UnrestrictedMode = value; }
|
||||
}
|
||||
|
||||
/// <param name="stateDirectory">
|
||||
/// Where logparser positions and on_change values are kept across agent restarts. Null keeps
|
||||
/// them in memory — required for <c>--run-gather-rules</c>, which runs beside the live agent
|
||||
/// under a throwaway session id and must not overwrite the session's file.
|
||||
/// </param>
|
||||
public GatherRuleExecutor(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger, string imeLogPathOverride = null, string debugLogPath = null,
|
||||
Action<string> debugEcho = null)
|
||||
Action<string> debugEcho = null, string stateDirectory = null)
|
||||
{
|
||||
if (sessionId == null) throw new ArgumentNullException(nameof(sessionId));
|
||||
if (tenantId == null) throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -89,8 +97,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
_logger = logger;
|
||||
_debug = string.IsNullOrEmpty(debugLogPath) ? null : new GatherRuleDebugLog(debugLogPath, logger, debugEcho);
|
||||
|
||||
var filePositionTracker = new LogFilePositionTracker();
|
||||
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker, _debug);
|
||||
var persistence = string.IsNullOrEmpty(stateDirectory) ? null : new GatherRuleStatePersistence(stateDirectory, logger);
|
||||
_state = new GatherRuleSessionState(sessionId, persistence, logger);
|
||||
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, _state, _debug);
|
||||
|
||||
foreach (var hash in _state.GetEmittedHashes())
|
||||
{
|
||||
_lastEmittedHash[hash.Key] = hash.Value;
|
||||
_hashFromPreviousRun.Add(hash.Key);
|
||||
}
|
||||
_logger.Info($"GatherRuleExecutor: gather state {_state.RestoreSummary}");
|
||||
DebugLog(null, GatherRuleDebugLog.StageConfig, $"gather state: {_state.RestoreSummary}");
|
||||
foreach (var detail in _state.RestoredDetails)
|
||||
DebugLog(detail.Key, GatherRuleDebugLog.StageConfig, detail.Value);
|
||||
|
||||
// Register all collector strategies
|
||||
var collectorList = new IGatherRuleCollector[]
|
||||
@@ -480,14 +499,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// empty result never reaches this point, so an emitOnlyIfExists miss neither
|
||||
// emits nor updates the hash — composing to "one event on appearance, then
|
||||
// only on change".
|
||||
string emittedHash = null;
|
||||
if (IsOnChangeMode(rule))
|
||||
{
|
||||
string hashPrefix;
|
||||
int suppressedStreak;
|
||||
if (!ShouldEmitOnChange(rule, result, out hashPrefix, out suppressedStreak))
|
||||
bool comparedWithPreviousRun;
|
||||
if (!ShouldEmitOnChange(rule, result, out emittedHash, out hashPrefix, out suppressedStreak, out comparedWithPreviousRun))
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageSuppress,
|
||||
$"result unchanged (hash {hashPrefix}) — emit suppressed (on_change), suppressed streak={suppressedStreak}");
|
||||
$"result unchanged (hash {hashPrefix}) — emit suppressed (on_change), suppressed streak={suppressedStreak}" +
|
||||
(comparedWithPreviousRun ? "; compared with the value a previous agent run emitted" : ""));
|
||||
return;
|
||||
}
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
|
||||
@@ -524,6 +546,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = result
|
||||
});
|
||||
|
||||
// Committed only after the event went out: a process that dies in between emits
|
||||
// the result once more after the restart instead of never.
|
||||
if (emittedHash != null)
|
||||
_state.CommitEmittedHash(rule.RuleId, emittedHash);
|
||||
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
|
||||
$"emitted {eventType} (severity={severity}, {result.Count} fields)");
|
||||
}
|
||||
@@ -679,9 +707,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
internal bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result,
|
||||
out string hashPrefix, out int suppressedStreak)
|
||||
{
|
||||
var hash = ComputeCanonicalHash(result);
|
||||
string hash;
|
||||
bool comparedWithPreviousRun;
|
||||
return ShouldEmitOnChange(rule, result, out hash, out hashPrefix, out suppressedStreak, out comparedWithPreviousRun);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full form: <paramref name="hash"/> is the result's canonical hash, committed to the session
|
||||
/// state once the event went out; <paramref name="comparedWithPreviousRun"/> says a suppression
|
||||
/// compared against the value a previous agent run emitted.
|
||||
/// </summary>
|
||||
private bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result,
|
||||
out string hash, out string hashPrefix, out int suppressedStreak, out bool comparedWithPreviousRun)
|
||||
{
|
||||
hash = ComputeCanonicalHash(result);
|
||||
hashPrefix = hash.Length > 8 ? hash.Substring(0, 8) : hash;
|
||||
suppressedStreak = 0;
|
||||
comparedWithPreviousRun = false;
|
||||
bool emit;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
@@ -693,11 +735,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
? (streak.Count + 1, streak.SinceUtc)
|
||||
: (1, DateTime.UtcNow);
|
||||
suppressedStreak = _suppressed[rule.RuleId].Count;
|
||||
comparedWithPreviousRun = _hashFromPreviousRun.Contains(rule.RuleId);
|
||||
emit = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
_lastEmittedHash[rule.RuleId] = hash;
|
||||
_hashFromPreviousRun.Remove(rule.RuleId);
|
||||
(int Count, DateTime SinceUtc) streak;
|
||||
if (_suppressed.TryGetValue(rule.RuleId, out streak) && streak.Count > 0)
|
||||
{
|
||||
@@ -820,6 +864,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
StopAllTimers();
|
||||
_startupRulesLatch?.Dispose();
|
||||
_startupRulesLatch = null;
|
||||
// A rule still finishing on a pool thread keeps its in-memory state but no longer writes:
|
||||
// the agent may be removing its own folder right now.
|
||||
_state.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Gather-rule state of one enrollment session that survives agent restarts: where each
|
||||
/// logparser rule stopped reading each file, and the on_change value each rule last emitted.
|
||||
/// <para>
|
||||
/// Positions are kept per (rule, file) — two rules parsing the same log each read all of it.
|
||||
/// Every position carries a fingerprint of the file's first bytes, so a file replaced under
|
||||
/// the same name is read from the beginning even when it is already longer than the position.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every commit is written to disk synchronously, AFTER the events it covers were handed on:
|
||||
/// a process that dies in between repeats that one pass after the restart instead of losing it.
|
||||
/// The lock guards memory and file write together and is never taken under the executor's
|
||||
/// scope lock. Without a persistence (diagnostic mode, tests) the state lives in memory only.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class GatherRuleSessionState
|
||||
{
|
||||
/// <summary>
|
||||
/// Files remembered per rule. A run reads at most <see cref="LogParserCollector.MaxFilesPerRun"/>
|
||||
/// files; the least recently touched entry beyond the cap is dropped, and its file — should it
|
||||
/// come back — is simply read again.
|
||||
/// </summary>
|
||||
internal const int MaxLogFilesPerRule = 5 * LogParserCollector.MaxFilesPerRun;
|
||||
|
||||
private static readonly char[] WildcardChars = { '*', '?' };
|
||||
|
||||
private readonly object _gate = new object();
|
||||
private readonly string _sessionId;
|
||||
private readonly GatherRuleStatePersistence _persistence;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Dictionary<string, Dictionary<string, Entry>> _logPositions =
|
||||
new Dictionary<string, Dictionary<string, Entry>>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _emittedHashes = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
private readonly List<KeyValuePair<string, string>> _restoredDetails = new List<KeyValuePair<string, string>>();
|
||||
private long _touchCounter;
|
||||
private bool _unsaved;
|
||||
private bool _saveFailureLogged;
|
||||
private bool _closed;
|
||||
|
||||
private sealed class Entry
|
||||
{
|
||||
public long Position;
|
||||
public long LineNumber;
|
||||
public int HeadLength;
|
||||
public string HeadHash;
|
||||
public bool FromPreviousRun;
|
||||
public long HeldTailStart = -1;
|
||||
public long LastTouched;
|
||||
}
|
||||
|
||||
public GatherRuleSessionState(string sessionId, GatherRuleStatePersistence persistence, AgentLogger logger)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_persistence = persistence;
|
||||
RestoreSummary = Restore();
|
||||
}
|
||||
|
||||
/// <summary>One line on what the constructor found on disk — for the agent log and the debug trace.</summary>
|
||||
public string RestoreSummary { get; }
|
||||
|
||||
/// <summary>(ruleId, message) per restored position and on_change value — for the debug trace.</summary>
|
||||
internal IReadOnlyList<KeyValuePair<string, string>> RestoredDetails => _restoredDetails;
|
||||
|
||||
// ===== logparser positions =====
|
||||
|
||||
/// <summary>The recorded position of <paramref name="filePath"/> for the rule, or null when it has none.</summary>
|
||||
internal LogPositionSnapshot PeekLogPosition(string ruleId, string filePath)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Dictionary<string, Entry> files;
|
||||
Entry entry;
|
||||
if (!_logPositions.TryGetValue(ruleId, out files) || !files.TryGetValue(filePath, out entry))
|
||||
return null;
|
||||
entry.LastTouched = ++_touchCounter;
|
||||
return new LogPositionSnapshot(entry.Position, entry.LineNumber, entry.HeadLength, entry.HeadHash,
|
||||
entry.FromPreviousRun, entry.HeldTailStart);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records where the rule stopped in the file and saves the state when it changed.
|
||||
/// <paramref name="heldTailStart"/> (in memory only) marks an unfinished last line left for
|
||||
/// the next run; -1 when none.
|
||||
/// </summary>
|
||||
internal void CommitLogPosition(string ruleId, string filePath, long position, long lineNumber,
|
||||
LogFileHead head, long heldTailStart)
|
||||
{
|
||||
var headLength = head.Length;
|
||||
var headHash = headLength > 0 ? head.Hash(headLength) : null;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
Dictionary<string, Entry> files;
|
||||
if (!_logPositions.TryGetValue(ruleId, out files))
|
||||
{
|
||||
files = new Dictionary<string, Entry>(StringComparer.OrdinalIgnoreCase);
|
||||
_logPositions[ruleId] = files;
|
||||
}
|
||||
|
||||
Entry entry;
|
||||
var changed = false;
|
||||
if (!files.TryGetValue(filePath, out entry))
|
||||
{
|
||||
entry = new Entry();
|
||||
files[filePath] = entry;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed || entry.Position != position || entry.LineNumber != lineNumber
|
||||
|| entry.HeadLength != headLength || !string.Equals(entry.HeadHash, headHash, StringComparison.Ordinal))
|
||||
{
|
||||
entry.Position = position;
|
||||
entry.LineNumber = lineNumber;
|
||||
entry.HeadLength = headLength;
|
||||
entry.HeadHash = headHash;
|
||||
entry.FromPreviousRun = false;
|
||||
changed = true;
|
||||
}
|
||||
entry.HeldTailStart = heldTailStart;
|
||||
entry.LastTouched = ++_touchCounter;
|
||||
|
||||
if (files.Count > MaxLogFilesPerRule)
|
||||
EvictLeastRecentlyTouched(ruleId, files);
|
||||
|
||||
if (changed || _unsaved)
|
||||
SaveLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where to continue reading: the recorded position while the file is still the one it was
|
||||
/// recorded for, otherwise 0 with the reason.
|
||||
/// </summary>
|
||||
internal static long ResolveStart(LogPositionSnapshot known, long fileLength, LogFileHead head, out LogReadStart start)
|
||||
{
|
||||
if (known == null)
|
||||
{
|
||||
start = LogReadStart.FirstRead;
|
||||
return 0;
|
||||
}
|
||||
if (fileLength < known.Position)
|
||||
{
|
||||
start = LogReadStart.Shrunk;
|
||||
return 0;
|
||||
}
|
||||
if (known.HeadLength > 0
|
||||
&& (head.Length < known.HeadLength
|
||||
|| !string.Equals(head.Hash(known.HeadLength), known.HeadHash, StringComparison.Ordinal)))
|
||||
{
|
||||
start = LogReadStart.Replaced;
|
||||
return 0;
|
||||
}
|
||||
start = LogReadStart.Continue;
|
||||
return known.Position;
|
||||
}
|
||||
|
||||
// ===== on_change values =====
|
||||
|
||||
/// <summary>Rule id → hash of the result the rule last emitted.</summary>
|
||||
internal Dictionary<string, string> GetEmittedHashes()
|
||||
{
|
||||
lock (_gate)
|
||||
return new Dictionary<string, string>(_emittedHashes, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Records the hash of a result that was just emitted and saves the state when it changed.</summary>
|
||||
internal void CommitEmittedHash(string ruleId, string hash)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
string current;
|
||||
var changed = !_emittedHashes.TryGetValue(ruleId, out current)
|
||||
|| !string.Equals(current, hash, StringComparison.Ordinal);
|
||||
_emittedHashes[ruleId] = hash;
|
||||
if (changed || _unsaved)
|
||||
SaveLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retries a save that failed earlier, then stops writing (executor disposed).</summary>
|
||||
internal void Close()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_unsaved)
|
||||
SaveLocked();
|
||||
_closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== persistence =====
|
||||
|
||||
private void SaveLocked()
|
||||
{
|
||||
if (_persistence == null || _closed)
|
||||
{
|
||||
_unsaved = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new GatherRuleStateData
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
SavedAtUtc = DateTime.UtcNow,
|
||||
LogPositions = _logPositions
|
||||
.SelectMany(rule => rule.Value.Select(file => new { RuleId = rule.Key, Path = file.Key, Entry = file.Value }))
|
||||
.OrderBy(x => x.Entry.LastTouched)
|
||||
.Select(x => new GatherLogPositionData
|
||||
{
|
||||
RuleId = x.RuleId,
|
||||
Path = x.Path,
|
||||
Position = x.Entry.Position,
|
||||
LineNumber = x.Entry.LineNumber,
|
||||
HeadLength = x.Entry.HeadLength,
|
||||
HeadHash = x.Entry.HeadHash,
|
||||
})
|
||||
.ToList(),
|
||||
OnChangeHashes = new Dictionary<string, string>(_emittedHashes, StringComparer.Ordinal),
|
||||
};
|
||||
|
||||
if (_persistence.Save(data))
|
||||
{
|
||||
_unsaved = false;
|
||||
_saveFailureLogged = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_unsaved = true;
|
||||
if (!_saveFailureLogged)
|
||||
{
|
||||
_saveFailureLogged = true;
|
||||
_logger.Warning($"GatherRuleSessionState: saving {_persistence.StateFilePath} failed — kept in memory, retried at the next change");
|
||||
}
|
||||
}
|
||||
|
||||
private string Restore()
|
||||
{
|
||||
if (_persistence == null)
|
||||
return "persistence off (no state directory) — positions and on_change values live in memory only";
|
||||
|
||||
string discardReason;
|
||||
var data = _persistence.Load(_sessionId, out discardReason);
|
||||
if (data == null)
|
||||
{
|
||||
return discardReason == null
|
||||
? "no saved state — first agent run of this session"
|
||||
: $"saved state discarded ({discardReason}) — starting fresh";
|
||||
}
|
||||
|
||||
// The file is input like any other: whatever it holds, the executor must come up. A
|
||||
// failure here would otherwise stop every collector host, at every restart of the session.
|
||||
try
|
||||
{
|
||||
return RestoreFrom(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logPositions.Clear();
|
||||
_emittedHashes.Clear();
|
||||
_restoredDetails.Clear();
|
||||
_logger.Warning($"GatherRuleSessionState: restoring {_persistence.StateFilePath} failed, starting fresh: {ex.Message}");
|
||||
return $"saved state discarded (restore failed: {ex.Message}) — starting fresh";
|
||||
}
|
||||
}
|
||||
|
||||
private string RestoreFrom(GatherRuleStateData data)
|
||||
{
|
||||
var dropped = 0;
|
||||
foreach (var position in data.LogPositions ?? new List<GatherLogPositionData>())
|
||||
{
|
||||
if (!IsValid(position))
|
||||
{
|
||||
dropped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Dictionary<string, Entry> files;
|
||||
if (!_logPositions.TryGetValue(position.RuleId, out files))
|
||||
{
|
||||
files = new Dictionary<string, Entry>(StringComparer.OrdinalIgnoreCase);
|
||||
_logPositions[position.RuleId] = files;
|
||||
}
|
||||
files[position.Path] = new Entry
|
||||
{
|
||||
Position = position.Position,
|
||||
LineNumber = position.LineNumber,
|
||||
HeadLength = position.HeadLength,
|
||||
HeadHash = position.HeadLength > 0 ? position.HeadHash : null,
|
||||
FromPreviousRun = true,
|
||||
LastTouched = ++_touchCounter,
|
||||
};
|
||||
if (files.Count > MaxLogFilesPerRule)
|
||||
EvictLeastRecentlyTouched(position.RuleId, files);
|
||||
}
|
||||
|
||||
foreach (var hash in data.OnChangeHashes ?? new Dictionary<string, string>())
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash.Key) || string.IsNullOrEmpty(hash.Value))
|
||||
{
|
||||
dropped++;
|
||||
continue;
|
||||
}
|
||||
_emittedHashes[hash.Key] = hash.Value;
|
||||
}
|
||||
|
||||
foreach (var rule in _logPositions)
|
||||
{
|
||||
foreach (var file in rule.Value)
|
||||
{
|
||||
_restoredDetails.Add(new KeyValuePair<string, string>(rule.Key,
|
||||
$"logparser position restored: {Path.GetFileName(file.Key)} at byte {file.Value.Position}, after line {file.Value.LineNumber}"));
|
||||
}
|
||||
}
|
||||
foreach (var hash in _emittedHashes)
|
||||
{
|
||||
_restoredDetails.Add(new KeyValuePair<string, string>(hash.Key,
|
||||
$"on_change value restored (hash {Prefix(hash.Value)}) — an unchanged result stays silent"));
|
||||
}
|
||||
|
||||
var positionCount = _logPositions.Sum(rule => rule.Value.Count);
|
||||
return $"restored {positionCount} logparser position(s) for {_logPositions.Count} rule(s) and {_emittedHashes.Count} on_change value(s), " +
|
||||
$"saved {data.SavedAtUtc:yyyy-MM-ddTHH:mm:ssZ} by a previous agent run of this session" +
|
||||
(dropped > 0 ? $"; {dropped} invalid entr{(dropped == 1 ? "y" : "ies")} dropped" : "") +
|
||||
" — logparser rules continue after their saved position, on_change rules stay silent until their result changes";
|
||||
}
|
||||
|
||||
private static bool IsValid(GatherLogPositionData position)
|
||||
{
|
||||
if (position == null || string.IsNullOrEmpty(position.RuleId) || string.IsNullOrEmpty(position.Path))
|
||||
return false;
|
||||
// A real resolved path never carries these; Path APIs throw on them under .NET Framework.
|
||||
if (position.Path.IndexOfAny(Path.GetInvalidPathChars()) >= 0 || position.Path.IndexOfAny(WildcardChars) >= 0)
|
||||
return false;
|
||||
if (position.Position < 0 || position.LineNumber < 0)
|
||||
return false;
|
||||
if (position.HeadLength < 0 || position.HeadLength > LogFileHead.MaxBytes)
|
||||
return false;
|
||||
if (position.HeadLength > 0 && string.IsNullOrEmpty(position.HeadHash))
|
||||
return false;
|
||||
// Reading any byte records a non-empty head; a position without one cannot be verified.
|
||||
return position.Position == 0 || position.HeadLength > 0;
|
||||
}
|
||||
|
||||
private void EvictLeastRecentlyTouched(string ruleId, Dictionary<string, Entry> files)
|
||||
{
|
||||
var oldest = files.OrderBy(file => file.Value.LastTouched).First();
|
||||
files.Remove(oldest.Key);
|
||||
_logger.Info($"GatherRuleSessionState: rule {ruleId} tracks more than {MaxLogFilesPerRule} files — forgot the position of {Path.GetFileName(oldest.Key)}");
|
||||
}
|
||||
|
||||
private static string Prefix(string hash) => hash.Length > 8 ? hash.Substring(0, 8) : hash;
|
||||
}
|
||||
|
||||
/// <summary>Immutable copy of a recorded logparser position.</summary>
|
||||
internal sealed class LogPositionSnapshot
|
||||
{
|
||||
public LogPositionSnapshot(long position, long lineNumber, int headLength, string headHash,
|
||||
bool fromPreviousRun, long heldTailStart)
|
||||
{
|
||||
Position = position;
|
||||
LineNumber = lineNumber;
|
||||
HeadLength = headLength;
|
||||
HeadHash = headHash;
|
||||
FromPreviousRun = fromPreviousRun;
|
||||
HeldTailStart = heldTailStart;
|
||||
}
|
||||
|
||||
public long Position { get; }
|
||||
public long LineNumber { get; }
|
||||
public int HeadLength { get; }
|
||||
public string HeadHash { get; }
|
||||
|
||||
/// <summary>Restored from disk and not advanced by this process yet.</summary>
|
||||
public bool FromPreviousRun { get; }
|
||||
|
||||
/// <summary>Start of an unfinished last line left for this run by the previous one; -1 when none.</summary>
|
||||
public long HeldTailStart { get; }
|
||||
}
|
||||
|
||||
/// <summary>How <see cref="GatherRuleSessionState.ResolveStart"/> decided where reading starts.</summary>
|
||||
internal enum LogReadStart
|
||||
{
|
||||
FirstRead,
|
||||
Continue,
|
||||
Shrunk,
|
||||
Replaced,
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists <see cref="GatherRuleSessionState"/> in the agent's state directory so one
|
||||
/// enrollment's logparser positions and on_change values survive agent restarts, reboots and
|
||||
/// the WhiteGlove Part 2 resume.
|
||||
/// <para>
|
||||
/// The state is only resumed for the SAME session id: the state directory is one fixed folder
|
||||
/// that a session rotation does not clear, and the new session starts empty in the backend, so
|
||||
/// it must read its logs from the beginning.
|
||||
/// </para>
|
||||
/// Fail-soft like <c>BandwidthStatePersistence</c>: I/O errors never throw; a missing, corrupt
|
||||
/// or foreign file loads as null (fresh start — the pre-persistence behavior).
|
||||
/// </summary>
|
||||
public class GatherRuleStatePersistence
|
||||
{
|
||||
public const string FileName = "gather-rule-state.json";
|
||||
|
||||
private const string SourceName = "GatherRuleStatePersistence";
|
||||
|
||||
private readonly string _stateDirectory;
|
||||
private readonly string _stateFilePath;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public GatherRuleStatePersistence(string stateDirectory, AgentLogger logger)
|
||||
{
|
||||
_stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory);
|
||||
_stateFilePath = Path.Combine(_stateDirectory, FileName);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string StateFilePath => _stateFilePath;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the persisted state, or null when absent, unreadable, or written for a different
|
||||
/// session. <paramref name="discardReason"/> is null when there simply was no file.
|
||||
/// </summary>
|
||||
public GatherRuleStateData Load(string expectedSessionId, out string discardReason)
|
||||
{
|
||||
discardReason = null;
|
||||
if (!File.Exists(_stateFilePath)) return null;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_stateFilePath);
|
||||
var state = JsonConvert.DeserializeObject<GatherRuleStateData>(json);
|
||||
if (state == null || string.IsNullOrEmpty(state.SessionId))
|
||||
{
|
||||
discardReason = "state file empty or invalid";
|
||||
_logger.Warning($"[{SourceName}] persisted state file was empty or invalid — starting fresh");
|
||||
return null;
|
||||
}
|
||||
if (!string.Equals(state.SessionId, expectedSessionId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
discardReason = $"saved for session {state.SessionId}, current session is {expectedSessionId}";
|
||||
_logger.Warning($"[{SourceName}] persisted state belongs to session {state.SessionId}, not {expectedSessionId} — starting fresh");
|
||||
return null;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
discardReason = $"state file unreadable: {ex.Message}";
|
||||
_logger.Warning($"[{SourceName}] failed to load persisted state, starting fresh: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Persists the state (atomic write via temp file). Never throws; false when the write failed.</summary>
|
||||
public bool Save(GatherRuleStateData state)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var tempPath = _stateFilePath + ".tmp";
|
||||
File.WriteAllText(tempPath, JsonConvert.SerializeObject(state));
|
||||
if (File.Exists(_stateFilePath))
|
||||
{
|
||||
File.Replace(tempPath, _stateFilePath, destinationBackupFileName: null, ignoreMetadataErrors: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(tempPath, _stateFilePath);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"[{SourceName}] failed to save state: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DTOs for state serialization ----
|
||||
|
||||
public class GatherRuleStateData
|
||||
{
|
||||
public string SessionId { get; set; }
|
||||
public DateTime SavedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Least recently used first, so a restore keeps the eviction order.</summary>
|
||||
public List<GatherLogPositionData> LogPositions { get; set; }
|
||||
|
||||
/// <summary>Rule id → canonical hash of the result the rule last emitted (on_change).</summary>
|
||||
public Dictionary<string, string> OnChangeHashes { get; set; }
|
||||
}
|
||||
|
||||
public class GatherLogPositionData
|
||||
{
|
||||
public string RuleId { get; set; }
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>File offset right behind the last line the rule processed.</summary>
|
||||
public long Position { get; set; }
|
||||
|
||||
/// <summary>Lines before <see cref="Position"/> — the base for <c>logLineNumber</c>.</summary>
|
||||
public long LineNumber { get; set; }
|
||||
|
||||
/// <summary>Bytes from the start of the file the fingerprint covers (0..4096).</summary>
|
||||
public int HeadLength { get; set; }
|
||||
|
||||
/// <summary>SHA-256 of those bytes, Base64; null when <see cref="HeadLength"/> is 0.</summary>
|
||||
public string HeadHash { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -132,12 +132,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_informationalEvents = informationalEvents;
|
||||
_parkedTripwireDwell = parkedTripwireDwell ?? DefaultParkedTripwireDwell;
|
||||
|
||||
// If recovery loaded a state that already sits on a terminal stage (e.g. a crash
|
||||
// after a success-path step but before Stop()), treat it as already-notified so we
|
||||
// do not re-fire the hook.
|
||||
_terminalNotified = initialState.Stage.IsTerminal();
|
||||
// If recovery loaded a state that already ends the session (e.g. a crash after a
|
||||
// success-path step but before Stop()), treat it as already-notified so we do not
|
||||
// re-fire the hook — the orchestrator handles that state at start.
|
||||
_terminalNotified = EndsSession(initialState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the state ends this agent's session: a terminal stage, or the engine stopped
|
||||
/// waiting for the user without a verdict (<see cref="DecisionState.StoppedWaitingReason"/>,
|
||||
/// D-310) — the stage stays non-terminal there, and the agent ends the session like its
|
||||
/// max-lifetime watchdog.
|
||||
/// </summary>
|
||||
internal static bool EndsSession(DecisionState state) =>
|
||||
state.Stage.IsTerminal() || state.StoppedWaitingReason != null;
|
||||
|
||||
public DecisionState CurrentState => _currentState;
|
||||
|
||||
/// <summary>Test-Observability — anzahl aufeinanderfolgender Journal-Failures.</summary>
|
||||
@@ -284,7 +293,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// orchestrator turns this into the public EnrollmentTerminated event so peripheral
|
||||
// consumers (CleanupService, SummaryDialog, DiagnosticsPackageService) can react
|
||||
// without touching the kernel state machine.
|
||||
if (!_terminalNotified && _currentState.Stage.IsTerminal())
|
||||
if (!_terminalNotified && EndsSession(_currentState))
|
||||
{
|
||||
_terminalNotified = true;
|
||||
try { _onTerminalStageReached?.Invoke(_currentState); }
|
||||
@@ -405,7 +414,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
private static bool IsParkedWithoutDeadline(DecisionState state)
|
||||
{
|
||||
if (state.Stage.IsTerminal()) return false;
|
||||
if (EndsSession(state)) return false;
|
||||
if (state.Stage == SessionStage.Unknown || state.Stage == SessionStage.SessionStarted) return false;
|
||||
|
||||
// WDP (afee7ae0): Device Preparation has no ESP, so neither the AccountSetup
|
||||
|
||||
@@ -547,9 +547,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
stateDirectory: _stateDirectory);
|
||||
hosts.Add(osBuildChangeHost);
|
||||
|
||||
// Windows Update during OOBE watcher — subscribes to WindowsUpdateClient/Operational and
|
||||
// backfills recent events (OOBE quality updates run before the agent starts). Surfaces
|
||||
// quality/cumulative updates installing/failing DURING enrollment — otherwise invisible.
|
||||
// OS update watchers (D-310) — the Windows Update client on the System log (install
|
||||
// events) and its Operational channel (scan/download), plus the CBS servicing steps on
|
||||
// the Setup log; each backfills recent events because updates can start before the
|
||||
// agent does. Surfaces Windows/.NET updates installing/failing DURING enrollment and,
|
||||
// unless OobeUpdatePhaseMode is Off, feeds them into the engine's OOBE update phase.
|
||||
if (collectors.WindowsUpdateWatcherEnabled)
|
||||
{
|
||||
hosts.Add(new WindowsUpdateWatcherHost(
|
||||
@@ -562,7 +564,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
backfillLookbackMinutes: collectors.WindowsUpdateBackfillLookbackMinutes,
|
||||
stateDirectory: _stateDirectory,
|
||||
channelCensusEnabled: collectors.WindowsUpdateChannelCensusEnabled,
|
||||
osBuildChangedProvider: () => osBuildChangeHost.BuildChanged));
|
||||
osBuildChangedProvider: () => osBuildChangeHost.BuildChanged,
|
||||
operationalEventIds: collectors.WindowsUpdateOperationalEventIds,
|
||||
servicingWatcherEnabled: collectors.ServicingWatcherEnabled,
|
||||
osUpdateSignalsEnabled: DecisionCore.State.OobeUpdatePhaseModes.PostsSignals(collectors.OobeUpdatePhaseMode)));
|
||||
}
|
||||
|
||||
// MDM reboot-policy watcher — subscribes to DeviceManagement-Enterprise-Diagnostics-
|
||||
@@ -736,7 +741,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
|
||||
unrestrictedMode: _agentConfig.UnrestrictedMode,
|
||||
gatherDebugLogPath: _agentConfig.GatherRuleDebugLogPath,
|
||||
timelineEvents: timelineEvents));
|
||||
timelineEvents: timelineEvents,
|
||||
stateDirectory: _stateDirectory));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_agentConfig.GatherRuleDebugLogPath))
|
||||
{
|
||||
|
||||
@@ -740,7 +740,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// itself is constructed inside the onIngressReady hook (step 13b), which has
|
||||
// already fired by this point — so when our recovery-fire propagates through
|
||||
// Task.Run, the handler is fully wired.
|
||||
if (initialState.Stage == SessionStage.Completed || initialState.Stage == SessionStage.Failed)
|
||||
// The same applies to a state in which the engine stopped waiting (D-310): a
|
||||
// crash between that step and the termination must not leave the session running.
|
||||
if (initialState.Stage == SessionStage.Completed || initialState.Stage == SessionStage.Failed
|
||||
|| initialState.StoppedWaitingReason != null)
|
||||
{
|
||||
_logger.Warning(
|
||||
$"EnrollmentOrchestrator: post-recovery initialState already terminal " +
|
||||
@@ -869,25 +872,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Stop the max-lifetime watchdog — the real terminal arrived before it could trip.
|
||||
try { _maxLifetimeTimer?.Dispose(); _maxLifetimeTimer = null; } catch { }
|
||||
|
||||
var outcome = terminalState.Stage switch
|
||||
{
|
||||
SessionStage.Completed or SessionStage.WhiteGloveSealed
|
||||
=> EnrollmentTerminationOutcome.Succeeded,
|
||||
SessionStage.Failed => EnrollmentTerminationOutcome.Failed,
|
||||
_ => EnrollmentTerminationOutcome.TimedOut,
|
||||
};
|
||||
// D-310: the engine stopped waiting for the user without a verdict. The stage stays
|
||||
// non-terminal; the session ends like the max-lifetime watchdog ends it (TimedOut), so
|
||||
// the backend classifies it honestly — never as a failure.
|
||||
var stoppedWaiting = !terminalState.Stage.IsTerminal() && terminalState.StoppedWaitingReason != null;
|
||||
|
||||
var outcome = stoppedWaiting
|
||||
? EnrollmentTerminationOutcome.TimedOut
|
||||
: terminalState.Stage switch
|
||||
{
|
||||
SessionStage.Completed or SessionStage.WhiteGloveSealed
|
||||
=> EnrollmentTerminationOutcome.Succeeded,
|
||||
SessionStage.Failed => EnrollmentTerminationOutcome.Failed,
|
||||
_ => EnrollmentTerminationOutcome.TimedOut,
|
||||
};
|
||||
var reason = stoppedWaiting
|
||||
? EnrollmentTerminationReason.StoppedWaiting
|
||||
: EnrollmentTerminationReason.DecisionTerminalStage;
|
||||
|
||||
_logger.Info(
|
||||
$"EnrollmentOrchestrator: decision terminal stage reached (stage={terminalState.Stage}, outcome={outcome}) — dispatching Terminated(DecisionTerminalStage) off worker.");
|
||||
$"EnrollmentOrchestrator: decision ended the session (stage={terminalState.Stage}, outcome={outcome}, reason={reason}) — dispatching Terminated off worker.");
|
||||
|
||||
string? details = null;
|
||||
if (stoppedWaiting)
|
||||
details = StoppedWaitingReasons.Describe(terminalState.StoppedWaitingReason!.Value);
|
||||
else if (terminalState.Stage.IsPauseBeforePart2())
|
||||
details = "WhiteGlove Part 1 sealed — session will resume on Part 2 post-reboot; self-destruct suppressed.";
|
||||
|
||||
var terminatedArgs = new EnrollmentTerminatedEventArgs(
|
||||
reason: EnrollmentTerminationReason.DecisionTerminalStage,
|
||||
reason: reason,
|
||||
outcome: outcome,
|
||||
stageName: terminalState.Stage.ToString(),
|
||||
terminatedAtUtc: _clock.UtcNow,
|
||||
details: terminalState.Stage.IsPauseBeforePart2()
|
||||
? "WhiteGlove Part 1 sealed — session will resume on Part 2 post-reboot; self-destruct suppressed."
|
||||
: null);
|
||||
details: details,
|
||||
stopReason: stoppedWaiting ? terminalState.StoppedWaitingReason!.Value : null);
|
||||
|
||||
// Codex Finding 2 — off-worker dispatch. Task.Run unblocks the ingress worker
|
||||
// immediately; the worker keeps draining the channel (including events the
|
||||
|
||||
+16
-1
@@ -16,6 +16,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// longer than the configured cap without reaching a terminal stage.
|
||||
/// </summary>
|
||||
MaxLifetimeExceeded,
|
||||
|
||||
/// <summary>
|
||||
/// The DecisionEngine stopped waiting for the user without a verdict — a bounded wait
|
||||
/// expired (<c>DecisionState.StoppedWaitingReason</c>, D-310). Ends the session like
|
||||
/// <see cref="MaxLifetimeExceeded"/> (outcome TimedOut), with the engine's reason.
|
||||
/// </summary>
|
||||
StoppedWaiting,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -60,18 +67,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
public DateTime TerminatedAtUtc { get; }
|
||||
public string? Details { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The engine's reason code for <see cref="EnrollmentTerminationReason.StoppedWaiting"/>
|
||||
/// (e.g. <c>oobe_update_no_sign_in</c>); null for every other reason.
|
||||
/// </summary>
|
||||
public string? StopReason { get; }
|
||||
|
||||
public EnrollmentTerminatedEventArgs(
|
||||
EnrollmentTerminationReason reason,
|
||||
EnrollmentTerminationOutcome outcome,
|
||||
string? stageName,
|
||||
DateTime terminatedAtUtc,
|
||||
string? details = null)
|
||||
string? details = null,
|
||||
string? stopReason = null)
|
||||
{
|
||||
Reason = reason;
|
||||
Outcome = outcome;
|
||||
StageName = stageName;
|
||||
TerminatedAtUtc = terminatedAtUtc;
|
||||
Details = details;
|
||||
StopReason = stopReason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -23,7 +23,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// V1 fired these from MonitoringService on every emitted EnrollmentEvent; the first V2
|
||||
// wiring subscribed to SignalIngress.SignalPosted instead, which fires at enqueue time
|
||||
// with the RAW collector payload — before the reducer has applied gates like the
|
||||
// RealmJoin completion gate. Consequence (session 32312a32, rsneuffen.de): a phase_change
|
||||
// RealmJoin completion gate. Consequence (session 32312a32): a phase_change
|
||||
// rule on FinalizingSetup fired at the raw EspPhaseChanged(FinalizingSetup) signal (ESP
|
||||
// exit / Hello wizard), 7 minutes before the engine declared phase_transition(FinalizingSetup)
|
||||
// on the timeline — and before the RealmJoin package wrote the registry key the rule was
|
||||
@@ -47,7 +47,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
string? imeLogPathOverride,
|
||||
bool unrestrictedMode = false,
|
||||
string? gatherDebugLogPath = null,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
string? stateDirectory = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -66,7 +67,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
var post = new InformationalEventPost(ingress, clock, logger);
|
||||
_executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor(
|
||||
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride,
|
||||
debugLogPath: gatherDebugLogPath);
|
||||
debugLogPath: gatherDebugLogPath, stateDirectory: stateDirectory);
|
||||
_timelineEvents = timelineEvents;
|
||||
}
|
||||
|
||||
|
||||
+183
-23
@@ -1,26 +1,41 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Standalone host for the <see cref="WindowsUpdateTracker"/> — watches
|
||||
/// <c>Microsoft-Windows-WindowsUpdateClient/Operational</c> for quality/cumulative update
|
||||
/// activity during OOBE / ESP and forwards it via the shared single-rail ingress.
|
||||
/// Mirrors <see cref="StallProbeHost"/>: builds its own <see cref="InformationalEventPost"/>
|
||||
/// and delegates Start/Stop to the tracker.
|
||||
/// Hosts the OS-update watchers (D-310): the Windows Update client on the System log
|
||||
/// (install events) and on its Operational channel (scan/download events), plus the CBS
|
||||
/// servicing steps on the Setup log. Routes every configured WU EventID to the channel the
|
||||
/// provider manifest assigns it, runs the blind-spot census across all channels, reports the
|
||||
/// counted (non-OS) activity once on stop, and — unless <c>OobeUpdatePhaseMode</c> is Off —
|
||||
/// feeds Windows/.NET update steps into the decision engine as
|
||||
/// <see cref="DecisionSignalKind.OsUpdateActivity"/>.
|
||||
/// </summary>
|
||||
internal sealed class WindowsUpdateWatcherHost : ICollectorHost
|
||||
{
|
||||
public string Name => "WindowsUpdateTracker";
|
||||
|
||||
private readonly WindowsUpdateTracker _tracker;
|
||||
private readonly WindowsUpdateTracker? _systemTracker;
|
||||
private readonly WindowsUpdateTracker? _operationalTracker;
|
||||
private readonly ServicingTracker? _servicingTracker;
|
||||
private readonly UpdateActivityTally _tally = new UpdateActivityTally();
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly ISignalIngressSink _ingress;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private int _summaryEmitted;
|
||||
private int _disposed;
|
||||
|
||||
public WindowsUpdateWatcherHost(
|
||||
@@ -33,36 +48,125 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int backfillLookbackMinutes,
|
||||
string stateDirectory,
|
||||
bool channelCensusEnabled = true,
|
||||
Func<bool>? osBuildChangedProvider = null)
|
||||
Func<bool>? osBuildChangedProvider = null,
|
||||
int[]? operationalEventIds = null,
|
||||
bool servicingWatcherEnabled = true,
|
||||
bool osUpdateSignalsEnabled = false)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_ingress = ingress ?? throw new ArgumentNullException(nameof(ingress));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = new InformationalEventPost(ingress, clock, logger);
|
||||
|
||||
var post = new InformationalEventPost(ingress, clock, logger);
|
||||
_tracker = new WindowsUpdateTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: post,
|
||||
logger: logger,
|
||||
targetedEventIds: targetedEventIds,
|
||||
backfillEnabled: backfillLookbackMinutes > 0,
|
||||
backfillLookbackMinutes: backfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
channelCensusEnabled: channelCensusEnabled,
|
||||
osBuildChangedProvider: osBuildChangedProvider);
|
||||
var (systemIds, operationalIds) = RouteEventIds(targetedEventIds, operationalEventIds);
|
||||
|
||||
Action<string, string, DateTime>? wuActivity = null;
|
||||
Action<string, string, DateTime>? servicingActivity = null;
|
||||
if (osUpdateSignalsEnabled)
|
||||
{
|
||||
wuActivity = (step, update, at) => PostOsUpdateActivity(step, "wu", update, at);
|
||||
servicingActivity = (step, update, at) => PostOsUpdateActivity(step, "servicing", update, at);
|
||||
}
|
||||
|
||||
if (systemIds.Length > 0)
|
||||
{
|
||||
_systemTracker = new WindowsUpdateTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: _post,
|
||||
logger: logger,
|
||||
targetedEventIds: systemIds,
|
||||
backfillEnabled: backfillLookbackMinutes > 0,
|
||||
backfillLookbackMinutes: backfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
channelCensusEnabled: false,
|
||||
channel: WindowsUpdateTracker.SystemChannel,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: wuActivity);
|
||||
}
|
||||
|
||||
if (operationalIds.Length > 0)
|
||||
{
|
||||
_operationalTracker = new WindowsUpdateTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: _post,
|
||||
logger: logger,
|
||||
targetedEventIds: operationalIds,
|
||||
backfillEnabled: backfillLookbackMinutes > 0,
|
||||
backfillLookbackMinutes: backfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
channelCensusEnabled: channelCensusEnabled,
|
||||
osBuildChangedProvider: osBuildChangedProvider,
|
||||
channel: WindowsUpdateTracker.Channel,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: wuActivity);
|
||||
}
|
||||
|
||||
if (servicingWatcherEnabled)
|
||||
{
|
||||
_servicingTracker = new ServicingTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: _post,
|
||||
logger: logger,
|
||||
backfillEnabled: backfillLookbackMinutes > 0,
|
||||
backfillLookbackMinutes: backfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
tally: _tally,
|
||||
onOsUpdateActivity: servicingActivity);
|
||||
}
|
||||
}
|
||||
|
||||
// Test seams — expose the trackers so unit tests can drive their ProcessEvent seams.
|
||||
internal WindowsUpdateTracker? SystemTrackerForTest => _systemTracker;
|
||||
internal WindowsUpdateTracker? OperationalTrackerForTest => _operationalTracker;
|
||||
internal ServicingTracker? ServicingTrackerForTest => _servicingTracker;
|
||||
|
||||
/// <summary>
|
||||
/// Splits the configured WU EventIDs by the channel the provider manifest writes them to.
|
||||
/// An ID configured in either list ends up on its real channel, so a misplaced ID still
|
||||
/// works instead of watching a channel that never carries it.
|
||||
/// </summary>
|
||||
internal static (int[] systemIds, int[] operationalIds) RouteEventIds(int[]? targetedEventIds, int[]? operationalEventIds)
|
||||
{
|
||||
var all = (targetedEventIds ?? Array.Empty<int>())
|
||||
.Concat(operationalEventIds ?? Array.Empty<int>())
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToArray();
|
||||
return (
|
||||
all.Where(WindowsUpdateTracker.IsSystemChannelEventId).ToArray(),
|
||||
all.Where(id => !WindowsUpdateTracker.IsSystemChannelEventId(id)).ToArray());
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_tracker.Start();
|
||||
_systemTracker?.Start();
|
||||
_operationalTracker?.Start();
|
||||
_servicingTracker?.Start();
|
||||
|
||||
// The census asks "did ANY channel see the update?" — only answerable after every
|
||||
// backfill ran.
|
||||
_operationalTracker?.EmitChannelCensusIfBlind(
|
||||
otherChannelsEmitted: _systemTracker?.EmittedThisRun ?? 0,
|
||||
otherTargetedEventIds: _systemTracker?.TargetedEventIds);
|
||||
|
||||
_logger.Info("WindowsUpdateWatcherHost: started.");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
try { _tracker.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdateWatcherHost: stop failed: {ex.Message}"); }
|
||||
try { _systemTracker?.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdateWatcherHost: system watcher stop failed: {ex.Message}"); }
|
||||
try { _operationalTracker?.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdateWatcherHost: operational watcher stop failed: {ex.Message}"); }
|
||||
try { _servicingTracker?.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"WindowsUpdateWatcherHost: servicing watcher stop failed: {ex.Message}"); }
|
||||
|
||||
EmitActivitySummaryOnce();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -70,5 +174,61 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
Stop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One summary of everything that was counted instead of emitted. Stop runs twice on the
|
||||
/// termination path (explicit stop, then dispose), hence the once-guard.
|
||||
/// </summary>
|
||||
internal void EmitActivitySummaryOnce()
|
||||
{
|
||||
if (_tally.IsEmpty) return;
|
||||
if (Interlocked.Exchange(ref _summaryEmitted, 1) == 1) return;
|
||||
|
||||
try
|
||||
{
|
||||
var counts = _tally.Snapshot();
|
||||
var data = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach (var kv in counts) data[kv.Key] = kv.Value;
|
||||
var total = counts.Sum(kv => kv.Value);
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = SharedConstants.EventTypes.WindowsUpdateActivitySummary,
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = "WindowsUpdateWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Update activity counted instead of listed: {total} event(s) " +
|
||||
"(Store, Defender and other non-OS updates, repeated scan results, servicing of non-OS packages)",
|
||||
Data = data,
|
||||
ImmediateUpload = false,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"WindowsUpdateWatcherHost: activity summary failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void PostOsUpdateActivity(string step, string source, string update, DateTime occurredAtUtc)
|
||||
{
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[OsUpdatePayloadKeys.Step] = step,
|
||||
[OsUpdatePayloadKeys.Source] = source,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(update)) payload[OsUpdatePayloadKeys.Update] = update;
|
||||
|
||||
_ingress.Post(
|
||||
kind: DecisionSignalKind.OsUpdateActivity,
|
||||
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||
sourceOrigin: source == "servicing" ? "ServicingWatcher" : "WindowsUpdateWatcher",
|
||||
evidence: new Evidence(
|
||||
kind: EvidenceKind.Raw,
|
||||
identifier: $"os-update-activity:{source}",
|
||||
summary: string.IsNullOrEmpty(update) ? step : $"{step}: {update}"),
|
||||
payload: payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
SharedConstants.EventTypes.ImePatternHits,
|
||||
// Bookkeeping of runs that were deliberately not reported as activity.
|
||||
SharedConstants.EventTypes.ScriptRecurrenceSummary,
|
||||
// Same for the update watchers' tally of Store/Defender/other update events (D-310):
|
||||
// emitted on stop, never progress. Windows/.NET update steps stay activity.
|
||||
SharedConstants.EventTypes.WindowsUpdateActivitySummary,
|
||||
// The OOBE update registry snapshot (D-310) is observation at agent start/stop and at
|
||||
// the update page — the page's own records (oobe_update_page) stay activity.
|
||||
SharedConstants.EventTypes.OobeUpdateState,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
+18
-9
@@ -1227,11 +1227,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
case EnrollmentTerminationReason.MaxLifetimeExceeded:
|
||||
reasonTag = "max_lifetime";
|
||||
break;
|
||||
case EnrollmentTerminationReason.StoppedWaiting:
|
||||
// The backend classifies this shutdown honestly, exactly like max_lifetime.
|
||||
reasonTag = "stopped_waiting";
|
||||
break;
|
||||
default:
|
||||
reasonTag = args.Reason.ToString();
|
||||
break;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "reason", reasonTag },
|
||||
{ "outcome", args.Outcome.ToString() },
|
||||
{ "stage", args.StageName ?? string.Empty },
|
||||
{ "uptimeMinutes", uptimeMinutes },
|
||||
{ "agentVersion", _agentVersion },
|
||||
};
|
||||
if (!string.IsNullOrEmpty(args.StopReason)) data["stopReason"] = args.StopReason;
|
||||
|
||||
EmitEventSafe(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
@@ -1240,15 +1254,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EnrollmentTerminationHandler",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Agent shutting down (reason={reasonTag}, outcome={args.Outcome}).",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "reason", reasonTag },
|
||||
{ "outcome", args.Outcome.ToString() },
|
||||
{ "stage", args.StageName ?? string.Empty },
|
||||
{ "uptimeMinutes", uptimeMinutes },
|
||||
{ "agentVersion", _agentVersion },
|
||||
},
|
||||
Message = args.Reason == EnrollmentTerminationReason.StoppedWaiting && !string.IsNullOrEmpty(args.Details)
|
||||
? $"Agent shutting down (reason={reasonTag}, outcome={args.Outcome}): {args.Details}"
|
||||
: $"Agent shutting down (reason={reasonTag}, outcome={args.Outcome}).",
|
||||
Data = data,
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,6 +230,9 @@ namespace AutopilotMonitor.Agent.V2
|
||||
logger.Info($"--run-gather-rules: debug trace -> {gatherDebugLogPath}");
|
||||
if (consoleMode) Console.WriteLine($"Debug trace: {gatherDebugLogPath}");
|
||||
|
||||
// No stateDirectory on purpose: this mode runs beside the live agent under a
|
||||
// throwaway session id and must neither resume nor overwrite the session's
|
||||
// gather state (logparser positions, on_change values).
|
||||
using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride,
|
||||
debugLogPath: gatherDebugLogPath,
|
||||
debugEcho: consoleMode ? (Action<string>)(line => Console.WriteLine($" {line}")) : null))
|
||||
|
||||
@@ -526,7 +526,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// analytics. WhiteGlove Part-2 resume runs as a fresh Classic
|
||||
// enrollment after PR-A's archive-and-reset, so the engine state is
|
||||
// empty and needs the facts re-seeded — same as a first boot.
|
||||
LifecycleEmitters.PostEnrollmentFactsObserved(orchestrator.IngressSink, agentConfig.HelloWaitTimeoutSeconds, logger);
|
||||
LifecycleEmitters.PostEnrollmentFactsObserved(
|
||||
orchestrator.IngressSink,
|
||||
agentConfig.HelloWaitTimeoutSeconds,
|
||||
logger,
|
||||
remoteConfig.Collectors?.OobeUpdatePhaseMode);
|
||||
|
||||
// V2 parity — post SessionStarted so the reducer establishes the session
|
||||
// anchor (HandleSessionStartedV1 in DecisionEngine.Shared.cs). Skipped only
|
||||
|
||||
@@ -315,7 +315,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// registry-derived enrollment facts (<c>enrollmentType</c> + <c>isHybridJoin</c>)
|
||||
/// so the reducer can seed <see cref="State.EnrollmentScenarioProfile"/> via the
|
||||
/// stage-agnostic <c>HandleEnrollmentFactsObservedV1</c> handler — plus the tenant's
|
||||
/// Hello wait when it extends the engine's built-in hello_safety window.
|
||||
/// Hello wait when it extends the engine's built-in hello_safety window, and the mode of
|
||||
/// the OOBE update phase (D-310).
|
||||
/// <para>
|
||||
/// Posted immediately before <see cref="PostSessionStarted"/> so the Inspector
|
||||
/// timeline reads naturally (facts → anchor) — but the reducer correctness does
|
||||
@@ -331,7 +332,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
public static void PostEnrollmentFactsObserved(
|
||||
ISignalIngressSink ingressSink,
|
||||
int helloWaitTimeoutSeconds,
|
||||
AgentLogger logger)
|
||||
AgentLogger logger,
|
||||
string oobeUpdatePhaseMode = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -364,6 +366,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
helloWaitSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// OOBE update phase mode (remote config) — stamped at every start, the same value
|
||||
// the update watchers were wired with: the engine lets the phase act only while
|
||||
// every start of the session said Active, so Off or Shadow is a kill switch.
|
||||
var phaseMode = OobeUpdatePhaseModes.Normalize(oobeUpdatePhaseMode);
|
||||
payload[SignalPayloadKeys.OobeUpdatePhaseMode] = phaseMode;
|
||||
|
||||
var evidence = new Evidence(
|
||||
kind: EvidenceKind.Synthetic,
|
||||
identifier: "enrollment_registry_facts_read",
|
||||
@@ -376,7 +384,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
evidence: evidence,
|
||||
payload: payload);
|
||||
|
||||
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}, helloWaitSeconds={helloWaitSeconds}).");
|
||||
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}, helloWaitSeconds={helloWaitSeconds}, oobeUpdatePhaseMode={phaseMode}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -117,7 +117,9 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
// Session 4910a5a5 (2026-07-23) added `espAdvisoryFailureResolvedUtc` (the failed
|
||||
// category recovered — lifts the advisory's re-arm/rebase guard exemption) and
|
||||
// `espAdvisoryFailureCategory` (which category the advisory was for) → 20.
|
||||
Assert.Equal(20, facts.Count);
|
||||
// D-310 (2026-10-02) added `stoppedWaitingReason` — the engine stopped waiting for
|
||||
// the user without a verdict (no sign-in after the OOBE update) → 21.
|
||||
Assert.Equal(21, facts.Count);
|
||||
Assert.All(facts.Values, v => Assert.Null(v));
|
||||
}
|
||||
|
||||
@@ -242,9 +244,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
// that vetoes/retracts the policy-disabled Hello skip → 18. Session 4910a5a5
|
||||
// (2026-07-23) added `espAdvisoryFailureResolvedUtc` + `espAdvisoryFailureCategory`
|
||||
// — advisory-recovery facts that lift the AdvisoryCompletion guard exemption → 20.
|
||||
// D-310 (2026-10-02) added `stoppedWaitingReason` — the engine stopped waiting for
|
||||
// the user without a verdict → 21.
|
||||
// If this number ever changes, both the count expectation AND the actual snapshot
|
||||
// output need to evolve in lockstep.
|
||||
Assert.Equal(20, expectedFactKeys.Count);
|
||||
Assert.Equal(21, expectedFactKeys.Count);
|
||||
|
||||
var snapshot = DecisionStateSnapshotBuilder.Build(DecisionState.CreateInitial("s", "t"));
|
||||
var facts = (Dictionary<string, object?>)snapshot["facts"]!;
|
||||
|
||||
@@ -0,0 +1,749 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Serialization;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Report 3a2207978e4c (2026-10-01, D-310) — the OOBE quality update. With the ESP setting
|
||||
/// "Install Windows quality updates" Windows installs the update after the Device-ESP page
|
||||
/// closes and before the user signs in: 20–40 minutes plus a restart to the lock screen. The
|
||||
/// IME had logged its AccountSetup phase line before that handoff exit, so the exit armed the
|
||||
/// 30-min <c>advisory_completion</c> window, and the session was failed
|
||||
/// (<c>esp_exit_without_completion_evidence</c>) while the update was still installing.
|
||||
/// <para>
|
||||
/// The OOBE update phase holds the window, every state bounded on its own (user decision
|
||||
/// 2026-10-02): the update runs at most 3 h, then fails as not finished; after the update the
|
||||
/// user has 60 min to sign in, then the engine stops waiting — no verdict, the backend
|
||||
/// classifies; after the sign-in the user's own setup gets one window.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only the Active mode acts (measurement first, user decision 2026-10-02). In the Shadow mode
|
||||
/// — the default, and what an unstamped session counts as — the fire reports what the phase
|
||||
/// would decide and today's rule decides; Off evaluates nothing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class OobeUpdatePhaseTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 10, 1, 11, 0, 0, DateTimeKind.Utc);
|
||||
private const string Lcu = "2026-09 Cumulative Update for Windows 11, version 25H2 for x64-based Systems (KB5099999)";
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null)
|
||||
{
|
||||
return new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
|
||||
payload: payload);
|
||||
}
|
||||
|
||||
private static DecisionSignal OsUpdate(long ordinal, DateTime at, string step, string? update = Lcu)
|
||||
{
|
||||
var payload = new Dictionary<string, string>
|
||||
{
|
||||
[OsUpdatePayloadKeys.Step] = step,
|
||||
[OsUpdatePayloadKeys.Source] = "wu",
|
||||
};
|
||||
if (update != null) payload[OsUpdatePayloadKeys.Update] = update;
|
||||
return MakeSignal(ordinal, DecisionSignalKind.OsUpdateActivity, at, payload);
|
||||
}
|
||||
|
||||
private static DecisionSignal AdvisoryFired(long ordinal, DateTime at) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, at,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.AdvisoryCompletion });
|
||||
|
||||
private static DecisionSignal Reboot(long ordinal, DateTime at) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.SystemRebootObserved, at);
|
||||
|
||||
/// <summary>The agent's mode stamp on <c>EnrollmentFactsObserved</c>, posted at every start.</summary>
|
||||
private static DecisionSignal PhaseMode(long ordinal, DateTime at, string mode) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.EnrollmentFactsObserved, at,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.OobeUpdatePhaseMode] = mode });
|
||||
|
||||
private static bool HasTimelineEffect(DecisionStep step, string eventType) =>
|
||||
step.Effects.Any(e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == eventType);
|
||||
|
||||
private static ActiveDeadline? FindDeadline(DecisionState state, string name) =>
|
||||
state.Deadlines.FirstOrDefault(d => d.Name == name);
|
||||
|
||||
private static DecisionEffect SingleTimelineEffect(DecisionStep step, string eventType) =>
|
||||
step.Effects.Single(e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == eventType);
|
||||
|
||||
/// <summary>
|
||||
/// The report's shape up to the arming exit: Classic user-driven, full ESP, DeviceSetup,
|
||||
/// the IME's pre-sign-in AccountSetup line, then the Device-ESP handoff exit at T0+21,
|
||||
/// which arms the window (due T0+51). <paramref name="beforeExit"/> runs before the exit;
|
||||
/// <paramref name="mode"/> is the agent's mode stamp (null: none, an older agent).
|
||||
/// </summary>
|
||||
private static DecisionState HandoffExitArmsTheWindow(
|
||||
DecisionEngine engine,
|
||||
Func<DecisionState, DecisionState>? beforeExit = null,
|
||||
string? mode = OobeUpdatePhaseModes.Active)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-3a220797", "tenant-3a220797", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
if (mode != null) state = engine.Reduce(state, PhaseMode(1, T0, mode)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.SkipUserEsp] = "false",
|
||||
[SignalPayloadKeys.SkipDeviceEsp] = "false",
|
||||
[SignalPayloadKeys.EspAllowContinueAnyway] = "false",
|
||||
})).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(20),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
|
||||
if (beforeExit != null) state = beforeExit(state);
|
||||
|
||||
state = engine.Reduce(state, MakeSignal(30, DecisionSignalKind.EspExiting, T0.AddMinutes(21))).NewState;
|
||||
var armed = FindDeadline(state, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(armed);
|
||||
Assert.Equal(T0.AddMinutes(51), armed!.DueAtUtc);
|
||||
Assert.Null(state.Outcome);
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires the armed advisory deadline at its due time until a fire does not re-arm it;
|
||||
/// returns every step. Proves each re-arm lands on time — the last one on the bound.
|
||||
/// </summary>
|
||||
private static List<DecisionStep> FireUntilResolved(DecisionEngine engine, DecisionState state, long firstOrdinal)
|
||||
{
|
||||
var steps = new List<DecisionStep>();
|
||||
var ordinal = firstOrdinal;
|
||||
while (true)
|
||||
{
|
||||
var deadline = FindDeadline(state, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(deadline);
|
||||
var step = engine.Reduce(state, AdvisoryFired(ordinal++, deadline!.DueAtUtc));
|
||||
steps.Add(step);
|
||||
state = step.NewState;
|
||||
if (FindDeadline(state, DeadlineNames.AdvisoryCompletion) == null) return steps;
|
||||
Assert.True(steps.Count < 50, "the phase never resolved");
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================= handler ====
|
||||
|
||||
[Fact]
|
||||
public void ActivityAfterTheHandoffExit_AnchorsThePhase()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
var first = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted));
|
||||
Assert.True(first.Transition.Taken);
|
||||
Assert.Equal("OsUpdateActivity:OobeUpdatePhaseStarted", first.Transition.Trigger);
|
||||
Assert.Equal(state.Stage, first.NewState.Stage);
|
||||
Assert.Empty(first.Effects);
|
||||
|
||||
var facts = first.NewState.OsUpdateFacts;
|
||||
Assert.Equal(T0.AddMinutes(22), facts.PostExitActivityUtc!.Value);
|
||||
Assert.Equal(40, facts.PostExitActivityUtc.SourceSignalOrdinal);
|
||||
Assert.True(DecisionEngine.IsInOobeUpdatePhase(first.NewState));
|
||||
|
||||
var second = engine.Reduce(first.NewState, OsUpdate(41, T0.AddMinutes(35), OsUpdateSteps.InstallStarted));
|
||||
Assert.Equal("OsUpdateActivity", second.Transition.Trigger);
|
||||
facts = second.NewState.OsUpdateFacts;
|
||||
Assert.Equal(T0.AddMinutes(22), facts.PostExitActivityUtc!.Value);
|
||||
Assert.Equal(T0.AddMinutes(35), facts.LastActivityUtc!.Value);
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, facts.LastStep!.Value);
|
||||
Assert.Equal(Lcu, facts.LastUpdate!.Value);
|
||||
Assert.Equal(2, facts.ActivityCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebootRequiredAndFailure_AreRecorded()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(30), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(31), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(42, T0.AddMinutes(32), OsUpdateSteps.Failed, update: null)).NewState;
|
||||
|
||||
var facts = state.OsUpdateFacts;
|
||||
Assert.Equal(T0.AddMinutes(30), facts.RebootRequiredUtc!.Value); // earliest
|
||||
Assert.Equal(T0.AddMinutes(32), facts.FailedUtc!.Value);
|
||||
Assert.Equal("KB5099999", facts.LastUpdate!.Value); // a step without an update keeps the last one
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivityWithoutStep_IsADeadEnd()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(40, DecisionSignalKind.OsUpdateActivity, T0.AddMinutes(22),
|
||||
new Dictionary<string, string> { [OsUpdatePayloadKeys.Source] = "wu" }));
|
||||
|
||||
Assert.False(step.Transition.Taken);
|
||||
Assert.Equal("os_update_activity_without_step", step.Transition.DeadEndReason);
|
||||
Assert.Equal(0, step.NewState.OsUpdateFacts.ActivityCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivityBeforeTheExit_IsNoPhase()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, beforeExit: s =>
|
||||
engine.Reduce(s, OsUpdate(25, T0.AddMinutes(15), OsUpdateSteps.Installed)).NewState);
|
||||
|
||||
Assert.NotNull(state.OsUpdateFacts.FirstActivityUtc);
|
||||
Assert.Null(state.OsUpdateFacts.PostExitActivityUtc);
|
||||
Assert.False(DecisionEngine.IsInOobeUpdatePhase(state));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledActivityThatPredatesTheExit_IsNoPhase()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(18), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
Assert.Null(state.OsUpdateFacts.PostExitActivityUtc);
|
||||
Assert.Equal(1, state.OsUpdateFacts.ActivityCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivityAfterTheUserSignedIn_IsNoPhase()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, MakeSignal(35, DecisionSignalKind.HelloWizardStarted, T0.AddMinutes(25))).NewState;
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(30), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
Assert.Null(state.OsUpdateFacts.PostExitActivityUtc);
|
||||
Assert.False(DecisionEngine.IsInOobeUpdatePhase(state));
|
||||
}
|
||||
|
||||
// ============================================================ restart facts ====
|
||||
|
||||
[Fact]
|
||||
public void RestartDuringThePhase_IsRecorded()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
state = engine.Reduce(state, Reboot(50, T0.AddMinutes(53))).NewState;
|
||||
|
||||
Assert.Equal(T0.AddMinutes(53), state.OsUpdateFacts.RestartUtc!.Value);
|
||||
Assert.Equal(50, state.OsUpdateFacts.RestartUtc.SourceSignalOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestartBeforeThePhase_OrAfterTheSignIn_IsNotTheUpdatesRestart()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, Reboot(35, T0.AddMinutes(21.5))).NewState; // no anchor yet
|
||||
Assert.Null(state.OsUpdateFacts.RestartUtc);
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(45, DecisionSignalKind.HelloWizardStarted, T0.AddMinutes(30))).NewState;
|
||||
state = engine.Reduce(state, Reboot(50, T0.AddMinutes(40))).NewState; // user already there
|
||||
|
||||
Assert.Null(state.OsUpdateFacts.RestartUtc);
|
||||
}
|
||||
|
||||
// ======================================================= advisory window ====
|
||||
|
||||
[Fact]
|
||||
public void Report3a2207978e4c_FireDuringTheUpdate_RearmsInsteadOfFailing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(35), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
var fireAt = T0.AddMinutes(51);
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, fireAt));
|
||||
|
||||
Assert.True(step.Transition.Taken);
|
||||
Assert.Null(step.NewState.Outcome);
|
||||
Assert.NotEqual(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdatePhase", step.Transition.Trigger);
|
||||
|
||||
var rearmed = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.Equal(fireAt.AddMinutes(30), rearmed!.DueAtUtc);
|
||||
var schedule = step.Effects.Single(e => e.Kind == DecisionEffectKind.ScheduleDeadline);
|
||||
Assert.Equal(DeadlineNames.AdvisoryCompletion, schedule.Deadline!.Name);
|
||||
|
||||
var waiting = SingleTimelineEffect(step, "completion_waiting");
|
||||
Assert.Contains("installing an update in OOBE", waiting.Parameters!["message"]);
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, waiting.Parameters!["osUpdateLastStep"]);
|
||||
Assert.Equal(Lcu, waiting.Parameters!["osUpdate"]);
|
||||
Assert.Equal(rearmed.DueAtUtc.ToString("o"), waiting.Parameters!["resolutionDeadlineDueAtUtc"]);
|
||||
// The update may run 3 h from its first activity.
|
||||
Assert.Equal(T0.AddMinutes(22).AddHours(3).ToString("o"), waiting.Parameters!["oobeUpdateHoldsUntilUtc"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutUpdateActivity_TheFireStillFails()
|
||||
{
|
||||
// Mutation proof: the guard keys on observed update activity, never on its absence.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("esp_exit_without_completion_evidence", SingleTimelineEffect(step, "enrollment_failed").Parameters!["reason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivityOnlyBeforeTheExit_TheFireStillFails()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, beforeExit: s =>
|
||||
engine.Reduce(s, OsUpdate(25, T0.AddMinutes(15), OsUpdateSteps.Installed)).NewState);
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateStillRunningAtTheCap_FailsAsNotFinished()
|
||||
{
|
||||
// A hanging update: the last step says it still runs, and no restart ever comes.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(30), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
var steps = FireUntilResolved(engine, state, firstOrdinal: 100);
|
||||
|
||||
Assert.All(steps.Take(steps.Count - 1), s =>
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdatePhase", s.Transition.Trigger));
|
||||
var cap = T0.AddMinutes(22).AddHours(3);
|
||||
// The last re-arm lands on the cap, not up to 30 min after it.
|
||||
Assert.Equal(cap, FindDeadline(steps[steps.Count - 2].NewState, DeadlineNames.AdvisoryCompletion)!.DueAtUtc);
|
||||
|
||||
var resolved = steps.Last();
|
||||
Assert.Equal(SessionStage.Failed, resolved.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentFailed, resolved.NewState.Outcome);
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdateNotFinished", resolved.Transition.Trigger);
|
||||
var failed = SingleTimelineEffect(resolved, "enrollment_failed");
|
||||
Assert.Equal("oobe_update_not_finished", failed.Parameters!["reason"]);
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, failed.Parameters!["osUpdateLastStep"]);
|
||||
Assert.Equal("3", failed.Parameters!["oobeUpdateCapHours"]);
|
||||
Assert.Null(resolved.NewState.StoppedWaitingReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateWithRestart_WaitsSixtyMinutesForTheSignIn_ThenStopsWaiting()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(40), OsUpdateSteps.InstallStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(42, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
|
||||
var beforeRestart = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdateAwaitingSignIn", beforeRestart.Transition.Trigger);
|
||||
state = beforeRestart.NewState;
|
||||
|
||||
// The update page restarts the device; the lock screen waits for the user.
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
Assert.Equal(T0.AddMinutes(83), FindDeadline(state, DeadlineNames.AdvisoryCompletion)!.DueAtUtc); // re-based
|
||||
|
||||
var steps = FireUntilResolved(engine, state, firstOrdinal: 100);
|
||||
|
||||
var waiting = steps.Take(steps.Count - 1).ToList();
|
||||
Assert.All(waiting, s => Assert.Equal("DeadlineFired:advisory_completion:OobeUpdateAwaitingSignIn", s.Transition.Trigger));
|
||||
Assert.Contains("waiting for the user to sign in",
|
||||
SingleTimelineEffect(waiting[0], "completion_waiting").Parameters!["message"]);
|
||||
|
||||
var stopped = steps.Last();
|
||||
Assert.Equal(T0.AddMinutes(53).AddMinutes(60), stopped.Transition.OccurredAtUtc);
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdateSignInWaitExpired", stopped.Transition.Trigger);
|
||||
Assert.True(stopped.Transition.Taken);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, stopped.NewState.StoppedWaitingReason!.Value);
|
||||
// No verdict: the stage stays, nothing is failed, nothing is armed any more.
|
||||
Assert.Equal(state.Stage, stopped.NewState.Stage);
|
||||
Assert.Null(stopped.NewState.Outcome);
|
||||
Assert.Empty(stopped.NewState.Deadlines);
|
||||
|
||||
var expired = SingleTimelineEffect(stopped, "completion_wait_expired");
|
||||
Assert.Equal("Warning", expired.Parameters!["severity"]);
|
||||
Assert.Equal("true", expired.Parameters!["immediateUpload"]);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, expired.Parameters!["reason"]);
|
||||
Assert.Equal("60", expired.Parameters!["signInWindowMinutes"]);
|
||||
Assert.Equal(T0.AddMinutes(53).ToString("o"), expired.Parameters!["oobeUpdateEndedUtc"]);
|
||||
Assert.Contains("Not a failure", expired.Parameters!["message"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateEndingWithoutRestart_SignInWindowStartsAtItsLastStep()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(40), OsUpdateSteps.Installed)).NewState;
|
||||
|
||||
var steps = FireUntilResolved(engine, state, firstOrdinal: 100);
|
||||
|
||||
var stopped = steps.Last();
|
||||
Assert.Equal(T0.AddMinutes(100), stopped.Transition.OccurredAtUtc);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, stopped.NewState.StoppedWaitingReason!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledOlderStepAfterTheRestart_DoesNotReopenTheUpdate()
|
||||
{
|
||||
// The watchers backfill after the restart: a record written before it arrives late.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(70, T0.AddMinutes(45), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
Assert.Equal(OsUpdateSteps.RebootRequired, state.OsUpdateFacts.LastStep!.Value);
|
||||
var phase = DecisionEngine.EvaluateOobeUpdatePhase(state, T0.AddMinutes(83));
|
||||
Assert.Equal(DecisionEngine.OobeUpdatePhaseKind.AwaitingSignIn, phase.Kind);
|
||||
Assert.Equal(T0.AddMinutes(113), phase.BoundUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewerUpdateStepAfterTheRestart_ReopensTheUpdate_BoundedByTheCap()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(70, T0.AddMinutes(60), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var phase = DecisionEngine.EvaluateOobeUpdatePhase(state, T0.AddMinutes(83));
|
||||
Assert.Equal(DecisionEngine.OobeUpdatePhaseKind.Updating, phase.Kind);
|
||||
Assert.Equal(T0.AddMinutes(22).AddHours(3), phase.BoundUtc); // the cap counts from the first activity
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserSignIn_GetsOneFullWindow_ThenTheFireResolvesNormally()
|
||||
{
|
||||
// The transition to AwaitingHello does not cancel the window. Without the grace a fire
|
||||
// right after the sign-in would fail the session inside the Hello wizard.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51))).NewState; // due T0+81
|
||||
|
||||
state = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.HelloWizardStarted, T0.AddMinutes(75))).NewState;
|
||||
Assert.False(DecisionEngine.IsInOobeUpdatePhase(state));
|
||||
|
||||
// 6 minutes after the sign-in — inside the grace, which ends at T0+105.
|
||||
var grace = engine.Reduce(state, AdvisoryFired(70, T0.AddMinutes(81)));
|
||||
Assert.Null(grace.NewState.Outcome);
|
||||
Assert.Equal("DeadlineFired:advisory_completion:OobeUpdatePhaseUserArrived", grace.Transition.Trigger);
|
||||
Assert.Contains("signed in after the OOBE update", SingleTimelineEffect(grace, "completion_waiting").Parameters!["message"]);
|
||||
Assert.Equal(T0.AddMinutes(105), FindDeadline(grace.NewState, DeadlineNames.AdvisoryCompletion)!.DueAtUtc);
|
||||
|
||||
// One full window after the sign-in, still no completion evidence — resolved the normal way.
|
||||
var resolved = engine.Reduce(grace.NewState, AdvisoryFired(80, T0.AddMinutes(105)));
|
||||
Assert.Equal(SessionStage.Failed, resolved.NewState.Stage);
|
||||
Assert.Equal("esp_exit_without_completion_evidence", SingleTimelineEffect(resolved, "enrollment_failed").Parameters!["reason"]);
|
||||
Assert.Null(resolved.NewState.StoppedWaitingReason);
|
||||
// Active decides itself — no shadow report.
|
||||
Assert.False(HasTimelineEffect(resolved, "oobe_update_phase_shadow"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdvisoryVariant_IsExempt()
|
||||
{
|
||||
// Mutation proof: an unresolved ESP terminal failure does not un-happen because
|
||||
// Windows installs an update afterwards.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
Assert.True(DecisionEngine.IsInOobeUpdatePhase(state));
|
||||
|
||||
var builder = state.ToBuilder();
|
||||
builder.EspAdvisoryFailureRecordedUtc = new SignalFact<DateTime>(T0.AddMinutes(21), 30);
|
||||
state = builder.Build();
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("esp_terminal_failure", SingleTimelineEffect(step, "enrollment_failed").Parameters!["reason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoPhase_WithoutAnAnchor()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
|
||||
Assert.Equal(DecisionEngine.OobeUpdatePhaseKind.None,
|
||||
DecisionEngine.EvaluateOobeUpdatePhase(state, T0.AddMinutes(51)).Kind);
|
||||
}
|
||||
|
||||
// ================================================= serialization + census ====
|
||||
|
||||
[Fact]
|
||||
public void SnapshotRoundTrip_PreservesTheUpdateFacts_AndTheStop()
|
||||
{
|
||||
// The update ends with a restart; the agent restores the engine state from its
|
||||
// snapshot, so the phase — and a decided stop — must survive it.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
var stopped = FireUntilResolved(engine, state, firstOrdinal: 100).Last().NewState;
|
||||
|
||||
var roundtripped = StateSerializer.Deserialize(StateSerializer.Serialize(stopped));
|
||||
|
||||
var facts = roundtripped.OsUpdateFacts;
|
||||
Assert.Equal(T0.AddMinutes(22), facts.PostExitActivityUtc!.Value);
|
||||
Assert.Equal(T0.AddMinutes(48), facts.RebootRequiredUtc!.Value);
|
||||
Assert.Equal(T0.AddMinutes(53), facts.RestartUtc!.Value);
|
||||
Assert.Equal(OsUpdateSteps.RebootRequired, facts.LastStep!.Value);
|
||||
Assert.Equal(StoppedWaitingReasons.OobeUpdateNoSignIn, roundtripped.StoppedWaitingReason!.Value);
|
||||
Assert.Equal(OobeUpdatePhaseModes.Active, roundtripped.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacySnapshot_WithoutTheNewFacts_DeserializesToEmpty()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var legacy = JObject.Parse(StateSerializer.Serialize(state));
|
||||
foreach (var prop in legacy.Properties()
|
||||
.Where(p => p.Name.IndexOf("OsUpdateFacts", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| p.Name.IndexOf("StoppedWaitingReason", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
.ToList())
|
||||
{
|
||||
prop.Remove();
|
||||
}
|
||||
|
||||
var roundtripped = StateSerializer.Deserialize(legacy.ToString());
|
||||
Assert.Same(OsUpdateFacts.Empty, roundtripped.OsUpdateFacts);
|
||||
Assert.Null(roundtripped.StoppedWaitingReason);
|
||||
Assert.False(DecisionEngine.IsInOobeUpdatePhase(roundtripped));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_SurfacesThePhase_AndTheStop()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine);
|
||||
Assert.DoesNotContain("oobe_update_phase", DecisionStateSignalCensus.Build(state).SignalsSeen);
|
||||
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.Installed)).NewState;
|
||||
var census = DecisionStateSignalCensus.Build(state);
|
||||
Assert.Contains("oobe_update_phase", census.SignalsSeen);
|
||||
Assert.Equal(T0.AddMinutes(22).ToString("o"), census.SignalTimestamps["oobeUpdatePhase"]);
|
||||
Assert.DoesNotContain("stopped_waiting", census.SignalsSeen);
|
||||
|
||||
var stopped = FireUntilResolved(engine, state, firstOrdinal: 100).Last().NewState;
|
||||
Assert.Contains("stopped_waiting", DecisionStateSignalCensus.Build(stopped).SignalsSeen);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OobeUpdatePhaseModes.Active, "Active")]
|
||||
[InlineData(OobeUpdatePhaseModes.Shadow, "Shadow")]
|
||||
[InlineData(null, "Shadow")] // never stamped
|
||||
public void Census_NamesTheModeThePhaseRanIn(string? mode, string expected)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: mode);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var evidence = (IDictionary<string, object>)DecisionStateSignalCensus.Build(state).SignalEvidence["oobeUpdatePhase"];
|
||||
Assert.Equal(expected, evidence["mode"]);
|
||||
}
|
||||
|
||||
// ======================================================= measurement mode ====
|
||||
|
||||
[Fact]
|
||||
public void ShadowMode_ReportsWhatThePhaseWouldDecide_AndTodaysRuleDecides()
|
||||
{
|
||||
// Report 3a2207978e4c in the Shadow mode: the fire mid-update fails the session as
|
||||
// before, and says that the phase would have kept waiting.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Shadow);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(35), OsUpdateSteps.InstallStarted)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("DeadlineFired:advisory_completion", step.Transition.Trigger);
|
||||
Assert.Equal("esp_exit_without_completion_evidence", SingleTimelineEffect(step, "enrollment_failed").Parameters!["reason"]);
|
||||
|
||||
var shadow = SingleTimelineEffect(step, "oobe_update_phase_shadow");
|
||||
var parameters = shadow.Parameters!;
|
||||
Assert.Equal("Debug", parameters["severity"]);
|
||||
Assert.Equal(OobeUpdatePhaseModes.Shadow, parameters["phaseMode"]);
|
||||
Assert.Equal("update_running", parameters["phaseState"]);
|
||||
Assert.Equal("hold", parameters["wouldDecide"]);
|
||||
Assert.Equal("esp_exit_without_completion_evidence", parameters["decidedReason"]);
|
||||
Assert.Equal(T0.AddMinutes(22).AddHours(3).ToString("o"), parameters["phaseBoundUtc"]);
|
||||
Assert.Equal(T0.AddMinutes(22).ToString("o"), parameters["oobeUpdatePhaseStartedUtc"]);
|
||||
Assert.Equal(OsUpdateSteps.InstallStarted, parameters["osUpdateLastStep"]);
|
||||
Assert.Equal(Lcu, parameters["osUpdate"]);
|
||||
Assert.Contains("still installing an update", parameters["message"]);
|
||||
// The report precedes the verdict on the timeline.
|
||||
Assert.True(step.Effects.ToList().IndexOf(shadow) < step.Effects.ToList().IndexOf(SingleTimelineEffect(step, "enrollment_failed")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShadowMode_AfterTheUpdatesRestart_ReportsTheSignInWait()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Shadow);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState; // re-bases the window to T0+83
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(70, T0.AddMinutes(83)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
var parameters = SingleTimelineEffect(step, "oobe_update_phase_shadow").Parameters!;
|
||||
Assert.Equal("awaiting_sign_in", parameters["phaseState"]);
|
||||
Assert.Equal("hold", parameters["wouldDecide"]);
|
||||
Assert.Equal(T0.AddMinutes(53).ToString("o"), parameters["oobeUpdateEndedUtc"]);
|
||||
Assert.Equal(T0.AddMinutes(113).ToString("o"), parameters["phaseBoundUtc"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShadowMode_HangingUpdate_ReportsTheNotFinishedFailure()
|
||||
{
|
||||
// A fire after the cap (a shadow session lives this long only when another guard held
|
||||
// it, e.g. enforcement progress) reports the not-finished failure the phase would give.
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Shadow);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.InstallStarted)).NewState;
|
||||
var afterCap = T0.AddMinutes(22).AddHours(3).AddMinutes(5);
|
||||
var armed = FindDeadline(state, DeadlineNames.AdvisoryCompletion)!;
|
||||
state = state.ToBuilder()
|
||||
.CancelDeadline(DeadlineNames.AdvisoryCompletion)
|
||||
.AddDeadline(new ActiveDeadline(armed.Name, afterCap, armed.FiresSignalKind, armed.FiresPayload))
|
||||
.Build();
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, afterCap));
|
||||
|
||||
Assert.Equal("esp_exit_without_completion_evidence", SingleTimelineEffect(step, "enrollment_failed").Parameters!["reason"]);
|
||||
var parameters = SingleTimelineEffect(step, "oobe_update_phase_shadow").Parameters!;
|
||||
Assert.Equal("update_not_finished", parameters["phaseState"]);
|
||||
Assert.Equal("fail_oobe_update_not_finished", parameters["wouldDecide"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnstampedSession_CountsAsShadow()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: null);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("update_running", SingleTimelineEffect(step, "oobe_update_phase_shadow").Parameters!["phaseState"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OffMode_EvaluatesNothing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Off);
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.False(HasTimelineEffect(step, "oobe_update_phase_shadow"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShadowMode_WithoutPhase_ReportsNothing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine, mode: OobeUpdatePhaseModes.Shadow);
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(50, T0.AddMinutes(51)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.False(HasTimelineEffect(step, "oobe_update_phase_shadow"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModeStamp_ActiveCanOnlyBeTakenAway_NeverGranted()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var initial = engine.Reduce(DecisionState.CreateInitial("s", "t", T0), MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
// A later agent start in Shadow or Off takes Active away (kill switch) ...
|
||||
var state = engine.Reduce(initial, PhaseMode(1, T0, "active")).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Active, state.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
state = engine.Reduce(state, PhaseMode(2, T0.AddMinutes(60), OobeUpdatePhaseModes.Active)).NewState;
|
||||
Assert.Equal(1, state.ScenarioObservations.OobeUpdatePhaseMode!.SourceSignalOrdinal);
|
||||
state = engine.Reduce(state, PhaseMode(3, T0.AddMinutes(90), OobeUpdatePhaseModes.Off)).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Off, state.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
Assert.Equal(3, state.ScenarioObservations.OobeUpdatePhaseMode.SourceSignalOrdinal);
|
||||
|
||||
// ... and switching it on mid-session applies from the next session only.
|
||||
state = engine.Reduce(state, PhaseMode(4, T0.AddMinutes(120), OobeUpdatePhaseModes.Active)).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Off, state.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
|
||||
var shadow = engine.Reduce(initial, PhaseMode(1, T0, "unknown-mode")).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Shadow, shadow.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
shadow = engine.Reduce(shadow, PhaseMode(2, T0.AddMinutes(60), OobeUpdatePhaseModes.Active)).NewState;
|
||||
Assert.Equal(OobeUpdatePhaseModes.Shadow, shadow.ScenarioObservations.OobeUpdatePhaseMode!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveTakenAwayAfterTheRestart_TheFireFallsBackToTodaysRule()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = HandoffExitArmsTheWindow(engine); // Active
|
||||
state = engine.Reduce(state, OsUpdate(40, T0.AddMinutes(22), OsUpdateSteps.DownloadStarted)).NewState;
|
||||
state = engine.Reduce(state, OsUpdate(41, T0.AddMinutes(48), OsUpdateSteps.RebootRequired, "KB5099999")).NewState;
|
||||
state = engine.Reduce(state, Reboot(60, T0.AddMinutes(53))).NewState;
|
||||
// The agent comes back after the update's restart with the phase switched back to Shadow.
|
||||
state = engine.Reduce(state, PhaseMode(61, T0.AddMinutes(54), OobeUpdatePhaseModes.Shadow)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, AdvisoryFired(70, T0.AddMinutes(83)));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal("awaiting_sign_in", SingleTimelineEffect(step, "oobe_update_phase_shadow").Parameters!["phaseState"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -90,6 +90,11 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||
lastClassifierVerdictId: "verdict-" + seed));
|
||||
}
|
||||
if (type == typeof(RealmJoinFacts)) return RealmJoinFacts.Empty.WithDetected(BaseUtc.AddMinutes(seed), ordinal);
|
||||
if (type == typeof(OsUpdateFacts))
|
||||
{
|
||||
return OsUpdateFacts.Empty.WithActivity(
|
||||
BaseUtc.AddMinutes(seed), ordinal, OsUpdateSteps.RebootRequired, "KB-" + seed, isPostExit: true);
|
||||
}
|
||||
|
||||
throw new Xunit.Sdk.XunitException(
|
||||
$"No value factory for builder property type {type}. A new DecisionState field " +
|
||||
|
||||
@@ -207,8 +207,8 @@ public class BuiltInRulesTests
|
||||
/// Sessions 81daa77f / 75d6ae8e (2026-08-25). IME writes this line and then returns before
|
||||
/// its "Completed user session" statement, so on a device with no user-targeted Win32 apps
|
||||
/// it is the ONLY evidence that user-phase enforcement finished. The exact wording is
|
||||
/// pinned here against the real IME line (verified against decompiled IME 1.104.102.0,
|
||||
/// ApplicationPoller and AppWorkloadAbstraction) because a silent drift would bring back
|
||||
/// pinned here against the real IME line (verified against IME 1.104.102.0) because a
|
||||
/// silent drift would bring back
|
||||
/// the 30-min esp_exit_without_completion_evidence false positive with no test failing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CpeMatchScorer.ProductKey"/> turns a finding's CPE ("base CPE:installed version")
|
||||
/// into the product identity the exposure summary groups by.
|
||||
/// </summary>
|
||||
public class CpeProductKeyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:7-zip:7-zip:24.08", "7-zip:7-zip")]
|
||||
[InlineData("cpe:2.3:a:7-zip:7-zip:", "7-zip:7-zip")] // finding without a version
|
||||
[InlineData("cpe:2.3:a:Microsoft:Teams:1.6.00.4472", "microsoft:teams")]
|
||||
[InlineData("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*:1.2.3", "contoso:widget")] // custom mapping typed in full
|
||||
[InlineData(@"cpe:2.3:a:microsoft:visual_c\+\+:14.0", @"microsoft:visual_c\+\+")] // CPE escapes stay part of the key
|
||||
[InlineData("cpe:2.3:a:*:*:1.0", "")]
|
||||
[InlineData("cpe:2.3:a", "")]
|
||||
[InlineData("", "")]
|
||||
[InlineData(null, "")]
|
||||
public void ProductKey_IsLowerCaseVendorAndProduct(string? cpe, string expected)
|
||||
=> Assert.Equal(expected, CpeMatchScorer.ProductKey(cpe));
|
||||
}
|
||||
@@ -122,4 +122,22 @@ public class CveIndexEntityMergeTests
|
||||
|
||||
Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, findings));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Product_IsTheCpeVendorAndProduct_ofTheWinningOccurrence()
|
||||
{
|
||||
var zip = Finding("7-Zip 24.08 (x64 edition)", "high", Vuln("CVE-1", 7.8, "HIGH"), Vuln("CVE-2", 3.3, "LOW"));
|
||||
zip["cpeUri"] = "cpe:2.3:a:7-zip:7-zip:24.08";
|
||||
var teams = Finding("Microsoft Teams", "high", Vuln("CVE-1", 8.8, "HIGH"));
|
||||
teams["cpeUri"] = "cpe:2.3:a:microsoft:teams:1.6.00.4472";
|
||||
var noCpe = Finding("Contoso Tool", "high", Vuln("CVE-3", 7.0, "HIGH"));
|
||||
|
||||
var entities = TableStorageService.BuildCveIndexEntities(Tenant, Session, new List<Dictionary<string, object>> { zip, teams, noCpe });
|
||||
|
||||
var cve1 = entities.Single(x => x.GetString("CveId") == "CVE-1");
|
||||
Assert.Equal("Microsoft Teams", cve1.GetString("SoftwareName")); // name and product move together
|
||||
Assert.Equal("microsoft:teams", cve1.GetString("Product"));
|
||||
Assert.Equal("7-zip:7-zip", entities.Single(x => x.GetString("CveId") == "CVE-2").GetString("Product"));
|
||||
Assert.Equal("", entities.Single(x => x.GetString("CveId") == "CVE-3").GetString("Product"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,6 +422,31 @@ public class EnrollmentTimeoutClassifierTests
|
||||
Assert.Equal(ClassifierRules.R1bWhiteGloveAwaiting, wgRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_engine_stopped_waiting_after_the_oobe_update_is_never_failed()
|
||||
{
|
||||
// D-310: nobody signed in within the sign-in window after the OOBE update, the engine
|
||||
// stopped waiting and the agent ended the session. Same honest classification as the
|
||||
// watchdog — user-driven terminalizes as Incomplete without naming the watchdog, a
|
||||
// WhiteGlove Part 2 stays AwaitingUser.
|
||||
var stopped = Evt("agent_shutting_down", data: new Dictionary<string, object>
|
||||
{
|
||||
["reason"] = "stopped_waiting",
|
||||
["stopReason"] = "oobe_update_no_sign_in",
|
||||
});
|
||||
|
||||
var (status, reason) = Classify(new[] { Esp(DeviceSetup44), stopped }, hoursSinceStart: 2);
|
||||
Assert.Equal(SessionStatus.Incomplete, status);
|
||||
Assert.Contains("stopped waiting for the user", reason);
|
||||
Assert.DoesNotContain("max-lifetime", reason);
|
||||
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(DeviceSetup44), stopped });
|
||||
var (wgStatus, _, wgRule) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, Start.AddHours(2), 72, isPreProvisioned: true, resumedAt: Start.AddHours(1));
|
||||
Assert.Equal(SessionStatus.AwaitingUser, wgStatus);
|
||||
Assert.Equal(ClassifierRules.R1bWhiteGloveAwaiting, wgRule);
|
||||
}
|
||||
|
||||
// -------- Rule 5a: completed (assumed) — calibration read 2026-08-27 --------
|
||||
|
||||
[Fact]
|
||||
@@ -429,12 +454,21 @@ public class EnrollmentTimeoutClassifierTests
|
||||
{
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("app_install_failed") }).HasAppInstallFailure);
|
||||
var timeout = Evt("enrollment_failed", data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { timeout }).HasAgentMaxLifetimeTimeout);
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { timeout }).AgentStoppedForGood);
|
||||
var shutdown = Evt("agent_shutting_down", data: new Dictionary<string, object> { ["reason"] = "max_lifetime" });
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { shutdown }).HasAgentMaxLifetimeTimeout);
|
||||
// Other shutdown reasons say nothing about max lifetime.
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { shutdown }).AgentStoppedForGood);
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { shutdown }).AgentStoppedWaiting);
|
||||
// D-310: the engine's bounded wait expired — the agent is gone just the same.
|
||||
var stopped = Evt("agent_shutting_down", data: new Dictionary<string, object>
|
||||
{
|
||||
["reason"] = "stopped_waiting",
|
||||
["stopReason"] = "oobe_update_no_sign_in",
|
||||
});
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { stopped }).AgentStoppedForGood);
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { stopped }).AgentStoppedWaiting);
|
||||
// Other shutdown reasons say nothing about the agent being gone for good.
|
||||
var ctrlC = Evt("agent_shutting_down", data: new Dictionary<string, object> { ["reason"] = "ctrl_c" });
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { ctrlC }).HasAgentMaxLifetimeTimeout);
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { ctrlC }).AgentStoppedForGood);
|
||||
// A real failure event does not raise the app flag.
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_failed") }).HasAppInstallFailure);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,37 @@ public class IngestMaxLifetimeShutdownTests
|
||||
Assert.False(EventIngestProcessor.IsMaxLifetimeAgentShutdown(null));
|
||||
}
|
||||
|
||||
// -------- stopped_waiting (D-310) --------
|
||||
// The engine stopped waiting for the user (no sign-in after the OOBE update) and the agent
|
||||
// ended the session like its watchdog: same contract, classified through the same path.
|
||||
|
||||
[Fact]
|
||||
public void StoppedWaitingReason_IsDetected_AsGaveUp_ButNotAsMaxLifetime()
|
||||
{
|
||||
var stopped = Shutdown("stopped_waiting");
|
||||
|
||||
Assert.True(EventIngestProcessor.IsStoppedWaitingAgentShutdown(stopped));
|
||||
Assert.True(EventIngestProcessor.IsAgentGaveUpShutdown(stopped));
|
||||
Assert.False(EventIngestProcessor.IsMaxLifetimeAgentShutdown(stopped));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxLifetimeReason_IsAlsoGaveUp()
|
||||
{
|
||||
Assert.True(EventIngestProcessor.IsAgentGaveUpShutdown(Shutdown("max_lifetime")));
|
||||
Assert.False(EventIngestProcessor.IsStoppedWaitingAgentShutdown(Shutdown("max_lifetime")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("decision_terminal")]
|
||||
[InlineData("ctrl_c")]
|
||||
[InlineData(null)]
|
||||
public void OtherShutdowns_AreNotGaveUp(string? reason)
|
||||
{
|
||||
Assert.False(EventIngestProcessor.IsAgentGaveUpShutdown(Shutdown(reason)));
|
||||
Assert.False(EventIngestProcessor.IsAgentGaveUpShutdown(null));
|
||||
}
|
||||
|
||||
// -------- IsAgentTimeoutFailure (misclassification audit 2026-07-16) --------
|
||||
// The V1-parity companion: enrollment_failed(failureType=agent_timeout) emitted by
|
||||
// LifecycleEmitters.CreateMaxLifetimeEmitter. Same semantics as the shutdown shape —
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A stored RuleResult keeps the rule's {{token}} template. The web and the MCP server resolve
|
||||
/// it when they render; a notification has no renderer, so the backend resolves it before the
|
||||
/// text leaves. Three things are pinned here: the backend resolution equals the shared cases
|
||||
/// every renderer runs, no placeholder reaches a notification for any built-in rule, and the
|
||||
/// cut happens after the resolution and on a text boundary.
|
||||
/// </summary>
|
||||
public class RuleTemplateInterpolatorTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "0f1e2d3c-4b5a-4978-8a6b-5c4d3e2f1a0b";
|
||||
private const string FailedAppName = "Contoso Productivity Suite";
|
||||
|
||||
// ── Shared cases (tests/fixtures/rule-template-interpolation) ─────────
|
||||
|
||||
private static readonly Lazy<JObject> Fixture = new(() =>
|
||||
{
|
||||
var path = Path.Combine(FindRepoRoot(), "tests", "fixtures", "rule-template-interpolation", "cases.json");
|
||||
// No date parsing: a timestamp-looking string must stay the string the renderers see.
|
||||
return JsonConvert.DeserializeObject<JObject>(
|
||||
File.ReadAllText(path), new JsonSerializerSettings { DateParseHandling = DateParseHandling.None })!;
|
||||
});
|
||||
|
||||
public static IEnumerable<object[]> CaseNames()
|
||||
=> Fixture.Value["cases"]!.Select(c => new object[] { c.Value<string>("name")! });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CaseNames))]
|
||||
public void Interpolate_matches_the_shared_case(string name)
|
||||
{
|
||||
var testCase = Fixture.Value["cases"]!.Single(c => c.Value<string>("name") == name);
|
||||
var template = testCase.Value<string?>("template");
|
||||
var expected = testCase.Value<string>("expected")!;
|
||||
var evidence = testCase["matchedConditions"] as JObject;
|
||||
|
||||
// Evidence as the rule engine builds it (nested dictionaries) ...
|
||||
Assert.Equal(expected, RuleTemplateInterpolator.Interpolate(template, AsDictionaries(evidence)));
|
||||
// ... and as a JSON read without normalization leaves it (JSON nodes as values).
|
||||
Assert.Equal(expected, RuleTemplateInterpolator.Interpolate(template, AsJsonNodes(evidence)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_cases_are_present_and_cover_every_auto_field()
|
||||
{
|
||||
Assert.True(Fixture.Value["cases"]!.Count() >= 20, "cases.json lost cases — the parity guard is only as wide as the file.");
|
||||
Assert.Equal(
|
||||
Fixture.Value["autoFields"]!.ToObject<string[]>()!,
|
||||
RuleEngine.EvidenceAutoFields);
|
||||
}
|
||||
|
||||
// ── Notification variant ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Notification_drops_a_list_item_whose_value_was_not_recorded()
|
||||
{
|
||||
var text = "Facts:\n\n- **Reason:** `{{reason}}`\n- **ESP HRESULT:** `{{errorCode}}`\n* **App:** {{appName}}\n1. step {{exitCode}}\n\nDone.";
|
||||
var evidence = new Dictionary<string, object>
|
||||
{
|
||||
["enrollment_failed_reason"] = new Dictionary<string, object> { ["field"] = "reason", ["value"] = "hello_timeout" },
|
||||
};
|
||||
|
||||
var rendered = RuleTemplateInterpolator.InterpolateForNotification(text, evidence);
|
||||
|
||||
Assert.Equal("Facts:\n\n- **Reason:** `hello_timeout`\n\nDone.", rendered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Notification_replaces_an_unrecorded_value_in_running_text()
|
||||
{
|
||||
var rendered = RuleTemplateInterpolator.InterpolateForNotification(
|
||||
"App **{{appName}}** failed with HRESULT **{{errorCode}}**.",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["failed_app"] = new Dictionary<string, object> { ["field"] = "appName", ["value"] = FailedAppName },
|
||||
});
|
||||
|
||||
Assert.Equal($"App **{FailedAppName}** failed with HRESULT **–**.", rendered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Notification_removes_the_sentence_about_literal_placeholders()
|
||||
{
|
||||
var rendered = RuleTemplateInterpolator.InterpolateForNotification(
|
||||
"See ANALYZE-APP-013 for the root cause. Any field still shown as a literal `{{…}}` placeholder was simply not recorded. Check the timeline.",
|
||||
new Dictionary<string, object>());
|
||||
|
||||
Assert.Equal("See ANALYZE-APP-013 for the root cause. Check the timeline.", rendered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Notification_without_evidence_or_text_is_safe()
|
||||
{
|
||||
Assert.Equal(string.Empty, RuleTemplateInterpolator.InterpolateForNotification(null, null));
|
||||
Assert.Equal(string.Empty, RuleTemplateInterpolator.InterpolateForNotification("", new Dictionary<string, object>()));
|
||||
Assert.Equal("Reason: –", RuleTemplateInterpolator.InterpolateForNotification("Reason: {{reason}}", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Notification_collapses_the_gap_a_dropped_paragraph_leaves()
|
||||
{
|
||||
var rendered = RuleTemplateInterpolator.InterpolateForNotification(
|
||||
"First.\n\nOnly a literal `{{…}}` remark.\n\nLast.", new Dictionary<string, object>());
|
||||
|
||||
Assert.Equal("First.\n\nLast.", rendered);
|
||||
}
|
||||
|
||||
// ── No placeholder leaves the backend ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void No_builtin_rule_sends_a_placeholder_whatever_the_evidence()
|
||||
{
|
||||
// Worst case for every rule: nothing recorded at all. Both alert builders are the only
|
||||
// paths that put a rule explanation into a notification.
|
||||
var offenders = new List<string>();
|
||||
var templated = 0;
|
||||
|
||||
foreach (var rule in BuiltInAnalyzeRules.GetAll())
|
||||
{
|
||||
if (rule.Explanation.Contains("{{")) templated++;
|
||||
|
||||
var result = new RuleResult
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
RuleTitle = rule.Title,
|
||||
Severity = "critical",
|
||||
Category = rule.Category,
|
||||
Explanation = rule.Explanation,
|
||||
};
|
||||
|
||||
var fired = NotificationAlertBuilder.BuildRuleFiredAlert(result, "DESKTOP-TEST01", "SN-0001");
|
||||
var enrollment = NotificationAlertBuilder.BuildEnrollmentAlert(
|
||||
"DESKTOP-TEST01", "SN-0001", null, null, success: false, failureReason: "x", duration: null);
|
||||
NotificationAlertBuilder.AddRuleResultSections(enrollment, new List<RuleResult> { result });
|
||||
|
||||
foreach (var section in fired.Sections.Concat(enrollment.Sections))
|
||||
{
|
||||
if (section.Text.Contains("{{") || section.Text.Contains("}}"))
|
||||
offenders.Add($"{rule.RuleId}: {section.Text}");
|
||||
Assert.True(section.Text.Length <= NotificationAlertBuilder.RuleFiredExplanationMaxLength);
|
||||
}
|
||||
}
|
||||
|
||||
// Positive control: the catalog really contains templated rules, so an empty offender
|
||||
// list is a statement about them and not about a catalog without placeholders.
|
||||
Assert.True(templated >= 10, $"Only {templated} built-in rules carry a placeholder — did the catalog load?");
|
||||
Assert.True(offenders.Count == 0, "Placeholder reached a notification:\n" + string.Join("\n", offenders));
|
||||
}
|
||||
|
||||
// ── End to end: engine evidence → alert ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Rule_fired_alert_shows_the_values_the_engine_recorded()
|
||||
{
|
||||
var result = await RunEnrollmentFailedRuleAsync(withEspFailure: true);
|
||||
|
||||
var alert = NotificationAlertBuilder.BuildRuleFiredAlert(result, "DESKTOP-TEST01", "SN-0001");
|
||||
|
||||
var text = Assert.Single(alert.Sections).Text;
|
||||
Assert.Equal(
|
||||
"The Autopilot enrollment has explicitly failed.\n\n" +
|
||||
"**Key facts extracted from this session's events:**\n\n" +
|
||||
"- **Failure reason:** `esp_terminal_failure`\n" +
|
||||
$"- **First failed app:** `{FailedAppName}`\n" +
|
||||
"- **ESP failed subcategory:** `Apps`\n" +
|
||||
"- **ESP HRESULT:** `0x87d1041c` …",
|
||||
text);
|
||||
// The stored result is untouched: the web and the MCP server still resolve the template.
|
||||
Assert.Contains("{{reason}}", result.Explanation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rule_fired_alert_reads_evidence_that_went_through_the_table()
|
||||
{
|
||||
var result = await RunEnrollmentFailedRuleAsync(withEspFailure: true);
|
||||
var fresh = NotificationAlertBuilder.BuildRuleFiredAlert(result, null, null).Sections.Single().Text;
|
||||
|
||||
// Same write and read as TableStorageService: JSON column, then nested dictionaries.
|
||||
var json = JsonConvert.SerializeObject(result.MatchedConditions);
|
||||
result.MatchedConditions = AsDictionaries(JObject.Parse(json))!;
|
||||
|
||||
Assert.Equal(fresh, NotificationAlertBuilder.BuildRuleFiredAlert(result, null, null).Sections.Single().Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rule_fired_alert_omits_the_facts_a_non_esp_failure_never_had()
|
||||
{
|
||||
var result = await RunEnrollmentFailedRuleAsync(withEspFailure: false);
|
||||
|
||||
var text = Assert.Single(NotificationAlertBuilder.BuildRuleFiredAlert(result, null, null).Sections).Text;
|
||||
|
||||
Assert.Contains("- **Failure reason:** `hello_timeout`", text);
|
||||
Assert.DoesNotContain("First failed app", text);
|
||||
Assert.DoesNotContain("ESP failed subcategory", text);
|
||||
Assert.DoesNotContain("ESP HRESULT:", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enrollment_alert_rule_section_is_resolved_and_cut_on_a_line()
|
||||
{
|
||||
var result = await RunEnrollmentFailedRuleAsync(withEspFailure: true);
|
||||
var alert = NotificationAlertBuilder.BuildEnrollmentAlert(
|
||||
"DESKTOP-TEST01", "SN-0001", null, null, success: false, failureReason: "x", duration: null);
|
||||
|
||||
NotificationAlertBuilder.AddRuleResultSections(alert, new List<RuleResult> { result });
|
||||
|
||||
var text = Assert.Single(alert.Sections).Text;
|
||||
Assert.True(text.Length <= NotificationAlertBuilder.RuleSectionExplanationMaxLength);
|
||||
Assert.EndsWith("- **Failure reason:** `esp_terminal_failure` …", text);
|
||||
}
|
||||
|
||||
// ── Cut ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Truncate_keeps_short_text()
|
||||
=> Assert.Equal("short", NotificationAlertBuilder.TruncateAtBoundary("short", 300));
|
||||
|
||||
[Fact]
|
||||
public void Truncate_prefers_a_line_break_then_a_sentence_then_a_word()
|
||||
{
|
||||
var lines = "First paragraph of the text.\n" + new string('a', 40);
|
||||
Assert.Equal("First paragraph of the text. …", NotificationAlertBuilder.TruncateAtBoundary(lines, 50));
|
||||
|
||||
var sentences = "One sentence here. Another sentence that runs on and on without an end";
|
||||
Assert.Equal("One sentence here. …", NotificationAlertBuilder.TruncateAtBoundary(sentences, 30));
|
||||
|
||||
var words = "word word word word word word word word word word";
|
||||
Assert.Equal("word word word word word …", NotificationAlertBuilder.TruncateAtBoundary(words, 30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncate_never_exceeds_the_limit_and_survives_text_without_any_boundary()
|
||||
{
|
||||
var unbroken = new string('x', 500);
|
||||
var cut = NotificationAlertBuilder.TruncateAtBoundary(unbroken, 100);
|
||||
|
||||
Assert.Equal(100, cut.Length);
|
||||
Assert.EndsWith(" …", cut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncate_ignores_a_boundary_that_would_waste_most_of_the_budget()
|
||||
{
|
||||
var text = "Hi.\n" + string.Join(" ", Enumerable.Repeat("word", 60));
|
||||
var cut = NotificationAlertBuilder.TruncateAtBoundary(text, 100);
|
||||
|
||||
Assert.True(cut.Length > 50, cut);
|
||||
Assert.True(cut.Length <= 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncate_does_not_leave_a_bold_or_code_span_open()
|
||||
{
|
||||
var bold = "See the **App Detection Failure During ESP** rule for the cause and the **remediation steps in detail** below";
|
||||
Assert.Equal("See the **App Detection Failure During ESP** rule for the cause and the remediation steps …",
|
||||
NotificationAlertBuilder.TruncateAtBoundary(bold, 94));
|
||||
|
||||
var code = "The installer returned `1603 fatal error during installation` and stopped the whole chain";
|
||||
Assert.Equal("The installer returned 1603 fatal error during …",
|
||||
NotificationAlertBuilder.TruncateAtBoundary(code, 50));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<RuleResult> RunEnrollmentFailedRuleAsync(bool withEspFailure)
|
||||
{
|
||||
var rule = BuiltInAnalyzeRules.GetAll().Single(r => r.RuleId == "ANALYZE-ENRL-001");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Event("enrollment_failed", 163, new Dictionary<string, object>
|
||||
{
|
||||
["reason"] = withEspFailure ? "esp_terminal_failure" : "hello_timeout",
|
||||
}),
|
||||
};
|
||||
if (withEspFailure)
|
||||
{
|
||||
events.Add(Event("esp_failure_settle_started", 161, new Dictionary<string, object>
|
||||
{
|
||||
["failedSubcategory"] = "Apps",
|
||||
["errorCode"] = "0x87d1041c",
|
||||
}));
|
||||
events.Add(Event("app_install_failed", 158, new Dictionary<string, object>
|
||||
{
|
||||
["appId"] = "00000000-0000-4000-8000-000000000001",
|
||||
["appName"] = FailedAppName,
|
||||
["errorCode"] = "0x87d1041c",
|
||||
}));
|
||||
}
|
||||
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { rule });
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
|
||||
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
|
||||
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
|
||||
|
||||
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<RuleTemplateInterpolatorTests>.Instance);
|
||||
|
||||
var outcome = await engine.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
return Assert.Single(outcome.Results);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent Event(string eventType, long sequence, Dictionary<string, object> data) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = eventType,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = sequence,
|
||||
Data = data,
|
||||
};
|
||||
|
||||
private static Dictionary<string, object>? AsJsonNodes(JObject? evidence)
|
||||
=> evidence?.Properties().ToDictionary(p => p.Name, p => (object)p.Value);
|
||||
|
||||
private static Dictionary<string, object>? AsDictionaries(JObject? evidence)
|
||||
=> evidence?.Properties().ToDictionary(p => p.Name, p => Plain(p.Value)!);
|
||||
|
||||
private static object? Plain(JToken token) => token switch
|
||||
{
|
||||
JObject o => o.Properties().ToDictionary(p => p.Name, p => Plain(p.Value)!),
|
||||
JArray a => a.Select(Plain).ToList(),
|
||||
JValue v => v.Value,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,18 @@ public class VerdictCalibrationRadarTests
|
||||
Assert.Equal((14, 21, 56, 84), gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StoppedWaiting_counts_as_a_classifier_verdict_but_never_as_silence()
|
||||
{
|
||||
// D-310: the engine deliberately stopped waiting for the user — not an agent that went
|
||||
// quiet, so the agent-liveness share must not see it.
|
||||
var rows = Horizon(w => new[] { ("sweep:r6", "Incomplete", 2), ("stopwait:r5_incomplete", "Incomplete", 1), ("agent:complete", "Succeeded", 17) });
|
||||
var silence = VerdictCalibrationRadar.CurrentSums(new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.SilenceShareRegression }, rows, Target);
|
||||
Assert.Equal((14, 140, 56, 560), silence);
|
||||
var gap = VerdictCalibrationRadar.CurrentSums(new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.EvidenceGap }, rows, Target);
|
||||
Assert.Equal((14, 21, 56, 84), gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rows_outside_the_horizon_are_ignored()
|
||||
{
|
||||
|
||||
@@ -62,6 +62,8 @@ public class VerdictPathTests
|
||||
Assert.True(VerdictPaths.IsClassifierPath(path));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("sweep:r5_assumed"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("maxlife:r1"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("stopwait:r5_incomplete"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("stopwait:r1b_awaiting"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("late:r4"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("retro:r6"));
|
||||
Assert.False(VerdictPaths.IsClassifierPath(VerdictPaths.AgentComplete));
|
||||
|
||||
+241
-1
@@ -14,13 +14,14 @@ public class VulnerabilitySummaryAggregatorTests
|
||||
private static CveExposureEntry Row(
|
||||
string cve, string session, string severity, double score,
|
||||
bool kev = false, string tenant = "t1", string software = "Acme App",
|
||||
DateTime? detectedAt = null)
|
||||
DateTime? detectedAt = null, string product = "")
|
||||
=> new()
|
||||
{
|
||||
TenantId = tenant,
|
||||
CveId = cve,
|
||||
SessionId = session,
|
||||
SoftwareName = software,
|
||||
Product = product,
|
||||
CvssScore = score,
|
||||
CvssSeverity = severity,
|
||||
IsKev = kev,
|
||||
@@ -168,6 +169,245 @@ public class VulnerabilitySummaryAggregatorTests
|
||||
Assert.True(r.Truncated);
|
||||
Assert.Equal(365, r.WindowDays); // clamped from 9999
|
||||
Assert.Empty(r.TopCves);
|
||||
Assert.Empty(r.TopSoftware);
|
||||
Assert.Equal(0, r.TotalAffectedSessions);
|
||||
Assert.Equal(0, r.DistinctSoftware);
|
||||
Assert.Equal(0, r.KevAffectedSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopCves_AppendKevCvesBelowTheCut_inDeviceOrder()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-WIDE", "s1", "High", 7.0), Row("CVE-WIDE", "s2", "High", 7.0), Row("CVE-WIDE", "s3", "High", 7.0),
|
||||
Row("CVE-MID", "s1", "High", 8.0), Row("CVE-MID", "s2", "High", 8.0),
|
||||
Row("CVE-KEV", "s4", "High", 7.8, kev: true),
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now);
|
||||
|
||||
Assert.Equal(new[] { "CVE-WIDE", "CVE-KEV" }, r.TopCves.Select(c => c.CveId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KevAdditions_AreCapped()
|
||||
{
|
||||
var rows = new List<CveExposureEntry> { Row("CVE-WIDE", "s1", "High", 7.0), Row("CVE-WIDE", "s2", "High", 7.0) };
|
||||
rows.AddRange(Enumerable.Range(0, VulnerabilitySummaryAggregator.MaxKevAdditions + 10)
|
||||
.Select(i => Row($"CVE-KEV-{i:D3}", $"k{i}", "High", 7.5, kev: true)));
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now);
|
||||
|
||||
Assert.Equal(1 + VulnerabilitySummaryAggregator.MaxKevAdditions, r.TopCves.Count);
|
||||
Assert.Equal("CVE-WIDE", r.TopCves[0].CveId);
|
||||
Assert.All(r.TopCves.Skip(1), c => Assert.True(c.IsKev));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KevAffectedSessions_CountsDistinctSessionsWithAKevCve()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-K1", "s1", "High", 8.8, kev: true),
|
||||
Row("CVE-K2", "s1", "High", 8.1, kev: true), // same session, counted once
|
||||
Row("CVE-K2", "s2", "High", 8.1, kev: true),
|
||||
Row("CVE-N", "s3", "High", 7.0),
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now);
|
||||
|
||||
Assert.Equal(2, r.KevAffectedSessions);
|
||||
Assert.Equal(3, r.TotalAffectedSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_FoldsAProductAcrossTitlesAndCves()
|
||||
{
|
||||
const string zip = "7-zip:7-zip";
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-A", "s1", "HIGH", 7.8, software: "7-Zip 24.08 (x64 edition)", product: zip),
|
||||
Row("CVE-B", "s1", "LOW", 3.3, software: "7-Zip 24.08 (x64 edition)", product: zip),
|
||||
Row("CVE-A", "s2", "HIGH", 7.8, software: "7-Zip 25.01 (x64)", product: zip),
|
||||
Row("CVE-A", "s3", "HIGH", 7.8, software: "7-Zip 24.08 (x64 edition)", product: zip),
|
||||
Row("CVE-T", "s4", "MEDIUM", 5.0, software: "Microsoft Teams", product: "microsoft:teams"),
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now);
|
||||
|
||||
Assert.Equal(2, r.DistinctSoftware);
|
||||
var p = r.TopSoftware[0];
|
||||
Assert.Equal(zip, p.Product);
|
||||
Assert.Equal("7-Zip", p.Name);
|
||||
Assert.Equal(3, p.AffectedSessions);
|
||||
Assert.Equal(2, p.CveCount);
|
||||
Assert.Equal(0, p.KevCount);
|
||||
Assert.Equal("HIGH", p.CvssSeverity);
|
||||
Assert.Equal(7.8, p.CvssScore);
|
||||
Assert.Equal(new[] { "7-Zip 24.08 (x64 edition)", "7-Zip 25.01 (x64)" }, p.Titles); // most sessions first
|
||||
var a = p.Cves.Single(c => c.CveId == "CVE-A");
|
||||
Assert.Equal(3, a.AffectedSessions);
|
||||
Assert.Equal(1, p.Cves.Single(c => c.CveId == "CVE-B").AffectedSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_RowWithoutProductBorrowsItFromTheSameTitle_elseUsesTheTitle()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-A", "s1", "High", 7.8, software: "7-Zip 24.08 (x64 edition)", product: "7-zip:7-zip"),
|
||||
Row("CVE-A", "s2", "High", 7.8, software: "7-zip 24.08 (X64 Edition)"), // legacy row, same title
|
||||
Row("CVE-C", "s3", "High", 7.1, software: "Contoso Tool 1.0"), // legacy row, title never seen with a product
|
||||
Row("CVE-D", "s4", "High", 7.1, software: ""), // neither: left out of the product view
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now);
|
||||
|
||||
Assert.Equal(2, r.DistinctSoftware);
|
||||
var zip = r.TopSoftware.Single(s => s.Product == "7-zip:7-zip");
|
||||
Assert.Equal(2, zip.AffectedSessions);
|
||||
var contoso = r.TopSoftware.Single(s => s.Product == "Contoso Tool 1.0");
|
||||
Assert.Equal("Contoso Tool", contoso.Name);
|
||||
Assert.Equal(3, r.DistinctCves); // the row without a title still counts as a CVE
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_CarriesTheMostUrgentCvesFirst_cappedWithTheFullCount()
|
||||
{
|
||||
const string chrome = "google:chrome";
|
||||
var act = Row("CVE-ACT", "s1", "High", 8.1, kev: true, software: "Google Chrome", product: chrome); act.Priority = "act";
|
||||
var attendEpss = Row("CVE-ATT-E", "s1", "Medium", 6.5, software: "Google Chrome", product: chrome); attendEpss.Priority = "attend"; attendEpss.EpssScore = 0.4;
|
||||
var attendCvss = Row("CVE-ATT-C", "s1", "Critical", 9.6, software: "Google Chrome", product: chrome); attendCvss.Priority = "attend"; attendCvss.EpssScore = 0.01;
|
||||
var rows = new List<CveExposureEntry> { act, attendEpss, attendCvss };
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var track = Row($"CVE-TRK-{i}", "s1", "High", 7.0 + i / 10.0, software: "Google Chrome", product: chrome);
|
||||
track.Priority = "track";
|
||||
rows.Add(track);
|
||||
}
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now);
|
||||
|
||||
var p = Assert.Single(r.TopSoftware);
|
||||
Assert.Equal(8, p.CveCount);
|
||||
Assert.Equal(1, p.KevCount);
|
||||
Assert.Equal("act", p.Priority);
|
||||
Assert.Equal("Critical", p.CvssSeverity); // worst band, with the score of that CVE
|
||||
Assert.Equal(9.6, p.CvssScore);
|
||||
Assert.Equal(VulnerabilitySummaryAggregator.MaxCvesPerSoftware, p.Cves.Count);
|
||||
Assert.Equal(new[] { "CVE-ACT", "CVE-ATT-E", "CVE-ATT-C" }, p.Cves.Select(c => c.CveId)); // act, then EPSS before CVSS
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_IsCappedIndependentlyOfTopN_plusCappedKevProducts()
|
||||
{
|
||||
var rows = new List<CveExposureEntry>();
|
||||
for (var p = 0; p < VulnerabilitySummaryAggregator.MaxSoftware + 5; p++)
|
||||
for (var s = 0; s <= p; s++) // product p has p + 1 sessions
|
||||
rows.Add(Row($"CVE-P{p}", $"p{p}-s{s}", "High", 7.0, software: $"Product {p}", product: $"acme:p{p}"));
|
||||
for (var k = 0; k < VulnerabilitySummaryAggregator.MaxKevSoftwareAdditions + 2; k++)
|
||||
rows.Add(Row($"CVE-K{k}", $"k{k}", "High", 7.8, kev: true, software: $"Kev Product {k}", product: $"acme:k{k}"));
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 100, days: 30, crossTenant: false, nowUtc: Now);
|
||||
|
||||
Assert.Equal(
|
||||
VulnerabilitySummaryAggregator.MaxSoftware + VulnerabilitySummaryAggregator.MaxKevSoftwareAdditions,
|
||||
r.TopSoftware.Count);
|
||||
Assert.Equal("acme:p24", r.TopSoftware[0].Product); // most sessions first
|
||||
Assert.All(r.TopSoftware.Skip(VulnerabilitySummaryAggregator.MaxSoftware), s => Assert.True(s.KevCount > 0));
|
||||
Assert.Equal(VulnerabilitySummaryAggregator.MaxSoftware + 5 + VulnerabilitySummaryAggregator.MaxKevSoftwareAdditions + 2, r.DistinctSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopSoftware_AppendsProductsWithAKevCveBelowTheCut()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("CVE-W", "s1", "High", 7.0, software: "Wide App", product: "acme:wide"),
|
||||
Row("CVE-W", "s2", "High", 7.0, software: "Wide App", product: "acme:wide"),
|
||||
Row("CVE-M", "s3", "High", 9.0, software: "Mid App", product: "acme:mid"),
|
||||
Row("CVE-K", "s4", "High", 7.8, kev: true, software: "WinRAR 6.22 (64-bit)", product: "rarlab:winrar"),
|
||||
};
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now);
|
||||
|
||||
Assert.Equal(new[] { "acme:wide", "rarlab:winrar" }, r.TopSoftware.Select(s => s.Product));
|
||||
Assert.Equal("WinRAR", r.TopSoftware[1].Name);
|
||||
Assert.Equal(3, r.DistinctSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BandCves_AddEachBandsTopEntriesOutsideTheList_onlyWhenAsked()
|
||||
{
|
||||
var rows = new List<CveExposureEntry>
|
||||
{
|
||||
Row("CVE-WIDE", "s1", "High", 7.0), Row("CVE-WIDE", "s2", "High", 7.0), Row("CVE-WIDE", "s3", "High", 7.0),
|
||||
Row("CVE-HIGH-2", "s1", "High", 7.5), Row("CVE-HIGH-2", "s2", "High", 7.5),
|
||||
Row("CVE-ATT-1", "s1", "Critical", 9.8), Row("CVE-ATT-1", "s4", "Critical", 9.8),
|
||||
Row("CVE-ATT-2", "s5", "Critical", 9.1),
|
||||
Row("CVE-ATT-3", "s6", "Medium", 5.0),
|
||||
Row("CVE-LOW", "s7", "Low", 2.0),
|
||||
};
|
||||
foreach (var r in rows)
|
||||
r.Priority = r.CveId.StartsWith("CVE-ATT", StringComparison.Ordinal) ? "attend" : "track";
|
||||
|
||||
var plain = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now);
|
||||
Assert.Null(plain.BandCves);
|
||||
Assert.Null(plain.BandSoftware);
|
||||
|
||||
var r2 = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now, perBand: 2);
|
||||
|
||||
Assert.Equal(new[] { "CVE-WIDE" }, r2.TopCves.Select(c => c.CveId));
|
||||
// critical: ATT-1, ATT-2 · high: WIDE (listed), HIGH-2 · medium: ATT-3 · low: LOW · attend: ATT-1, ATT-2 · track: WIDE, HIGH-2
|
||||
Assert.Equal(new[] { "CVE-ATT-1", "CVE-HIGH-2", "CVE-ATT-2", "CVE-ATT-3", "CVE-LOW" }, r2.BandCves!.Select(c => c.CveId));
|
||||
Assert.All(r2.BandCves!, c => Assert.NotEmpty(c.SampleSoftware));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BandSoftware_BandsProductsByTheirWorstValues_outsideTopSoftware()
|
||||
{
|
||||
var wide1 = Row("CVE-W", "s1", "High", 7.0, software: "Wide App", product: "acme:wide"); wide1.Priority = "track";
|
||||
var wide2 = Row("CVE-W", "s2", "High", 7.0, software: "Wide App", product: "acme:wide"); wide2.Priority = "track";
|
||||
var crit = Row("CVE-C", "s3", "Critical", 9.6, software: "Edge Tool", product: "acme:edge"); crit.Priority = "attend";
|
||||
var low = Row("CVE-L", "s4", "Low", 2.1, software: "Small Tool", product: "acme:small"); low.Priority = "track";
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(
|
||||
new[] { wide1, wide2, crit, low }, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now, perBand: 1);
|
||||
|
||||
Assert.Equal(new[] { "acme:wide" }, r.TopSoftware.Select(s => s.Product));
|
||||
// critical + attend → edge; low → small; high + track are already held by wide.
|
||||
Assert.Equal(new[] { "acme:edge", "acme:small" }, r.BandSoftware!.Select(s => s.Product));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerBand_IsClamped()
|
||||
{
|
||||
var rows = Enumerable.Range(0, VulnerabilitySummaryAggregator.MaxPerBand + 10)
|
||||
.Select(i => Row($"CVE-{i:D3}", $"s{i}", "Medium", 5.0))
|
||||
.ToList();
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now, perBand: 999);
|
||||
|
||||
Assert.Equal(VulnerabilitySummaryAggregator.MaxPerBand - 1, r.BandCves!.Count); // the band's first entry is already listed
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("7-Zip", "7-Zip 24.08 (x64 edition)", "7-Zip 25.01 (x64)", "7-Zip 26.00 (x64 edition)")]
|
||||
[InlineData("Cisco Secure Client", "Cisco Secure Client - AnyConnect VPN ", "Cisco Secure Client - Cloud Management")]
|
||||
[InlineData("Microsoft Teams", "Microsoft Teams", "MSTeams", "Teams Machine-Wide Installer")]
|
||||
[InlineData("Microsoft Teams", "Microsoft Teams", "Microsoft 365 Apps")] // one shared word is not a name
|
||||
[InlineData("WinRAR", "WinRAR 6.22 (64-bit)")]
|
||||
[InlineData("Mozilla Firefox", "Mozilla Firefox (x64 en-US)")]
|
||||
[InlineData("Google Chrome", "Google Chrome 127.0.6533.74")]
|
||||
[InlineData("Microsoft Office 2016", "Microsoft Office 2016")] // a bare year or major version stays
|
||||
[InlineData("PowerShell 7-x64", "PowerShell 7-x64")]
|
||||
[InlineData("Contoso Agent", "Contoso Agent v2.4.1 x64")]
|
||||
[InlineData("1.2.3", "1.2.3")] // never stripped to nothing
|
||||
public void DisplayName_UsesSharedWordsOrTheMostCommonTitle_withoutVersionAndArchitecture(string expected, params string[] titles)
|
||||
=> Assert.Equal(expected, VulnerabilitySummaryAggregator.DisplayName(titles));
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_OfNoTitlesIsEmpty()
|
||||
=> Assert.Equal("", VulnerabilitySummaryAggregator.DisplayName(Array.Empty<string>()));
|
||||
}
|
||||
|
||||
@@ -655,12 +655,12 @@ public class VulnerabilityWireParityTests
|
||||
}
|
||||
|
||||
// ---- GetVulnerabilitySummary / GetGlobalVulnerabilitySummary -------------------------
|
||||
// The endpoint always serialized the named CveExposureSummary record (never anonymous);
|
||||
// it only gained the IApiResponse marker. The anonymous side here replicates the record's
|
||||
// established wire shape to pin key names, order and the TotalAffectedTenants omission.
|
||||
// The endpoint always serialized the named CveExposureSummary record (never anonymous).
|
||||
// The anonymous side pins the wire shape: key names, order and the TotalAffectedTenants /
|
||||
// EpssScore omission. D-307 added distinctSoftware, kevAffectedSessions and topSoftware.
|
||||
|
||||
[Fact]
|
||||
public void CveExposureSummary_wire_shape_is_unchanged_by_the_IApiResponse_marker()
|
||||
public void CveExposureSummary_pins_the_cross_tenant_wire_shape()
|
||||
{
|
||||
var breakdown = new SeverityBreakdown { Critical = 1, High = 2, Medium = 0, Low = 0 };
|
||||
var priorities = new PriorityBreakdown { Act = 1, Attend = 1, Track = 1 };
|
||||
@@ -686,10 +686,32 @@ public class VulnerabilityWireParityTests
|
||||
totalAffectedSessions = 11,
|
||||
totalAffectedTenants = (int?)3,
|
||||
distinctCves = 3,
|
||||
distinctSoftware = 2,
|
||||
kevCves = 1,
|
||||
kevAffectedSessions = 7,
|
||||
severityBreakdown = breakdown,
|
||||
priorityBreakdown = priorities,
|
||||
topCves,
|
||||
topSoftware = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
product = "7-zip:7-zip",
|
||||
name = "7-Zip",
|
||||
affectedSessions = 7,
|
||||
cveCount = 2,
|
||||
kevCount = 1,
|
||||
cvssSeverity = "critical",
|
||||
cvssScore = 9.8,
|
||||
priority = "act",
|
||||
titles = new[] { "7-Zip 24.08 (x64)", "7-Zip 25.01 (x64)" },
|
||||
cves = new object[]
|
||||
{
|
||||
new { cveId = "CVE-2026-12345", cvssSeverity = "critical", cvssScore = 9.8, isKev = true, epssScore = (double?)0.94, priority = "act", affectedSessions = 7 },
|
||||
new { cveId = "CVE-2026-23456", cvssSeverity = "low", cvssScore = 3.3, isKev = false, epssScore = (double?)null, priority = "track", affectedSessions = 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
truncated = false,
|
||||
},
|
||||
new CveExposureSummary
|
||||
@@ -698,10 +720,88 @@ public class VulnerabilityWireParityTests
|
||||
TotalAffectedSessions = 11,
|
||||
TotalAffectedTenants = 3,
|
||||
DistinctCves = 3,
|
||||
DistinctSoftware = 2,
|
||||
KevCves = 1,
|
||||
KevAffectedSessions = 7,
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
TopSoftware = new List<TopSoftware>
|
||||
{
|
||||
new TopSoftware
|
||||
{
|
||||
Product = "7-zip:7-zip",
|
||||
Name = "7-Zip",
|
||||
AffectedSessions = 7,
|
||||
CveCount = 2,
|
||||
KevCount = 1,
|
||||
CvssSeverity = "critical",
|
||||
CvssScore = 9.8,
|
||||
Priority = "act",
|
||||
Titles = new List<string> { "7-Zip 24.08 (x64)", "7-Zip 25.01 (x64)" },
|
||||
Cves = new List<SoftwareCve>
|
||||
{
|
||||
new SoftwareCve { CveId = "CVE-2026-12345", CvssSeverity = "critical", CvssScore = 9.8, IsKev = true, EpssScore = 0.94, Priority = "act", AffectedSessions = 7 },
|
||||
new SoftwareCve { CveId = "CVE-2026-23456", CvssSeverity = "low", CvssScore = 3.3, IsKev = false, EpssScore = null, Priority = "track", AffectedSessions = 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
Truncated = false,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CveExposureSummary_carries_the_band_lists_after_topSoftware_when_requested()
|
||||
{
|
||||
var breakdown = new SeverityBreakdown { Critical = 1, High = 1, Medium = 0, Low = 0 };
|
||||
var priorities = new PriorityBreakdown { Act = 0, Attend = 1, Track = 1 };
|
||||
var wide = new TopCve
|
||||
{
|
||||
CveId = "CVE-2026-11111", CvssSeverity = "HIGH", CvssScore = 7.5, IsKev = false, EpssScore = 0.01,
|
||||
Priority = "track", AffectedSessions = 40, SampleSoftware = new List<string> { "Contoso Agent 2.1" },
|
||||
};
|
||||
var attend = new TopCve
|
||||
{
|
||||
CveId = "CVE-2026-22222", CvssSeverity = "CRITICAL", CvssScore = 9.6, IsKev = false, EpssScore = null,
|
||||
Priority = "attend", AffectedSessions = 2, SampleSoftware = new List<string> { "Contoso Viewer 1.0" },
|
||||
};
|
||||
var topCves = new List<TopCve> { wide };
|
||||
var topSoftware = new List<TopSoftware>();
|
||||
var bandCves = new List<TopCve> { attend };
|
||||
var bandSoftware = new List<TopSoftware>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
windowDays = 30,
|
||||
totalAffectedSessions = 42,
|
||||
distinctCves = 2,
|
||||
distinctSoftware = 2,
|
||||
kevCves = 0,
|
||||
kevAffectedSessions = 0,
|
||||
severityBreakdown = breakdown,
|
||||
priorityBreakdown = priorities,
|
||||
topCves,
|
||||
topSoftware,
|
||||
bandCves,
|
||||
bandSoftware,
|
||||
truncated = false,
|
||||
},
|
||||
new CveExposureSummary
|
||||
{
|
||||
WindowDays = 30,
|
||||
TotalAffectedSessions = 42,
|
||||
TotalAffectedTenants = null,
|
||||
DistinctCves = 2,
|
||||
DistinctSoftware = 2,
|
||||
KevCves = 0,
|
||||
KevAffectedSessions = 0,
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
TopSoftware = topSoftware,
|
||||
BandCves = bandCves,
|
||||
BandSoftware = bandSoftware,
|
||||
Truncated = false,
|
||||
});
|
||||
}
|
||||
@@ -712,6 +812,7 @@ public class VulnerabilityWireParityTests
|
||||
var breakdown = new SeverityBreakdown { Critical = 0, High = 0, Medium = 0, Low = 0 };
|
||||
var priorities = new PriorityBreakdown { Act = 0, Attend = 0, Track = 0 };
|
||||
var topCves = new List<TopCve>();
|
||||
var topSoftware = new List<TopSoftware>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
@@ -720,10 +821,13 @@ public class VulnerabilityWireParityTests
|
||||
totalAffectedSessions = 0,
|
||||
totalAffectedTenants = (int?)null,
|
||||
distinctCves = 0,
|
||||
distinctSoftware = 0,
|
||||
kevCves = 0,
|
||||
kevAffectedSessions = 0,
|
||||
severityBreakdown = breakdown,
|
||||
priorityBreakdown = priorities,
|
||||
topCves,
|
||||
topSoftware,
|
||||
truncated = true,
|
||||
},
|
||||
new CveExposureSummary
|
||||
@@ -732,10 +836,13 @@ public class VulnerabilityWireParityTests
|
||||
TotalAffectedSessions = 0,
|
||||
TotalAffectedTenants = null,
|
||||
DistinctCves = 0,
|
||||
DistinctSoftware = 0,
|
||||
KevCves = 0,
|
||||
KevAffectedSessions = 0,
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
TopSoftware = topSoftware,
|
||||
Truncated = true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// Windows Update during OOBE — pins the firing behaviour of the built-in analyze rules
|
||||
/// that surface update activity captured by the agent's WindowsUpdateTracker:
|
||||
/// - ANALYZE-DEV-004 (high): a quality/cumulative update FAILED during enrollment.
|
||||
/// - ANALYZE-DEV-004 (high): a quality/cumulative update FAILED to install or download during
|
||||
/// enrollment (wuPhase install/download — never an update-scan failure).
|
||||
/// - ANALYZE-DEV-005 (info): a quality/cumulative update INSTALLED during enrollment.
|
||||
/// - ANALYZE-DEV-006 (info): the OS build changed across a mid-enrollment reboot —
|
||||
/// deterministic corroboration that works even when the WU channel showed nothing
|
||||
@@ -51,6 +52,62 @@ public class WindowsUpdateRuleTests
|
||||
Assert.Equal("WU_E_ALL_UPDATES_FAILED", AsString(symbol["value"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_004_fires_on_a_failed_download()
|
||||
{
|
||||
// EventID 31 from the Operational channel (D-310): the update never finished downloading.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-004");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
WindowsUpdateFailed("2026-09 Cumulative Update for Windows 11 (KB5099999)", "0x80248007", "WU_E_DS_NODATA",
|
||||
wuEventId: 31, wuPhase: "download"),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal("0x80248007", AsString(AsDict(result.MatchedConditions["wu_hresult"])["value"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_004_does_not_fire_on_an_update_scan_failure()
|
||||
{
|
||||
// EventID 25 names no update: a failed scan (often a transient network or proxy error) is
|
||||
// not a failed update and must not raise a high-severity finding.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-004");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
WindowsUpdateScanFailed("0x8024401C", "WU_E_PT_HTTP_STATUS_REQUEST_TIMEOUT"),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_004_takes_its_evidence_from_the_install_failure_not_the_scan_failure()
|
||||
{
|
||||
// The phase filter applies to every evidence condition: the explanation must name the
|
||||
// failed update and its error, never the error of an earlier scan.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-004");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
WindowsUpdateScanFailed("0x8024401C", "WU_E_PT_HTTP_STATUS_REQUEST_TIMEOUT"),
|
||||
WindowsUpdateFailed("2026-09 Cumulative Update for Windows 11 (KB5099999)", "0x800F0922", "CBS_E_INSTALLERS_FAILED"),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal("0x800F0922", AsString(AsDict(result.MatchedConditions["wu_hresult"])["value"]));
|
||||
Assert.Equal("CBS_E_INSTALLERS_FAILED", AsString(AsDict(result.MatchedConditions["wu_hresult_symbol"])["value"]));
|
||||
Assert.Equal("2026-09 Cumulative Update for Windows 11 (KB5099999)",
|
||||
AsString(AsDict(result.MatchedConditions["wu_update_title"])["value"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_004_does_not_fire_without_a_failed_update()
|
||||
{
|
||||
@@ -217,11 +274,12 @@ public class WindowsUpdateRuleTests
|
||||
["wuClientCensus"] = "21=1,25=3",
|
||||
["updateOrchestratorCensus"] = "200=2",
|
||||
["lookbackMinutes"] = 60,
|
||||
["targetedEventIds"] = "19,20,43,44",
|
||||
["targetedEventIds"] = "19,20,25,26,31,41,43,44",
|
||||
}
|
||||
};
|
||||
|
||||
private static EnrollmentEvent WindowsUpdateFailed(string title, string hresult, string hresultSymbol) => new()
|
||||
private static EnrollmentEvent WindowsUpdateFailed(
|
||||
string title, string hresult, string hresultSymbol, int wuEventId = 20, string wuPhase = "install") => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
@@ -231,7 +289,10 @@ public class WindowsUpdateRuleTests
|
||||
Sequence = 42,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["wuEventId"] = 20,
|
||||
["wuEventId"] = wuEventId,
|
||||
["wuChannel"] = wuEventId == 20 ? "system" : "operational",
|
||||
["wuPhase"] = wuPhase,
|
||||
["updateClass"] = "os",
|
||||
["updateTitle"] = title,
|
||||
["updateGuid"] = "{8b1c8726-1111-2222-3333-444455556666}",
|
||||
["hresult"] = hresult,
|
||||
@@ -240,6 +301,25 @@ public class WindowsUpdateRuleTests
|
||||
}
|
||||
};
|
||||
|
||||
private static EnrollmentEvent WindowsUpdateScanFailed(string hresult, string hresultSymbol) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "windows_update_failed",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = 41,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["wuEventId"] = 25,
|
||||
["wuChannel"] = "operational",
|
||||
["wuPhase"] = "scan",
|
||||
["hresult"] = hresult,
|
||||
["hresultSymbol"] = hresultSymbol,
|
||||
["backfilled"] = false,
|
||||
}
|
||||
};
|
||||
|
||||
private static EnrollmentEvent WindowsUpdateSucceeded(string title) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
@@ -251,6 +331,9 @@ public class WindowsUpdateRuleTests
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["wuEventId"] = 19,
|
||||
["wuChannel"] = "system",
|
||||
["wuPhase"] = "installed",
|
||||
["updateClass"] = "os",
|
||||
["updateTitle"] = title,
|
||||
["updateGuid"] = "{8b1c8726-1111-2222-3333-444455556666}",
|
||||
["backfilled"] = false,
|
||||
|
||||
+353
-56
@@ -7,7 +7,7 @@ using AutopilotMonitor.Shared.Models.Vulnerability;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
{
|
||||
// The wire types (CveExposureSummary, SeverityBreakdown, TopCve) live in
|
||||
// The wire types (CveExposureSummary, SeverityBreakdown, TopCve, TopSoftware) live in
|
||||
// AutopilotMonitor.Shared.Models/VulnerabilityApiModels.cs so the manifest export sees them.
|
||||
|
||||
/// <summary>
|
||||
@@ -17,6 +17,38 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
/// </summary>
|
||||
public static class VulnerabilitySummaryAggregator
|
||||
{
|
||||
// The caps keep the response under the MCP tool's 50k-character result cap at topN = 100
|
||||
// (a TopCve is ~210 characters, a TopSoftware entry ~800); the MCP passes it through verbatim.
|
||||
|
||||
/// <summary>
|
||||
/// Cap on the KEV CVEs appended below the topN cut. Known-exploited CVEs are "patch first"
|
||||
/// whatever their device count, so a list ranked by devices must not drop them.
|
||||
/// </summary>
|
||||
public const int MaxKevAdditions = 25;
|
||||
|
||||
/// <summary>Products ranked by devices, before the KEV additions (the list is min(topN, this) long).</summary>
|
||||
public const int MaxSoftware = 20;
|
||||
|
||||
/// <summary>Cap on the products with a KEV CVE appended below the product cut.</summary>
|
||||
public const int MaxKevSoftwareAdditions = 10;
|
||||
|
||||
/// <summary>CVEs carried per product entry; <see cref="TopSoftware.CveCount"/> keeps the full count.</summary>
|
||||
public const int MaxCvesPerSoftware = 3;
|
||||
|
||||
/// <summary>Raw titles carried per product entry.</summary>
|
||||
public const int MaxTitlesPerSoftware = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound of the per-band representatives (<see cref="CveExposureSummary.BandCves"/>).
|
||||
/// Only the portal asks for them; the MCP tool never does, so its response stays within its cap.
|
||||
/// </summary>
|
||||
public const int MaxPerBand = 25;
|
||||
|
||||
private static readonly HashSet<string> ArchitectureTokens = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"x64", "x86", "amd64", "arm64", "64-bit", "32-bit",
|
||||
};
|
||||
|
||||
private static int SeverityRank(string? severity) => severity?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"critical" => 4,
|
||||
@@ -26,15 +58,39 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static string SeverityKey(int rank) => rank switch
|
||||
{
|
||||
4 => "critical",
|
||||
3 => "high",
|
||||
2 => "medium",
|
||||
1 => "low",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private static string PriorityKey(string? priority) => CvePriority.Rank(priority) switch
|
||||
{
|
||||
3 => CvePriority.Act,
|
||||
2 => CvePriority.Attend,
|
||||
1 => CvePriority.Track,
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <param name="perBand">
|
||||
/// When above 0, the summary also carries <see cref="CveExposureSummary.BandCves"/> and
|
||||
/// <see cref="CveExposureSummary.BandSoftware"/>: per severity and priority band its top
|
||||
/// entries outside the lists, so a band filter never comes up empty while the band has entries.
|
||||
/// </param>
|
||||
public static CveExposureSummary Aggregate(
|
||||
IReadOnlyList<CveExposureEntry> rows,
|
||||
bool truncated,
|
||||
int topN,
|
||||
int days,
|
||||
bool crossTenant,
|
||||
DateTime nowUtc)
|
||||
DateTime nowUtc,
|
||||
int perBand = 0)
|
||||
{
|
||||
if (topN < 1) topN = 1;
|
||||
perBand = Math.Clamp(perBand, 0, MaxPerBand);
|
||||
var windowDays = Math.Clamp(days, 1, 365);
|
||||
var cutoff = nowUtc.AddDays(-windowDays);
|
||||
|
||||
@@ -44,73 +100,62 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
var inWindow = rows
|
||||
.Where(r => !r.DetectedAt.HasValue || r.DetectedAt.Value >= cutoff)
|
||||
.ToList();
|
||||
var withCve = inWindow.Where(r => !string.IsNullOrEmpty(r.CveId)).ToList();
|
||||
|
||||
var groups = inWindow
|
||||
var cves = withCve
|
||||
.GroupBy(r => r.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(g => !string.IsNullOrEmpty(g.Key))
|
||||
.Select(g =>
|
||||
{
|
||||
var topSeverityRow = g.OrderByDescending(r => SeverityRank(r.CvssSeverity)).First();
|
||||
return new
|
||||
{
|
||||
CveId = g.Key,
|
||||
Severity = topSeverityRow.CvssSeverity,
|
||||
SeverityRank = SeverityRank(topSeverityRow.CvssSeverity),
|
||||
CvssScore = g.Max(r => r.CvssScore),
|
||||
IsKev = g.Any(r => r.IsKev),
|
||||
// Max over a nullable ignores nulls and yields null when nothing is scored.
|
||||
EpssScore = g.Max(r => r.EpssScore),
|
||||
Priority = g.Select(r => r.Priority)
|
||||
.Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best),
|
||||
AffectedSessions = g.Select(r => r.SessionId)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
SampleSoftware = g.Select(r => r.SoftwareName)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(3).ToList(),
|
||||
};
|
||||
})
|
||||
.Select(g => FoldCve(g.Key, g.ToList()))
|
||||
.ToList();
|
||||
|
||||
var topCves = groups
|
||||
var rankedCves = cves
|
||||
.OrderByDescending(c => c.AffectedSessions)
|
||||
.ThenByDescending(c => c.CvssScore)
|
||||
.ThenBy(c => c.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(topN)
|
||||
.Select(c => new TopCve
|
||||
{
|
||||
CveId = c.CveId,
|
||||
CvssSeverity = c.Severity,
|
||||
CvssScore = c.CvssScore,
|
||||
IsKev = c.IsKev,
|
||||
EpssScore = c.EpssScore,
|
||||
Priority = c.Priority,
|
||||
AffectedSessions = c.AffectedSessions,
|
||||
SampleSoftware = c.SampleSoftware,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var listedCves = WithKevAdditions(rankedCves, topN, c => c.IsKev, MaxKevAdditions);
|
||||
|
||||
var software = FoldSoftware(withCve);
|
||||
var rankedSoftware = software
|
||||
.OrderByDescending(s => s.AffectedSessions)
|
||||
.ThenByDescending(s => CvePriority.Rank(s.Priority))
|
||||
.ThenByDescending(s => s.CvssScore)
|
||||
.ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(s => s.Product, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var topSoftware = WithKevAdditions(
|
||||
rankedSoftware, Math.Min(topN, MaxSoftware), s => s.KevCount > 0, MaxKevSoftwareAdditions);
|
||||
|
||||
// A product is banded by its worst values, the only ones it carries.
|
||||
List<TopCve>? bandCves = null;
|
||||
List<TopSoftware>? bandSoftware = null;
|
||||
if (perBand > 0)
|
||||
{
|
||||
bandCves = BandRepresentatives(
|
||||
rankedCves, listedCves, perBand,
|
||||
c => SeverityKey(c.SeverityRank), c => PriorityKey(c.Priority))
|
||||
.Select(ToTopCve)
|
||||
.ToList();
|
||||
bandSoftware = BandRepresentatives(
|
||||
rankedSoftware, topSoftware, perBand,
|
||||
s => SeverityKey(SeverityRank(s.CvssSeverity)), s => PriorityKey(s.Priority));
|
||||
}
|
||||
|
||||
var breakdown = new SeverityBreakdown
|
||||
{
|
||||
Critical = groups.Count(c => c.SeverityRank == 4),
|
||||
High = groups.Count(c => c.SeverityRank == 3),
|
||||
Medium = groups.Count(c => c.SeverityRank == 2),
|
||||
Low = groups.Count(c => c.SeverityRank == 1),
|
||||
Critical = cves.Count(c => c.SeverityRank == 4),
|
||||
High = cves.Count(c => c.SeverityRank == 3),
|
||||
Medium = cves.Count(c => c.SeverityRank == 2),
|
||||
Low = cves.Count(c => c.SeverityRank == 1),
|
||||
};
|
||||
|
||||
var priorities = new PriorityBreakdown
|
||||
{
|
||||
Act = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Act)),
|
||||
Attend = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Attend)),
|
||||
Track = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Track)),
|
||||
Act = cves.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Act)),
|
||||
Attend = cves.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Attend)),
|
||||
Track = cves.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Track)),
|
||||
};
|
||||
|
||||
var totalAffectedSessions = inWindow.Select(r => r.SessionId)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
||||
|
||||
int? totalAffectedTenants = crossTenant
|
||||
? inWindow.Select(r => r.TenantId)
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
@@ -120,15 +165,267 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
return new CveExposureSummary
|
||||
{
|
||||
WindowDays = windowDays,
|
||||
TotalAffectedSessions = totalAffectedSessions,
|
||||
TotalAffectedSessions = DistinctSessions(inWindow),
|
||||
TotalAffectedTenants = totalAffectedTenants,
|
||||
DistinctCves = groups.Count,
|
||||
KevCves = groups.Count(c => c.IsKev),
|
||||
DistinctCves = cves.Count,
|
||||
DistinctSoftware = software.Count,
|
||||
KevCves = cves.Count(c => c.IsKev),
|
||||
KevAffectedSessions = DistinctSessions(withCve.Where(r => r.IsKev)),
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
TopCves = listedCves.Select(ToTopCve).ToList(),
|
||||
TopSoftware = topSoftware,
|
||||
BandCves = bandCves,
|
||||
BandSoftware = bandSoftware,
|
||||
Truncated = truncated,
|
||||
};
|
||||
}
|
||||
|
||||
private static TopCve ToTopCve(CveFold c) => new()
|
||||
{
|
||||
CveId = c.CveId,
|
||||
CvssSeverity = c.Severity,
|
||||
CvssScore = c.CvssScore,
|
||||
IsKev = c.IsKev,
|
||||
EpssScore = c.EpssScore,
|
||||
Priority = c.Priority,
|
||||
AffectedSessions = c.AffectedSessions,
|
||||
SampleSoftware = c.Rows.Select(r => r.SoftwareName)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(3).ToList(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// For every band of every banding (severity, priority): the band's first
|
||||
/// <paramref name="perBand"/> entries of the ranked list, minus the entries already
|
||||
/// <paramref name="listed"/>. Entries without a band add nothing; the ranked order is kept.
|
||||
/// </summary>
|
||||
internal static List<T> BandRepresentatives<T>(
|
||||
IReadOnlyList<T> ranked,
|
||||
IEnumerable<T> listed,
|
||||
int perBand,
|
||||
params Func<T, string>[] bandings) where T : class
|
||||
{
|
||||
var alreadyListed = new HashSet<T>(listed, ReferenceEqualityComparer.Instance);
|
||||
var picked = new SortedSet<int>();
|
||||
foreach (var bandOf in bandings)
|
||||
{
|
||||
var taken = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < ranked.Count; i++)
|
||||
{
|
||||
var band = bandOf(ranked[i]);
|
||||
if (string.IsNullOrEmpty(band)) continue;
|
||||
taken.TryGetValue(band, out var count);
|
||||
if (count >= perBand) continue;
|
||||
taken[band] = count + 1;
|
||||
if (!alreadyListed.Contains(ranked[i])) picked.Add(i);
|
||||
}
|
||||
}
|
||||
return picked.Select(i => ranked[i]).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The first <paramref name="topN"/> entries of a ranked list plus the KEV entries below the
|
||||
/// cut (at most <paramref name="maxAdditions"/>). The input order is kept, so a list ranked by
|
||||
/// affected sessions stays ranked.
|
||||
/// </summary>
|
||||
internal static List<T> WithKevAdditions<T>(IReadOnlyList<T> ranked, int topN, Func<T, bool> isKev, int maxAdditions)
|
||||
=> ranked.Take(topN)
|
||||
.Concat(ranked.Skip(topN).Where(isKev).Take(maxAdditions))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Display name for a product from its detected titles, most sessions first: the leading
|
||||
/// words every title shares when that is at least two words, else the most common title.
|
||||
/// A trailing version or architecture suffix is dropped ("7-Zip 24.08 (x64 edition)" →
|
||||
/// "7-Zip"); a name that would end up empty keeps its title.
|
||||
/// </summary>
|
||||
internal static string DisplayName(IReadOnlyList<string> titles)
|
||||
{
|
||||
var cleaned = titles.Select(t => t?.Trim() ?? string.Empty).Where(t => t.Length > 0).ToList();
|
||||
if (cleaned.Count == 0) return string.Empty;
|
||||
|
||||
var words = cleaned.Select(t => t.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToList();
|
||||
var shared = 0;
|
||||
while (shared < words[0].Length
|
||||
&& words.All(w => w.Length > shared && string.Equals(w[shared], words[0][shared], StringComparison.OrdinalIgnoreCase)))
|
||||
shared++;
|
||||
|
||||
var prefix = TrimTrailingSeparators(words[0].Take(shared).ToList());
|
||||
var baseName = cleaned.Count > 1 && prefix.Count >= 2 ? string.Join(" ", prefix) : cleaned[0];
|
||||
var stripped = StripVersionSuffix(baseName);
|
||||
return stripped.Length > 0 ? stripped : baseName;
|
||||
}
|
||||
|
||||
private static string StripVersionSuffix(string name)
|
||||
{
|
||||
var current = name.Trim();
|
||||
while (true)
|
||||
{
|
||||
var before = current;
|
||||
|
||||
// A trailing parenthetical: "(x64 edition)", "(64-bit)", "(x64 en-US)".
|
||||
if (current.EndsWith(")", StringComparison.Ordinal))
|
||||
{
|
||||
var open = current.LastIndexOf('(');
|
||||
if (open > 0) current = current.Substring(0, open).TrimEnd();
|
||||
}
|
||||
|
||||
var tokens = current.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).ToList();
|
||||
if (tokens.Count > 1 && (IsVersionToken(tokens[^1]) || ArchitectureTokens.Contains(tokens[^1])))
|
||||
tokens.RemoveAt(tokens.Count - 1);
|
||||
current = string.Join(" ", TrimTrailingSeparators(tokens));
|
||||
|
||||
if (current == before) return current;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>"24.08", "v1.2.3", "127.0.6533.74" — dotted digits, optional leading v. A bare integer ("2016", "7") is part of the name.</summary>
|
||||
private static bool IsVersionToken(string token)
|
||||
{
|
||||
var s = token.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? token.Substring(1) : token;
|
||||
if (s.Length < 3 || !char.IsDigit(s[0]) || !char.IsDigit(s[^1])) return false;
|
||||
var dots = 0;
|
||||
foreach (var ch in s)
|
||||
{
|
||||
if (ch == '.') dots++;
|
||||
else if (!char.IsDigit(ch)) return false;
|
||||
}
|
||||
return dots > 0;
|
||||
}
|
||||
|
||||
private static List<string> TrimTrailingSeparators(List<string> tokens)
|
||||
{
|
||||
var list = tokens.ToList();
|
||||
while (list.Count > 0)
|
||||
{
|
||||
var last = list[^1].TrimEnd('-', ':', ',', '|', '/', '–', '—');
|
||||
if (last.Length == 0) { list.RemoveAt(list.Count - 1); continue; }
|
||||
list[^1] = last;
|
||||
break;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static int DistinctSessions(IEnumerable<CveExposureEntry> rows)
|
||||
=> rows.Select(r => r.SessionId)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
||||
|
||||
private static CveFold FoldCve(string cveId, List<CveExposureEntry> rows)
|
||||
{
|
||||
var topSeverityRow = rows.OrderByDescending(r => SeverityRank(r.CvssSeverity)).First();
|
||||
return new CveFold(
|
||||
CveId: cveId,
|
||||
Severity: topSeverityRow.CvssSeverity,
|
||||
SeverityRank: SeverityRank(topSeverityRow.CvssSeverity),
|
||||
CvssScore: rows.Max(r => r.CvssScore),
|
||||
IsKev: rows.Any(r => r.IsKev),
|
||||
// Max over a nullable ignores nulls and yields null when nothing is scored.
|
||||
EpssScore: rows.Max(r => r.EpssScore),
|
||||
Priority: rows.Select(r => r.Priority)
|
||||
.Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best),
|
||||
AffectedSessions: DistinctSessions(rows),
|
||||
Rows: rows);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folds the rows per product. A row without a product (written before the index carried
|
||||
/// it, or a finding without a CPE) borrows the product of another row with the same title —
|
||||
/// so the transition converges as new sessions are correlated — and otherwise is keyed by
|
||||
/// its title. Rows with neither are left out of the product view.
|
||||
/// </summary>
|
||||
private static List<TopSoftware> FoldSoftware(List<CveExposureEntry> rows)
|
||||
{
|
||||
var productByTitle = rows
|
||||
.Where(r => !string.IsNullOrEmpty(r.Product) && !string.IsNullOrWhiteSpace(r.SoftwareName))
|
||||
.GroupBy(r => r.SoftwareName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.GroupBy(r => r.Product, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(p => p.Count())
|
||||
.ThenBy(p => p.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.First().Key,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
string ProductKey(CveExposureEntry r)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(r.Product)) return r.Product;
|
||||
var title = r.SoftwareName?.Trim() ?? string.Empty;
|
||||
return title.Length > 0 && productByTitle.TryGetValue(title, out var product) ? product : title;
|
||||
}
|
||||
|
||||
return rows
|
||||
.Select(r => (Row: r, Key: ProductKey(r)))
|
||||
.Where(x => x.Key.Length > 0)
|
||||
.GroupBy(x => x.Key, x => x.Row, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g =>
|
||||
{
|
||||
var productRows = g.ToList();
|
||||
var productCves = productRows
|
||||
.GroupBy(r => r.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(cg => FoldCve(cg.Key, cg.ToList()))
|
||||
.ToList();
|
||||
var worst = productCves
|
||||
.OrderByDescending(c => c.SeverityRank)
|
||||
.ThenByDescending(c => c.CvssScore)
|
||||
.First();
|
||||
var titles = productRows
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r.SoftwareName))
|
||||
.GroupBy(r => r.SoftwareName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(tg => (Title: tg.Key, Sessions: DistinctSessions(tg)))
|
||||
.OrderByDescending(t => t.Sessions)
|
||||
.ThenBy(t => t.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(t => t.Title)
|
||||
.ToList();
|
||||
|
||||
return new TopSoftware
|
||||
{
|
||||
Product = g.Key,
|
||||
Name = DisplayName(titles),
|
||||
AffectedSessions = DistinctSessions(productRows),
|
||||
CveCount = productCves.Count,
|
||||
KevCount = productCves.Count(c => c.IsKev),
|
||||
CvssSeverity = worst.Severity,
|
||||
CvssScore = worst.CvssScore,
|
||||
Priority = productCves.Select(c => c.Priority)
|
||||
.Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best),
|
||||
Titles = titles.Take(MaxTitlesPerSoftware).ToList(),
|
||||
Cves = productCves
|
||||
.OrderByDescending(c => CvePriority.Rank(c.Priority))
|
||||
.ThenByDescending(c => c.EpssScore ?? -1)
|
||||
.ThenByDescending(c => c.CvssScore)
|
||||
.ThenByDescending(c => c.AffectedSessions)
|
||||
.ThenBy(c => c.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxCvesPerSoftware)
|
||||
.Select(c => new SoftwareCve
|
||||
{
|
||||
CveId = c.CveId,
|
||||
CvssSeverity = c.Severity,
|
||||
CvssScore = c.CvssScore,
|
||||
IsKev = c.IsKev,
|
||||
EpssScore = c.EpssScore,
|
||||
Priority = c.Priority,
|
||||
AffectedSessions = c.AffectedSessions,
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>One CVE folded across its rows (the whole scope, or one product's rows).</summary>
|
||||
private sealed record CveFold(
|
||||
string CveId,
|
||||
string Severity,
|
||||
int SeverityRank,
|
||||
double CvssScore,
|
||||
bool IsKev,
|
||||
double? EpssScore,
|
||||
string Priority,
|
||||
int AffectedSessions,
|
||||
List<CveExposureEntry> Rows);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -34,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/metrics/vulnerability?days=30&topN=20
|
||||
/// GET /api/metrics/vulnerability?days=30&topN=20&perBand=10
|
||||
/// Fleet vulnerability summary for the caller's tenant (MemberRead).
|
||||
/// </summary>
|
||||
[Function("GetVulnerabilitySummary")]
|
||||
@@ -47,7 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/metrics/vulnerability?days=30&topN=20&tenantId=...
|
||||
/// GET /api/global/metrics/vulnerability?days=30&topN=20&perBand=10&tenantId=...
|
||||
/// Cross-tenant summary (Global Admin). With tenantId, scopes to one tenant.
|
||||
/// </summary>
|
||||
[Function("GetGlobalVulnerabilitySummary")]
|
||||
@@ -67,14 +67,17 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
var topN = QueryParams.Int(query["topN"], @default: 20, min: 1, max: 100);
|
||||
// Per-band representatives for the portal's band filters; callers that omit it
|
||||
// (the MCP tool) get the lists without them.
|
||||
var perBand = QueryParams.Int(query["perBand"], @default: 0, min: 0, max: VulnerabilitySummaryAggregator.MaxPerBand);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Vulnerability summary requested (scope={Scope}, days={Days}, topN={TopN})",
|
||||
crossTenant ? "cross-tenant" : tenantId, days, topN);
|
||||
"Vulnerability summary requested (scope={Scope}, days={Days}, topN={TopN}, perBand={PerBand})",
|
||||
crossTenant ? "cross-tenant" : tenantId, days, topN, perBand);
|
||||
|
||||
var (rows, truncated) = await _sessionRepo.ScanCveIndexAsync(tenantId, MaxScanRows);
|
||||
var summary = VulnerabilitySummaryAggregator.Aggregate(
|
||||
rows, truncated, topN, days, crossTenant, DateTime.UtcNow);
|
||||
rows, truncated, topN, days, crossTenant, DateTime.UtcNow, perBand);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(summary);
|
||||
|
||||
@@ -101,7 +101,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
bool RealmJoinDetected,
|
||||
bool RealmJoinResolved,
|
||||
bool HasAppInstallFailure,
|
||||
bool HasAgentMaxLifetimeTimeout);
|
||||
// The agent is gone for good: its max-lifetime watchdog fired, or its decision engine
|
||||
// stopped waiting for the user (D-310, AgentStoppedWaiting).
|
||||
bool AgentStoppedForGood,
|
||||
bool AgentStoppedWaiting = false);
|
||||
|
||||
/// <summary>
|
||||
/// Walk a session's events and distill the ESP rollup. Tolerant of missing/empty input
|
||||
@@ -117,7 +120,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
int acctBestN = 0, acctBestM = 0;
|
||||
bool acctFallbackAll = false;
|
||||
bool hasFailure = false, hasComplete = false, hasEnrollmentComplete = false, hasEmergencyBreak = false;
|
||||
bool hasAppInstallFailure = false, hasMaxLifetimeTimeout = false;
|
||||
bool hasAppInstallFailure = false, agentStoppedForGood = false, agentStoppedWaiting = false;
|
||||
bool desktopArrived = false, helloResolved = false;
|
||||
bool realmJoinDetected = false, realmJoinResolved = false;
|
||||
bool sawHelloPolicyEnabled = false, sawHelloPolicyDisabled = false;
|
||||
@@ -138,15 +141,19 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (!Eq(TryGetDataString(evt, "failureType") ?? string.Empty, "agent_timeout"))
|
||||
hasFailure = true;
|
||||
else
|
||||
hasMaxLifetimeTimeout = true;
|
||||
agentStoppedForGood = true;
|
||||
}
|
||||
// The V2 watchdog shape of the same fact: the agent shut itself down at its
|
||||
// max-lifetime cap. Either shape proves the agent is gone for good — rule 5 uses
|
||||
// that to skip the AwaitingUser grace (a wait nothing can ever end).
|
||||
// max-lifetime cap — or its decision engine stopped waiting for the user (D-310).
|
||||
// Every shape proves the agent is gone for good — rule 5 uses that to skip the
|
||||
// AwaitingUser grace (a wait nothing can ever end).
|
||||
else if (Eq(type, "agent_shutting_down"))
|
||||
{
|
||||
if (Eq(TryGetDataString(evt, "reason") ?? string.Empty, "max_lifetime"))
|
||||
hasMaxLifetimeTimeout = true;
|
||||
var reason = TryGetDataString(evt, "reason") ?? string.Empty;
|
||||
if (Eq(reason, "max_lifetime"))
|
||||
agentStoppedForGood = true;
|
||||
else if (Eq(reason, "stopped_waiting"))
|
||||
agentStoppedForGood = agentStoppedWaiting = true;
|
||||
}
|
||||
else if (Eq(type, "app_install_failed"))
|
||||
hasAppInstallFailure = true;
|
||||
@@ -247,7 +254,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
RealmJoinDetected: realmJoinDetected,
|
||||
RealmJoinResolved: realmJoinResolved,
|
||||
HasAppInstallFailure: hasAppInstallFailure,
|
||||
HasAgentMaxLifetimeTimeout: hasMaxLifetimeTimeout);
|
||||
AgentStoppedForGood: agentStoppedForGood,
|
||||
AgentStoppedWaiting: agentStoppedWaiting);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -436,12 +444,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
var elapsedHours = (nowUtc - startedAtUtc).TotalHours;
|
||||
// The grace only buys time for a LIVE agent to come back with real evidence. Once
|
||||
// the agent's own max-lifetime watchdog has fired it has cleaned up and exited —
|
||||
// the agent stopped for good (its max-lifetime watchdog, or its engine stopped
|
||||
// waiting for the user — D-310) it has cleaned up and exited —
|
||||
// parking such a session AwaitingUser is a countdown, not a wait (all 5 observed
|
||||
// maxlife:r5_awaiting episodes expired unhealed; calibration read 2026-08-27), so
|
||||
// it skips straight to the terminal fork. Nothing is lost by deciding now: late
|
||||
// straggler telemetry still heals a terminal verdict (TryLateTelemetryReconcileAsync).
|
||||
if (elapsedHours < graceHours && !rollup.HasAgentMaxLifetimeTimeout)
|
||||
if (elapsedHours < graceHours && !rollup.AgentStoppedForGood)
|
||||
{
|
||||
var acct = rollup.AccountSetupTotal > 0
|
||||
? $" (Account Setup {rollup.AccountSetupSucceededCount}/{rollup.AccountSetupTotal})"
|
||||
@@ -471,11 +480,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var acctDetail = rollup.AccountSetupTotal > 0
|
||||
? $"last Account Setup {rollup.AccountSetupSucceededCount}/{rollup.AccountSetupTotal}"
|
||||
: "Account Setup progress never observed";
|
||||
// Inside the grace the only way here is the max-lifetime short-circuit — the
|
||||
// "within {grace}h" wording would be false, so name the watchdog instead.
|
||||
// Inside the grace the only way here is the agent-gone short-circuit — the
|
||||
// "within {grace}h" wording would be false, so name what stopped the agent instead.
|
||||
// The engine's own stop (D-310) states its reason in the verdict suffix.
|
||||
if (elapsedHours < graceHours)
|
||||
return (SessionStatus.Incomplete,
|
||||
$"Agent max-lifetime watchdog fired — agent gone without completion after Device Setup completed ({acctDetail})",
|
||||
rollup.AgentStoppedWaiting
|
||||
? $"Device Setup completed ({acctDetail}); the agent stopped waiting for the user."
|
||||
: $"Agent max-lifetime watchdog fired — agent gone without completion after Device Setup completed ({acctDetail})",
|
||||
ClassifierRules.R5DeviceSetupIncomplete);
|
||||
return (SessionStatus.Incomplete,
|
||||
$"No completion signal within {graceHours}h grace after Device Setup completed ({acctDetail})",
|
||||
|
||||
+67
-37
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -92,11 +93,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
classification.HasCompletionEvidenceCandidate = true;
|
||||
break;
|
||||
case "agent_shutting_down":
|
||||
// Every shutdown ends this agent run's observation; the max-lifetime
|
||||
// flavour additionally carries the verdict mapping below.
|
||||
// Every shutdown ends this agent run's observation; the gave-up flavours
|
||||
// (max-lifetime watchdog, engine stopped waiting) additionally carry the
|
||||
// verdict mapping below.
|
||||
classification.AgentShutdownEvent = evt;
|
||||
if (IsMaxLifetimeAgentShutdown(evt))
|
||||
classification.AgentMaxLifetimeShutdownEvent = evt;
|
||||
if (IsAgentGaveUpShutdown(evt))
|
||||
classification.AgentGaveUpShutdownEvent = evt;
|
||||
break;
|
||||
case "system_reboot_detected":
|
||||
// Per-batch incremental reboot count (live value during enrollment).
|
||||
@@ -177,14 +179,30 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static bool IsMaxLifetimeAgentShutdown(EnrollmentEvent? evt)
|
||||
internal static bool IsMaxLifetimeAgentShutdown(EnrollmentEvent? evt) =>
|
||||
string.Equals(AgentShutdownReason(evt), "max_lifetime", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// True for an <c>agent_shutting_down</c> event whose <c>Data.reason</c> is
|
||||
/// <c>stopped_waiting</c>: the agent's decision engine stopped waiting for the user — a
|
||||
/// bounded wait expired (D-310, <c>Data.stopReason</c> names it, e.g. no sign-in after the
|
||||
/// OOBE update). Same contract as <see cref="IsMaxLifetimeAgentShutdown"/>: the last event
|
||||
/// of the session, no enrollment verdict — the backend classifies it honestly.
|
||||
/// </summary>
|
||||
internal static bool IsStoppedWaitingAgentShutdown(EnrollmentEvent? evt) =>
|
||||
string.Equals(AgentShutdownReason(evt), "stopped_waiting", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>The agent gave up waiting for good — either shutdown shape above.</summary>
|
||||
internal static bool IsAgentGaveUpShutdown(EnrollmentEvent? evt) =>
|
||||
IsMaxLifetimeAgentShutdown(evt) || IsStoppedWaitingAgentShutdown(evt);
|
||||
|
||||
private static string? AgentShutdownReason(EnrollmentEvent? evt)
|
||||
{
|
||||
if (evt == null || !string.Equals(evt.EventType, "agent_shutting_down", StringComparison.Ordinal))
|
||||
return false;
|
||||
var reason = evt.Data != null && evt.Data.ContainsKey("reason")
|
||||
return null;
|
||||
return evt.Data != null && evt.Data.ContainsKey("reason")
|
||||
? evt.Data["reason"]?.ToString()
|
||||
: null;
|
||||
return string.Equals(reason, "max_lifetime", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -227,20 +245,24 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared verdict path for both max-lifetime shapes (enrollment_failed/agent_timeout and
|
||||
/// agent_shutting_down/max_lifetime): classify the session honestly via
|
||||
/// <see cref="EnrollmentTimeoutClassifier"/> instead of hard-failing it. The watchdog
|
||||
/// firing only proves the agent stopped waiting — the enrollment itself may be fully
|
||||
/// Shared verdict path for every "the agent gave up waiting" shape — the max-lifetime
|
||||
/// watchdog (enrollment_failed/agent_timeout, agent_shutting_down/max_lifetime) and the
|
||||
/// engine's bounded wait (agent_shutting_down/stopped_waiting, D-310): classify the session
|
||||
/// honestly via <see cref="EnrollmentTimeoutClassifier"/> instead of hard-failing it. The
|
||||
/// agent stopping only proves it stopped waiting — the enrollment itself may be fully
|
||||
/// provisioned with the user simply absent (AwaitingUser), silently dead (Incomplete),
|
||||
/// or even provably finished (Succeeded reconcile). Mirrors the maintenance sweep's
|
||||
/// stage-2 classification (misclassification audit 2026-07-16).
|
||||
/// Returns (transitioned, failureReason) where failureReason is only set for the
|
||||
/// terminal non-success outcomes so downstream failure notifications stay accurate.
|
||||
/// </summary>
|
||||
private async Task<(bool transitioned, string? failureReason)> ApplyMaxLifetimeVerdictAsync(
|
||||
private async Task<(bool transitioned, string? failureReason)> ApplyAgentGaveUpVerdictAsync(
|
||||
IngestEventsRequest request, string sessionPrefix, EventClassification c,
|
||||
EnrollmentEvent triggerEvent, SessionStatus? preFetchedStatus)
|
||||
{
|
||||
var stoppedWaiting = IsStoppedWaitingAgentShutdown(triggerEvent);
|
||||
var trigger = stoppedWaiting ? "stopped_waiting" : "max_lifetime";
|
||||
|
||||
var session = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId);
|
||||
var currentStatus = session?.Status ?? preFetchedStatus;
|
||||
|
||||
@@ -249,7 +271,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (currentStatus == SessionStatus.Pending)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"{SessionPrefix} max_lifetime verdict skipped — session is Pending (WhiteGlove)", sessionPrefix);
|
||||
"{SessionPrefix} {Trigger} verdict skipped — session is Pending (WhiteGlove)", sessionPrefix, trigger);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
@@ -266,7 +288,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
catch (Exception readEx)
|
||||
{
|
||||
_logger.LogWarning(readEx,
|
||||
"{SessionPrefix} failed to read events for max_lifetime classification; proceeding with batch only", sessionPrefix);
|
||||
"{SessionPrefix} failed to read events for {Trigger} classification; proceeding with batch only", sessionPrefix, trigger);
|
||||
sessionEvents = new List<EnrollmentEvent>();
|
||||
}
|
||||
|
||||
@@ -279,7 +301,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
catch (Exception cfgEx)
|
||||
{
|
||||
_logger.LogWarning(cfgEx, "{SessionPrefix} failed to read tenant config for max_lifetime classification; using defaults", sessionPrefix);
|
||||
_logger.LogWarning(cfgEx, "{SessionPrefix} failed to read tenant config for {Trigger} classification; using defaults", sessionPrefix, trigger);
|
||||
}
|
||||
var graceHours = EnrollmentTimeoutClassifier.ResolveGraceHours(tenantGraceHours, absoluteMaxHours);
|
||||
|
||||
@@ -289,7 +311,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var effectiveStart = session?.ResumedAt ?? session?.StartedAt ?? triggerEvent.Timestamp;
|
||||
|
||||
// The trigger event itself may not be persisted yet when this read runs — append it so
|
||||
// the rollup always carries the max-lifetime fact (rule 5 skips the AwaitingUser grace
|
||||
// the rollup always carries the agent-gone fact (rule 5 skips the AwaitingUser grace
|
||||
// on it: the agent is provably gone). The rollup folds booleans, so a stored duplicate
|
||||
// is harmless.
|
||||
sessionEvents.Add(triggerEvent);
|
||||
@@ -300,14 +322,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
isPreProvisioned: session?.IsPreProvisioned == true, resumedAt: session?.ResumedAt,
|
||||
isSelfDeployingProfile: session?.IsSelfDeployingProfile == true);
|
||||
|
||||
// Keep the max-lifetime trigger visible: the Succeeded reasons already carry their own
|
||||
// silence-transparency clause, everything else gets the watchdog context appended.
|
||||
// Keep the trigger visible: the Succeeded reasons already carry their own
|
||||
// silence-transparency clause, everything else names what stopped the agent.
|
||||
if (targetStatus != SessionStatus.Succeeded)
|
||||
reason += " Verdict triggered by the agent's max-lifetime watchdog shutdown.";
|
||||
{
|
||||
reason += stoppedWaiting
|
||||
? " " + StoppedWaitingReasons.Describe(triggerEvent.Data != null && triggerEvent.Data.ContainsKey("stopReason")
|
||||
? triggerEvent.Data["stopReason"]?.ToString()
|
||||
: null)
|
||||
: " Verdict triggered by the agent's max-lifetime watchdog shutdown.";
|
||||
}
|
||||
|
||||
if (targetStatus == currentStatus)
|
||||
{
|
||||
_logger.LogInformation("{SessionPrefix} max_lifetime verdict {Status} equals current status — no transition", sessionPrefix, targetStatus);
|
||||
_logger.LogInformation("{SessionPrefix} {Trigger} verdict {Status} equals current status — no transition", sessionPrefix, trigger, targetStatus);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
@@ -319,20 +347,21 @@ namespace AutopilotMonitor.Functions.Services
|
||||
try { snapshotJson = FailureSnapshotBuilder.Build(sessionEvents, now); }
|
||||
catch (Exception snapEx)
|
||||
{
|
||||
_logger.LogWarning(snapEx, "{SessionPrefix} failed to build failure snapshot for max_lifetime verdict", sessionPrefix);
|
||||
_logger.LogWarning(snapEx, "{SessionPrefix} failed to build failure snapshot for {Trigger} verdict", sessionPrefix, trigger);
|
||||
}
|
||||
}
|
||||
|
||||
var origin = stoppedWaiting ? VerdictPaths.OriginStoppedWaiting : VerdictPaths.OriginMaxLifetime;
|
||||
var transitioned = await _sessionRepo.UpdateSessionStatusAsync(
|
||||
request.TenantId, request.SessionId, targetStatus, VerdictPaths.Compose(VerdictPaths.OriginMaxLifetime, rule),
|
||||
request.TenantId, request.SessionId, targetStatus, VerdictPaths.Compose(origin, rule),
|
||||
triggerEvent.Phase, reason,
|
||||
completedAt: isTerminalNonSuccess ? triggerEvent.Timestamp : (DateTime?)null,
|
||||
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp,
|
||||
failureSource: isTerminalNonSuccess ? "max_lifetime_watchdog" : null,
|
||||
failureSource: isTerminalNonSuccess ? (stoppedWaiting ? "stopped_waiting" : "max_lifetime_watchdog") : null,
|
||||
failureSnapshotJson: snapshotJson);
|
||||
|
||||
_logger.LogWarning("{SessionPrefix} Status: {Status} via max_lifetime honest classification - {Reason} (transitioned={Transitioned})",
|
||||
sessionPrefix, targetStatus, reason, transitioned);
|
||||
_logger.LogWarning("{SessionPrefix} Status: {Status} via {Trigger} honest classification - {Reason} (transitioned={Transitioned})",
|
||||
sessionPrefix, targetStatus, trigger, reason, transitioned);
|
||||
|
||||
return (transitioned, isTerminalNonSuccess ? reason : null);
|
||||
}
|
||||
@@ -495,7 +524,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// in (misclassification audit 2026-07-16, tenant a53e67ec: honest verdict
|
||||
// AwaitingUser). Route through the same honest classification the maintenance
|
||||
// sweep uses instead.
|
||||
(statusTransitioned, failureReason) = await ApplyMaxLifetimeVerdictAsync(
|
||||
(statusTransitioned, failureReason) = await ApplyAgentGaveUpVerdictAsync(
|
||||
request, sessionPrefix, c, c.FailureEvent, preFetchedStatus);
|
||||
}
|
||||
else
|
||||
@@ -608,10 +637,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_logger.LogInformation("{SessionPrefix} WhiteGlove resumed skipped, session already {Status}", sessionPrefix, currentSession?.Status);
|
||||
}
|
||||
}
|
||||
else if (c.AgentMaxLifetimeShutdownEvent != null)
|
||||
else if (c.AgentGaveUpShutdownEvent != null)
|
||||
{
|
||||
// Lowest-priority status writer (session 8bc1180f): the V2 max-lifetime
|
||||
// watchdog stops the agent permanently without a session verdict — this
|
||||
// watchdog — or the engine's bounded wait for the user (D-310, stopped_waiting) —
|
||||
// stops the agent permanently without a session verdict — this
|
||||
// shutdown event is the last one the session ever sends, so without this
|
||||
// mapping the session stays InProgress forever. Any genuine terminal event
|
||||
// in the same batch wins via the else-if chain above; an already-terminal
|
||||
@@ -621,8 +651,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// verdict (misclassification audit 2026-07-16). Pending (WhiteGlove) sessions
|
||||
// are skipped inside the helper — they are deliberately long-lived and resume
|
||||
// via re-registration.
|
||||
(statusTransitioned, failureReason) = await ApplyMaxLifetimeVerdictAsync(
|
||||
request, sessionPrefix, c, c.AgentMaxLifetimeShutdownEvent, preFetchedStatus);
|
||||
(statusTransitioned, failureReason) = await ApplyAgentGaveUpVerdictAsync(
|
||||
request, sessionPrefix, c, c.AgentGaveUpShutdownEvent, preFetchedStatus);
|
||||
}
|
||||
|
||||
if (c.WhiteGloveStartedEvent != null)
|
||||
@@ -660,7 +690,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var batchWroteStatus = c.CompletionEvent != null || c.FailureEvent != null
|
||||
|| c.EspFailureEvent != null || c.GatherCompletionEvent != null
|
||||
|| c.WhiteGloveEvent != null || c.WhiteGloveResumedEvent != null
|
||||
|| c.AgentMaxLifetimeShutdownEvent != null;
|
||||
|| c.AgentGaveUpShutdownEvent != null;
|
||||
var currentStatus = !batchWroteStatus && preFetchedStatus.HasValue
|
||||
? preFetchedStatus
|
||||
: (await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId))?.Status;
|
||||
@@ -717,12 +747,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
public EnrollmentEvent? AgentShutdownEvent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>agent_shutting_down</c> with <c>Data.reason == "max_lifetime"</c> — the V2
|
||||
/// watchdog shutdown that deliberately carries no enrollment verdict. Mapped to a
|
||||
/// terminal Failed status as the lowest-priority status writer so the session does
|
||||
/// not stay InProgress forever (session 8bc1180f).
|
||||
/// <c>agent_shutting_down</c> with <c>Data.reason</c> <c>max_lifetime</c> (the V2
|
||||
/// watchdog, session 8bc1180f) or <c>stopped_waiting</c> (the engine's bounded wait,
|
||||
/// D-310) — shutdowns that deliberately carry no enrollment verdict. Classified honestly
|
||||
/// as the lowest-priority status writer so the session does not stay InProgress forever.
|
||||
/// </summary>
|
||||
public EnrollmentEvent? AgentMaxLifetimeShutdownEvent { get; set; }
|
||||
public EnrollmentEvent? AgentGaveUpShutdownEvent { get; set; }
|
||||
public bool HasNonPeriodicRealEvent { get; set; }
|
||||
public EnrollmentEvent? DeviceLocationEvent { get; set; }
|
||||
public DateTime? EarliestEventTimestamp { get; set; }
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
// A failure alert requires an actual failure-ish verdict: an agent_timeout
|
||||
// enrollment_failed can honestly classify to AwaitingUser or even Succeeded
|
||||
// (ApplyMaxLifetimeVerdictAsync), in which case failureReason stays null and no
|
||||
// (ApplyAgentGaveUpVerdictAsync), in which case failureReason stays null and no
|
||||
// failure notification must go out for a session that did not fail.
|
||||
var failureVerdictApplies = c.FailureEvent != null && failureReason != null;
|
||||
if (statusTransitioned && (c.CompletionEvent != null || failureVerdictApplies))
|
||||
|
||||
+66
-11
@@ -233,13 +233,9 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
},
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(result.Explanation))
|
||||
{
|
||||
var explanation = result.Explanation.Length > 300
|
||||
? result.Explanation[..300] + "..."
|
||||
: result.Explanation;
|
||||
var explanation = RenderExplanation(result, RuleFiredExplanationMaxLength);
|
||||
if (explanation.Length > 0)
|
||||
alert.Sections.Add(new NotificationSection { Title = "Explanation", Text = explanation });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(sessionUrl))
|
||||
alert.Actions.Add(new NotificationAction { Type = "openUrl", Title = "Open session", Url = sessionUrl });
|
||||
@@ -273,18 +269,77 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
_ => "\ud83d\udfe1"
|
||||
};
|
||||
|
||||
var explanation = r.Explanation?.Length > 200
|
||||
? r.Explanation[..200] + "..."
|
||||
: r.Explanation ?? "";
|
||||
|
||||
alert.Sections.Add(new NotificationSection
|
||||
{
|
||||
Title = $"{emoji} {r.RuleTitle}",
|
||||
Text = explanation
|
||||
Text = RenderExplanation(r, RuleSectionExplanationMaxLength)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal const int RuleFiredExplanationMaxLength = 300;
|
||||
internal const int RuleSectionExplanationMaxLength = 200;
|
||||
|
||||
/// <summary>
|
||||
/// A stored <see cref="RuleResult.Explanation"/> is the rule's template. Tokens are
|
||||
/// resolved BEFORE the cut: cutting first would split a placeholder or spend the
|
||||
/// budget on placeholder names instead of the values.
|
||||
/// </summary>
|
||||
private static string RenderExplanation(RuleResult result, int maxLength)
|
||||
=> TruncateAtBoundary(
|
||||
RuleTemplateInterpolator.InterpolateForNotification(result.Explanation, result.MatchedConditions),
|
||||
maxLength);
|
||||
|
||||
/// <summary>
|
||||
/// Shortens to at most <paramref name="maxLength"/> characters at the last line break
|
||||
/// inside the limit, else the last sentence end, else the last word. A boundary in the
|
||||
/// first half of the limit is not taken, so the cut never throws away most of the budget.
|
||||
/// A bold or code span the cut left open is closed by dropping its opening marker.
|
||||
/// </summary>
|
||||
internal static string TruncateAtBoundary(string text, int maxLength)
|
||||
{
|
||||
const string marker = " …";
|
||||
if (text.Length <= maxLength)
|
||||
return text;
|
||||
|
||||
var window = text[..(maxLength - marker.Length)];
|
||||
var floor = window.Length / 2;
|
||||
|
||||
var cut = window.LastIndexOf('\n');
|
||||
if (cut < floor)
|
||||
{
|
||||
cut = -1;
|
||||
for (var i = window.Length - 2; i >= floor; i--)
|
||||
{
|
||||
if (window[i] is '.' or '!' or '?' && char.IsWhiteSpace(window[i + 1]))
|
||||
{
|
||||
cut = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cut < floor)
|
||||
cut = window.LastIndexOf(' ');
|
||||
if (cut < floor)
|
||||
cut = window.Length;
|
||||
|
||||
var kept = DropUnpairedMarker(DropUnpairedMarker(window[..cut], "**"), "`");
|
||||
return kept.TrimEnd() + marker;
|
||||
}
|
||||
|
||||
private static string DropUnpairedMarker(string text, string markdownMarker)
|
||||
{
|
||||
var count = 0;
|
||||
var last = -1;
|
||||
for (var i = text.IndexOf(markdownMarker, StringComparison.Ordinal); i >= 0;
|
||||
i = text.IndexOf(markdownMarker, i + markdownMarker.Length, StringComparison.Ordinal))
|
||||
{
|
||||
count++;
|
||||
last = i;
|
||||
}
|
||||
return count % 2 == 0 ? text : text.Remove(last, markdownMarker.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a notification alert for an SLA breach. Dispatches on
|
||||
/// <paramref name="breachType"/>: <c>SuccessRate</c>, <c>Duration</c>, or <c>AppInstall</c>.
|
||||
|
||||
@@ -1307,6 +1307,17 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return (true, primary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same-event fields copied into every evidence entry. Whitelist: short identifiers only;
|
||||
/// errorDetail/message are excluded (can be multi-KB). Every renderer resolves these as
|
||||
/// <c>{{token}}</c> — the web, the MCP server and <see cref="RuleTemplateInterpolator"/>
|
||||
/// carry the same list, pinned by tests/fixtures/rule-template-interpolation/cases.json.
|
||||
/// </summary>
|
||||
internal static readonly IReadOnlyList<string> EvidenceAutoFields = new[]
|
||||
{
|
||||
"appId", "appName", "errorPatternId", "errorCode", "exitCode", "status", "timeoutHours"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Adds slim identifying data fields from an event to the evidence dictionary with a prefix.
|
||||
/// Free-text fields like <c>errorDetail</c> (often a stack trace) and <c>message</c> are
|
||||
@@ -1317,10 +1328,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
if (evt.Data == null) return;
|
||||
|
||||
// Whitelist: short identifiers only. errorDetail/message removed (can be multi-KB).
|
||||
var knownFields = new[] { "appId", "appName", "errorPatternId", "errorCode", "exitCode", "status", "timeoutHours" };
|
||||
|
||||
foreach (var field in knownFields)
|
||||
foreach (var field in EvidenceAutoFields)
|
||||
{
|
||||
var val = GetDataFieldValue(evt, field);
|
||||
if (val != null)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the <c>{{token}}</c> placeholders of a rule's explanation/remediation text from a
|
||||
/// result's <see cref="RuleResult.MatchedConditions"/>. A stored <see cref="RuleResult"/> keeps
|
||||
/// the rule's raw template; the web (<c>lib/interpolateRuleTemplate.ts</c>) and the MCP server
|
||||
/// (<c>src/interpolate-rule-template.ts</c>) resolve it when they render. This is the backend
|
||||
/// copy for text that leaves without passing either of them (notifications).
|
||||
/// <para>
|
||||
/// Resolution order, identical in all three and pinned by
|
||||
/// <c>tests/fixtures/rule-template-interpolation/cases.json</c>:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>an evidence entry whose <c>field</c> equals the token (the last such entry wins);</item>
|
||||
/// <item>a same-event auto-field named like the token (<see cref="RuleEngine.EvidenceAutoFields"/>;
|
||||
/// the first non-empty one wins, which pins it to the rule's earliest matched condition);</item>
|
||||
/// <item>an evidence entry whose signal name equals the token;</item>
|
||||
/// <item>otherwise the token stays as written.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static class RuleTemplateInterpolator
|
||||
{
|
||||
/// <summary>Shown in a notification where running text names a value that was not recorded.</summary>
|
||||
internal const string NotRecorded = "–";
|
||||
|
||||
private static readonly Regex TokenRegex = new Regex(
|
||||
@"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex ListItemRegex = new Regex(
|
||||
@"^\s*(?:[-*+]|\d+[.)])\s+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex SentenceBreakRegex = new Regex(
|
||||
@"(?<=[.!?])\s+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex BlankRunRegex = new Regex(
|
||||
@"\n{3,}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces every resolvable token; an unresolved one stays literal, exactly as the web
|
||||
/// and the MCP server render it.
|
||||
/// </summary>
|
||||
public static string Interpolate(string? text, IReadOnlyDictionary<string, object>? matchedConditions)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return string.Empty;
|
||||
|
||||
var lookup = Lookup.From(matchedConditions);
|
||||
return TokenRegex.Replace(text, m => lookup.Resolve(m.Groups[1].Value) ?? m.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interpolation for text that leaves the backend as a notification. The recipient has no
|
||||
/// renderer and no footnote explaining a literal placeholder, so none may survive: a list
|
||||
/// item whose token was not recorded is dropped, a token in running text becomes
|
||||
/// <see cref="NotRecorded"/>, and a sentence that talks about literal placeholders is removed.
|
||||
/// </summary>
|
||||
public static string InterpolateForNotification(string? text, IReadOnlyDictionary<string, object>? matchedConditions)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return string.Empty;
|
||||
|
||||
var lookup = Lookup.From(matchedConditions);
|
||||
var lines = new List<string>();
|
||||
|
||||
foreach (var line in text.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
var unresolved = false;
|
||||
var rendered = TokenRegex.Replace(line, m =>
|
||||
{
|
||||
var value = lookup.Resolve(m.Groups[1].Value);
|
||||
if (value != null)
|
||||
return value;
|
||||
unresolved = true;
|
||||
return NotRecorded;
|
||||
});
|
||||
|
||||
if (unresolved && ListItemRegex.IsMatch(line))
|
||||
continue;
|
||||
|
||||
if (rendered.Contains("{{", StringComparison.Ordinal))
|
||||
{
|
||||
rendered = string.Join(" ", SentenceBreakRegex.Split(rendered)
|
||||
.Where(sentence => !sentence.Contains("{{", StringComparison.Ordinal)));
|
||||
if (rendered.Length == 0)
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.Add(rendered);
|
||||
}
|
||||
|
||||
return BlankRunRegex.Replace(string.Join("\n", lines), "\n\n").Trim();
|
||||
}
|
||||
|
||||
private sealed class Lookup
|
||||
{
|
||||
private readonly Dictionary<string, string> _byField = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _byAutoField = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _bySignal = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
public string? Resolve(string token)
|
||||
{
|
||||
if (_byField.TryGetValue(token, out var value)) return value;
|
||||
if (_byAutoField.TryGetValue(token, out value)) return value;
|
||||
if (_bySignal.TryGetValue(token, out value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Lookup From(IReadOnlyDictionary<string, object>? matchedConditions)
|
||||
{
|
||||
var lookup = new Lookup();
|
||||
if (matchedConditions == null || matchedConditions.Count == 0)
|
||||
return lookup;
|
||||
|
||||
// Evidence arrives in two shapes: nested dictionaries straight from the rule engine,
|
||||
// or whatever the table read produced. Going through JSON gives both the form the
|
||||
// web and the MCP server see. JsonSerializer.Create() ignores the process-wide
|
||||
// default settings, so a naming strategy configured there cannot rename the keys.
|
||||
JObject root;
|
||||
try
|
||||
{
|
||||
root = JObject.FromObject(matchedConditions, JsonSerializer.Create());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Evidence that cannot be serialized resolves nothing; the text is still sent.
|
||||
return lookup;
|
||||
}
|
||||
|
||||
foreach (var property in root.Properties())
|
||||
{
|
||||
if (property.Value is not JObject evidence)
|
||||
{
|
||||
if (property.Value is JValue scalar && scalar.Type != JTokenType.Null && scalar.Type != JTokenType.Undefined)
|
||||
lookup._bySignal[property.Name] = Format(scalar);
|
||||
continue;
|
||||
}
|
||||
|
||||
var hasValue = evidence.TryGetValue("value", out var valueToken);
|
||||
if (hasValue)
|
||||
{
|
||||
if (evidence["field"] is JValue { Type: JTokenType.String } field)
|
||||
lookup._byField[(string)field.Value!] = Format(valueToken);
|
||||
lookup._bySignal[property.Name] = Format(valueToken);
|
||||
}
|
||||
|
||||
foreach (var autoField in RuleEngine.EvidenceAutoFields)
|
||||
{
|
||||
if (lookup._byAutoField.ContainsKey(autoField))
|
||||
continue;
|
||||
var formatted = Format(evidence[autoField]);
|
||||
if (formatted.Length > 0)
|
||||
lookup._byAutoField[autoField] = formatted;
|
||||
}
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
// Mirrors the TypeScript formatValue: strings as they are, numbers and booleans the
|
||||
// way JavaScript prints them, anything structured as compact JSON.
|
||||
private static string Format(JToken? token)
|
||||
{
|
||||
if (token == null)
|
||||
return string.Empty;
|
||||
|
||||
switch (token.Type)
|
||||
{
|
||||
case JTokenType.Null:
|
||||
case JTokenType.Undefined:
|
||||
return string.Empty;
|
||||
case JTokenType.String:
|
||||
return (string)((JValue)token).Value!;
|
||||
case JTokenType.Boolean:
|
||||
return (bool)token ? "true" : "false";
|
||||
case JTokenType.Integer:
|
||||
return Convert.ToString(((JValue)token).Value, CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
case JTokenType.Float:
|
||||
return Convert.ToDouble(((JValue)token).Value, CultureInfo.InvariantCulture)
|
||||
.ToString(CultureInfo.InvariantCulture);
|
||||
case JTokenType.Object:
|
||||
case JTokenType.Array:
|
||||
return token.ToString(Formatting.None);
|
||||
default:
|
||||
// Date, Guid, TimeSpan, Uri: their JSON string form without the quotes.
|
||||
return token.ToString(Formatting.None).Trim('"');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,8 +291,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
/// <summary>
|
||||
/// Collapses report findings to one entity per CVE. Merge rule: the occurrence with the
|
||||
/// highest CVSS score supplies score/severity/software name (ties keep report order),
|
||||
/// IsKev is OR-ed, OverallRisk is the highest risk level seen across occurrences.
|
||||
/// highest CVSS score supplies score/severity/software name/product (ties keep report
|
||||
/// order), IsKev is OR-ed, OverallRisk is the highest risk level seen across occurrences.
|
||||
/// Product is the finding's CPE "vendor:product" (<see cref="Vulnerability.CpeMatchScorer.ProductKey"/>).
|
||||
/// </summary>
|
||||
internal static List<TableEntity> BuildCveIndexEntities(string tenantId, string sessionId, List<Dictionary<string, object>> findings)
|
||||
{
|
||||
@@ -302,6 +303,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
var softwareName = finding.TryGetValue("softwareName", out var sn) ? sn?.ToString() ?? "" : "";
|
||||
var product = Vulnerability.CpeMatchScorer.ProductKey(finding.TryGetValue("cpeUri", out var cu) ? cu?.ToString() : null);
|
||||
var overallRisk = finding.TryGetValue("riskLevel", out var rl) ? rl?.ToString() ?? "" : "";
|
||||
|
||||
if (!finding.TryGetValue("vulnerabilities", out var vulnsObj) || vulnsObj == null) continue;
|
||||
@@ -330,6 +332,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
existing["CvssScore"] = cvssScore;
|
||||
existing["CvssSeverity"] = cvssSeverity;
|
||||
existing["SoftwareName"] = softwareName;
|
||||
existing["Product"] = product;
|
||||
}
|
||||
existing["IsKev"] = existing.GetBoolean("IsKev") == true || isKev;
|
||||
if (epssScore.HasValue && epssScore.Value > (existing.GetDouble("EpssScore") ?? -1))
|
||||
@@ -347,6 +350,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["TenantId"] = tenantId,
|
||||
["CveId"] = cveId,
|
||||
["SoftwareName"] = softwareName,
|
||||
["Product"] = product,
|
||||
["CvssScore"] = cvssScore,
|
||||
["CvssSeverity"] = cvssSeverity,
|
||||
["IsKev"] = isKev,
|
||||
@@ -1380,7 +1384,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var select = new[]
|
||||
{
|
||||
"SessionId", "TenantId", "CveId", "SoftwareName",
|
||||
"SessionId", "TenantId", "CveId", "SoftwareName", "Product",
|
||||
"CvssScore", "CvssSeverity", "IsKev", "EpssScore", "Priority", "OverallRisk", "DetectedAt",
|
||||
};
|
||||
|
||||
@@ -1406,6 +1410,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
CveId = e.GetString("CveId") ?? string.Empty,
|
||||
SessionId = e.GetString("SessionId") ?? e.RowKey,
|
||||
SoftwareName = e.GetString("SoftwareName") ?? string.Empty,
|
||||
Product = e.GetString("Product") ?? string.Empty,
|
||||
CvssScore = e.GetDouble("CvssScore") ?? 0,
|
||||
CvssSeverity = e.GetString("CvssSeverity") ?? string.Empty,
|
||||
IsKev = e.GetBoolean("IsKev") ?? false,
|
||||
|
||||
@@ -148,6 +148,23 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
|
||||
return (parts[2], parts[3], parts[4]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product identity of a CPE 2.3 name as "vendor:product" in lower case
|
||||
/// ("cpe:2.3:a:7-zip:7-zip:24.08" → "7-zip:7-zip"); empty when the name has no concrete
|
||||
/// vendor and product. CveIndex rows carry it so the exposure summary can fold every title
|
||||
/// and version of one product together.
|
||||
/// </summary>
|
||||
public static string ProductKey(string? cpeName)
|
||||
{
|
||||
var parts = ParseCpeComponents(cpeName ?? "");
|
||||
if (parts == null) return "";
|
||||
var (_, vendor, product) = parts.Value;
|
||||
if (!IsConcrete(vendor) || !IsConcrete(product)) return "";
|
||||
return $"{vendor}:{product}".ToLowerInvariant();
|
||||
|
||||
static bool IsConcrete(string value) => value.Length > 0 && value != "*" && value != "-";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a CpeMappingEntry from an NVD CPE entry for saving as a custom mapping.
|
||||
/// </summary>
|
||||
|
||||
+3
-3
@@ -2284,9 +2284,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
@@ -10,9 +10,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { scanUntilMatch } from '../client.js';
|
||||
import {
|
||||
isKnownEventType,
|
||||
isGatherOutputEventType,
|
||||
isFilterableEventType,
|
||||
assertKnownEventType,
|
||||
withEventTypeNote,
|
||||
assertKnownDevicePropertyKeys,
|
||||
eventTypePrefixOf,
|
||||
ALL_DEVICE_PROPERTY_KEYS,
|
||||
@@ -146,50 +144,59 @@ describe('scanUntilMatch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('event-type validation', () => {
|
||||
it('accepts known event types', () => {
|
||||
describe('event-type filter', () => {
|
||||
it('keeps isKnownEventType strict: built-in types only', () => {
|
||||
expect(isKnownEventType('app_install_failed')).toBe(true);
|
||||
expect(isKnownEventType('enrollment_failed')).toBe(true);
|
||||
expect(() => assertKnownEventType('app_install_failed')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an unknown event type with a helpful, suggestion-bearing error', () => {
|
||||
expect(isKnownEventType('made_up_type')).toBe(false);
|
||||
expect(() => assertKnownEventType('app_install_fialed')).toThrow(/event_types catalog/);
|
||||
// "app_install_fialed" shares the "app" token → at least one suggestion surfaces.
|
||||
expect(() => assertKnownEventType('app_install_fialed')).toThrow(/Did you mean/);
|
||||
});
|
||||
|
||||
it('accepts the hybrid user-affinity types added to the catalog', () => {
|
||||
expect(isKnownEventType('gather_result')).toBe(true);
|
||||
for (const t of ['desktop_real_user_detected', 'desktop_excluded_user', 'ime_user_token_acquired', 'entra_user_affinity_pending', 'device_registration_event']) {
|
||||
expect(isKnownEventType(t), t).toBe(true);
|
||||
expect(() => assertKnownEventType(t)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets gather-rule output types through the filter gate without cataloguing them', () => {
|
||||
// Built-in and tenant-authored outputEventType values are rule data, never catalogued.
|
||||
for (const t of ['gather_dsregcmd_status', 'gather_custom_anything']) {
|
||||
expect(isGatherOutputEventType(t), t).toBe(true);
|
||||
expect(isFilterableEventType(t), t).toBe(true);
|
||||
expect(() => assertKnownEventType(t)).not.toThrow();
|
||||
// The strict predicate stays strict: rule-validation uses it as "is a built-in type".
|
||||
// Gather-rule output types are rule data, never catalogued — rule-validation relies on this.
|
||||
for (const t of ['gather_dsregcmd_status', 'HPiA-UpdateStatus', 'made_up_type']) {
|
||||
expect(isKnownEventType(t), t).toBe(false);
|
||||
}
|
||||
// Catalogued code-emitted gather_* lifecycle types are known AND filterable.
|
||||
expect(isKnownEventType('gather_result')).toBe(true);
|
||||
expect(isFilterableEventType('gather_result')).toBe(true);
|
||||
});
|
||||
|
||||
it('requires a non-empty suffix after the gather_ prefix', () => {
|
||||
expect(isGatherOutputEventType('gather_')).toBe(false);
|
||||
expect(isFilterableEventType('gather_')).toBe(false);
|
||||
expect(() => assertKnownEventType('gather_')).toThrow(/event_types catalog/);
|
||||
it('passes a result for a built-in type through unchanged, even when empty', () => {
|
||||
const empty = { success: true, count: 0, events: [] };
|
||||
expect(withEventTypeNote(empty, 'app_install_failed', true)).toBe(empty);
|
||||
});
|
||||
|
||||
it('still rejects a typo that only resembles the gather_ prefix', () => {
|
||||
expect(isFilterableEventType('gathre_dsregcmd_status')).toBe(false);
|
||||
expect(() => assertKnownEventType('gathre_dsregcmd_status')).toThrow(/Unknown eventType "gathre_dsregcmd_status" — it is not in the event_types catalog/);
|
||||
it('passes events of a gather rule\'s own type through unchanged, whatever its spelling', () => {
|
||||
// Regression anchor: "HPiA-UpdateStatus" was rejected because it is neither catalogued nor gather_-prefixed.
|
||||
for (const t of ['HPiA-UpdateStatus', 'CustomNetworkStatus', 'gather_dsregcmd_status']) {
|
||||
const page = { success: true, count: 1, events: [{ eventType: t }] };
|
||||
expect(withEventTypeNote(page, t, true), t).toBe(page);
|
||||
}
|
||||
});
|
||||
|
||||
it('marks an exhausted empty result for an uncatalogued type and names close built-in types', () => {
|
||||
const res = withEventTypeNote({ success: true, count: 0, events: [] }, 'app_install_fialed', true);
|
||||
|
||||
expect(res.eventTypeNote).toMatch(/"app_install_fialed" is not a built-in event type and no event of it was found/);
|
||||
// "app_install_fialed" shares the "app" token → at least one suggestion surfaces.
|
||||
expect(res.eventTypeNote).toMatch(/Closest built-in types: .*app_/);
|
||||
});
|
||||
|
||||
it('leaves a page alone that is not the final answer yet', () => {
|
||||
expect(withEventTypeNote({ count: 0, events: [], nextLink: '/x?continuation=t' }, 'custom', true).eventTypeNote).toBeUndefined();
|
||||
expect(withEventTypeNote({ count: 0, events: [], moreToScan: true }, 'custom', true).eventTypeNote).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays silent when the type was not the only filter of a first call', () => {
|
||||
// Another filter (severity, source, time) or an earlier page of the sweep may explain the
|
||||
// emptiness — blaming the type would steer the reader towards a typo that is not there.
|
||||
expect(withEventTypeNote({ count: 0, events: [] }, 'HPiA-UpdateStatus', false).eventTypeNote).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recognises the sessions[] shape of search_sessions_by_event', () => {
|
||||
expect(withEventTypeNote({ success: true, count: 0, sessions: [] }, 'HPiA-UpdateStatus', true).eventTypeNote)
|
||||
.toMatch(/not a built-in event type/);
|
||||
});
|
||||
|
||||
it('does nothing without an eventType filter', () => {
|
||||
const empty = { count: 0, events: [] };
|
||||
expect(withEventTypeNote(empty, undefined, true)).toBe(empty);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* eventType filters accept a gather rule's own output type. Regression anchor: get_session_events
|
||||
* rejected "HPiA-UpdateStatus" — a tenant rule's outputEventType — because it is neither catalogued
|
||||
* nor gather_-prefixed, so the rule's events could not be filtered at all. An empty result for an
|
||||
* uncatalogued type is marked instead of rejected up front.
|
||||
*
|
||||
* Pure unit tests: fetch is stubbed and the registered tool handlers are invoked directly.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { registerTools } from '../tools.js';
|
||||
import { runWithCaller } from '../client.js';
|
||||
|
||||
type ToolHandler = (args: Record<string, unknown>, extra: unknown) => Promise<{
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
isError?: boolean;
|
||||
}>;
|
||||
|
||||
const SESSION = 'e259c121-1234-4abc-9def-0123456789ab';
|
||||
const GA = { token: 'ga', isGlobalAdmin: true };
|
||||
const extra = { signal: new AbortController().signal };
|
||||
|
||||
function handlerFor(name: string): ToolHandler {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
registerTools(server, undefined, undefined, undefined, true, true, false);
|
||||
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler }> })._registeredTools;
|
||||
const tool = registry[name];
|
||||
if (!tool) throw new Error(`tool ${name} not registered`);
|
||||
return tool.handler;
|
||||
}
|
||||
|
||||
function stubFetch(body: Record<string, unknown>): { urls: string[] } {
|
||||
const urls: string[] = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||
urls.push(decodeURIComponent(String(url)));
|
||||
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response;
|
||||
}));
|
||||
return { urls };
|
||||
}
|
||||
|
||||
function resultJson(r: { content?: Array<{ text?: string }> }): Record<string, unknown> {
|
||||
return JSON.parse((r.content ?? []).map((c) => c.text ?? '').join('')) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('eventType filter with a gather rule\'s own output type', () => {
|
||||
it('get_session_events filters by the custom type and returns its events unmarked', async () => {
|
||||
const { urls } = stubFetch({ success: true, count: 1, events: [{ eventType: 'HPiA-UpdateStatus', sequence: 69 }] });
|
||||
|
||||
const result = await runWithCaller(GA, () => handlerFor('get_session_events')({ sessionId: SESSION, eventType: 'HPiA-UpdateStatus' }, extra));
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(urls[0]).toContain('eventType=HPiA-UpdateStatus');
|
||||
const body = resultJson(result);
|
||||
expect(body.count).toBe(1);
|
||||
expect(body).not.toHaveProperty('eventTypeNote');
|
||||
});
|
||||
|
||||
it('an empty result for an uncatalogued type carries the note instead of passing as "nothing happened"', async () => {
|
||||
stubFetch({ success: true, count: 0, events: [] });
|
||||
|
||||
const result = await runWithCaller(GA, () => handlerFor('get_session_events')({ sessionId: SESSION, eventType: 'app_install_fialed' }, extra));
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(String(resultJson(result).eventTypeNote)).toMatch(/"app_install_fialed" is not a built-in event type.*Closest built-in types/);
|
||||
});
|
||||
|
||||
it('no note when severity, not the type, may have emptied the result, or on a continued sweep', async () => {
|
||||
stubFetch({ success: true, count: 0, events: [] });
|
||||
|
||||
const filtered = await runWithCaller(GA, () => handlerFor('get_session_events')(
|
||||
{ sessionId: SESSION, eventType: 'HPiA-UpdateStatus', severity: 'Error' }, extra));
|
||||
expect(resultJson(filtered)).not.toHaveProperty('eventTypeNote');
|
||||
|
||||
const continued = await runWithCaller(GA, () => handlerFor('search_sessions_by_event')(
|
||||
{ eventType: 'HPiA-UpdateStatus', continuation: '/api/global/search/sessions-by-event?eventType=HPiA-UpdateStatus&continuation=tok' }, extra));
|
||||
expect(resultJson(continued)).not.toHaveProperty('eventTypeNote');
|
||||
});
|
||||
|
||||
it('a built-in type keeps a plain empty result', async () => {
|
||||
stubFetch({ success: true, count: 0, events: [] });
|
||||
|
||||
const result = await runWithCaller(GA, () => handlerFor('get_session_events')({ sessionId: SESSION, eventType: 'app_install_failed' }, extra));
|
||||
|
||||
expect(resultJson(result)).not.toHaveProperty('eventTypeNote');
|
||||
});
|
||||
|
||||
it('search_sessions_by_event and query_raw_events accept the custom type too', async () => {
|
||||
stubFetch({ success: true, count: 0, sessions: [] });
|
||||
const bySession = await runWithCaller(GA, () => handlerFor('search_sessions_by_event')({ eventType: 'CustomNetworkStatus' }, extra));
|
||||
expect(bySession.isError).toBeFalsy();
|
||||
expect(String(resultJson(bySession).eventTypeNote)).toMatch(/not a built-in event type/);
|
||||
|
||||
const { urls } = stubFetch({ success: true, count: 1, events: [{ EventType: 'CustomNetworkStatus' }] });
|
||||
const raw = await runWithCaller(GA, () => handlerFor('query_raw_events')({ eventType: 'CustomNetworkStatus' }, extra));
|
||||
expect(raw.isError).toBeFalsy();
|
||||
expect(urls[0]).toContain('eventType=CustomNetworkStatus');
|
||||
expect(resultJson(raw)).not.toHaveProperty('eventTypeNote');
|
||||
});
|
||||
});
|
||||
+41
-1
@@ -1,5 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { interpolateRuleTemplate, interpolateAnalysisResults } from '../interpolate-rule-template.js';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
interpolateRuleTemplate,
|
||||
interpolateAnalysisResults,
|
||||
RULE_TEMPLATE_AUTO_FIELDS,
|
||||
} from '../interpolate-rule-template.js';
|
||||
|
||||
interface SharedCase {
|
||||
name: string;
|
||||
template: string | null;
|
||||
matchedConditions: Record<string, unknown> | null;
|
||||
expected: string;
|
||||
}
|
||||
|
||||
/** tests/fixtures/rule-template-interpolation at the repo root — the cases every renderer runs. */
|
||||
function loadSharedCases(): { autoFields: string[]; cases: SharedCase[] } {
|
||||
let dir = dirname(fileURLToPath(import.meta.url));
|
||||
while (!existsSync(join(dir, 'AutopilotMonitor.sln'))) {
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) throw new Error('repository root (AutopilotMonitor.sln) not found');
|
||||
dir = parent;
|
||||
}
|
||||
return JSON.parse(
|
||||
readFileSync(join(dir, 'tests', 'fixtures', 'rule-template-interpolation', 'cases.json'), 'utf8'),
|
||||
);
|
||||
}
|
||||
|
||||
describe('interpolateRuleTemplate — shared cases (web = MCP = backend)', () => {
|
||||
const shared = loadSharedCases();
|
||||
|
||||
it('has cases and the same auto-field list as the rule engine', () => {
|
||||
expect(shared.cases.length).toBeGreaterThanOrEqual(20);
|
||||
expect([...RULE_TEMPLATE_AUTO_FIELDS]).toEqual(shared.autoFields);
|
||||
});
|
||||
|
||||
it.each(shared.cases.map((c) => [c.name, c] as const))('%s', (_name, c) => {
|
||||
expect(interpolateRuleTemplate(c.template, c.matchedConditions)).toBe(c.expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolateRuleTemplate', () => {
|
||||
// Shape of a real ANALYZE-ENRL-001 matchedConditions map (the case from the
|
||||
|
||||
@@ -680,7 +680,7 @@ const defaultPageFetcher: PageFetcher = (path) => apiFetch<Record<string, unknow
|
||||
const ITEM_ARRAY_KEYS = ['events', 'sessions', 'items', 'results'] as const;
|
||||
|
||||
/** Find the first array-valued envelope field — the page's item list. */
|
||||
function extractItems(page: Record<string, unknown>): unknown[] | undefined {
|
||||
export function extractItems(page: Record<string, unknown>): unknown[] | undefined {
|
||||
for (const key of ITEM_ARRAY_KEYS) {
|
||||
const v = page[key];
|
||||
if (Array.isArray(v)) return v;
|
||||
|
||||
@@ -1079,8 +1079,14 @@ export interface CollectorConfiguration {
|
||||
modernDeploymentBackfillLookbackMinutes: number;
|
||||
/** Master switch for the Windows Update watcher. When enabled the agent subscribes to Microsoft-Windows-WindowsUpdateClient/Operational and forwards update install/failure events (windows_update_succeeded / _failed / _started) so quality and cumulative updates that run DURING enrollment become visible with evidence. Default: true. */
|
||||
windowsUpdateWatcherEnabled: boolean;
|
||||
/** WindowsUpdateClient/Operational EventIDs to capture. Kept configurable so additional IDs can be enabled after real-device validation WITHOUT an agent redeploy. Verified set: 19=install success, 20=install failure (carries HRESULT), 43=install started, 44=download started. IDs whose semantics are still unverified (25/26/31, restart-required 21 vs 41) are intentionally excluded until confirmed on a device trace. Default: [19, 20, 43, 44]. */
|
||||
/** WindowsUpdateClient EventIDs the install watcher captures: 19=install success, 20=install failure (carries HRESULT), 43=install started, 44=download started. The provider manifest writes these to the System log, so the agent routes every ID to the channel the manifest assigns it (System: 16–24, 27, 28, 32, 33, 43, 44, 212–218; Operational: all others). Default: [19, 20, 43, 44]. */
|
||||
windowsUpdateTargetedEventIds: number[];
|
||||
/** WindowsUpdateClient/Operational EventIDs the scan/download watcher captures: 25=scan failed, 26=scan found N updates, 31=download failed, 41=update downloaded. Separate from WindowsUpdateTargetedEventIds on purpose: agents that predate the update classification ignore this field, so they never receive the high-volume Store/Defender download events unthrottled. Default: [25, 26, 31, 41]. */
|
||||
windowsUpdateOperationalEventIds: number[];
|
||||
/** Master switch for the servicing watcher on the Setup log (Microsoft-Windows-Servicing EventIDs 1/2/3/4/6): the CBS package steps of an OS update — staged, installed, reboot required, failed. Default: true. */
|
||||
servicingWatcherEnabled: boolean;
|
||||
/** Mode of the OOBE update phase: "Off", "Shadow" or "Active". Off: Windows/.NET update activity is only shown on the timeline. Shadow: the update watchers also feed it into the decision engine, which records the phase and reports at the post-ESP-exit completion window what the phase would decide; today's rule still decides. Active: the phase holds that window while Windows installs an update in OOBE and waits a bounded time for the sign-in after the update restart instead of failing the session. Any other value counts as Shadow. Default: "Shadow". */
|
||||
oobeUpdatePhaseMode: string;
|
||||
/** Lookback window in minutes for the Windows Update backfill scan on startup. OOBE quality updates run at the early "Getting updates" screen — often BEFORE the agent starts — but the WindowsUpdateClient/Operational .evtx persists across the mid-OOBE reboot, so a generous backfill catches them. Set higher than the ModernDeployment backfill because the update scan/download can begin well before MDM enrollment. 0 = backfill disabled. Default: 60 minutes. */
|
||||
windowsUpdateBackfillLookbackMinutes: number;
|
||||
/** Self-evidencing blind-spot census (session 7443317c: an OOBE quality update installed — OS build changed across the reboot — yet the WindowsUpdateClient channel carried NONE of the targeted EventIDs). When enabled and the OS build changed across an agent restart while zero targeted WU events were captured, the tracker emits a one-shot windows_update_channel_census event with an unfiltered EventID histogram of the WindowsUpdateClient AND UpdateOrchestrator channels over the backfill lookback window — field evidence for which EventIDs/channel to add to WindowsUpdateTargetedEventIds via remote config, without diagnostics upload access. Default: true. */
|
||||
@@ -1247,10 +1253,21 @@ export interface CveExposureSummary {
|
||||
/** Distinct affected tenants; null (key omitted) on tenant-scoped reads. */
|
||||
totalAffectedTenants?: number;
|
||||
distinctCves: number;
|
||||
/** Distinct affected products, grouped as in TopSoftware, with at least one CVE in the window. */
|
||||
distinctSoftware: number;
|
||||
kevCves: number;
|
||||
/** Distinct affected sessions that carry at least one CISA KEV (known-exploited) CVE. */
|
||||
kevAffectedSessions: number;
|
||||
severityBreakdown: SeverityBreakdown;
|
||||
priorityBreakdown: PriorityBreakdown;
|
||||
/** The topN CVEs by affected sessions, plus the KEV CVEs that rank below them (at most 25 extra), all ordered by affected sessions. The list can therefore be longer than topN. */
|
||||
topCves: TopCve[];
|
||||
/** The top min(topN, 20) products by affected sessions, plus the products with a KEV CVE that rank below them (at most 10 extra), ordered by affected sessions. */
|
||||
topSoftware: TopSoftware[];
|
||||
/** For list filters: per CVSS severity band and per priority band, the band's top CVEs by affected sessions that TopCves does not already hold (at most perBand per band). Only on request (perBand above 0, which the portal sends); null (key omitted) otherwise. */
|
||||
bandCves?: TopCve[];
|
||||
/** The same for products, banded by their most severe CVE and their highest priority, minus the entries of TopSoftware. Only on request; null (key omitted) otherwise. */
|
||||
bandSoftware?: TopSoftware[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
@@ -4477,6 +4494,17 @@ export interface SleepSpan {
|
||||
kind: string;
|
||||
}
|
||||
|
||||
/** One CVE of a TopSoftware entry; AffectedSessions counts that product's sessions only. */
|
||||
export interface SoftwareCve {
|
||||
cveId: string;
|
||||
cvssSeverity: string;
|
||||
cvssScore: number;
|
||||
isKev: boolean;
|
||||
epssScore?: number;
|
||||
priority: string;
|
||||
affectedSessions: number;
|
||||
}
|
||||
|
||||
/** Shared response of GET vulnerability/software-inventory (Global Admin, ?tenantId=) and GET metrics/software-inventory (caller's own tenant): the raw SoftwareInventory rows plus matched/unmatched counts. Consumed by the MCP get_software_inventory tool — key names are part of that contract. */
|
||||
export interface SoftwareInventoryResponse {
|
||||
success: boolean;
|
||||
@@ -5214,6 +5242,26 @@ export interface TopFailingApp {
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
/** One affected product: every CveIndex row of the product folded together. Severity, score and priority are the worst values across its CVEs; AffectedSessions counts distinct sessions. */
|
||||
export interface TopSoftware {
|
||||
/** Grouping key: the CPE "vendor:product" the correlation matched (e.g. "7-zip:7-zip"). A row written before the index carried it borrows the product of another row with the same title; failing that, its raw title is the key. */
|
||||
product: string;
|
||||
/** Display name derived from the detected titles (shared leading words, or the most common title without its version and architecture). */
|
||||
name: string;
|
||||
affectedSessions: number;
|
||||
cveCount: number;
|
||||
kevCount: number;
|
||||
/** Highest CVSS severity band across the product's CVEs. */
|
||||
cvssSeverity: string;
|
||||
cvssScore: number;
|
||||
/** Highest act/attend/track band across the product's CVEs (empty when only legacy rows carry it). */
|
||||
priority: string;
|
||||
/** Raw display names the product was detected under, most sessions first (at most 3). */
|
||||
titles: string[];
|
||||
/** The product's most urgent CVEs (priority, then EPSS, then CVSS), at most 3; CveCount is the full count. */
|
||||
cves: SoftwareCve[];
|
||||
}
|
||||
|
||||
/** One serial-number bucket in the GetTpmPssUnsupported aggregation. All values are self-reported by devices through the unauthenticated distress channel — UNVERIFIED. */
|
||||
export interface TpmPssUnsupportedItem {
|
||||
serialNumber: string;
|
||||
|
||||
@@ -8,15 +8,33 @@
|
||||
* Keep the resolution order in lock-step with the web copy:
|
||||
* 1. matchedConditions entry whose `field` equals `token`.
|
||||
* 2. matchedConditions entry carrying a whitelisted same-event auto-field
|
||||
* named `token` (backend `AddDataFieldsToEvidence`: appId, appName,
|
||||
* errorPatternId, errorCode, exitCode, status). First non-empty wins so the
|
||||
* named `token` (`RULE_TEMPLATE_AUTO_FIELDS`). First non-empty wins so the
|
||||
* value pins to the rule's required-condition event.
|
||||
* 3. matchedConditions entry whose key (signal name) equals `token`.
|
||||
* 4. Unresolved tokens are left untouched so authors notice the typo (and the
|
||||
* reworded ANALYZE-ENRL-001 footnote explains a genuinely-absent field).
|
||||
*
|
||||
* Token chars: [a-zA-Z0-9_]. Whitespace inside the braces is tolerated.
|
||||
*
|
||||
* Web, MCP and the backend (notifications) all run the cases in
|
||||
* `tests/fixtures/rule-template-interpolation/cases.json` at the repo root.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mirror of the backend evidence auto-capture whitelist (`RuleEngine.EvidenceAutoFields`).
|
||||
* Explicit list keeps a rogue evidence key (e.g. accidental `description`) from shadowing
|
||||
* a token. Also the list validate_rule lints tokens against.
|
||||
*/
|
||||
export const RULE_TEMPLATE_AUTO_FIELDS = [
|
||||
'appId',
|
||||
'appName',
|
||||
'errorPatternId',
|
||||
'errorCode',
|
||||
'exitCode',
|
||||
'status',
|
||||
'timeoutHours',
|
||||
] as const;
|
||||
|
||||
export function interpolateRuleTemplate(
|
||||
text: string | null | undefined,
|
||||
matchedConditions: Record<string, unknown> | null | undefined
|
||||
@@ -28,10 +46,6 @@ export function interpolateRuleTemplate(
|
||||
const byAutoField = new Map<string, string>();
|
||||
const bySignal = new Map<string, string>();
|
||||
|
||||
// Mirror of the backend AddDataFieldsToEvidence whitelist. Explicit list keeps a
|
||||
// rogue evidence key (e.g. accidental `description`) from shadowing a token.
|
||||
const AUTO_FIELDS = ['appId', 'appName', 'errorPatternId', 'errorCode', 'exitCode', 'status'];
|
||||
|
||||
for (const [signal, evidence] of Object.entries(matchedConditions)) {
|
||||
if (!evidence || typeof evidence !== 'object') {
|
||||
if (evidence != null) bySignal.set(signal, formatValue(evidence));
|
||||
@@ -44,7 +58,7 @@ export function interpolateRuleTemplate(
|
||||
if ('value' in e) {
|
||||
bySignal.set(signal, formatValue(e.value));
|
||||
}
|
||||
for (const af of AUTO_FIELDS) {
|
||||
for (const af of RULE_TEMPLATE_AUTO_FIELDS) {
|
||||
if (byAutoField.has(af)) continue;
|
||||
const v = e[af];
|
||||
if (v != null && v !== '') byAutoField.set(af, formatValue(v));
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* (MCP-protocol resources) and the `get_resource` tool (workaround for
|
||||
* stateless-HTTP MCP clients whose resource discovery is broken).
|
||||
*/
|
||||
import { extractItems } from './client.js';
|
||||
import { DIAG_ZIP_MAP } from './diag-zip-map.js';
|
||||
import { RULE_AUTHORING_GUIDE } from './rule-authoring-guide.js';
|
||||
import { GATHER_RULE_SCHEMA, ANALYZE_RULE_SCHEMA, RULE_GUARDRAILS } from './rule-authoring.generated.js';
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
// (all catalog values ∪ INTERNAL_EVENT_TYPES) === the C# const values. When the
|
||||
// agent adds a new event type to Constants.EventTypes, add it to a group here too.
|
||||
// Gather-rule output types ("gather_dsregcmd_status", ...) are rule data, not code-emitted
|
||||
// types, and stay OUT of the catalog on purpose — see isGatherOutputEventType below.
|
||||
// types, and stay OUT of the catalog on purpose — see withEventTypeNote below.
|
||||
export const EVENT_TYPES_CATALOG = {
|
||||
phase_events: [
|
||||
'phase_transition',
|
||||
@@ -40,6 +41,8 @@ export const EVENT_TYPES_CATALOG = {
|
||||
'esp_config_detected',
|
||||
'completion_check',
|
||||
'completion_waiting',
|
||||
'completion_wait_expired',
|
||||
'oobe_update_phase_shadow',
|
||||
'enrollment_complete',
|
||||
'enrollment_failed',
|
||||
'desktop_arrived',
|
||||
@@ -161,6 +164,10 @@ export const EVENT_TYPES_CATALOG = {
|
||||
'windows_update_succeeded',
|
||||
'windows_update_failed',
|
||||
'windows_update_started',
|
||||
'windows_update_servicing',
|
||||
'windows_update_activity_summary',
|
||||
'oobe_update_page',
|
||||
'oobe_update_state',
|
||||
'windows_update_reboot_pending',
|
||||
'windows_update_history',
|
||||
'os_build_changed',
|
||||
@@ -357,38 +364,20 @@ export const DEVICE_PROPERTIES_CATALOG = {
|
||||
//
|
||||
// Without these, an invalid eventType / deviceProperties key produces the SAME
|
||||
// `count: 0` as a genuine miss, so "bad filter" is indistinguishable from "no
|
||||
// matches". The search tools call these to reject a typo with a clear, correctable
|
||||
// error instead of a silent empty result.
|
||||
// matches". The tools reject a deviceProperties typo with a clear error and mark an
|
||||
// empty result for an uncatalogued eventType with a correctable note.
|
||||
|
||||
/** Set form of ALL_EVENT_TYPES for O(1) membership checks. */
|
||||
const EVENT_TYPE_SET = new Set<string>(ALL_EVENT_TYPES);
|
||||
|
||||
/** True if `s` is a known event type (public catalog ∪ internal). */
|
||||
/**
|
||||
* True if `s` is a built-in event type (public catalog ∪ internal). Strict on purpose:
|
||||
* rule-validation relies on it for its outputEventType collision check.
|
||||
*/
|
||||
export function isKnownEventType(s: string): boolean {
|
||||
return EVENT_TYPE_SET.has(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather-rule output types (`outputEventType`, e.g. "gather_dsregcmd_status") are rule
|
||||
* DATA — built-in and tenant-authored — not code-emitted types, so they are deliberately
|
||||
* NOT catalogued: D-072 scopes the catalog to `Constants.EventTypes`, and the C# guard
|
||||
* test excludes gather outputs for the same reason. They are real events on the wire
|
||||
* nonetheless, so the filter gate lets any `gather_`-prefixed type (with a non-empty
|
||||
* suffix) through — the same "a valid prefix passes even when the full key is
|
||||
* uncatalogued" stance as assertKnownDevicePropertyKeys. Kept apart from
|
||||
* isKnownEventType, whose strict "is a built-in type" meaning rule-validation relies
|
||||
* on for its outputEventType collision check.
|
||||
*/
|
||||
const GATHER_OUTPUT_PREFIX = 'gather_';
|
||||
export function isGatherOutputEventType(s: string): boolean {
|
||||
return s.startsWith(GATHER_OUTPUT_PREFIX) && s.length > GATHER_OUTPUT_PREFIX.length;
|
||||
}
|
||||
|
||||
/** True if `s` is accepted as an eventType filter: catalogued, internal, or a gather-rule output. */
|
||||
export function isFilterableEventType(s: string): boolean {
|
||||
return isKnownEventType(s) || isGatherOutputEventType(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every catalogued deviceProperties key, flattened across event-type groups
|
||||
* (excluding the `_usage` doc block). This is a CURATED subset — not exhaustive —
|
||||
@@ -415,19 +404,35 @@ function suggestEventTypes(input: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a descriptive Error if `eventType` is neither a known type nor a
|
||||
* gather-rule output (see isFilterableEventType). The thrown message routes
|
||||
* through the tools' `toolError` handler so the model gets an actionable
|
||||
* correction instead of a misleading empty result.
|
||||
* eventType filters take ANY type and pass it on as given. Gather-rule output types
|
||||
* (`outputEventType`, e.g. "gather_dsregcmd_status", "HPiA-UpdateStatus") are rule DATA — built-in
|
||||
* and tenant-authored — so they are deliberately NOT catalogued (D-072), yet they are real events
|
||||
* on the wire, and the portal suggests free names like "CustomNetworkStatus". A gate on the catalog
|
||||
* (or on a naming prefix) locked those out. What the gate protected — a typo must not read as
|
||||
* "nothing happened" — this note keeps: when the uncatalogued type was the only filter of a first
|
||||
* call and the answer is an exhausted empty result, it says so and names the closest built-in
|
||||
* types. With other filters or on a continued sweep the emptiness proves nothing about the type,
|
||||
* and non-empty or still-continuable pages pass unchanged.
|
||||
*/
|
||||
export function assertKnownEventType(eventType: string): void {
|
||||
if (isFilterableEventType(eventType)) return;
|
||||
export function withEventTypeNote<T extends object>(
|
||||
data: T,
|
||||
eventType: string | undefined,
|
||||
soleFilterOfFirstCall: boolean,
|
||||
): T & { eventTypeNote?: string } {
|
||||
const page = data as Record<string, unknown>;
|
||||
if (!eventType || !soleFilterOfFirstCall || isKnownEventType(eventType) || page.nextLink || page.moreToScan) return data;
|
||||
const items = extractItems(page);
|
||||
const empty = typeof page.count === 'number' ? page.count === 0 : items !== undefined && items.length === 0;
|
||||
if (!empty) return data;
|
||||
const suggestions = suggestEventTypes(eventType);
|
||||
const hint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(', ')}?` : '';
|
||||
throw new Error(
|
||||
`Unknown eventType "${eventType}" — it is not in the event_types catalog.${hint} ` +
|
||||
'Call get_resource(name="event_types") for the full list of valid event types.',
|
||||
);
|
||||
const hint = suggestions.length > 0 ? ` Closest built-in types: ${suggestions.join(', ')}.` : '';
|
||||
return {
|
||||
...data,
|
||||
eventTypeNote:
|
||||
`"${eventType}" is not a built-in event type and no event of it was found here. Gather rules emit their own ` +
|
||||
`output types (outputEventType) — check the spelling against the rule.${hint} ` +
|
||||
'Built-in types: get_resource(name="event_types").',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -250,7 +250,7 @@ export const RULE_AUTHORING_GUIDE = {
|
||||
'explanation and remediation text may contain {{token}} placeholders, resolved from the ' +
|
||||
'matched evidence at display time: first a matched condition whose dataField equals the ' +
|
||||
'token, then the auto-captured fields appId / appName / errorPatternId / errorCode / ' +
|
||||
'exitCode / status from the matched events, then a condition SIGNAL name. Unresolved tokens ' +
|
||||
'exitCode / status / timeoutHours from the matched events, then a condition SIGNAL name. Unresolved tokens ' +
|
||||
'stay visible as {{token}} so typos are noticeable. test_analyze_rule returns the ' +
|
||||
'interpolated preview for a real session.',
|
||||
markSessionAsFailed:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user