using System.Collections.Immutable;
using System.Diagnostics;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notifier.Worker.Correlation;
namespace StellaOps.Notifier.Worker.Simulation;
///
/// Default implementation of the notification simulation engine.
/// Dry-runs rules against events to preview what actions would be triggered.
///
public sealed class DefaultNotifySimulationEngine : INotifySimulationEngine
{
private readonly INotifyRuleRepository _ruleRepository;
private readonly INotifyChannelRepository _channelRepository;
private readonly INotifyAuditRepository _auditRepository;
private readonly INotifyRuleEvaluator _ruleEvaluator;
private readonly INotifyThrottler? _throttler;
private readonly IQuietHoursEvaluator? _quietHoursEvaluator;
private readonly TimeProvider _timeProvider;
private readonly ILogger _logger;
private static readonly TimeSpan DefaultThrottleWindow = TimeSpan.FromMinutes(5);
public DefaultNotifySimulationEngine(
INotifyRuleRepository ruleRepository,
INotifyChannelRepository channelRepository,
INotifyAuditRepository auditRepository,
INotifyRuleEvaluator ruleEvaluator,
INotifyThrottler? throttler,
IQuietHoursEvaluator? quietHoursEvaluator,
TimeProvider timeProvider,
ILogger logger)
{
_ruleRepository = ruleRepository ?? throw new ArgumentNullException(nameof(ruleRepository));
_channelRepository = channelRepository ?? throw new ArgumentNullException(nameof(channelRepository));
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
_ruleEvaluator = ruleEvaluator ?? throw new ArgumentNullException(nameof(ruleEvaluator));
_throttler = throttler;
_quietHoursEvaluator = quietHoursEvaluator;
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task SimulateAsync(
NotifySimulationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var stopwatch = Stopwatch.StartNew();
var simulationId = Guid.NewGuid().ToString("N");
var evaluationTime = request.EvaluationTimestamp ?? _timeProvider.GetUtcNow();
_logger.LogInformation(
"Starting simulation {SimulationId} for tenant {TenantId}: period {PeriodStart} to {PeriodEnd}",
simulationId, request.TenantId, request.PeriodStart, request.PeriodEnd);
// Load rules
var allRules = await _ruleRepository.ListAsync(request.TenantId, cancellationToken).ConfigureAwait(false);
var rules = FilterRules(allRules, request.RuleIds);
_logger.LogDebug(
"Simulation {SimulationId}: loaded {RuleCount} rules ({FilteredCount} after filtering)",
simulationId, allRules.Count, rules.Count);
// Load historical events from audit log
var auditEntries = await _auditRepository.QueryAsync(
request.TenantId,
request.PeriodStart,
request.MaxEvents,
cancellationToken).ConfigureAwait(false);
// Convert audit entries to events for simulation
var events = ConvertAuditEntriesToEvents(auditEntries, request.PeriodStart, request.PeriodEnd, request.EventKinds);
_logger.LogDebug(
"Simulation {SimulationId}: loaded {EventCount} events from audit log",
simulationId, events.Count);
// Load channels for action evaluation
var channels = await LoadChannelsAsync(request.TenantId, rules, cancellationToken).ConfigureAwait(false);
// Run simulation
var eventResults = new List();
var ruleSummaries = new Dictionary(StringComparer.Ordinal);
foreach (var rule in rules)
{
ruleSummaries[rule.RuleId] = new RuleSummaryBuilder(rule);
}
foreach (var @event in events)
{
cancellationToken.ThrowIfCancellationRequested();
var eventResult = await SimulateEventAsync(
@event, rules, channels, request, evaluationTime, ruleSummaries, cancellationToken).ConfigureAwait(false);
eventResults.Add(eventResult);
}
stopwatch.Stop();
var result = new NotifySimulationResult
{
SimulationId = simulationId,
TenantId = request.TenantId,
SimulatedAt = _timeProvider.GetUtcNow(),
EventsEvaluated = events.Count,
RulesEvaluated = rules.Count,
TotalMatches = eventResults.Sum(e => e.MatchedRules),
TotalActions = eventResults.Sum(e => e.TriggeredActions),
EventResults = eventResults.ToImmutableArray(),
RuleSummaries = ruleSummaries.Values
.Select(b => b.Build())
.OrderByDescending(s => s.MatchCount)
.ToImmutableArray(),
Duration = stopwatch.Elapsed
};
_logger.LogInformation(
"Completed simulation {SimulationId}: {EventsEvaluated} events, {TotalMatches} matches, {TotalActions} actions in {Duration}ms",
simulationId, result.EventsEvaluated, result.TotalMatches, result.TotalActions, result.Duration.TotalMilliseconds);
return result;
}
public async Task SimulateSingleEventAsync(
string tenantId,
JsonObject eventPayload,
IEnumerable? ruleIds = null,
DateTimeOffset? evaluationTimestamp = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(eventPayload);
var evaluationTime = evaluationTimestamp ?? _timeProvider.GetUtcNow();
// Parse event from payload
var @event = ParseEventFromPayload(tenantId, eventPayload);
// Load rules
var allRules = await _ruleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
var rules = FilterRules(allRules, ruleIds?.ToImmutableArray() ?? []);
// Load channels
var channels = await LoadChannelsAsync(tenantId, rules, cancellationToken).ConfigureAwait(false);
// Create dummy request for simulation
var request = new NotifySimulationRequest
{
TenantId = tenantId,
PeriodStart = evaluationTime.AddHours(-1),
PeriodEnd = evaluationTime,
EvaluationTimestamp = evaluationTime,
EvaluateThrottling = true,
EvaluateQuietHours = true,
IncludeNonMatches = true
};
var ruleSummaries = new Dictionary(StringComparer.Ordinal);
return await SimulateEventAsync(@event, rules, channels, request, evaluationTime, ruleSummaries, cancellationToken)
.ConfigureAwait(false);
}
private async Task SimulateEventAsync(
NotifyEvent @event,
IReadOnlyList rules,
IReadOnlyDictionary channels,
NotifySimulationRequest request,
DateTimeOffset evaluationTime,
Dictionary ruleSummaries,
CancellationToken cancellationToken)
{
var matches = new List();
var nonMatches = new List();
foreach (var rule in rules)
{
var outcome = _ruleEvaluator.Evaluate(rule, @event, evaluationTime);
if (outcome.IsMatch)
{
var actionResults = await EvaluateActionsAsync(
@event, rule, outcome.Actions, channels, request, evaluationTime, cancellationToken).ConfigureAwait(false);
var explanations = BuildMatchExplanations(rule, @event);
matches.Add(new SimulatedRuleMatch
{
RuleId = rule.RuleId,
RuleName = rule.Name ?? rule.RuleId,
Priority = 0, // NotifyRule doesn't have priority, default to 0
MatchedAt = outcome.MatchedAt ?? evaluationTime,
Actions = actionResults,
MatchExplanations = explanations
});
if (ruleSummaries.TryGetValue(rule.RuleId, out var summary))
{
summary.RecordMatch(actionResults.Length);
}
}
else if (request.IncludeNonMatches)
{
var explanation = BuildNonMatchExplanation(outcome.Reason ?? "unknown", rule, @event);
nonMatches.Add(new SimulatedRuleNonMatch
{
RuleId = rule.RuleId,
RuleName = rule.Name ?? rule.RuleId,
Reason = outcome.Reason ?? "unknown",
Explanation = explanation
});
if (ruleSummaries.TryGetValue(rule.RuleId, out var summary))
{
summary.RecordNonMatch(outcome.Reason ?? "unknown");
}
}
}
return new SimulatedEventResult
{
EventId = @event.EventId,
Kind = @event.Kind,
EventTimestamp = @event.Ts,
MatchedRules = matches.Count,
TriggeredActions = matches.Sum(m => m.Actions.Count(a => a.WouldDeliver)),
Matches = matches.OrderBy(m => m.Priority).ToImmutableArray(),
NonMatches = nonMatches.ToImmutableArray()
};
}
private async Task> EvaluateActionsAsync(
NotifyEvent @event,
NotifyRule rule,
ImmutableArray actions,
IReadOnlyDictionary channels,
NotifySimulationRequest request,
DateTimeOffset evaluationTime,
CancellationToken cancellationToken)
{
var results = new List();
foreach (var action in actions)
{
if (!action.Enabled)
{
continue;
}
var channelId = action.Channel?.Trim() ?? string.Empty;
channels.TryGetValue(channelId, out var channel);
var wouldDeliver = true;
var deliveryExplanation = "Would be delivered successfully";
string? throttleReason = null;
string? quietHoursReason = null;
string? channelBlockReason = null;
// Check channel availability
if (channel is null)
{
wouldDeliver = false;
channelBlockReason = $"Channel '{channelId}' not found";
deliveryExplanation = channelBlockReason;
}
else if (!channel.Enabled)
{
wouldDeliver = false;
channelBlockReason = $"Channel '{channelId}' is disabled";
deliveryExplanation = channelBlockReason;
}
// Check throttling
if (wouldDeliver && request.EvaluateThrottling && _throttler is not null)
{
var throttleKey = $"{rule.RuleId}:{action.ActionId}:{@event.Kind}";
var throttleWindow = action.Throttle is { Ticks: > 0 } ? action.Throttle.Value : DefaultThrottleWindow;
var throttleResult = await _throttler.CheckAsync(
@event.Tenant,
throttleKey,
throttleWindow,
null,
cancellationToken).ConfigureAwait(false);
if (throttleResult.IsThrottled)
{
wouldDeliver = false;
throttleReason = $"Would be throttled (key: {throttleKey})";
deliveryExplanation = throttleReason;
}
}
// Check quiet hours
if (wouldDeliver && request.EvaluateQuietHours && _quietHoursEvaluator is not null)
{
var quietHoursResult = await _quietHoursEvaluator.EvaluateAsync(
@event.Tenant, @event.Kind, cancellationToken).ConfigureAwait(false);
if (quietHoursResult.IsSuppressed)
{
wouldDeliver = false;
quietHoursReason = quietHoursResult.Reason ?? "In quiet hours period";
deliveryExplanation = quietHoursReason;
}
}
if (wouldDeliver)
{
deliveryExplanation = $"Would deliver to {channel?.Type.ToString() ?? "unknown"} channel '{channelId}'";
if (!string.IsNullOrWhiteSpace(action.Template))
{
deliveryExplanation += $" using template '{action.Template}'";
}
}
results.Add(new SimulatedActionResult
{
ActionId = action.ActionId,
ChannelId = channelId,
ChannelType = channel?.Type ?? NotifyChannelType.Custom,
TemplateId = action.Template,
WouldDeliver = wouldDeliver,
DeliveryExplanation = deliveryExplanation,
ThrottleReason = throttleReason,
QuietHoursReason = quietHoursReason,
ChannelBlockReason = channelBlockReason
});
}
return results.ToImmutableArray();
}
private static ImmutableArray BuildMatchExplanations(NotifyRule rule, NotifyEvent @event)
{
var explanations = new List();
var match = rule.Match;
if (!match.EventKinds.IsDefaultOrEmpty)
{
explanations.Add($"Event kind '{@event.Kind}' matched filter [{string.Join(", ", match.EventKinds)}]");
}
else
{
explanations.Add("Event kind matched (no filter specified)");
}
if (!match.Namespaces.IsDefaultOrEmpty && !string.IsNullOrWhiteSpace(@event.Scope?.Namespace))
{
explanations.Add($"Namespace '{@event.Scope.Namespace}' matched filter");
}
if (!match.Repositories.IsDefaultOrEmpty && !string.IsNullOrWhiteSpace(@event.Scope?.Repo))
{
explanations.Add($"Repository '{@event.Scope.Repo}' matched filter");
}
if (!string.IsNullOrWhiteSpace(match.MinSeverity))
{
explanations.Add($"Severity met minimum threshold of '{match.MinSeverity}'");
}
if (!match.Labels.IsDefaultOrEmpty)
{
explanations.Add($"Labels matched required set: [{string.Join(", ", match.Labels)}]");
}
return explanations.ToImmutableArray();
}
private static string BuildNonMatchExplanation(string reason, NotifyRule rule, NotifyEvent @event)
{
return reason switch
{
"rule_disabled" => $"Rule '{rule.Name ?? rule.RuleId}' is disabled",
"event_kind_mismatch" => $"Event kind '{@event.Kind}' not in rule filter [{string.Join(", ", rule.Match.EventKinds)}]",
"namespace_mismatch" => $"Namespace '{@event.Scope?.Namespace ?? "(none)"}' not in rule filter [{string.Join(", ", rule.Match.Namespaces)}]",
"repository_mismatch" => $"Repository '{@event.Scope?.Repo ?? "(none)"}' not in rule filter [{string.Join(", ", rule.Match.Repositories)}]",
"digest_mismatch" => $"Digest '{@event.Scope?.Digest ?? "(none)"}' not in rule filter",
"component_mismatch" => "Event component PURLs did not match rule filter",
"kev_required" => "Rule requires KEV label but event does not have it",
"label_mismatch" => $"Event labels did not match required set [{string.Join(", ", rule.Match.Labels)}]",
"severity_below_threshold" => $"Event severity below minimum '{rule.Match.MinSeverity}'",
"verdict_mismatch" => $"Event verdict not in rule filter [{string.Join(", ", rule.Match.Verdicts)}]",
"no_enabled_actions" => "Rule has no enabled actions",
_ => $"Rule did not match: {reason}"
};
}
private static IReadOnlyList FilterRules(
IReadOnlyList rules,
ImmutableArray ruleIds)
{
if (ruleIds.IsDefaultOrEmpty)
{
return rules.Where(r => r.Enabled).ToList();
}
var ruleIdSet = ruleIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
return rules.Where(r => ruleIdSet.Contains(r.RuleId)).ToList();
}
private async Task> LoadChannelsAsync(
string tenantId,
IReadOnlyList rules,
CancellationToken cancellationToken)
{
var channelIds = rules
.SelectMany(r => r.Actions)
.Where(a => !string.IsNullOrWhiteSpace(a.Channel))
.Select(a => a.Channel!.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var channels = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var channelId in channelIds)
{
var channel = await _channelRepository.GetAsync(tenantId, channelId, cancellationToken).ConfigureAwait(false);
if (channel is not null)
{
channels[channelId] = channel;
}
}
return channels;
}
private static IReadOnlyList ConvertAuditEntriesToEvents(
IReadOnlyList auditEntries,
DateTimeOffset periodStart,
DateTimeOffset periodEnd,
ImmutableArray eventKinds)
{
var kindSet = eventKinds.IsDefaultOrEmpty
? null
: eventKinds.ToHashSet(StringComparer.OrdinalIgnoreCase);
var events = new List();
foreach (var entry in auditEntries)
{
if (entry.Timestamp < periodStart || entry.Timestamp >= periodEnd)
{
continue;
}
var eventKind = ExtractEventKindFromAuditEntry(entry);
if (string.IsNullOrWhiteSpace(eventKind))
{
continue;
}
if (kindSet is not null && !kindSet.Contains(eventKind))
{
continue;
}
var eventId = ExtractEventIdFromAuditEntry(entry);
var payload = ToPayload(entry.Data);
var @event = NotifyEvent.Create(
eventId: eventId,
kind: eventKind,
tenant: entry.TenantId,
ts: entry.Timestamp,
payload: payload);
events.Add(@event);
}
return events;
}
private static string? ExtractEventKindFromAuditEntry(NotifyAuditEntry entry)
{
// The event kind might be encoded in the action field or payload
// Action format is typically "event.kind.action" or we look in payload
var action = entry.Action;
// Try to extract from action (e.g., "pack.approval.ingested" -> "pack.approval")
if (!string.IsNullOrWhiteSpace(action))
{
var parts = action.Split('.', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
return string.Join(".", parts.Take(parts.Length - 1));
}
}
// Try to extract from payload
if (entry.Data.TryGetValue("Kind", out var kind) ||
entry.Data.TryGetValue("kind", out kind))
{
return kind;
}
return null;
}
private static Guid ExtractEventIdFromAuditEntry(NotifyAuditEntry entry)
{
if (entry.Data.TryGetValue("eventId", out var eventId) &&
Guid.TryParse(eventId, out var parsed))
{
return parsed;
}
return Guid.NewGuid();
}
private static JsonNode ToPayload(IReadOnlyDictionary data)
{
var obj = new JsonObject();
foreach (var (key, value) in data)
{
obj[key] = value;
}
return obj;
}
private static NotifyEvent ParseEventFromPayload(string tenantId, JsonObject payload)
{
var eventId = payload.TryGetPropertyValue("eventId", out var idNode) && idNode is JsonValue idValue
? (Guid.TryParse(idValue.ToString(), out var id) ? id : Guid.NewGuid())
: Guid.NewGuid();
var kind = payload.TryGetPropertyValue("kind", out var kindNode) && kindNode is JsonValue kindValue
? kindValue.ToString()
: "simulation.test";
var ts = payload.TryGetPropertyValue("ts", out var tsNode) && tsNode is JsonValue tsValue
&& DateTimeOffset.TryParse(tsValue.ToString(), out var timestamp)
? timestamp
: DateTimeOffset.UtcNow;
var eventPayload = payload.TryGetPropertyValue("payload", out var payloadNode)
? payloadNode
: payload;
NotifyEventScope? scope = null;
if (payload.TryGetPropertyValue("scope", out var scopeNode) && scopeNode is JsonObject scopeObj)
{
scope = NotifyEventScope.Create(
@namespace: GetStringProperty(scopeObj, "namespace"),
repo: GetStringProperty(scopeObj, "repo"),
digest: GetStringProperty(scopeObj, "digest"),
component: GetStringProperty(scopeObj, "component"),
image: GetStringProperty(scopeObj, "image"));
}
var attributes = ImmutableDictionary.Empty;
if (payload.TryGetPropertyValue("attributes", out var attrNode) && attrNode is JsonObject attrObj)
{
var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal);
foreach (var prop in attrObj)
{
if (prop.Value is JsonValue value)
{
builder[prop.Key] = value.ToString();
}
}
attributes = builder.ToImmutable();
}
return NotifyEvent.Create(
eventId: eventId,
kind: kind,
tenant: tenantId,
ts: ts,
payload: eventPayload,
scope: scope,
attributes: attributes);
}
private static string? GetStringProperty(JsonObject obj, string name)
{
return obj.TryGetPropertyValue(name, out var node) && node is JsonValue value
? value.ToString()
: null;
}
private sealed class RuleSummaryBuilder
{
private readonly NotifyRule _rule;
private int _matchCount;
private int _actionCount;
private readonly Dictionary _nonMatchReasons = new(StringComparer.Ordinal);
public RuleSummaryBuilder(NotifyRule rule)
{
_rule = rule;
}
public void RecordMatch(int actions)
{
_matchCount++;
_actionCount += actions;
}
public void RecordNonMatch(string reason)
{
_nonMatchReasons.TryGetValue(reason, out var count);
_nonMatchReasons[reason] = count + 1;
}
public SimulatedRuleSummary Build()
{
return new SimulatedRuleSummary
{
RuleId = _rule.RuleId,
RuleName = _rule.Name ?? _rule.RuleId,
MatchCount = _matchCount,
ActionCount = _actionCount,
NonMatchReasons = _nonMatchReasons.ToImmutableDictionary()
};
}
}
}