mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
fix: make DevPrep shadow lookup fire-and-forget
The shadow validator previously awaited the Graph round-trip inline, adding 200ms-30s of latency to every RegisterSession/Ingest response on opted-in tenants — defeating the "shadow, never blocks" intent. Detach the lookup as Task.Run with captured locals so the agent gets its OK immediately. Result is logged at Warning level (Information gets filtered by sampling/log-level config and never surfaced). Drop the now-unused EnrichRequestTelemetryWithDeviceAssociation method and its tests — Activity.AddTag from a detached task wouldn't make it into the request telemetry anyway, since the parent activity has already stopped by the time the background lookup completes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
using System.Diagnostics;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DeviceAssociationValidator"/> — focused on pure-function pieces:
|
||||
/// JSON-to-DTO mapping (incl. exact-match guard), cache key shape, and Activity-tag
|
||||
/// enrichment used by SecurityValidator. The HTTP/cache/retry resilience is mirrored
|
||||
/// 1:1 from <c>AutopilotDeviceValidator</c>; behavioural drift would surface here.
|
||||
/// JSON-to-DTO mapping (incl. exact-match guard) and cache key shape. The HTTP/cache/retry
|
||||
/// resilience is mirrored 1:1 from <c>AutopilotDeviceValidator</c>; behavioural drift
|
||||
/// would surface here.
|
||||
/// </summary>
|
||||
public class DeviceAssociationValidatorTests
|
||||
{
|
||||
@@ -128,54 +127,4 @@ public class DeviceAssociationValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
// -- EnrichRequestTelemetryWithDeviceAssociation --
|
||||
|
||||
[Fact]
|
||||
public void Enrich_PopulatesActivityTags_WhenMatched()
|
||||
{
|
||||
using var activity = new Activity("test").Start();
|
||||
var result = new DeviceAssociationResult
|
||||
{
|
||||
IsValid = true,
|
||||
IsTransient = false,
|
||||
AssociationState = "preassociated",
|
||||
DevicePreparationPolicyId = "policy-1",
|
||||
PreAssociationDateTime = new DateTime(2026, 4, 14, 12, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
SecurityValidator.EnrichRequestTelemetryWithDeviceAssociation(result);
|
||||
|
||||
var tags = activity.Tags.ToDictionary(t => t.Key, t => t.Value);
|
||||
Assert.Equal("true", tags["devprep.association.matched"]);
|
||||
Assert.Equal("false", tags["devprep.association.transient"]);
|
||||
Assert.Equal("preassociated", tags["devprep.association.state"]);
|
||||
Assert.Equal("policy-1", tags["devprep.association.policyId"]);
|
||||
Assert.Contains("devprep.association.preAssociationUtc", tags.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_OnlyMatchedAndTransient_WhenNoOtherFields()
|
||||
{
|
||||
using var activity = new Activity("test").Start();
|
||||
var result = new DeviceAssociationResult { IsValid = false, IsTransient = true };
|
||||
|
||||
SecurityValidator.EnrichRequestTelemetryWithDeviceAssociation(result);
|
||||
|
||||
var tags = activity.Tags.ToDictionary(t => t.Key, t => t.Value);
|
||||
Assert.Equal("false", tags["devprep.association.matched"]);
|
||||
Assert.Equal("true", tags["devprep.association.transient"]);
|
||||
Assert.DoesNotContain("devprep.association.state", tags.Keys);
|
||||
Assert.DoesNotContain("devprep.association.policyId", tags.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enrich_NoActivity_DoesNotThrow()
|
||||
{
|
||||
// Activity.Current is null in environments where no listener registered the activity source.
|
||||
// Must be a safe no-op.
|
||||
Activity.Current = null;
|
||||
var result = new DeviceAssociationResult { IsValid = true };
|
||||
var ex = Record.Exception(() => SecurityValidator.EnrichRequestTelemetryWithDeviceAssociation(result));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -364,25 +363,34 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
|
||||
// 5. DevPrep "Device association" lookup (shadow mode — does NOT gate enrollment).
|
||||
// Runs only when the tenant has opted in and the existing validators have already
|
||||
// authorized the device. Result is attached to the request telemetry (App Insights)
|
||||
// via Activity tags so we can observe DevPrep readiness without breaking enrollment.
|
||||
// Fire-and-forget: kicked off as a detached Task so it never adds latency to the
|
||||
// RegisterSession/Ingest response. Graph round-trips for tenantAssociatedDevices can
|
||||
// take 200ms–30s (504 timeouts observed), and shadow mode must never block the agent.
|
||||
// Result is logged at Warning level so it surfaces in App Insights traces.
|
||||
if (config.ValidateDeviceAssociation && _deviceAssociationValidator != null && !string.IsNullOrEmpty(serialNumber))
|
||||
{
|
||||
try
|
||||
var validator = _deviceAssociationValidator;
|
||||
var capturedTenant = tenantId;
|
||||
var capturedSerial = serialNumber;
|
||||
var capturedSession = sessionId;
|
||||
var logger = _logger;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var devPrepResult = await _deviceAssociationValidator.LookupAsync(tenantId, serialNumber, sessionId);
|
||||
EnrichRequestTelemetryWithDeviceAssociation(devPrepResult);
|
||||
_logger.LogInformation(
|
||||
"DevPrep association lookup (shadow) for tenant {TenantId}, serial {SerialNumber}: matched={Matched}, transient={Transient}, state={State}, policy={PolicyId}",
|
||||
tenantId, serialNumber, devPrepResult.IsValid, devPrepResult.IsTransient,
|
||||
devPrepResult.AssociationState ?? "<none>", devPrepResult.DevicePreparationPolicyId ?? "<none>");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Shadow mode must never break the enrollment flow — log and move on.
|
||||
_logger.LogWarning(ex, "DevPrep association lookup (shadow) failed for tenant {TenantId}, serial {SerialNumber} — ignored.", tenantId, serialNumber);
|
||||
}
|
||||
try
|
||||
{
|
||||
var devPrepResult = await validator.LookupAsync(capturedTenant, capturedSerial, capturedSession);
|
||||
logger.LogWarning(
|
||||
"DevPrep association lookup (shadow) for tenant {TenantId}, session {SessionId}, serial {SerialNumber}: matched={Matched}, transient={Transient}, state={State}, policy={PolicyId}",
|
||||
capturedTenant, capturedSession ?? "<none>", capturedSerial,
|
||||
devPrepResult.IsValid, devPrepResult.IsTransient,
|
||||
devPrepResult.AssociationState ?? "<none>", devPrepResult.DevicePreparationPolicyId ?? "<none>");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "DevPrep association lookup (shadow) failed for tenant {TenantId}, serial {SerialNumber} — ignored.", capturedTenant, capturedSerial);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
@@ -399,26 +407,6 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches DevPrep association lookup outcome to the current Activity so it surfaces
|
||||
/// in Application Insights request telemetry as <c>customDimensions</c>.
|
||||
/// Centralised here so the SecurityValidator's call-site stays small.
|
||||
/// </summary>
|
||||
internal static void EnrichRequestTelemetryWithDeviceAssociation(DeviceAssociationResult result)
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
if (activity == null) return;
|
||||
|
||||
activity.AddTag("devprep.association.matched", result.IsValid ? "true" : "false");
|
||||
activity.AddTag("devprep.association.transient", result.IsTransient ? "true" : "false");
|
||||
|
||||
if (!string.IsNullOrEmpty(result.AssociationState))
|
||||
activity.AddTag("devprep.association.state", result.AssociationState);
|
||||
if (!string.IsNullOrEmpty(result.DevicePreparationPolicyId))
|
||||
activity.AddTag("devprep.association.policyId", result.DevicePreparationPolicyId);
|
||||
if (result.PreAssociationDateTime.HasValue)
|
||||
activity.AddTag("devprep.association.preAssociationUtc", result.PreAssociationDateTime.Value.ToString("O"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user