Analyze Rule phase_change fix

This commit is contained in:
Oliver Kieselbach
2026-02-10 15:35:02 +01:00
parent 2daeda2846
commit 6c444c1c73
4 changed files with 142 additions and 35 deletions
@@ -174,19 +174,23 @@ namespace AutopilotMonitor.Functions.Services
{
RuleId = "ANALYZE-ESP-001",
Title = "ESP Blocking App Timeout",
Description = "Detects when the ESP is waiting too long for a blocking app to install.",
Description = "Detects when the DeviceSetup phase of the ESP took longer than 30 minutes, indicating a stuck or very slow blocking app.",
Severity = "high",
Category = "esp",
BaseConfidence = 60,
// BaseConfidence is intentionally below ConfidenceThreshold so the rule only fires
// when the duration factor (phase_duration > 1800s) also matches.
BaseConfidence = 50,
Conditions = new List<RuleCondition>
{
new RuleCondition { Signal = "esp_stalled", Source = "phase_duration", EventType = "esp_phase_changed", DataField = "espPhase", Operator = "equals", Value = "DeviceSetup", Required = true }
},
ConfidenceFactors = new List<ConfidenceFactor>
{
new ConfidenceFactor { Signal = "long_esp", Condition = "phase_duration > 1800", Weight = 30 }
new ConfidenceFactor { Signal = "long_esp", Condition = "phase_duration > 1800", Weight = 40 }
},
Explanation = "The Enrollment Status Page has been waiting for a blocking app for an extended period. This often indicates the app is stuck installing or the ESP timeout is about to trigger.",
// Only fire when duration factor also matched (50 base + 40 factor = 90 > 70 threshold)
ConfidenceThreshold = 70,
Explanation = "The ESP DeviceSetup phase took more than 30 minutes. This indicates a blocking app is stuck installing, downloading very slowly, or has entered a retry loop.",
Remediation = new List<RemediationStep>
{
new RemediationStep { Title = "Investigate blocking app", Steps = new List<string> { "Identify which app is blocking (check ESP events)", "Verify app content is downloadable", "Consider reducing the number of blocking apps" } }
@@ -178,14 +178,30 @@ namespace AutopilotMonitor.Functions.Services
var fieldValue = GetDataFieldValue(evt, condition.DataField);
if (fieldValue != null && MatchesOperator(fieldValue, condition.Operator, condition.Value))
{
return (true, new { eventType = evt.EventType, field = condition.DataField, value = fieldValue });
return (true, new Dictionary<string, object>
{
["eventId"] = evt.EventId,
["sequence"] = evt.Sequence,
["timestamp"] = evt.Timestamp,
["eventType"] = evt.EventType,
["field"] = condition.DataField,
["value"] = fieldValue
});
}
}
return (false, "data field not matched");
}
// Just check if event type exists
return (true, new { eventType = condition.EventType, count = matchingEvents.Count });
// Just check if event type exists — return first matching event for reference
var first = matchingEvents[0];
return (true, new Dictionary<string, object>
{
["eventId"] = first.EventId,
["sequence"] = first.Sequence,
["timestamp"] = first.Timestamp,
["eventType"] = condition.EventType,
["count"] = matchingEvents.Count
});
}
private (bool matched, object evidence) EvaluateEventDataCondition(RuleCondition condition, List<EnrollmentEvent> events)
@@ -197,7 +213,14 @@ namespace AutopilotMonitor.Functions.Services
var fieldValue = GetDataFieldValue(evt, condition.DataField);
if (fieldValue != null && MatchesOperator(fieldValue, condition.Operator, condition.Value))
{
return (true, new { field = condition.DataField, value = fieldValue });
return (true, new Dictionary<string, object>
{
["eventId"] = evt.EventId,
["sequence"] = evt.Sequence,
["timestamp"] = evt.Timestamp,
["field"] = condition.DataField,
["value"] = fieldValue
});
}
}
@@ -206,18 +229,29 @@ namespace AutopilotMonitor.Functions.Services
private (bool matched, object evidence) EvaluateEventCountCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
var count = events.Count(e => MatchesEventType(e, condition.EventType));
var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList();
var count = matchingEvents.Count;
if (condition.Operator == "count_gte" && int.TryParse(condition.Value, out var threshold))
{
if (count >= threshold)
return (true, new { count, threshold });
{
var first = matchingEvents[0];
return (true, new Dictionary<string, object>
{
["eventId"] = first.EventId,
["sequence"] = first.Sequence,
["timestamp"] = first.Timestamp,
["count"] = count,
["threshold"] = threshold
});
}
}
return (false, new { count });
return (false, new Dictionary<string, object> { ["count"] = count });
}
private (bool matched, object evidence) EvaluatePhaseDurationCondition(RuleCondition condition, List<EnrollmentEvent> events)
private static (bool matched, object evidence) EvaluatePhaseDurationCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
// Find phase change events to calculate phase duration
var phaseEvents = events
@@ -228,48 +262,71 @@ namespace AutopilotMonitor.Functions.Services
if (!phaseEvents.Any())
return (false, "no phase events");
// Check if a specific phase took too long
// condition.DataField = field to look up (e.g. "espPhase"), condition.Value = target phase name (e.g. "DeviceSetup")
var targetPhase = condition.Value;
var lookupField = string.IsNullOrEmpty(condition.DataField) ? "espPhase" : condition.DataField;
for (int i = 0; i < phaseEvents.Count; i++)
{
var phaseData = phaseEvents[i].Data;
var currentPhase = phaseData?.ContainsKey("espPhase") == true
? phaseData["espPhase"]?.ToString()
var evt = phaseEvents[i];
var currentPhase = evt.Data?.ContainsKey(lookupField) == true
? evt.Data[lookupField]?.ToString()
: null;
if (currentPhase == targetPhase)
{
// Calculate how long this phase lasted
DateTime phaseEnd;
if (i + 1 < phaseEvents.Count)
{
phaseEnd = phaseEvents[i + 1].Timestamp;
}
else
{
phaseEnd = DateTime.UtcNow; // Phase is still active
}
if (!string.Equals(currentPhase, targetPhase, StringComparison.OrdinalIgnoreCase))
continue;
var duration = (phaseEnd - phaseEvents[i].Timestamp).TotalSeconds;
return (true, new { phase = targetPhase, durationSeconds = duration });
// Calculate how long this phase lasted
DateTime phaseEnd;
string? phaseEndEventId = null;
if (i + 1 < phaseEvents.Count)
{
phaseEnd = phaseEvents[i + 1].Timestamp;
phaseEndEventId = phaseEvents[i + 1].EventId;
}
else
{
phaseEnd = DateTime.UtcNow; // Phase is still active
}
var durationSeconds = (phaseEnd - evt.Timestamp).TotalSeconds;
return (true, new Dictionary<string, object>
{
["eventId"] = evt.EventId,
["sequence"] = evt.Sequence,
["phaseStartTimestamp"] = evt.Timestamp,
["phaseEndEventId"] = phaseEndEventId ?? "(still active)",
["phase"] = targetPhase,
["durationSeconds"] = durationSeconds,
["durationFormatted"] = FormatDuration(durationSeconds)
});
}
return (false, "phase not found");
}
private static string FormatDuration(double totalSeconds)
{
var ts = TimeSpan.FromSeconds(totalSeconds);
if (ts.TotalHours >= 1)
return $"{(int)ts.TotalHours}h {ts.Minutes}m {ts.Seconds}s";
if (ts.TotalMinutes >= 1)
return $"{ts.Minutes}m {ts.Seconds}s";
return $"{ts.Seconds}s";
}
private bool EvaluateConfidenceFactor(ConfidenceFactor factor, List<EnrollmentEvent> events, Dictionary<string, object> matchedConditions)
{
// Simple confidence factor evaluation
if (factor.Condition.StartsWith("phase_duration >"))
{
if (int.TryParse(factor.Condition.Replace("phase_duration >", "").Trim(), out var threshold))
{
foreach (var mc in matchedConditions.Values)
{
if (mc is IDictionary<string, object> dict && dict.ContainsKey("durationSeconds"))
if (mc is Dictionary<string, object> dict && dict.TryGetValue("durationSeconds", out var rawDuration))
{
var duration = Convert.ToDouble(dict["durationSeconds"]);
var duration = Convert.ToDouble(rawDuration);
return duration > threshold;
}
}
@@ -1018,7 +1018,7 @@ namespace AutopilotMonitor.Functions.Services
Explanation = entity.GetString("Explanation") ?? string.Empty,
Remediation = DeserializeJson<List<RemediationStep>>(entity.GetString("RemediationJson")),
RelatedDocs = DeserializeJson<List<RelatedDoc>>(entity.GetString("RelatedDocsJson")),
MatchedConditions = DeserializeJson<Dictionary<string, object>>(entity.GetString("MatchedConditionsJson")),
MatchedConditions = DeserializeMatchedConditions(entity.GetString("MatchedConditionsJson")),
DetectedAt = entity.GetDateTimeOffset("DetectedAt")?.UtcDateTime ?? DateTime.UtcNow
});
}
@@ -1757,6 +1757,52 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Deserializes MatchedConditions JSON and normalizes nested JObject/JArray values
/// to plain Dictionary/List so System.Text.Json can serialize them correctly.
/// </summary>
private Dictionary<string, object> DeserializeMatchedConditions(string? json)
{
if (string.IsNullOrEmpty(json))
return new Dictionary<string, object>();
try
{
var raw = JsonConvert.DeserializeObject<Dictionary<string, object>>(json)
?? new Dictionary<string, object>();
var result = new Dictionary<string, object>();
foreach (var kv in raw)
result[kv.Key] = NormalizeJToken(kv.Value);
return result;
}
catch
{
return new Dictionary<string, object>();
}
}
private static object NormalizeJToken(object? value)
{
if (value is Newtonsoft.Json.Linq.JObject jObj)
{
var dict = new Dictionary<string, object>();
foreach (var prop in jObj.Properties())
dict[prop.Name] = NormalizeJToken(prop.Value);
return dict;
}
if (value is Newtonsoft.Json.Linq.JArray jArr)
{
var list = new List<object>();
foreach (var item in jArr)
list.Add(NormalizeJToken(item));
return list;
}
if (value is Newtonsoft.Json.Linq.JValue jVal)
return jVal.Value ?? string.Empty;
return value ?? string.Empty;
}
private string[] DeserializeJsonArray(string? json)
{
if (string.IsNullOrEmpty(json))
@@ -364,7 +364,7 @@ export default function FleetHealthPage() {
/>
</svg>
<h1 className="text-3xl font-bold text-gray-900">
FLEET HEALTH
Feet Health
</h1>
</div>
</div>