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",