diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/BandwidthEstimatorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/BandwidthEstimatorTests.cs
new file mode 100644
index 00000000..8f33b210
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/BandwidthEstimatorTests.cs
@@ -0,0 +1,224 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
+{
+ ///
+ /// Pins the passive-bandwidth math that feeds the one-shot network_bandwidth_estimate
+ /// event: per-FileId baselining (first sighting counts nothing), per-file delta windows
+ /// (a job that vanishes from the DO list and returns must be averaged over its own
+ /// absence, not one poll), counter-reset and gap re-baselining, the WAN/LAN source
+ /// split, and the p90/bucket/confidence reduction.
+ ///
+ public class BandwidthEstimatorTests
+ {
+ private static readonly DateTime T0 = new DateTime(2026, 7, 9, 12, 0, 0, DateTimeKind.Utc);
+
+ private static List Jobs(params BandwidthJobSample[] jobs)
+ => new List(jobs);
+
+ private static BandwidthJobSample Job(string fileId, long wan, long lan = 0)
+ => new BandwidthJobSample { FileId = fileId, WanBytes = wan, LanBytes = lan };
+
+ [Fact]
+ public void FirstSighting_OnlyBaselines_NoSample()
+ {
+ var estimator = new BandwidthEstimator(3);
+ // 500 MB already on the counter at first sight — must NOT be booked as one interval.
+ estimator.AddSnapshot(T0, Jobs(Job("a", 500_000_000)));
+
+ Assert.Null(estimator.TryBuildEstimate());
+ }
+
+ [Fact]
+ public void SteadyDownload_ProducesCorrectMbps()
+ {
+ var estimator = new BandwidthEstimator(3);
+ estimator.AddSnapshot(T0, Jobs(Job("a", 0)));
+ // 6 MB in 3 s = 6e6 * 8 / 3 / 1e6 = 16 Mbps
+ estimator.AddSnapshot(T0.AddSeconds(3), Jobs(Job("a", 6_000_000)));
+
+ var estimate = estimator.TryBuildEstimate();
+ Assert.NotNull(estimate);
+ Assert.Equal(1, estimate.WanSampleCount);
+ Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3);
+ Assert.Equal(16.0, estimate.WanMbpsMax.Value, 3);
+ Assert.Equal(6_000_000, estimate.WanBytesObserved);
+ Assert.Equal("10-50", estimate.Bucket);
+ }
+
+ [Fact]
+ public void ParallelJobs_RatesAdd()
+ {
+ var estimator = new BandwidthEstimator(3);
+ estimator.AddSnapshot(T0, Jobs(Job("a", 0), Job("b", 0)));
+ // 3 MB + 3 MB in 3 s = 8 + 8 = 16 Mbps combined
+ estimator.AddSnapshot(T0.AddSeconds(3), Jobs(Job("a", 3_000_000), Job("b", 3_000_000)));
+
+ var estimate = estimator.TryBuildEstimate();
+ Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3);
+ }
+
+ [Fact]
+ public void JobVanishesAndReturns_WithinWindow_AveragedOverOwnAbsence()
+ {
+ var estimator = new BandwidthEstimator(3);
+ estimator.AddSnapshot(T0, Jobs(Job("a", 0), Job("b", 0)));
+ // "a" missing from this poll; "b" moves 3 MB / 3 s = 8 Mbps.
+ estimator.AddSnapshot(T0.AddSeconds(3), Jobs(Job("b", 3_000_000)));
+ // "a" returns with 6 MB accumulated over ITS 6 s window → 8 Mbps, not 16.
+ // "b" adds another 3 MB / 3 s = 8 Mbps → combined sample 16 Mbps.
+ estimator.AddSnapshot(T0.AddSeconds(6), Jobs(Job("a", 6_000_000), Job("b", 6_000_000)));
+
+ var estimate = estimator.TryBuildEstimate();
+ Assert.Equal(2, estimate.WanSampleCount);
+ Assert.Equal(16.0, estimate.WanMbpsMax.Value, 3);
+ }
+
+ [Fact]
+ public void GapBeyondWindow_RebaselinesWithoutSample()
+ {
+ var estimator = new BandwidthEstimator(3);
+ estimator.AddSnapshot(T0, Jobs(Job("a", 0)));
+ // 60 s dormancy gap (> 3 × 3 s): 100 MB accrued — must NOT become a rate sample.
+ estimator.AddSnapshot(T0.AddSeconds(60), Jobs(Job("a", 100_000_000)));
+
+ Assert.Null(estimator.TryBuildEstimate());
+
+ // But the re-baseline must hold: the next in-window delta samples normally.
+ estimator.AddSnapshot(T0.AddSeconds(63), Jobs(Job("a", 106_000_000)));
+ var estimate = estimator.TryBuildEstimate();
+ Assert.Equal(1, estimate.WanSampleCount);
+ Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3);
+ }
+
+ [Fact]
+ public void CounterReset_RebaselinesWithoutSample()
+ {
+ var estimator = new BandwidthEstimator(3);
+ estimator.AddSnapshot(T0, Jobs(Job("a", 50_000_000)));
+ // DO job restart: counter moved backwards — no (negative or bogus) sample.
+ estimator.AddSnapshot(T0.AddSeconds(3), Jobs(Job("a", 1_000_000)));
+
+ Assert.Null(estimator.TryBuildEstimate());
+ }
+
+ [Fact]
+ public void TinyMovement_BelowThreshold_NoSampleButNotNullWhenOthersExist()
+ {
+ var estimator = new BandwidthEstimator(3);
+ estimator.AddSnapshot(T0, Jobs(Job("a", 0)));
+ // 100 KB < 256 KB threshold → no rate sample.
+ estimator.AddSnapshot(T0.AddSeconds(3), Jobs(Job("a", 100_000)));
+ Assert.Null(estimator.TryBuildEstimate());
+
+ // A real sample afterwards; the tiny delta still counted as observed bytes.
+ estimator.AddSnapshot(T0.AddSeconds(6), Jobs(Job("a", 6_100_000)));
+ var estimate = estimator.TryBuildEstimate();
+ Assert.Equal(1, estimate.WanSampleCount);
+ Assert.Equal(6_100_000, estimate.WanBytesObserved);
+ }
+
+ [Fact]
+ public void LanTraffic_DoesNotInflateWanEstimate()
+ {
+ var estimator = new BandwidthEstimator(3);
+ estimator.AddSnapshot(T0, Jobs(Job("a", wan: 0, lan: 0)));
+ // 375 MB from LAN peers in 3 s (≈1 Gbps LAN) + 6 MB from the internet (16 Mbps).
+ estimator.AddSnapshot(T0.AddSeconds(3), Jobs(Job("a", wan: 6_000_000, lan: 375_000_000)));
+
+ var estimate = estimator.TryBuildEstimate();
+ Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3);
+ Assert.Equal(1000.0, estimate.LanMbpsP90.Value, 3);
+ Assert.Equal("10-50", estimate.Bucket); // bucket keyed to WAN, not LAN
+ }
+
+ [Fact]
+ public void LanOnlySession_YieldsUnknownBucketAndNoWanFigure()
+ {
+ var estimator = new BandwidthEstimator(3);
+ estimator.AddSnapshot(T0, Jobs(Job("a", wan: 0, lan: 0)));
+ estimator.AddSnapshot(T0.AddSeconds(3), Jobs(Job("a", wan: 0, lan: 375_000_000)));
+
+ var estimate = estimator.TryBuildEstimate();
+ Assert.NotNull(estimate);
+ Assert.Null(estimate.WanMbpsP90);
+ Assert.Equal(0, estimate.WanSampleCount);
+ Assert.Equal("unknown", estimate.Bucket);
+ Assert.Equal(1, estimate.LanSampleCount);
+ }
+
+ [Theory]
+ [InlineData(3_000_000, "<10")] // 8 Mbps
+ [InlineData(6_000_000, "10-50")] // 16 Mbps
+ [InlineData(30_000_000, "50-100")] // 80 Mbps
+ [InlineData(75_000_000, "100-250")] // 200 Mbps
+ [InlineData(150_000_000, "250+")] // 400 Mbps
+ public void Buckets_MapFromWanP90(long bytesPer3s, string expectedBucket)
+ {
+ var estimator = new BandwidthEstimator(3);
+ estimator.AddSnapshot(T0, Jobs(Job("a", 0)));
+ estimator.AddSnapshot(T0.AddSeconds(3), Jobs(Job("a", bytesPer3s)));
+
+ Assert.Equal(expectedBucket, estimator.TryBuildEstimate().Bucket);
+ }
+
+ [Fact]
+ public void P90_ClipsSingleBurstOutlier()
+ {
+ var estimator = new BandwidthEstimator(3);
+ estimator.AddSnapshot(T0, Jobs(Job("a", 0)));
+ // 19 steady polls at 16 Mbps, then one DO accounting burst at ~160 Mbps.
+ long total = 0;
+ for (int i = 1; i <= 19; i++)
+ {
+ total += 6_000_000;
+ estimator.AddSnapshot(T0.AddSeconds(3 * i), Jobs(Job("a", total)));
+ }
+ total += 60_000_000;
+ estimator.AddSnapshot(T0.AddSeconds(60), Jobs(Job("a", total)));
+
+ var estimate = estimator.TryBuildEstimate();
+ Assert.Equal(20, estimate.WanSampleCount);
+ Assert.Equal(16.0, estimate.WanMbpsP90.Value, 3); // p90 stays on the steady plateau
+ Assert.Equal(160.0, estimate.WanMbpsMax.Value, 3);
+ }
+
+ [Fact]
+ public void Confidence_ScalesWithSamplesAndVolume()
+ {
+ // low: 1 sample / 6 MB
+ var low = new BandwidthEstimator(3);
+ low.AddSnapshot(T0, Jobs(Job("a", 0)));
+ low.AddSnapshot(T0.AddSeconds(3), Jobs(Job("a", 6_000_000)));
+ Assert.Equal("low", low.TryBuildEstimate().Confidence);
+
+ // medium: ≥3 samples and ≥25 MB
+ var medium = new BandwidthEstimator(3);
+ medium.AddSnapshot(T0, Jobs(Job("a", 0)));
+ for (int i = 1; i <= 3; i++)
+ medium.AddSnapshot(T0.AddSeconds(3 * i), Jobs(Job("a", i * 10_000_000L)));
+ Assert.Equal("medium", medium.TryBuildEstimate().Confidence);
+
+ // high: ≥10 samples and ≥150 MB
+ var high = new BandwidthEstimator(3);
+ high.AddSnapshot(T0, Jobs(Job("a", 0)));
+ for (int i = 1; i <= 10; i++)
+ high.AddSnapshot(T0.AddSeconds(3 * i), Jobs(Job("a", i * 20_000_000L)));
+ Assert.Equal("high", high.TryBuildEstimate().Confidence);
+ }
+
+ [Fact]
+ public void EmptyOrNullSnapshots_AreIgnored()
+ {
+ var estimator = new BandwidthEstimator(3);
+ estimator.AddSnapshot(T0, null);
+ estimator.AddSnapshot(T0, Jobs());
+ estimator.AddSnapshot(T0.AddSeconds(3), Jobs(Job(null, 5_000_000), Job("", 5_000_000)));
+
+ Assert.Null(estimator.TryBuildEstimate());
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/BandwidthEstimator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/BandwidthEstimator.cs
new file mode 100644
index 00000000..fca6842d
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/BandwidthEstimator.cs
@@ -0,0 +1,230 @@
+using System;
+using System.Collections.Generic;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
+{
+ ///
+ /// One DO job's cumulative byte counters at a single poll, split by source class.
+ /// WAN = BytesFromHttp + BytesFromInternetPeers (traffic that traversed the internet line).
+ /// LAN = LAN/group/link-local peers + Connected Cache (local sources — must NOT count
+ /// towards the internet-bandwidth estimate or a peer-fed branch office would report
+ /// hundreds of Mbps on a 10-Mbit line).
+ ///
+ public struct BandwidthJobSample
+ {
+ public string FileId;
+ public long WanBytes;
+ public long LanBytes;
+ }
+
+ /// Result of .
+ public sealed class BandwidthEstimate
+ {
+ public double? WanMbpsP90 { get; set; }
+ public double? WanMbpsMax { get; set; }
+ public int WanSampleCount { get; set; }
+ public long WanBytesObserved { get; set; }
+
+ public double? LanMbpsP90 { get; set; }
+ public double? LanMbpsMax { get; set; }
+ public int LanSampleCount { get; set; }
+ public long LanBytesObserved { get; set; }
+
+ /// WAN p90 bucket: "<10" | "10-50" | "50-100" | "100-250" | "250+" | "unknown".
+ public string Bucket { get; set; }
+
+ /// "high" | "medium" | "low" — driven by WAN sample count and observed volume.
+ public string Confidence { get; set; }
+ }
+
+ ///
+ /// Passive bandwidth estimator fed from the DO poll loop. Tracks per-FileId cumulative
+ /// counters, turns poll-to-poll deltas into Mbps rate samples, and reduces them to a
+ /// p90/max estimate at enrollment end. Pure arithmetic on data the collector already
+ /// polls — deliberately generates zero network traffic and zero measurable CPU load.
+ ///
+ /// Delta windows are tracked PER FileId (not per snapshot): DO's job list churns, and a
+ /// job that drops out for a few polls and returns with accumulated bytes must have its
+ /// delta averaged over ITS OWN absence window — booking it into a single poll interval
+ /// would overshoot the rate by the number of missed polls.
+ ///
+ /// Sampling rules (each keeps a distinct distortion out of the estimate):
+ /// - A FileId's first appearance only sets its baseline (its pre-existing bytes would
+ /// otherwise be booked into one interval and overshoot massively).
+ /// - A counter that moved backwards re-baselines that FileId (DO job restart).
+ /// - A per-file window outside [MinElapsedSeconds, MaxElapsedFactor × interval]
+ /// re-baselines that file without a rate contribution (dormancy, long list absence).
+ /// - Poll sums below MinSampleBytes produce no rate sample (idle/installing polls would
+ /// flood the distribution with near-zero noise) but still count as observed bytes.
+ /// Taking the p90 of the surviving samples approximates the sustained line capacity
+ /// from below: ramp-up and tail-end polls sit in the lower quantiles, and single-poll
+ /// accounting bursts above the p90 are clipped by not using the max as the headline.
+ ///
+ public sealed class BandwidthEstimator
+ {
+ private const long MinSampleBytes = 256 * 1024; // ignore polls with < 256 KB movement
+ private const double MinElapsedSeconds = 1.0;
+ private const int MaxElapsedFactor = 3; // per-file gap > 3× interval → re-baseline only
+ private const int MaxSamples = 4096; // safety cap (≈ 3.4 h at 3 s polls)
+
+ // Confidence thresholds (WAN): enough independent samples AND enough volume that
+ // TCP has demonstrably saturated the line at least part of the time.
+ private const int HighSampleCount = 10;
+ private const long HighBytes = 150L * 1024 * 1024; // 150 MB
+ private const int MediumSampleCount = 3;
+ private const long MediumBytes = 25L * 1024 * 1024; // 25 MB
+
+ private sealed class FileBaseline
+ {
+ public long WanBytes;
+ public long LanBytes;
+ public DateTime LastSeenUtc;
+ }
+
+ private readonly object _sync = new object();
+ private readonly double _maxElapsedSeconds;
+
+ private readonly Dictionary _baselines =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ private readonly List _wanSamplesMbps = new List();
+ private readonly List _lanSamplesMbps = new List();
+ private long _wanBytesObserved;
+ private long _lanBytesObserved;
+
+ public BandwidthEstimator(int pollIntervalSeconds)
+ {
+ if (pollIntervalSeconds <= 0) pollIntervalSeconds = 3;
+ _maxElapsedSeconds = pollIntervalSeconds * MaxElapsedFactor;
+ }
+
+ ///
+ /// Feeds one poll's job list. is injected (not read from the
+ /// clock) so tests can drive synthetic timelines.
+ ///
+ public void AddSnapshot(DateTime utcNow, IReadOnlyList jobs)
+ {
+ if (jobs == null || jobs.Count == 0) return;
+
+ lock (_sync)
+ {
+ // Per-poll aggregates. Rates add across parallel jobs; each job's rate is
+ // averaged over its own last-seen window so list churn cannot distort it.
+ double wanRateMbps = 0, lanRateMbps = 0;
+ long wanDelta = 0, lanDelta = 0;
+
+ for (int i = 0; i < jobs.Count; i++)
+ {
+ var job = jobs[i];
+ if (string.IsNullOrEmpty(job.FileId)) continue;
+
+ FileBaseline baseline;
+ if (!_baselines.TryGetValue(job.FileId, out baseline))
+ {
+ _baselines[job.FileId] = new FileBaseline
+ {
+ WanBytes = job.WanBytes,
+ LanBytes = job.LanBytes,
+ LastSeenUtc = utcNow
+ };
+ continue;
+ }
+
+ var elapsed = (utcNow - baseline.LastSeenUtc).TotalSeconds;
+ var fileWanDelta = job.WanBytes - baseline.WanBytes;
+ var fileLanDelta = job.LanBytes - baseline.LanBytes;
+
+ // Counter reset (job restart) or an implausible window → re-baseline only.
+ var valid = fileWanDelta >= 0 && fileLanDelta >= 0 &&
+ elapsed >= MinElapsedSeconds && elapsed <= _maxElapsedSeconds;
+
+ baseline.WanBytes = job.WanBytes;
+ baseline.LanBytes = job.LanBytes;
+ baseline.LastSeenUtc = utcNow;
+
+ if (!valid) continue;
+
+ wanDelta += fileWanDelta;
+ lanDelta += fileLanDelta;
+ wanRateMbps += fileWanDelta * 8.0 / elapsed / 1_000_000.0;
+ lanRateMbps += fileLanDelta * 8.0 / elapsed / 1_000_000.0;
+ }
+
+ _wanBytesObserved += wanDelta;
+ _lanBytesObserved += lanDelta;
+
+ if (wanDelta >= MinSampleBytes && _wanSamplesMbps.Count < MaxSamples)
+ _wanSamplesMbps.Add(wanRateMbps);
+ if (lanDelta >= MinSampleBytes && _lanSamplesMbps.Count < MaxSamples)
+ _lanSamplesMbps.Add(lanRateMbps);
+ }
+ }
+
+ /// Returns the estimate, or null when not a single valid rate sample exists.
+ public BandwidthEstimate TryBuildEstimate()
+ {
+ lock (_sync)
+ {
+ if (_wanSamplesMbps.Count == 0 && _lanSamplesMbps.Count == 0) return null;
+
+ var estimate = new BandwidthEstimate
+ {
+ WanSampleCount = _wanSamplesMbps.Count,
+ WanBytesObserved = _wanBytesObserved,
+ LanSampleCount = _lanSamplesMbps.Count,
+ LanBytesObserved = _lanBytesObserved,
+ };
+
+ if (_wanSamplesMbps.Count > 0)
+ {
+ estimate.WanMbpsP90 = Percentile90(_wanSamplesMbps);
+ estimate.WanMbpsMax = Max(_wanSamplesMbps);
+ }
+ if (_lanSamplesMbps.Count > 0)
+ {
+ estimate.LanMbpsP90 = Percentile90(_lanSamplesMbps);
+ estimate.LanMbpsMax = Max(_lanSamplesMbps);
+ }
+
+ estimate.Bucket = ToBucket(estimate.WanMbpsP90);
+ estimate.Confidence = ToConfidence(_wanSamplesMbps.Count, _wanBytesObserved);
+ return estimate;
+ }
+ }
+
+ private static string ToBucket(double? wanMbpsP90)
+ {
+ if (!wanMbpsP90.HasValue) return "unknown";
+ var v = wanMbpsP90.Value;
+ if (v < 10) return "<10";
+ if (v < 50) return "10-50";
+ if (v < 100) return "50-100";
+ if (v < 250) return "100-250";
+ return "250+";
+ }
+
+ private static string ToConfidence(int wanSamples, long wanBytes)
+ {
+ if (wanSamples >= HighSampleCount && wanBytes >= HighBytes) return "high";
+ if (wanSamples >= MediumSampleCount && wanBytes >= MediumBytes) return "medium";
+ return "low";
+ }
+
+ private static double Percentile90(List samples)
+ {
+ var sorted = new List(samples);
+ sorted.Sort();
+ var index = (int)Math.Ceiling(0.9 * sorted.Count) - 1;
+ if (index < 0) index = 0;
+ return sorted[index];
+ }
+
+ private static double Max(List samples)
+ {
+ var max = double.MinValue;
+ for (int i = 0; i < samples.Count; i++)
+ if (samples[i] > max) max = samples[i];
+ return max;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs
index 75a6ad2c..47c5d895 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs
@@ -73,6 +73,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
// Change detection for JSONL log: only write when overall state changed
private long _lastSnapshotFingerprint;
+ // Passive session-level bandwidth estimate: fed with EVERY DO job's byte counters
+ // (IME apps, Office CDN, WU/Store/Defender — all real traffic) on each poll; reduced
+ // to a network_bandwidth_estimate event. Emitted (at most) twice per session: an
+ // interim snapshot when the ESP leaves DeviceSetup (the bulk of Win32 downloads is
+ // done by then — keeps the estimate available for analysis even when the session
+ // later starves in AccountSetup and the agent never stops cleanly) and the
+ // authoritative final one when the collector stops at enrollment end.
+ // Zero extra traffic/load — pure arithmetic on the existing poll.
+ private readonly BandwidthEstimator _bandwidthEstimator;
+ private readonly int _pollIntervalSeconds;
+ private bool _bandwidthEstimateEmitted;
+ private int _interimBandwidthEmitted; // Interlocked guard — phase callback runs on the IME tracker thread
+
public DeliveryOptimizationCollector(
string sessionId,
string tenantId,
@@ -89,6 +102,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
_onDoTelemetryReceived = onDoTelemetryReceived;
_logFilePath = Path.Combine(logDirectory, LogFileName);
_onOfficeDoSample = onOfficeDoSample;
+ _pollIntervalSeconds = intervalSeconds;
+ _bandwidthEstimator = new BandwidthEstimator(intervalSeconds);
}
/// Start dormant — timer does not fire until WakeUp() is called.
@@ -101,6 +116,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
protected override void OnAfterStop()
{
+ // Emit before the runspace teardown: the termination handler stops peripheral
+ // collectors BEFORE the diagnostics ZIP is built, so this one-shot still lands
+ // ahead of diagnostics_collecting in the timeline.
+ EmitBandwidthEstimateOnce();
DisposeRunspace();
}
@@ -358,6 +377,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
// Build JSONL snapshot for log (only if fingerprint changes)
var logEntries = new JArray();
+ // Every DO job feeds the passive bandwidth estimate — including non-IME,
+ // non-Office jobs (WU, Store, Defender): all of it is real line traffic.
+ var bandwidthJobs = new List(results.Count);
+
foreach (var result in results)
{
var fileId = GetPropString(result, "FileId");
@@ -378,6 +401,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
var sourceUrl = GetPropString(result, "SourceURL");
var cacheHost = GetPropUriString(result, "CacheHost");
+ bandwidthJobs.Add(new BandwidthJobSample
+ {
+ FileId = fileId,
+ WanBytes = bytesFromHttp + bytesInternetPeers,
+ LanBytes = bytesLanPeers + bytesGroupPeers + bytesLinkLocalPeers + bytesFromCacheServer
+ });
+
// Build log entry for JSONL
logEntries.Add(new JObject
{
@@ -503,6 +533,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
// Decrement the bounded registry-hint probe window only while no Office job is being seen.
if (!officeJobsThisPoll && _officeExpectedPolls > 0) _officeExpectedPolls--;
+ _bandwidthEstimator.AddSnapshot(DateTime.UtcNow, bandwidthJobs);
+
// Write JSONL log only when overall state changed
var fingerprint = ComputeFingerprint(logEntries);
if (fingerprint != _lastSnapshotFingerprint)
@@ -533,6 +565,115 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
return false;
}
+ // -----------------------------------------------------------------------
+ // Passive bandwidth estimate (one-shot at collector stop)
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Interim snapshot trigger: the IME tracker reports an ESP phase. On the FIRST
+ /// AccountSetup sighting (= DeviceSetup, and with it the bulk of Win32 downloads, is
+ /// over) the current estimate is emitted with snapshotTrigger=device_setup_end — so
+ /// an analysis still has a bandwidth figure when the session later starves in
+ /// AccountSetup and the final collector-stop emission never happens. The tracker
+ /// invokes the callback on every phase RE-match too (IME re-logs the phase string
+ /// periodically), hence the Interlocked once-guard. Called from the tracker thread.
+ ///
+ public void NotifyEspPhaseChanged(string phase)
+ {
+ if (!"AccountSetup".Equals(phase, StringComparison.OrdinalIgnoreCase)) return;
+ if (Interlocked.CompareExchange(ref _interimBandwidthEmitted, 1, 0) != 0) return;
+ EmitBandwidthEstimate("device_setup_end");
+ }
+
+ ///
+ /// Emits the authoritative session-level network_bandwidth_estimate once, at collector
+ /// stop. Guarded against the double OnAfterStop from Stop() + Dispose().
+ ///
+ private void EmitBandwidthEstimateOnce()
+ {
+ if (_bandwidthEstimateEmitted) return;
+ _bandwidthEstimateEmitted = true;
+ EmitBandwidthEstimate("collector_stop");
+ }
+
+ ///
+ /// Builds and emits one network_bandwidth_estimate event. Silently skipped when no
+ /// valid rate sample exists yet (e.g. no DO-tracked downloads happened so far).
+ ///
+ private void EmitBandwidthEstimate(string trigger)
+ {
+ try
+ {
+ var estimate = _bandwidthEstimator.TryBuildEstimate();
+ if (estimate == null)
+ {
+ Logger.Info($"[DeliveryOptimizationCollector] No bandwidth estimate at {trigger} — no valid DO rate samples yet");
+ return;
+ }
+
+ var data = new Dictionary
+ {
+ ["wanSampleCount"] = estimate.WanSampleCount,
+ ["wanBytesObserved"] = estimate.WanBytesObserved,
+ ["lanSampleCount"] = estimate.LanSampleCount,
+ ["lanBytesObserved"] = estimate.LanBytesObserved,
+ ["bandwidthBucket"] = estimate.Bucket,
+ ["confidence"] = estimate.Confidence,
+ ["pollIntervalSeconds"] = _pollIntervalSeconds,
+ ["snapshotTrigger"] = trigger,
+ ["doSource"] = "os_cmdlet"
+ };
+ if (estimate.WanMbpsP90.HasValue)
+ {
+ data["estimatedWanMbps"] = Math.Round(estimate.WanMbpsP90.Value, 1);
+ data["wanMbpsMax"] = Math.Round(estimate.WanMbpsMax.Value, 1);
+ }
+ if (estimate.LanMbpsP90.HasValue)
+ {
+ data["estimatedLanMbps"] = Math.Round(estimate.LanMbpsP90.Value, 1);
+ data["lanMbpsMax"] = Math.Round(estimate.LanMbpsMax.Value, 1);
+ }
+
+ var interimSuffix = trigger == "device_setup_end" ? " [interim, after DeviceSetup]" : "";
+ string message;
+ if (estimate.WanMbpsP90.HasValue)
+ {
+ message = $"Estimated internet bandwidth ~{estimate.WanMbpsP90.Value:0.#} Mbit/s " +
+ $"(bucket {estimate.Bucket}, confidence {estimate.Confidence}; " +
+ $"{estimate.WanSampleCount} samples / {estimate.WanBytesObserved / (1024 * 1024)} MB via internet)" +
+ interimSuffix;
+ }
+ else
+ {
+ // All observed download traffic came from LAN peers / Connected Cache —
+ // the internet line was never exercised enough to estimate it.
+ message = $"No internet-path bandwidth estimate — downloads were LAN/cache-fed " +
+ $"(~{estimate.LanMbpsP90.Value:0.#} Mbit/s from LAN, " +
+ $"{estimate.LanBytesObserved / (1024 * 1024)} MB observed)" +
+ interimSuffix;
+ }
+
+ Post.Emit(new EnrollmentEvent
+ {
+ SessionId = SessionId,
+ TenantId = TenantId,
+ EventType = Constants.EventTypes.NetworkBandwidthEstimate,
+ Severity = EventSeverity.Info,
+ Source = "DeliveryOptimizationCollector",
+ Phase = EnrollmentPhase.Unknown,
+ Message = message,
+ Data = data,
+ ImmediateUpload = true
+ });
+
+ Logger.Info($"[DeliveryOptimizationCollector] Bandwidth estimate emitted: {message}");
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning($"[DeliveryOptimizationCollector] Bandwidth estimate emission failed: {ex.Message}");
+ }
+ }
+
// -----------------------------------------------------------------------
// PSObject property helpers (safe extraction, no exceptions on missing props)
// -----------------------------------------------------------------------
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/DeliveryOptimizationHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/DeliveryOptimizationHost.cs
index 33278707..456e6e57 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/DeliveryOptimizationHost.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/DeliveryOptimizationHost.cs
@@ -20,6 +20,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly OfficeInstallDetectorHost? _officeHost;
private Action? _prevStateChanged;
private Action? _chainedHandler;
+ private Action? _prevEspPhaseChanged;
+ private Action? _chainedEspPhaseHandler;
private int _disposed;
public DeliveryOptimizationHost(
@@ -75,6 +77,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
};
_imeHost.Tracker.OnAppStateChanged = _chainedHandler;
+ // Chain into the ESP phase callback (same preserve/restore pattern): the collector
+ // emits an interim bandwidth snapshot on the first AccountSetup sighting, so the
+ // estimate survives sessions that later starve in the account phase and never
+ // reach the clean collector-stop emission.
+ _prevEspPhaseChanged = _imeHost.Tracker.OnEspPhaseChanged;
+ _chainedEspPhaseHandler = phase =>
+ {
+ try { _prevEspPhaseChanged?.Invoke(phase); }
+ catch (Exception ex) { _logger.Warning($"DeliveryOptimizationHost: previous OnEspPhaseChanged handler threw: {ex.Message}"); }
+
+ try { _collector.NotifyEspPhaseChanged(phase); }
+ catch (Exception ex) { _logger.Warning($"DeliveryOptimizationHost: NotifyEspPhaseChanged threw: {ex.Message}"); }
+ };
+ _imeHost.Tracker.OnEspPhaseChanged = _chainedEspPhaseHandler;
+
// Office C2R wake sources (no IME package keeps the collector polling otherwise):
// - the worker process up/down (keep-awake while a worker runs), and
// - the early registry hint (Scenario\INSTALL) so we probe for the Office-CDN job before
@@ -106,6 +123,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
_imeHost.Tracker.OnAppStateChanged = _prevStateChanged;
}
+ if (_chainedEspPhaseHandler != null
+ && object.ReferenceEquals(_imeHost.Tracker.OnEspPhaseChanged, _chainedEspPhaseHandler))
+ {
+ _imeHost.Tracker.OnEspPhaseChanged = _prevEspPhaseChanged;
+ }
if (_officeHost != null)
{
try { _officeHost.ProcessWatcher.Started -= OnOfficeStarted; } catch { }
diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
index 41c28d19..d2f92359 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
@@ -82,6 +82,7 @@ export const EVENT_TYPES_CATALOG = {
'dns_configuration',
'proxy_configuration',
'wifi_signal_info',
+ 'network_bandwidth_estimate',
],
device_info_events: [
'os_info',
@@ -138,6 +139,7 @@ export const EVENT_TYPES_CATALOG = {
'system_reboot_detected',
'prior_run_died_with_state',
'previous_crash_detected',
+ 'agent_emergency_break',
'performance_collector_stopped',
'agent_metrics_collector_stopped',
'keep_awake_engaged',
diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs
index 3b847286..cdc486a7 100644
--- a/src/Shared/AutopilotMonitor.Shared/Constants.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs
@@ -337,6 +337,16 @@ namespace AutopilotMonitor.Shared
public const string DoTelemetry = "do_telemetry";
public const string AllAppsCompleted = "all_apps_completed";
public const string AppTrackingSummary = "app_tracking_summary"; // Plan §5 Fix 4b — terminal per-session app summary
+ // Session-level internet-bandwidth estimate derived PASSIVELY from the DO byte counters
+ // the DeliveryOptimizationCollector already polls — no synthetic traffic, no extra load.
+ // At most twice per session (snapshotTrigger disambiguates): an interim snapshot at the
+ // first AccountSetup sighting (device_setup_end — survives sessions that starve in the
+ // account phase) and the authoritative final at collector stop (collector_stop).
+ // WAN (HTTP + internet peers) and LAN
+ // (LAN/group/link-local peers + Connected Cache) are reported separately so peer-fed
+ // downloads cannot inflate the WAN figure; p90 of per-poll rates ≈ lower bound of the
+ // effective line capacity during enrollment ("DSL 16 vs 250").
+ public const string NetworkBandwidthEstimate = "network_bandwidth_estimate";
// Microsoft 365 Apps (Office Click-to-Run) install lifecycle — emitted by the V2
// OfficeInstallDetector which watches the C2R client (registry + process) directly.
diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts
index 5955c585..04a16d28 100644
--- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts
+++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts
@@ -134,6 +134,8 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [
description: "Certificate validation result." },
{ value: "network_state_change", label: "network_state_change", category: "diagnostics",
description: "Network state changed (online/offline/interface)." },
+ { value: "network_bandwidth_estimate", label: "network_bandwidth_estimate", category: "diagnostics",
+ description: "Session-level internet-bandwidth estimate derived passively from the Delivery Optimization byte counters (no synthetic traffic). At most twice per session: an interim snapshot after DeviceSetup (snapshotTrigger=device_setup_end — survives account-phase starvation) and the authoritative final at enrollment end (collector_stop). p90/max Mbit/s for internet-path (HTTP + internet peers) and LAN sources (peers/Connected Cache) separately, plus bucket (<10 / 10-50 / 50-100 / 100-250 / 250+) and confidence." },
{ value: "vulnerability_report", label: "vulnerability_report", category: "diagnostics",
description: "Aggregated vulnerability scan finding." },
{ value: "software_inventory_analysis", label: "software_inventory_analysis", category: "diagnostics",