diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DoThrottlePolicyReaderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DoThrottlePolicyReaderTests.cs new file mode 100644 index 00000000..d63d68cd --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DoThrottlePolicyReaderTests.cs @@ -0,0 +1,103 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry +{ + /// + /// DoThrottlePolicyReader — pins the two-store semantics (GPO and Intune/MDM land in + /// DIFFERENT registry paths and both must be read), the "0 = dynamic/no limit → not + /// configured" cap rule vs. "DownloadMode 0 is a real value", and fail-soft conversion. + /// + public sealed class DoThrottlePolicyReaderTests + { + private static Func Fake(Dictionary<(string, string), object?> values) + => (key, name) => values.TryGetValue((key, name), out var v) ? v : null; + + [Fact] + public void Gpo_and_Mdm_are_read_from_their_own_stores() + { + var snapshot = DoThrottlePolicyReader.ReadCore(Fake(new Dictionary<(string, string), object?> + { + [(DoThrottlePolicyReader.GpoKeyPath, "DOMaxForegroundDownloadBandwidth")] = 2500, // KB/s via GPO + [(DoThrottlePolicyReader.MdmKeyPath, "DOPercentageMaxBackgroundBandwidth")] = 40, // % via Intune + })); + + Assert.Equal(2500, snapshot.GpoMaxForegroundKBps); + Assert.Null(snapshot.MdmMaxForegroundKBps); // MDM store has no absolute cap + Assert.Equal(40, snapshot.MdmPctMaxBackground); + Assert.Null(snapshot.GpoPctMaxBackground); // GPO store has no percentage cap + Assert.True(snapshot.ThrottleConfigured); + Assert.Equal("gpo+mdm", snapshot.ThrottleSources); + } + + [Fact] + public void Zero_caps_mean_dynamic_and_are_not_configured() + { + var snapshot = DoThrottlePolicyReader.ReadCore(Fake(new Dictionary<(string, string), object?> + { + [(DoThrottlePolicyReader.GpoKeyPath, "DOMaxForegroundDownloadBandwidth")] = 0, + [(DoThrottlePolicyReader.MdmKeyPath, "DOPercentageMaxForegroundBandwidth")] = 0, + })); + + Assert.Null(snapshot.GpoMaxForegroundKBps); + Assert.Null(snapshot.MdmPctMaxForeground); + Assert.False(snapshot.ThrottleConfigured); + Assert.Null(snapshot.ThrottleSources); + } + + [Fact] + public void DownloadMode_zero_is_a_real_value_but_not_a_throttle() + { + var snapshot = DoThrottlePolicyReader.ReadCore(Fake(new Dictionary<(string, string), object?> + { + [(DoThrottlePolicyReader.MdmKeyPath, "DODownloadMode")] = 0, // HTTP only — real setting + })); + + Assert.Equal(0, snapshot.MdmDownloadMode); + Assert.False(snapshot.ThrottleConfigured); // DownloadMode alone is no bandwidth cap + Assert.Null(snapshot.ThrottleSources); + } + + [Fact] + public void String_stored_numbers_are_tolerated() + { + // PolicyManager occasionally materializes int policies as strings. + var snapshot = DoThrottlePolicyReader.ReadCore(Fake(new Dictionary<(string, string), object?> + { + [(DoThrottlePolicyReader.MdmKeyPath, "DOMaxBackgroundDownloadBandwidth")] = "1200", + })); + + Assert.Equal(1200, snapshot.MdmMaxBackgroundKBps); + Assert.Equal("mdm", snapshot.ThrottleSources); + } + + [Fact] + public void Garbage_values_and_thrown_getters_are_fail_soft() + { + var garbage = DoThrottlePolicyReader.ReadCore(Fake(new Dictionary<(string, string), object?> + { + [(DoThrottlePolicyReader.GpoKeyPath, "DOMaxForegroundDownloadBandwidth")] = "not-a-number", + })); + Assert.Null(garbage.GpoMaxForegroundKBps); + Assert.False(garbage.ThrottleConfigured); + + var throwing = DoThrottlePolicyReader.ReadCore((_, __) => throw new InvalidOperationException("registry broken")); + Assert.False(throwing.ThrottleConfigured); + Assert.Null(throwing.ThrottleSources); + } + + [Fact] + public void Empty_registry_yields_empty_snapshot() + { + var snapshot = DoThrottlePolicyReader.ReadCore((_, __) => null); + + Assert.False(snapshot.ThrottleConfigured); + Assert.Null(snapshot.ThrottleSources); + Assert.Null(snapshot.GpoDownloadMode); + Assert.Null(snapshot.MdmDownloadMode); + } + } +} 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 98f1c3e0..123ca360 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 @@ -685,7 +685,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic data["lanMbpsMax"] = Math.Round(estimate.LanMbpsMax.Value, 1); } + // DO throttle-policy context (read fresh — Intune policies land DURING the + // enrollment): lets an analysis distinguish "line-limited" (no caps → the p90 + // is a real capacity lower bound) from "policy-limited" (caps present → the + // measured rate reflects the throttle, the line is likely faster). GPO and MDM + // are SEPARATE registry stores; both are reported when present. + var throttle = DoThrottlePolicyReader.Read(); + data["doThrottleConfigured"] = throttle.ThrottleConfigured; + if (throttle.ThrottleSources != null) data["doThrottleSources"] = throttle.ThrottleSources; + if (throttle.GpoMaxForegroundKBps.HasValue) data["doPolicyGpoMaxForegroundKBps"] = throttle.GpoMaxForegroundKBps.Value; + if (throttle.GpoMaxBackgroundKBps.HasValue) data["doPolicyGpoMaxBackgroundKBps"] = throttle.GpoMaxBackgroundKBps.Value; + if (throttle.GpoPctMaxForeground.HasValue) data["doPolicyGpoPctMaxForeground"] = throttle.GpoPctMaxForeground.Value; + if (throttle.GpoPctMaxBackground.HasValue) data["doPolicyGpoPctMaxBackground"] = throttle.GpoPctMaxBackground.Value; + if (throttle.GpoDownloadMode.HasValue) data["doPolicyGpoDownloadMode"] = throttle.GpoDownloadMode.Value; + if (throttle.MdmMaxForegroundKBps.HasValue) data["doPolicyMdmMaxForegroundKBps"] = throttle.MdmMaxForegroundKBps.Value; + if (throttle.MdmMaxBackgroundKBps.HasValue) data["doPolicyMdmMaxBackgroundKBps"] = throttle.MdmMaxBackgroundKBps.Value; + if (throttle.MdmPctMaxForeground.HasValue) data["doPolicyMdmPctMaxForeground"] = throttle.MdmPctMaxForeground.Value; + if (throttle.MdmPctMaxBackground.HasValue) data["doPolicyMdmPctMaxBackground"] = throttle.MdmPctMaxBackground.Value; + if (throttle.MdmDownloadMode.HasValue) data["doPolicyMdmDownloadMode"] = throttle.MdmDownloadMode.Value; + var interimSuffix = trigger == "device_setup_end" ? " [interim, after DeviceSetup]" : ""; + if (throttle.ThrottleConfigured) interimSuffix += " [DO throttled by policy]"; string message; if (estimate.WanMbpsP90.HasValue) { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DoThrottlePolicyReader.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DoThrottlePolicyReader.cs new file mode 100644 index 00000000..c87f6c77 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DoThrottlePolicyReader.cs @@ -0,0 +1,145 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic +{ + /// + /// Snapshot of the configured Delivery Optimization bandwidth-throttle policies from BOTH + /// policy stores. GPO and Intune/MDM do NOT land in the same registry path (verified against + /// the Policy CSP / DO reference docs, 2026-07): + /// + /// GPO (ADMX): HKLM\SOFTWARE\Policies\Microsoft\Windows\DeliveryOptimization, + /// DO-prefixed value names (DOMaxForegroundDownloadBandwidth, …). + /// Intune / Policy CSP: HKLM\SOFTWARE\Microsoft\PolicyManager\current\device\DeliveryOptimization, + /// same value names. + /// + /// Absolute caps are in KB/s; percentage caps 0-100. A value of 0 means "dynamic, no limit" + /// (the OS default) — treated as not configured for the caps. DownloadMode 0 however is a + /// real setting (HTTP only) and is reported whenever the value exists. + /// + public sealed class DoThrottlePolicySnapshot + { + // GPO store (SOFTWARE\Policies\...) + public int? GpoMaxForegroundKBps { get; set; } + public int? GpoMaxBackgroundKBps { get; set; } + public int? GpoPctMaxForeground { get; set; } + public int? GpoPctMaxBackground { get; set; } + public int? GpoDownloadMode { get; set; } + + // MDM / Policy CSP store (SOFTWARE\Microsoft\PolicyManager\current\device\...) + public int? MdmMaxForegroundKBps { get; set; } + public int? MdmMaxBackgroundKBps { get; set; } + public int? MdmPctMaxForeground { get; set; } + public int? MdmPctMaxBackground { get; set; } + public int? MdmDownloadMode { get; set; } + + /// True when any download-bandwidth cap is configured in either store. + public bool ThrottleConfigured => + GpoMaxForegroundKBps.HasValue || GpoMaxBackgroundKBps.HasValue || + GpoPctMaxForeground.HasValue || GpoPctMaxBackground.HasValue || + MdmMaxForegroundKBps.HasValue || MdmMaxBackgroundKBps.HasValue || + MdmPctMaxForeground.HasValue || MdmPctMaxBackground.HasValue; + + /// "gpo" | "mdm" | "gpo+mdm" — which store(s) carry throttle caps. Null when none. + public string? ThrottleSources + { + get + { + var gpo = GpoMaxForegroundKBps.HasValue || GpoMaxBackgroundKBps.HasValue || + GpoPctMaxForeground.HasValue || GpoPctMaxBackground.HasValue; + var mdm = MdmMaxForegroundKBps.HasValue || MdmMaxBackgroundKBps.HasValue || + MdmPctMaxForeground.HasValue || MdmPctMaxBackground.HasValue; + if (gpo && mdm) return "gpo+mdm"; + if (gpo) return "gpo"; + if (mdm) return "mdm"; + return null; + } + } + } + + /// + /// Reads the DO throttle policies fresh at each emission (Intune policies land DURING the + /// enrollment — a constructor-time read would miss them). Fail-soft: any registry error + /// yields an empty snapshot, never throws. + /// + public static class DoThrottlePolicyReader + { + public const string GpoKeyPath = @"SOFTWARE\Policies\Microsoft\Windows\DeliveryOptimization"; + public const string MdmKeyPath = @"SOFTWARE\Microsoft\PolicyManager\current\device\DeliveryOptimization"; + + private const string MaxForegroundValue = "DOMaxForegroundDownloadBandwidth"; + private const string MaxBackgroundValue = "DOMaxBackgroundDownloadBandwidth"; + private const string PctMaxForegroundValue = "DOPercentageMaxForegroundBandwidth"; + private const string PctMaxBackgroundValue = "DOPercentageMaxBackgroundBandwidth"; + private const string DownloadModeValue = "DODownloadMode"; + + /// Reads both policy stores from the 64-bit registry view. + public static DoThrottlePolicySnapshot Read() + { + return ReadCore(RegistryValueGetter); + } + + /// + /// Testable core: maps (keyPath, valueName) to the raw + /// registry value (or null when absent). + /// + internal static DoThrottlePolicySnapshot ReadCore(Func getValue) + { + var snapshot = new DoThrottlePolicySnapshot(); + try + { + snapshot.GpoMaxForegroundKBps = ReadCap(getValue, GpoKeyPath, MaxForegroundValue); + snapshot.GpoMaxBackgroundKBps = ReadCap(getValue, GpoKeyPath, MaxBackgroundValue); + snapshot.GpoPctMaxForeground = ReadCap(getValue, GpoKeyPath, PctMaxForegroundValue); + snapshot.GpoPctMaxBackground = ReadCap(getValue, GpoKeyPath, PctMaxBackgroundValue); + snapshot.GpoDownloadMode = ReadInt(getValue, GpoKeyPath, DownloadModeValue); + + snapshot.MdmMaxForegroundKBps = ReadCap(getValue, MdmKeyPath, MaxForegroundValue); + snapshot.MdmMaxBackgroundKBps = ReadCap(getValue, MdmKeyPath, MaxBackgroundValue); + snapshot.MdmPctMaxForeground = ReadCap(getValue, MdmKeyPath, PctMaxForegroundValue); + snapshot.MdmPctMaxBackground = ReadCap(getValue, MdmKeyPath, PctMaxBackgroundValue); + snapshot.MdmDownloadMode = ReadInt(getValue, MdmKeyPath, DownloadModeValue); + } + catch + { + // Fail-soft: a partially filled snapshot is fine; context fields are best-effort. + } + return snapshot; + } + + /// Cap semantics: 0 = "dynamic / no limit" (OS default) → treated as absent. + private static int? ReadCap(Func getValue, string keyPath, string valueName) + { + var value = ReadInt(getValue, keyPath, valueName); + return value.HasValue && value.Value > 0 ? value : null; + } + + private static int? ReadInt(Func getValue, string keyPath, string valueName) + { + try + { + var raw = getValue(keyPath, valueName); + if (raw == null) return null; + // DWORDs arrive as int; PolicyManager occasionally stores numbers as strings. + return Convert.ToInt32(raw); + } + catch + { + return null; + } + } + + private static object? RegistryValueGetter(string keyPath, string valueName) + { + // Registry64 explicitly — SOFTWARE\Policies is WOW64-redirected, and the policy + // stores live in the 64-bit view regardless of the agent's bitness. + using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64)) + using (var key = hklm.OpenSubKey(keyPath)) + { + return key?.GetValue(valueName); + } + } + } +}