eventdata projection to support also older event schema for new rule ANALYZE-ESP-002

This commit is contained in:
Oliver Kieselbach
2026-04-14 21:51:57 +02:00
parent 45edbab0e1
commit 78cccae908
3 changed files with 128 additions and 1 deletions
@@ -1 +1 @@
971
972
@@ -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<string, object>
{
["category"] = "DeviceSetup",
["changeType"] = "subcategory_state_change",
["transitions"] = new List<object>
{
new Dictionary<string, object>
{
["subcategory"] = "Certificates",
["previousState"] = "inProgress",
["newState"] = "failed"
},
new Dictionary<string, object>
{
["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<EnrollmentEvent> { 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<string, object>
{
["failedSubcategories"] = "AgentProvidedValue",
["transitions"] = new List<object>
{
new Dictionary<string, object>
{
["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<EnrollmentEvent> { modernEvent } });
Assert.Equal("AgentProvidedValue", modernEvent.Data["failedSubcategories"]);
}
[Fact]
public void RuleState_ExplicitOverride_WinsOverDefault()
{
@@ -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<string> existingRuleIds;
@@ -104,6 +108,60 @@ namespace AutopilotMonitor.Functions.Services
return outcome;
}
/// <summary>
/// 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.
/// </summary>
private static void BackfillDerivedEventFields(List<EnrollmentEvent> 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<object>, so every
// transition shows up as a Dictionary<string, object> after the JToken conversion.
if (transitionsObj is not System.Collections.IEnumerable enumerable)
continue;
var failed = new List<string>();
foreach (var item in enumerable)
{
if (item is not System.Collections.Generic.IDictionary<string, object> 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);
}
}
/// <summary>
/// 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