mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
add generic event_correlation source for flexible cross-event rule correlation
Replaces the hardcoded app_state_regression source with a generic event_correlation source that allows correlating any two event types by a shared field (e.g. appId), with optional time windows and filters on both events — all configurable via JSON without backend code changes. RuleCondition gains 5 new nullable properties: - CorrelateEventType: second event type (Event B) - JoinField: field to match across both events (e.g. "appId") - TimeWindowSeconds: max time between Event A and B - EventAFilterField/Operator/Value: optional filter on Event A ANALYZE-APP-010 migrated to use event_correlation source. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -569,7 +569,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Detects when an app was initially reported as successfully installed but then
|
||||
/// transitioned back to an error state (IME-ERROR-REPORT), correlated by appId.
|
||||
/// Uses the app_state_regression source which matches the exact same app across both events.
|
||||
/// Uses the generic event_correlation source to match the exact same app across
|
||||
/// app_install_completed and app_install_failed events.
|
||||
/// This is a silent failure that can leave apps broken post-enrollment.
|
||||
/// </summary>
|
||||
private static AnalyzeRule CreateAppPostSuccessFailureRule() => new AnalyzeRule
|
||||
@@ -583,12 +584,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
BaseConfidence = 75,
|
||||
Conditions = new List<RuleCondition>
|
||||
{
|
||||
// Single condition: finds apps where completed -> failed regression exists for the same appId,
|
||||
// and the failed event has errorPatternId = IME-ERROR-REPORT
|
||||
// Correlate app_install_completed (Event A) with app_install_failed (Event B)
|
||||
// for the same appId, where Event B has errorPatternId = IME-ERROR-REPORT
|
||||
new RuleCondition
|
||||
{
|
||||
Signal = "app_regression",
|
||||
Source = "app_state_regression",
|
||||
Source = "event_correlation",
|
||||
EventType = "app_install_completed",
|
||||
CorrelateEventType = "app_install_failed",
|
||||
JoinField = "appId",
|
||||
DataField = "errorPatternId",
|
||||
Operator = "equals",
|
||||
Value = "IME-ERROR-REPORT",
|
||||
|
||||
@@ -161,8 +161,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
case "app_install_duration":
|
||||
return EvaluateAppInstallDurationCondition(condition, events);
|
||||
|
||||
case "app_state_regression":
|
||||
return EvaluateAppStateRegressionCondition(condition, events);
|
||||
case "event_correlation":
|
||||
return EvaluateEventCorrelationCondition(condition, events);
|
||||
|
||||
default:
|
||||
return (false, "unknown source");
|
||||
@@ -517,69 +517,149 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects apps that regressed: first seen as app_install_completed, then as app_install_failed,
|
||||
/// correlated by appId. Optionally filters the failed event by a DataField/Operator/Value condition.
|
||||
/// Returns evidence with appId, appName, and both event details.
|
||||
/// Generic event correlation: finds pairs of events (A before B) sharing
|
||||
/// the same value in a join field, with optional filters on each event
|
||||
/// and optional time window constraint.
|
||||
///
|
||||
/// Condition fields used:
|
||||
/// EventType = Event A type (required)
|
||||
/// CorrelateEventType = Event B type (required)
|
||||
/// JoinField = field that must match in both events (required, e.g. "appId")
|
||||
/// TimeWindowSeconds = max seconds between A and B (optional, 0/null = unlimited)
|
||||
/// DataField/Operator/Value = optional filter on Event B
|
||||
/// EventAFilterField/Operator/Value = optional filter on Event A
|
||||
/// </summary>
|
||||
private (bool matched, object evidence) EvaluateAppStateRegressionCondition(RuleCondition condition, List<EnrollmentEvent> events)
|
||||
private (bool matched, object evidence) EvaluateEventCorrelationCondition(RuleCondition condition, List<EnrollmentEvent> events)
|
||||
{
|
||||
var sortedEvents = events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
|
||||
|
||||
var completedEvents = sortedEvents
|
||||
.Where(e => string.Equals(e.EventType, "app_install_completed", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
var failedEvents = sortedEvents
|
||||
.Where(e => string.Equals(e.EventType, "app_install_failed", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (!completedEvents.Any() || !failedEvents.Any())
|
||||
return (false, "no completed/failed event pair found");
|
||||
|
||||
foreach (var failedEvent in failedEvents)
|
||||
if (string.IsNullOrEmpty(condition.EventType) ||
|
||||
string.IsNullOrEmpty(condition.CorrelateEventType) ||
|
||||
string.IsNullOrEmpty(condition.JoinField))
|
||||
{
|
||||
// Apply optional DataField filter on the failed event
|
||||
if (!string.IsNullOrEmpty(condition.DataField) && !string.IsNullOrEmpty(condition.Operator))
|
||||
{
|
||||
var fieldValue = GetDataFieldValue(failedEvent, condition.DataField);
|
||||
if (fieldValue == null || !MatchesOperator(fieldValue, condition.Operator, condition.Value ?? ""))
|
||||
continue;
|
||||
}
|
||||
|
||||
var failedAppId = GetDataFieldValue(failedEvent, "appId");
|
||||
var failedAppName = GetDataFieldValue(failedEvent, "appName");
|
||||
var appKey = !string.IsNullOrWhiteSpace(failedAppId) ? failedAppId : failedAppName;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(appKey))
|
||||
continue;
|
||||
|
||||
// Find a completed event for the same app that occurred before the failed event
|
||||
var matchingCompleted = completedEvents.LastOrDefault(c =>
|
||||
c.Timestamp <= failedEvent.Timestamp &&
|
||||
string.Equals(
|
||||
GetDataFieldValue(c, "appId") ?? GetDataFieldValue(c, "appName"),
|
||||
appKey,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (matchingCompleted == null)
|
||||
continue;
|
||||
|
||||
return (true, new Dictionary<string, object>
|
||||
{
|
||||
["appId"] = failedAppId ?? string.Empty,
|
||||
["appName"] = failedAppName ?? appKey,
|
||||
["completedEventId"] = matchingCompleted.EventId ?? string.Empty,
|
||||
["completedSequence"] = matchingCompleted.Sequence,
|
||||
["completedTimestamp"] = matchingCompleted.Timestamp,
|
||||
["failedEventId"] = failedEvent.EventId ?? string.Empty,
|
||||
["failedSequence"] = failedEvent.Sequence,
|
||||
["failedTimestamp"] = failedEvent.Timestamp,
|
||||
["errorPatternId"] = GetDataFieldValue(failedEvent, "errorPatternId") ?? string.Empty,
|
||||
["errorDetail"] = GetDataFieldValue(failedEvent, "errorDetail") ?? failedEvent.Message ?? string.Empty
|
||||
});
|
||||
_logger.LogWarning($"event_correlation condition '{condition.Signal}' missing required fields (EventType, CorrelateEventType, JoinField)");
|
||||
return (false, "event_correlation requires EventType, CorrelateEventType, and JoinField");
|
||||
}
|
||||
|
||||
return (false, "no app found with completed-then-failed regression");
|
||||
var sortedEvents = events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
|
||||
|
||||
// Collect Event A candidates with optional filter
|
||||
var eventAList = sortedEvents
|
||||
.Where(e => MatchesEventType(e, condition.EventType))
|
||||
.Where(e =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(condition.EventAFilterField))
|
||||
return true;
|
||||
var val = GetDataFieldValue(e, condition.EventAFilterField);
|
||||
return val != null && MatchesOperator(val, condition.EventAFilterOperator ?? "exists", condition.EventAFilterValue ?? "");
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Collect Event B candidates with optional DataField/Operator/Value filter
|
||||
var eventBList = sortedEvents
|
||||
.Where(e => MatchesEventType(e, condition.CorrelateEventType))
|
||||
.Where(e =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(condition.DataField))
|
||||
return true;
|
||||
var val = GetDataFieldValue(e, condition.DataField);
|
||||
return val != null && MatchesOperator(val, condition.Operator ?? "exists", condition.Value ?? "");
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (!eventAList.Any() || !eventBList.Any())
|
||||
return (false, "no matching event pairs found");
|
||||
|
||||
// Index Event A by join field value for O(1) lookup
|
||||
var eventAByJoinKey = eventAList
|
||||
.Select(e => new { Event = e, Key = GetDataFieldValue(e, condition.JoinField) })
|
||||
.Where(x => !string.IsNullOrEmpty(x.Key))
|
||||
.GroupBy(x => x.Key!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => x.Event).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// For each Event B, find a matching Event A (same join key, A before B, within time window)
|
||||
var matchedPairs = new List<Dictionary<string, object>>();
|
||||
var matchedJoinKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var eventB in eventBList)
|
||||
{
|
||||
var bKey = GetDataFieldValue(eventB, condition.JoinField);
|
||||
if (string.IsNullOrEmpty(bKey) || !eventAByJoinKey.ContainsKey(bKey))
|
||||
continue;
|
||||
|
||||
// Find the latest Event A that occurred before Event B
|
||||
var matchingA = eventAByJoinKey[bKey]
|
||||
.Where(a => a.Timestamp < eventB.Timestamp ||
|
||||
(a.Timestamp == eventB.Timestamp && a.Sequence < eventB.Sequence))
|
||||
.Where(a =>
|
||||
{
|
||||
if (condition.TimeWindowSeconds == null || condition.TimeWindowSeconds <= 0)
|
||||
return true;
|
||||
return (eventB.Timestamp - a.Timestamp).TotalSeconds <= condition.TimeWindowSeconds.Value;
|
||||
})
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.ThenByDescending(a => a.Sequence)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (matchingA == null)
|
||||
continue;
|
||||
|
||||
// One match per join key to avoid duplicates
|
||||
if (matchedJoinKeys.Contains(bKey))
|
||||
continue;
|
||||
matchedJoinKeys.Add(bKey);
|
||||
|
||||
// Build evidence for this correlated pair
|
||||
var pair = new Dictionary<string, object>
|
||||
{
|
||||
["joinField"] = condition.JoinField,
|
||||
["joinValue"] = bKey,
|
||||
["eventA_eventId"] = matchingA.EventId ?? string.Empty,
|
||||
["eventA_eventType"] = matchingA.EventType ?? string.Empty,
|
||||
["eventA_timestamp"] = matchingA.Timestamp,
|
||||
["eventA_sequence"] = matchingA.Sequence,
|
||||
["eventA_message"] = matchingA.Message ?? string.Empty,
|
||||
["eventB_eventId"] = eventB.EventId ?? string.Empty,
|
||||
["eventB_eventType"] = eventB.EventType ?? string.Empty,
|
||||
["eventB_timestamp"] = eventB.Timestamp,
|
||||
["eventB_sequence"] = eventB.Sequence,
|
||||
["eventB_message"] = eventB.Message ?? string.Empty,
|
||||
["timeDeltaSeconds"] = (eventB.Timestamp - matchingA.Timestamp).TotalSeconds
|
||||
};
|
||||
|
||||
// Add well-known data fields from both events
|
||||
AddDataFieldsToEvidence(pair, matchingA, "eventA_", condition.JoinField);
|
||||
AddDataFieldsToEvidence(pair, eventB, "eventB_", condition.JoinField);
|
||||
|
||||
matchedPairs.Add(pair);
|
||||
}
|
||||
|
||||
if (!matchedPairs.Any())
|
||||
return (false, "no correlated event pairs found");
|
||||
|
||||
// Single match: flat dictionary; multiple: first as primary + allMatches list
|
||||
var primary = matchedPairs[0];
|
||||
primary["totalMatches"] = matchedPairs.Count;
|
||||
if (matchedPairs.Count > 1)
|
||||
{
|
||||
primary["allMatches"] = matchedPairs;
|
||||
}
|
||||
return (true, primary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds well-known data fields from an event to the evidence dictionary with a prefix.
|
||||
/// </summary>
|
||||
private void AddDataFieldsToEvidence(Dictionary<string, object> evidence, EnrollmentEvent evt, string prefix, string joinField)
|
||||
{
|
||||
if (evt.Data == null) return;
|
||||
|
||||
var knownFields = new[] { "appId", "appName", "errorPatternId", "errorDetail", "errorCode", "status" };
|
||||
|
||||
foreach (var field in knownFields)
|
||||
{
|
||||
var val = GetDataFieldValue(evt, field);
|
||||
if (val != null)
|
||||
evidence[$"{prefix}{field}"] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,28 +134,34 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string Signal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of the signal: "event_type", "event_data", "phase_duration", "event_count", "app_install_duration", "app_state_regression"
|
||||
/// Source of the signal: "event_type", "event_data", "phase_duration", "event_count", "app_install_duration", "event_correlation"
|
||||
/// </summary>
|
||||
public string Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Event type to match on (when Source = "event_type" or "event_data")
|
||||
/// Event type to match on.
|
||||
/// For "event_type"/"event_data": the event type to match.
|
||||
/// For "event_correlation": the FIRST event type (Event A).
|
||||
/// </summary>
|
||||
public string EventType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Data field to match on (when Source = "event_data")
|
||||
/// Uses dot notation for nested fields (e.g., "data.errorCode")
|
||||
/// Data field to match on.
|
||||
/// For "event_data": field to check with Operator/Value.
|
||||
/// For "event_correlation": optional filter field on Event B (the second event).
|
||||
/// Uses dot notation for nested fields (e.g., "data.errorCode").
|
||||
/// </summary>
|
||||
public string DataField { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Comparison operator: "equals", "contains", "regex", "gt", "lt", "gte", "lte", "exists", "count_gte"
|
||||
/// For "event_correlation": operator for the Event B filter (applied to DataField).
|
||||
/// </summary>
|
||||
public string Operator { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Value to compare against
|
||||
/// Value to compare against.
|
||||
/// For "event_correlation": value for the Event B filter.
|
||||
/// </summary>
|
||||
public string Value { get; set; }
|
||||
|
||||
@@ -164,6 +170,42 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// If false, it only contributes to confidence scoring
|
||||
/// </summary>
|
||||
public bool Required { get; set; } = false;
|
||||
|
||||
// ===== Event Correlation Properties =====
|
||||
// Used only when Source = "event_correlation"
|
||||
|
||||
/// <summary>
|
||||
/// The second event type to correlate with (Event B).
|
||||
/// Example: "app_install_failed"
|
||||
/// </summary>
|
||||
public string CorrelateEventType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The data field to join on — must have the same value in both Event A and Event B.
|
||||
/// Example: "appId" means both events must share the same appId value.
|
||||
/// </summary>
|
||||
public string JoinField { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time in seconds between Event A and Event B. Null or 0 means no time limit.
|
||||
/// </summary>
|
||||
public int? TimeWindowSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional filter field on Event A (the first event).
|
||||
/// Combined with EventAFilterOperator and EventAFilterValue.
|
||||
/// </summary>
|
||||
public string EventAFilterField { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Operator for the Event A filter. Uses same operators as the main Operator field.
|
||||
/// </summary>
|
||||
public string EventAFilterOperator { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Value for the Event A filter.
|
||||
/// </summary>
|
||||
public string EventAFilterValue { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user