diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs
index 34263a27..4cc5fa35 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs
@@ -1,13 +1,12 @@
-using System.Diagnostics;
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
///
/// Tests for — 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 AutopilotDeviceValidator; 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 AutopilotDeviceValidator; behavioural drift
+/// would surface here.
///
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);
- }
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs
index 32fd507c..3c745c2e 100644
--- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs
@@ -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 ?? "", devPrepResult.DevicePreparationPolicyId ?? "");
- }
- 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 ?? "", capturedSerial,
+ devPrepResult.IsValid, devPrepResult.IsTransient,
+ devPrepResult.AssociationState ?? "", devPrepResult.DevicePreparationPolicyId ?? "");
+ }
+ 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
};
}
- ///
- /// Attaches DevPrep association lookup outcome to the current Activity so it surfaces
- /// in Application Insights request telemetry as customDimensions.
- /// Centralised here so the SecurityValidator's call-site stays small.
- ///
- 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"));
- }
}
///