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:
Oliver Kieselbach
2026-02-24 13:38:46 +01:00
parent 6deb2f9845
commit 2235170a57
3 changed files with 195 additions and 69 deletions
@@ -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>