From 78cccae9080bfe2daaa0472d9e4835436b362cf5 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 14 Apr 2026 21:51:57 +0200 Subject: [PATCH] eventdata projection to support also older event schema for new rule ANALYZE-ESP-002 --- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../AnalyzeRuleSessionFailureTests.cs | 69 +++++++++++++++++++ .../Services/RuleEngine.cs | 58 ++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 3d78d3c5..66ae0255 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -971 +972 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRuleSessionFailureTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRuleSessionFailureTests.cs index fb6fef01..b6d60247 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRuleSessionFailureTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRuleSessionFailureTests.cs @@ -65,6 +65,75 @@ public class AnalyzeRuleSessionFailureTests Assert.False(effective); } + [Fact] + public void BackfillDerivedEventFields_SynthesizesFailedSubcategoriesFromTransitions() + { + // Mirrors an esp_provisioning_status event emitted by an older agent build: no flat + // `failedSubcategories` field, but the `transitions` list is present. The RuleEngine's + // read-time projection must derive the flat field so ANALYZE-ESP-002 matches. + var legacyEvent = new EnrollmentEvent + { + EventType = "esp_provisioning_status", + Data = new Dictionary + { + ["category"] = "DeviceSetup", + ["changeType"] = "subcategory_state_change", + ["transitions"] = new List + { + new Dictionary + { + ["subcategory"] = "Certificates", + ["previousState"] = "inProgress", + ["newState"] = "failed" + }, + new Dictionary + { + ["subcategory"] = "Apps", + ["previousState"] = "inProgress", + ["newState"] = "succeeded" + } + } + } + }; + + var method = typeof(RuleEngine).GetMethod( + "BackfillDerivedEventFields", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.NotNull(method); + method!.Invoke(null, new object[] { new List { legacyEvent } }); + + Assert.True(legacyEvent.Data.ContainsKey("failedSubcategories")); + Assert.Equal("Certificates", legacyEvent.Data["failedSubcategories"]); + } + + [Fact] + public void BackfillDerivedEventFields_DoesNotOverwriteAgentProvidedValue() + { + var modernEvent = new EnrollmentEvent + { + EventType = "esp_provisioning_status", + Data = new Dictionary + { + ["failedSubcategories"] = "AgentProvidedValue", + ["transitions"] = new List + { + new Dictionary + { + ["subcategory"] = "Certificates", + ["newState"] = "failed" + } + } + } + }; + + var method = typeof(RuleEngine).GetMethod( + "BackfillDerivedEventFields", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + method!.Invoke(null, new object[] { new List { modernEvent } }); + + Assert.Equal("AgentProvidedValue", modernEvent.Data["failedSubcategories"]); + } + [Fact] public void RuleState_ExplicitOverride_WinsOverDefault() { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs index 2ec21643..e22cf7db 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs @@ -46,6 +46,10 @@ namespace AutopilotMonitor.Functions.Services return outcome; } + // Backfill derived fields for backward compatibility with events produced by older agents. + // This is a pure read-time projection — we never persist the synthesized values. + BackfillDerivedEventFields(allEvents); + // On reanalyze: skip deduplication so all rules are re-evaluated from scratch // On normal run: skip rules already evaluated to avoid duplicate storage HashSet existingRuleIds; @@ -104,6 +108,60 @@ namespace AutopilotMonitor.Functions.Services return outcome; } + /// + /// Read-time backfill of derived Event.Data fields that older agent builds didn't emit. + /// Keeps rules forward-compatible with historical sessions. Pure in-memory mutation — the + /// original event records in Table Storage are not modified. + /// + /// Current projections: + /// - esp_provisioning_status: synthesize `failedSubcategories` (comma-joined registry names) + /// from `transitions[]` entries where newState == "failed". Matches what ProvisioningStatusTracker + /// now emits natively, so ANALYZE-ESP-002 fires against pre-upgrade data too. + /// + private static void BackfillDerivedEventFields(List events) + { + foreach (var evt in events) + { + if (evt.Data == null) continue; + if (!string.Equals(evt.EventType, "esp_provisioning_status", System.StringComparison.OrdinalIgnoreCase)) + continue; + + // Don't overwrite what the agent already provided. + if (evt.Data.ContainsKey("failedSubcategories")) + continue; + + if (!evt.Data.TryGetValue("transitions", out var transitionsObj) || transitionsObj == null) + continue; + + // TableStorageService.DeserializeEventData normalizes JArray → List, so every + // transition shows up as a Dictionary after the JToken conversion. + if (transitionsObj is not System.Collections.IEnumerable enumerable) + continue; + + var failed = new List(); + foreach (var item in enumerable) + { + if (item is not System.Collections.Generic.IDictionary dict) + continue; + + if (!dict.TryGetValue("newState", out var newStateObj) || newStateObj == null) + continue; + if (!string.Equals(newStateObj.ToString(), "failed", System.StringComparison.OrdinalIgnoreCase)) + continue; + + if (dict.TryGetValue("subcategory", out var nameObj) && nameObj != null) + { + var name = nameObj.ToString(); + if (!string.IsNullOrWhiteSpace(name)) + failed.Add(name!); + } + } + + if (failed.Count > 0) + evt.Data["failedSubcategories"] = string.Join(",", failed); + } + } + /// /// Promotes a fired rule to a terminal Failed status on the session, but only when the session /// is still in a non-terminal state (InProgress/Pending/Stalled). Terminal states