Compare commits

...

2 Commits

Author SHA1 Message Date
Oliver Kieselbach d8c84f41c1 fix(agent): spool pressure trigger on pending backlog, not file size
99/100 spool_pressure_detected events in the field were file-size-only
triggers on a near-empty backlog (append-only spool.jsonl grows past 5 MB
in healthy long sessions). TelemetrySpool now tracks PendingBytes (exact
serialized size of the not-yet-uploaded tail, maintained on enqueue /
mark-uploaded / restart rehydrate) and the collector thresholds on that
instead of SpoolFileSizeBytes, which stays as an informational metric.
Snapshot gains spool_pending_bytes; pressure event data carries
pendingBytes + pendingBytesThreshold.

Also: startup recovery scan streams via File.ReadLines instead of
materializing the whole spool with File.ReadAllLines (corrupt-tail
break semantics unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 17:43:15 +02:00
Oliver Kieselbach 12b51bbc24 feat(landing): scenario coverage chips in hero + MSI channel in about claims
Hero gets a quiet checkmark chip row (user-driven, pre-provisioning,
self-deploying, Device Preparation, Windows 365, Entra ID & Hybrid join);
about page deployment claim now includes the MSI LOB channel for WDP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 15:47:15 +02:00
6 changed files with 178 additions and 32 deletions
@@ -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()
{
@@ -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,26 @@ 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();
}
}
}
/// <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 +291,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;
@@ -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 />