mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1e7077d0a | |||
| d8c84f41c1 | |||
| 12b51bbc24 |
@@ -221,6 +221,81 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.Equal(1, System.Threading.Volatile.Read(ref signalled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_tracks_enqueue_and_shrinks_to_zero_when_all_uploaded()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var spool = new TelemetrySpool(tmp.Path, Clock());
|
||||
Assert.Equal(0, spool.PendingBytes);
|
||||
|
||||
spool.Enqueue(EventDraft("a"));
|
||||
spool.Enqueue(EventDraft("b"));
|
||||
spool.Enqueue(EventDraft("c"));
|
||||
|
||||
// PendingBytes must equal the full file size while nothing is uploaded yet
|
||||
// (every line written by Enqueue is still pending).
|
||||
Assert.Equal(spool.SpoolFileSizeBytes, spool.PendingBytes);
|
||||
|
||||
spool.MarkUploaded(2);
|
||||
Assert.Equal(0, spool.PendingBytes);
|
||||
// File keeps its append-only size — the two measures MUST diverge here,
|
||||
// otherwise the pressure trigger regresses to the file-size false positive.
|
||||
Assert.True(spool.SpoolFileSizeBytes > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_after_partial_upload_equals_remaining_tail_line_bytes()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var spool = new TelemetrySpool(tmp.Path, Clock());
|
||||
spool.Enqueue(EventDraft("a"));
|
||||
spool.Enqueue(EventDraft("b"));
|
||||
spool.Enqueue(EventDraft("c-longer-rowkey-for-distinct-length"));
|
||||
|
||||
spool.MarkUploaded(1);
|
||||
|
||||
// Expected = on-disk byte length of the last line incl. its trailing \n.
|
||||
var lines = File.ReadAllLines(Path.Combine(tmp.Path, "spool.jsonl"), Encoding.UTF8);
|
||||
var expected = Encoding.UTF8.GetByteCount(lines[2]) + 1;
|
||||
Assert.Equal(expected, spool.PendingBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_is_rehydrated_on_restart_from_pending_tail_only()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var s1 = new TelemetrySpool(tmp.Path, Clock());
|
||||
s1.Enqueue(EventDraft("a"));
|
||||
s1.Enqueue(EventDraft("b"));
|
||||
s1.Enqueue(EventDraft("c"));
|
||||
s1.MarkUploaded(1);
|
||||
var expectedPendingBytes = s1.PendingBytes;
|
||||
Assert.True(expectedPendingBytes > 0);
|
||||
|
||||
// Restart: rehydrated PendingBytes must match the pre-restart value exactly
|
||||
// (uploaded prefix excluded), not the full file size.
|
||||
var s2 = new TelemetrySpool(tmp.Path, Clock());
|
||||
Assert.Equal(expectedPendingBytes, s2.PendingBytes);
|
||||
Assert.True(s2.PendingBytes < s2.SpoolFileSizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_rehydrate_ignores_corrupt_tail_line()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var s1 = new TelemetrySpool(tmp.Path, Clock());
|
||||
s1.Enqueue(EventDraft("a"));
|
||||
var cleanPendingBytes = s1.PendingBytes;
|
||||
|
||||
File.AppendAllText(
|
||||
Path.Combine(tmp.Path, "spool.jsonl"),
|
||||
"{\"Kind\":\"Event\",\"PartitionKey\":\"corrupt-no-",
|
||||
Encoding.UTF8);
|
||||
|
||||
var s2 = new TelemetrySpool(tmp.Path, Clock());
|
||||
Assert.Equal(cleanPendingBytes, s2.PendingBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enqueue_without_RequiresImmediateFlush_does_not_raise_ImmediateFlushRequested()
|
||||
{
|
||||
|
||||
+25
-19
@@ -22,11 +22,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
// Pressure thresholds — once either is crossed within a session, a one-shot
|
||||
// `spool_pressure_detected` event is emitted with RequiresImmediateFlush=true
|
||||
// so the backend can surface the condition without waiting for the next snapshot
|
||||
// to be queried out of Storage. Sized so a healthy 6h provisioning session never
|
||||
// trips them; tripping is a strong "upload backlog is growing or downstream stalled"
|
||||
// signal worth investigating in the field.
|
||||
// to be queried out of Storage. Both measure the PENDING backlog (items not yet
|
||||
// marked uploaded), never the append-only spool file size — a healthy long session
|
||||
// grows the file past any fixed size while its backlog stays near zero (field data
|
||||
// 2026-08: 99/100 pressure events were file-size-only false positives). Tripping is
|
||||
// therefore a genuine "upload is stalled or falling behind" signal.
|
||||
internal const int PressurePendingItemThreshold = 2000;
|
||||
internal const long PressureFileSizeBytesThreshold = 5L * 1024 * 1024; // 5 MB
|
||||
internal const long PressurePendingBytesThreshold = 5L * 1024 * 1024; // 5 MB
|
||||
|
||||
private readonly string _agentVersion;
|
||||
private readonly NetworkMetrics _networkMetrics;
|
||||
@@ -77,11 +79,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
|
||||
protected override void Collect()
|
||||
{
|
||||
// Full path writes 19 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (4) + network delta (9: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests/latency). cap=19 →
|
||||
// HashHelpers.GetPrime(19)=23 buckets → no resize on the 19th key.
|
||||
var data = new Dictionary<string, object>(capacity: 19, StringComparer.Ordinal)
|
||||
// Full path writes 20 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (5) + network delta (9: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests/latency). cap=20 →
|
||||
// HashHelpers.GetPrime(20)=23 buckets → no resize on the 20th key.
|
||||
var data = new Dictionary<string, object>(capacity: 20, StringComparer.Ordinal)
|
||||
{
|
||||
{ "agent_version", _agentVersion }
|
||||
};
|
||||
@@ -126,17 +128,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
// EnrollmentOrchestrator.Start into IComponentFactory.CreateCollectorHosts
|
||||
// (ARCH-F4). Null only on test fakes that don't construct a real spool.
|
||||
int pendingItemCount = 0;
|
||||
long pendingBytes = 0L;
|
||||
long spoolFileSizeBytes = 0L;
|
||||
if (_telemetrySpool != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
pendingItemCount = _telemetrySpool.PendingItemCount;
|
||||
pendingBytes = _telemetrySpool.PendingBytes;
|
||||
spoolFileSizeBytes = _telemetrySpool.SpoolFileSizeBytes;
|
||||
var peakPending = _telemetrySpool.PeakPendingItemCount;
|
||||
var totalEnqueued = _telemetrySpool.LastAssignedItemId + 1; // -1 sentinel → 0
|
||||
|
||||
data["spool_pending_item_count"] = pendingItemCount;
|
||||
data["spool_pending_bytes"] = pendingBytes;
|
||||
data["spool_peak_pending_item_count"] = peakPending;
|
||||
data["spool_file_size_bytes"] = spoolFileSizeBytes;
|
||||
data["spool_total_enqueued_count"] = totalEnqueued;
|
||||
@@ -194,20 +199,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
});
|
||||
}
|
||||
|
||||
// One-shot pressure event \u2014 fires once per session when the spool grows past
|
||||
// either threshold. ImmediateUpload=true so it shows up promptly on the
|
||||
// backend without waiting for the next batch drain.
|
||||
// One-shot pressure event \u2014 fires once per session when the pending backlog
|
||||
// grows past either threshold. ImmediateUpload=true so it shows up promptly on
|
||||
// the backend without waiting for the next batch drain.
|
||||
if (_telemetrySpool != null
|
||||
&& (pendingItemCount > PressurePendingItemThreshold
|
||||
|| spoolFileSizeBytes > PressureFileSizeBytesThreshold)
|
||||
|| pendingBytes > PressurePendingBytesThreshold)
|
||||
&& Interlocked.CompareExchange(ref _pressureEmitted, 1, 0) == 0)
|
||||
{
|
||||
var pressureData = new Dictionary<string, object>(capacity: 8, StringComparer.Ordinal)
|
||||
{
|
||||
{ "pendingItemCount", pendingItemCount },
|
||||
{ "fileSizeBytes", spoolFileSizeBytes },
|
||||
{ "pendingBytes", pendingBytes },
|
||||
{ "fileSizeBytes", spoolFileSizeBytes }, // informational only, not a trigger
|
||||
{ "pendingThreshold", PressurePendingItemThreshold },
|
||||
{ "fileSizeThresholdBytes", PressureFileSizeBytesThreshold },
|
||||
{ "pendingBytesThreshold", PressurePendingBytesThreshold },
|
||||
{ "totalEnqueuedCount", _telemetrySpool.LastAssignedItemId + 1 },
|
||||
{ "lastUploadedItemId", _telemetrySpool.LastUploadedItemId },
|
||||
{ "ImmediateUpload", true }
|
||||
@@ -222,15 +228,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "AgentSelfMetricsCollector",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Telemetry spool pressure detected: pending={pendingItemCount}, " +
|
||||
$"fileBytes={spoolFileSizeBytes} \u2014 upload may be stalled or session " +
|
||||
$"is unusually long.",
|
||||
Message = $"Telemetry spool pressure detected: pending={pendingItemCount} items / " +
|
||||
$"{pendingBytes} bytes not yet uploaded \u2014 upload is stalled or " +
|
||||
$"falling behind.",
|
||||
Data = pressureData
|
||||
});
|
||||
|
||||
Logger.Warning(
|
||||
$"AgentSelfMetricsCollector: spool pressure detected " +
|
||||
$"(pending={pendingItemCount}, fileBytes={spoolFileSizeBytes}). " +
|
||||
$"(pending={pendingItemCount}, pendingBytes={pendingBytes}). " +
|
||||
$"Emitted spool_pressure_detected (one-shot per session).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,10 +59,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
/// </summary>
|
||||
int PeakPendingItemCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Serialized on-disk byte size of the pending (not-yet-uploaded) tail — the actual
|
||||
/// upload backlog. Unlike <see cref="SpoolFileSizeBytes"/>, which grows append-only
|
||||
/// over the whole session regardless of upload progress, this shrinks back to 0 as
|
||||
/// <see cref="MarkUploaded"/> advances the cursor, so it is the correct input for
|
||||
/// back-pressure thresholds.
|
||||
/// </summary>
|
||||
long PendingBytes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current size of the on-disk <c>spool.jsonl</c> file in bytes. Returns 0 when the
|
||||
/// file does not yet exist. Cheap (single <c>FileInfo.Length</c> call) and safe to
|
||||
/// poll from periodic collectors.
|
||||
/// poll from periodic collectors. Append-only cumulative — informational only, NOT a
|
||||
/// backlog measure (use <see cref="PendingBytes"/> for that).
|
||||
/// </summary>
|
||||
long SpoolFileSizeBytes { get; }
|
||||
|
||||
|
||||
@@ -56,9 +56,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
// Populated from disk in the ctor and kept in sync by Enqueue / MarkUploaded. Peek
|
||||
// reads from here so the periodic drain loop is O(batchSize) instead of O(N) per
|
||||
// call. Disk JSONL stays the source of truth — the cache is rebuilt on every
|
||||
// process start.
|
||||
private readonly LinkedList<TelemetryItem> _pending = new LinkedList<TelemetryItem>();
|
||||
// process start. Each entry carries its on-disk line length (incl. trailing \n) so
|
||||
// MarkUploaded can decrement _pendingBytes exactly.
|
||||
private readonly LinkedList<PendingEntry> _pending = new LinkedList<PendingEntry>();
|
||||
private int _peakPendingCount;
|
||||
private long _pendingBytes;
|
||||
|
||||
private readonly struct PendingEntry
|
||||
{
|
||||
public PendingEntry(TelemetryItem item, int byteLength)
|
||||
{
|
||||
Item = item;
|
||||
ByteLength = byteLength;
|
||||
}
|
||||
|
||||
public TelemetryItem Item { get; }
|
||||
public int ByteLength { get; }
|
||||
}
|
||||
|
||||
public TelemetrySpool(string directoryPath, IClock clock, Logging.AgentLogger? logger = null)
|
||||
{
|
||||
@@ -105,6 +119,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
get { lock (_lock) return _peakPendingCount; }
|
||||
}
|
||||
|
||||
public long PendingBytes
|
||||
{
|
||||
get { lock (_lock) return _pendingBytes; }
|
||||
}
|
||||
|
||||
public long SpoolFileSizeBytes
|
||||
{
|
||||
get
|
||||
@@ -171,7 +190,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
}
|
||||
|
||||
_lastAssignedItemId = itemId;
|
||||
_pending.AddLast(item);
|
||||
_pending.AddLast(new PendingEntry(item, bytes.Length));
|
||||
_pendingBytes += bytes.Length;
|
||||
if (_pending.Count > _peakPendingCount)
|
||||
_peakPendingCount = _pending.Count;
|
||||
|
||||
@@ -209,7 +229,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
var node = _pending.First;
|
||||
while (node != null && batch.Count < max)
|
||||
{
|
||||
batch.Add(node.Value);
|
||||
batch.Add(node.Value.Item);
|
||||
node = node.Next;
|
||||
}
|
||||
return batch;
|
||||
@@ -233,25 +253,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
// Trim the in-memory tail. The list is in TelemetryItemId-monotonic order
|
||||
// (Enqueue only appends), so we can drop from the front until the first
|
||||
// remaining item is past the cursor.
|
||||
while (_pending.First != null && _pending.First.Value.TelemetryItemId <= upToItemIdInclusive)
|
||||
while (_pending.First != null && _pending.First.Value.Item.TelemetryItemId <= upToItemIdInclusive)
|
||||
{
|
||||
_pendingBytes -= _pending.First.Value.ByteLength;
|
||||
_pending.RemoveFirst();
|
||||
}
|
||||
|
||||
// _pendingBytes drives the spool-pressure trigger: negative drift would
|
||||
// silently disarm it, a stale positive on an empty list would pin it on.
|
||||
// The list is authoritative — on drift, recompute from it and log loudly.
|
||||
if (_pendingBytes < 0 || (_pending.Count == 0 && _pendingBytes != 0))
|
||||
{
|
||||
long recomputed = 0;
|
||||
foreach (var entry in _pending) recomputed += entry.ByteLength;
|
||||
_logger?.Warning(
|
||||
$"TelemetrySpool: pending-bytes accounting drifted to {_pendingBytes} with {_pending.Count} pending items after MarkUploaded({upToItemIdInclusive}) — recomputed to {recomputed}.");
|
||||
_pendingBytes = recomputed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-pass scan of <c>spool.jsonl</c>: returns the highest <c>TelemetryItemId</c>
|
||||
/// found AND populates <see cref="_pending"/> with every item past
|
||||
/// <see cref="_lastUploadedItemId"/>. Called once from the ctor — replaces the
|
||||
/// previous per-Peek <c>File.ReadAllLines</c> hot path.
|
||||
/// Single-pass streaming scan of <c>spool.jsonl</c> (<c>File.ReadLines</c> — never
|
||||
/// materializes the whole file): returns the highest <c>TelemetryItemId</c> found AND
|
||||
/// populates <see cref="_pending"/> / <see cref="_pendingBytes"/> with every item past
|
||||
/// <see cref="_lastUploadedItemId"/>. Called once from the ctor.
|
||||
/// </summary>
|
||||
private long ScanSpoolAndRehydratePending()
|
||||
{
|
||||
if (!File.Exists(_spoolPath)) return -1;
|
||||
|
||||
long highest = -1;
|
||||
foreach (var line in File.ReadAllLines(_spoolPath, Encoding.UTF8))
|
||||
foreach (var line in File.ReadLines(_spoolPath, Encoding.UTF8))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
TelemetryItem item;
|
||||
@@ -270,7 +303,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
if (item.TelemetryItemId > highest) highest = item.TelemetryItemId;
|
||||
if (item.TelemetryItemId > _lastUploadedItemId)
|
||||
{
|
||||
_pending.AddLast(item);
|
||||
// +1 = the trailing \n Enqueue writes after every line, so the rehydrated
|
||||
// byte count matches what Enqueue would have accumulated.
|
||||
var byteLength = Encoding.UTF8.GetByteCount(line) + 1;
|
||||
_pending.AddLast(new PendingEntry(item, byteLength));
|
||||
_pendingBytes += byteLength;
|
||||
}
|
||||
}
|
||||
return highest;
|
||||
|
||||
@@ -192,6 +192,33 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
Assert.Equal(SessionStage.Completed, completed.NewState.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackstopFired_WithoutDesktop_ParksInAwaitingDesktopUntilDesktopCompletes()
|
||||
{
|
||||
// The arming site (HandleDesktopArrivedV1) guarantees desktop-first, so this
|
||||
// fires only on a stale/replayed timer — Dispatch routes DeadlineFired purely on
|
||||
// the payload name, no armed-state check. The fallback must keep the handler
|
||||
// total: synthetic Hello timeout recorded, session parks in AwaitingDesktop, and
|
||||
// a later real desktop arrival still completes it.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
|
||||
var fired = engine.Reduce(state, DeadlineFired(20, T0.AddMinutes(39), DeadlineNames.DevicePrepCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingDesktop, fired.NewState.Stage);
|
||||
Assert.NotNull(fired.NewState.HelloResolvedUtc);
|
||||
Assert.Equal("Timeout", fired.NewState.HelloOutcome!.Value);
|
||||
Assert.DoesNotContain(fired.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
|
||||
// Hello is satisfied (synthetically) — only the desktop is still missing.
|
||||
var waiting = Assert.Single(CompletionWaitingEffects(fired));
|
||||
Assert.Equal("desktop_arrival", waiting.Parameters!["missingPrerequisites"]);
|
||||
|
||||
var completed = engine.Reduce(fired.NewState,
|
||||
MakeSignal(21, DecisionSignalKind.DesktopArrived, T0.AddMinutes(45)));
|
||||
Assert.Equal(SessionStage.Finalizing, completed.NewState.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelloResolved_BeforeBackstopFires_CompletesNormally()
|
||||
{
|
||||
|
||||
@@ -226,7 +226,7 @@ export default function AboutPage() {
|
||||
items: [
|
||||
".NET binary — lightweight, low-overhead monitoring",
|
||||
"Runs via scheduled task (no Windows service — easy, residue-free removal)",
|
||||
"Deployed via an Intune platform script (PowerShell bootstrapper)",
|
||||
"Deployed via an Intune platform script (PowerShell bootstrapper); for Autopilot Device Preparation additionally as a thin MSI line-of-business app",
|
||||
"Mutual TLS using the existing Intune MDM device certificate",
|
||||
"Self-destruct on enrollment completion (on by default — removes task and files)",
|
||||
],
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { HeroShots } from "./HeroShots";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
const SCENARIOS = [
|
||||
"User-driven",
|
||||
"Pre-provisioning",
|
||||
"Self-deploying",
|
||||
"Device Preparation",
|
||||
"Windows 365",
|
||||
"Entra ID & Hybrid join",
|
||||
];
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<header className="pt-16 sm:pt-20 pb-0 px-6">
|
||||
@@ -42,6 +51,27 @@ export function Hero() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scenario coverage — quiet chips, no competition with the CTAs */}
|
||||
<div className="mt-7 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
{SCENARIOS.map((scenario) => (
|
||||
<span
|
||||
key={scenario}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] text-[var(--lp-ink-soft)]"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-[var(--lp-accent-ink)] shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{scenario}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The portal, front and center */}
|
||||
<div className="mt-12 sm:mt-14 pb-16 sm:pb-20">
|
||||
<HeroShots />
|
||||
|
||||
Reference in New Issue
Block a user