mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat(agent): DO throttle-policy context fields on bandwidth estimate
The network_bandwidth_estimate event now carries the CONFIGURED Delivery
Optimization bandwidth caps, so analyses can 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).
Verified against MS docs (Policy CSP + DO reference): GPO and Intune/CSP
do NOT share a registry store - both are read from the Registry64 view:
- GPO (ADMX): HKLM\SOFTWARE\Policies\Microsoft\Windows\
DeliveryOptimization, DO-prefixed value names
- Intune/Policy CSP: HKLM\SOFTWARE\Microsoft\PolicyManager\current\
device\DeliveryOptimization, same value names
Semantics: absolute caps in KB/s, percentage caps 0-100; a value of 0
means dynamic/no limit (OS default) and is treated as not configured.
DODownloadMode 0 is a real setting (HTTP only) and is reported whenever
present, but is not a throttle. Policies are read FRESH at each emission
because Intune policies land during the enrollment.
Event fields (omitted when absent): doThrottleConfigured,
doThrottleSources (gpo|mdm|gpo+mdm), doPolicy{Gpo,Mdm}{MaxForegroundKBps,
MaxBackgroundKBps,PctMaxForeground,PctMaxBackground,DownloadMode}; the
message gains a "[DO throttled by policy]" marker. Not covered (v1):
DOSetHoursToLimit* business-hours string policies.
+6 DoThrottlePolicyReaderTests (two-store split, 0-semantics,
string-DWORD tolerance, fail-soft); agent suite 1674/1674 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+103
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class DoThrottlePolicyReaderTests
|
||||
{
|
||||
private static Func<string, string, object?> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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)
|
||||
{
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
{
|
||||
/// <summary>
|
||||
/// 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):
|
||||
/// <list type="bullet">
|
||||
/// <item>GPO (ADMX): <c>HKLM\SOFTWARE\Policies\Microsoft\Windows\DeliveryOptimization</c>,
|
||||
/// DO-prefixed value names (<c>DOMaxForegroundDownloadBandwidth</c>, …).</item>
|
||||
/// <item>Intune / Policy CSP: <c>HKLM\SOFTWARE\Microsoft\PolicyManager\current\device\DeliveryOptimization</c>,
|
||||
/// same value names.</item>
|
||||
/// </list>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>True when any download-bandwidth cap is configured in either store.</summary>
|
||||
public bool ThrottleConfigured =>
|
||||
GpoMaxForegroundKBps.HasValue || GpoMaxBackgroundKBps.HasValue ||
|
||||
GpoPctMaxForeground.HasValue || GpoPctMaxBackground.HasValue ||
|
||||
MdmMaxForegroundKBps.HasValue || MdmMaxBackgroundKBps.HasValue ||
|
||||
MdmPctMaxForeground.HasValue || MdmPctMaxBackground.HasValue;
|
||||
|
||||
/// <summary>"gpo" | "mdm" | "gpo+mdm" — which store(s) carry throttle caps. Null when none.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
/// <summary>Reads both policy stores from the 64-bit registry view.</summary>
|
||||
public static DoThrottlePolicySnapshot Read()
|
||||
{
|
||||
return ReadCore(RegistryValueGetter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Testable core: <paramref name="getValue"/> maps (keyPath, valueName) to the raw
|
||||
/// registry value (or null when absent).
|
||||
/// </summary>
|
||||
internal static DoThrottlePolicySnapshot ReadCore(Func<string, string, object?> 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;
|
||||
}
|
||||
|
||||
/// <summary>Cap semantics: 0 = "dynamic / no limit" (OS default) → treated as absent.</summary>
|
||||
private static int? ReadCap(Func<string, string, object?> getValue, string keyPath, string valueName)
|
||||
{
|
||||
var value = ReadInt(getValue, keyPath, valueName);
|
||||
return value.HasValue && value.Value > 0 ? value : null;
|
||||
}
|
||||
|
||||
private static int? ReadInt(Func<string, string, object?> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user