up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 07:46:56 +02:00
parent d63af51f84
commit ea970ead2a
302 changed files with 43161 additions and 1534 deletions

View File

@@ -0,0 +1,16 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request for creating or updating a channel.
/// </summary>
public sealed record ChannelUpsertRequest
{
public string? Name { get; init; }
public NotifyChannelType? Type { get; init; }
public string? Endpoint { get; init; }
public string? Target { get; init; }
public string? SecretRef { get; init; }
public string? Description { get; init; }
}

View File

@@ -0,0 +1,149 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create/update an escalation policy.
/// </summary>
public sealed record EscalationPolicyUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public ImmutableArray<EscalationLevelRequest> Levels { get; init; }
public int? RepeatCount { get; init; }
public bool? Enabled { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Escalation level configuration.
/// </summary>
public sealed record EscalationLevelRequest
{
public int Order { get; init; }
public TimeSpan EscalateAfter { get; init; }
public ImmutableArray<EscalationTargetRequest> Targets { get; init; }
}
/// <summary>
/// Escalation target configuration.
/// </summary>
public sealed record EscalationTargetRequest
{
public string? Type { get; init; }
public string? TargetId { get; init; }
}
/// <summary>
/// Request to start an escalation for an incident.
/// </summary>
public sealed record StartEscalationRequest
{
public string? IncidentId { get; init; }
public string? PolicyId { get; init; }
}
/// <summary>
/// Request to acknowledge an escalation.
/// </summary>
public sealed record AcknowledgeEscalationRequest
{
public string? StateIdOrIncidentId { get; init; }
public string? AcknowledgedBy { get; init; }
}
/// <summary>
/// Request to resolve an escalation.
/// </summary>
public sealed record ResolveEscalationRequest
{
public string? StateIdOrIncidentId { get; init; }
public string? ResolvedBy { get; init; }
}
/// <summary>
/// Request to create/update an on-call schedule.
/// </summary>
public sealed record OnCallScheduleUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public string? TimeZone { get; init; }
public ImmutableArray<OnCallLayerRequest> Layers { get; init; }
public bool? Enabled { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// On-call layer configuration.
/// </summary>
public sealed record OnCallLayerRequest
{
public string? LayerId { get; init; }
public string? Name { get; init; }
public int Priority { get; init; }
public DateTimeOffset RotationStartsAt { get; init; }
public TimeSpan RotationInterval { get; init; }
public ImmutableArray<OnCallParticipantRequest> Participants { get; init; }
public OnCallRestrictionRequest? Restrictions { get; init; }
}
/// <summary>
/// On-call participant configuration.
/// </summary>
public sealed record OnCallParticipantRequest
{
public string? UserId { get; init; }
public string? Name { get; init; }
public string? Email { get; init; }
public ImmutableArray<ContactMethodRequest> ContactMethods { get; init; }
}
/// <summary>
/// Contact method configuration.
/// </summary>
public sealed record ContactMethodRequest
{
public string? Type { get; init; }
public string? Address { get; init; }
}
/// <summary>
/// On-call restriction configuration.
/// </summary>
public sealed record OnCallRestrictionRequest
{
public string? Type { get; init; }
public ImmutableArray<TimeRangeRequest> TimeRanges { get; init; }
}
/// <summary>
/// Time range for on-call restrictions.
/// </summary>
public sealed record TimeRangeRequest
{
public TimeOnly StartTime { get; init; }
public TimeOnly EndTime { get; init; }
public DayOfWeek? DayOfWeek { get; init; }
}
/// <summary>
/// Request to add an on-call override.
/// </summary>
public sealed record OnCallOverrideRequest
{
public string? UserId { get; init; }
public DateTimeOffset StartsAt { get; init; }
public DateTimeOffset EndsAt { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// Request to resolve who is on-call.
/// </summary>
public sealed record OnCallResolveRequest
{
public DateTimeOffset? EvaluationTime { get; init; }
}

View File

@@ -0,0 +1,45 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create/update a localization bundle.
/// </summary>
public sealed record LocalizationBundleUpsertRequest
{
public string? Locale { get; init; }
public string? BundleKey { get; init; }
public IReadOnlyDictionary<string, string>? Strings { get; init; }
public bool? IsDefault { get; init; }
public string? ParentLocale { get; init; }
public string? Description { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to resolve localized strings.
/// </summary>
public sealed record LocalizationResolveRequest
{
public string? BundleKey { get; init; }
public IReadOnlyList<string>? StringKeys { get; init; }
public string? Locale { get; init; }
}
/// <summary>
/// Response containing resolved localized strings.
/// </summary>
public sealed record LocalizationResolveResponse
{
public required IReadOnlyDictionary<string, LocalizedStringResult> Strings { get; init; }
public required string RequestedLocale { get; init; }
public required IReadOnlyList<string> FallbackChain { get; init; }
}
/// <summary>
/// Result for a single localized string.
/// </summary>
public sealed record LocalizedStringResult
{
public required string Value { get; init; }
public required string ResolvedLocale { get; init; }
public required bool UsedFallback { get; init; }
}

View File

@@ -0,0 +1,60 @@
using System.Collections.Immutable;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create or update a quiet hours schedule.
/// </summary>
public sealed class QuietHoursUpsertRequest
{
public required string Name { get; init; }
public required string CronExpression { get; init; }
public required TimeSpan Duration { get; init; }
public required string TimeZone { get; init; }
public string? ChannelId { get; init; }
public bool? Enabled { get; init; }
public string? Description { get; init; }
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create or update a maintenance window.
/// </summary>
public sealed class MaintenanceWindowUpsertRequest
{
public required string Name { get; init; }
public required DateTimeOffset StartsAt { get; init; }
public required DateTimeOffset EndsAt { get; init; }
public bool? SuppressNotifications { get; init; }
public string? Reason { get; init; }
public ImmutableArray<string> ChannelIds { get; init; } = [];
public ImmutableArray<string> RuleIds { get; init; } = [];
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create or update a throttle configuration.
/// </summary>
public sealed class ThrottleConfigUpsertRequest
{
public required string Name { get; init; }
public required TimeSpan DefaultWindow { get; init; }
public int? MaxNotificationsPerWindow { get; init; }
public string? ChannelId { get; init; }
public bool? IsDefault { get; init; }
public bool? Enabled { get; init; }
public string? Description { get; init; }
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create an operator override.
/// </summary>
public sealed class OperatorOverrideCreateRequest
{
public required string OverrideType { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public string? ChannelId { get; init; }
public string? RuleId { get; init; }
public string? Reason { get; init; }
}

View File

@@ -0,0 +1,33 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request for creating or updating a rule.
/// </summary>
public sealed record RuleUpsertRequest
{
public string? Name { get; init; }
public RuleMatchRequest? Match { get; init; }
public IReadOnlyList<RuleActionRequest>? Actions { get; init; }
public bool? Enabled { get; init; }
public string? Description { get; init; }
}
/// <summary>
/// Match criteria for a rule.
/// </summary>
public sealed record RuleMatchRequest
{
public string[]? EventKinds { get; init; }
}
/// <summary>
/// Action definition for a rule.
/// </summary>
public sealed record RuleActionRequest
{
public string? ActionId { get; init; }
public string? Channel { get; init; }
public string? Template { get; init; }
public string? Locale { get; init; }
public bool? Enabled { get; init; }
}

View File

@@ -0,0 +1,30 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to run a historical simulation against past events.
/// </summary>
public sealed class SimulationRunRequest
{
public required DateTimeOffset PeriodStart { get; init; }
public required DateTimeOffset PeriodEnd { get; init; }
public ImmutableArray<string> RuleIds { get; init; } = [];
public ImmutableArray<string> EventKinds { get; init; } = [];
public int MaxEvents { get; init; } = 1000;
public bool IncludeNonMatches { get; init; } = true;
public bool EvaluateThrottling { get; init; } = true;
public bool EvaluateQuietHours { get; init; } = true;
public DateTimeOffset? EvaluationTimestamp { get; init; }
}
/// <summary>
/// Request to simulate a single event against current rules.
/// </summary>
public sealed class SimulateSingleEventRequest
{
public required JsonObject EventPayload { get; init; }
public ImmutableArray<string> RuleIds { get; init; } = [];
public DateTimeOffset? EvaluationTimestamp { get; init; }
}

View File

@@ -0,0 +1,30 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request for creating or updating a template.
/// </summary>
public sealed record TemplateUpsertRequest
{
public string? Key { get; init; }
public string? Body { get; init; }
public string? Locale { get; init; }
public NotifyChannelType? ChannelType { get; init; }
public NotifyTemplateRenderMode? RenderMode { get; init; }
public NotifyDeliveryFormat? Format { get; init; }
public string? Description { get; init; }
public IEnumerable<KeyValuePair<string, string>>? Metadata { get; init; }
}
/// <summary>
/// Request for previewing a template render.
/// </summary>
public sealed record TemplatePreviewRequest
{
public JsonNode? SamplePayload { get; init; }
public bool? IncludeProvenance { get; init; }
public string? ProvenanceBaseUrl { get; init; }
public NotifyDeliveryFormat? FormatOverride { get; init; }
}

View File

@@ -0,0 +1,348 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Web;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Advanced template renderer with Handlebars-style syntax, format conversion, and redaction support.
/// Supports {{property}}, {{#each}}, {{#if}}, and format-specific output (Markdown/HTML/JSON/PlainText).
/// </summary>
public sealed partial class AdvancedTemplateRenderer : INotifyTemplateRenderer
{
private static readonly Regex PlaceholderPattern = PlaceholderRegex();
private static readonly Regex EachBlockPattern = EachBlockRegex();
private static readonly Regex IfBlockPattern = IfBlockRegex();
private static readonly Regex ElseBlockPattern = ElseBlockRegex();
private readonly ILogger<AdvancedTemplateRenderer> _logger;
public AdvancedTemplateRenderer(ILogger<AdvancedTemplateRenderer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null)
{
ArgumentNullException.ThrowIfNull(template);
var body = template.Body;
if (string.IsNullOrWhiteSpace(body))
{
return string.Empty;
}
options ??= new TemplateRenderOptions();
try
{
// Process conditional blocks first
body = ProcessIfBlocks(body, payload);
// Process {{#each}} blocks
body = ProcessEachBlocks(body, payload);
// Substitute simple placeholders
body = SubstitutePlaceholders(body, payload);
// Convert to target format based on render mode
body = ConvertToTargetFormat(body, template.RenderMode, options.FormatOverride ?? template.Format);
// Append provenance link if requested
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
{
body = AppendProvenanceLink(body, template, options.ProvenanceBaseUrl);
}
return body;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Template rendering failed for {TemplateId}.", template.TemplateId);
return $"[Render Error: {ex.Message}]";
}
}
private static string ProcessIfBlocks(string body, JsonNode? payload)
{
// Process {{#if condition}}...{{else}}...{{/if}} blocks
return IfBlockPattern.Replace(body, match =>
{
var conditionPath = match.Groups[1].Value.Trim();
var ifContent = match.Groups[2].Value;
var elseMatch = ElseBlockPattern.Match(ifContent);
string trueContent;
string falseContent;
if (elseMatch.Success)
{
trueContent = ifContent[..elseMatch.Index];
falseContent = elseMatch.Groups[1].Value;
}
else
{
trueContent = ifContent;
falseContent = string.Empty;
}
var conditionValue = ResolvePath(payload, conditionPath);
var isTruthy = EvaluateTruthy(conditionValue);
return isTruthy ? trueContent : falseContent;
});
}
private static bool EvaluateTruthy(JsonNode? value)
{
if (value is null)
{
return false;
}
return value switch
{
JsonValue jv when jv.TryGetValue(out bool b) => b,
JsonValue jv when jv.TryGetValue(out string? s) => !string.IsNullOrEmpty(s),
JsonValue jv when jv.TryGetValue(out int i) => i != 0,
JsonValue jv when jv.TryGetValue(out double d) => d != 0.0,
JsonArray arr => arr.Count > 0,
JsonObject obj => obj.Count > 0,
_ => true
};
}
private static string ProcessEachBlocks(string body, JsonNode? payload)
{
return EachBlockPattern.Replace(body, match =>
{
var collectionPath = match.Groups[1].Value.Trim();
var innerTemplate = match.Groups[2].Value;
var collection = ResolvePath(payload, collectionPath);
if (collection is JsonArray arr)
{
var results = new List<string>();
var index = 0;
foreach (var item in arr)
{
var itemResult = innerTemplate
.Replace("{{@index}}", index.ToString())
.Replace("{{this}}", item?.ToString() ?? string.Empty);
// Also substitute nested properties from item
if (item is JsonObject itemObj)
{
itemResult = SubstitutePlaceholders(itemResult, itemObj);
}
results.Add(itemResult);
index++;
}
return string.Join(string.Empty, results);
}
if (collection is JsonObject obj)
{
var results = new List<string>();
foreach (var (key, value) in obj)
{
var itemResult = innerTemplate
.Replace("{{@key}}", key)
.Replace("{{this}}", value?.ToString() ?? string.Empty);
results.Add(itemResult);
}
return string.Join(string.Empty, results);
}
return string.Empty;
});
}
private static string SubstitutePlaceholders(string body, JsonNode? payload)
{
return PlaceholderPattern.Replace(body, match =>
{
var path = match.Groups[1].Value.Trim();
var resolved = ResolvePath(payload, path);
return resolved?.ToString() ?? string.Empty;
});
}
private static JsonNode? ResolvePath(JsonNode? root, string path)
{
if (root is null || string.IsNullOrWhiteSpace(path))
{
return null;
}
var segments = path.Split('.');
var current = root;
foreach (var segment in segments)
{
if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next))
{
current = next;
}
else if (current is JsonArray arr && int.TryParse(segment, out var index) && index >= 0 && index < arr.Count)
{
current = arr[index];
}
else
{
return null;
}
}
return current;
}
private string ConvertToTargetFormat(string body, NotifyTemplateRenderMode sourceMode, NotifyDeliveryFormat targetFormat)
{
// If source is already in the target format family, return as-is
if (sourceMode == NotifyTemplateRenderMode.Json && targetFormat == NotifyDeliveryFormat.Json)
{
return body;
}
return targetFormat switch
{
NotifyDeliveryFormat.Json => ConvertToJson(body, sourceMode),
NotifyDeliveryFormat.Slack => ConvertToSlack(body, sourceMode),
NotifyDeliveryFormat.Teams => ConvertToTeams(body, sourceMode),
NotifyDeliveryFormat.Email => ConvertToEmail(body, sourceMode),
NotifyDeliveryFormat.Webhook => body, // Pass through as-is
_ => body
};
}
private static string ConvertToJson(string body, NotifyTemplateRenderMode sourceMode)
{
// Wrap content in a JSON structure
var content = new JsonObject
{
["content"] = body,
["format"] = sourceMode.ToString()
};
return content.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
}
private static string ConvertToSlack(string body, NotifyTemplateRenderMode sourceMode)
{
// Convert Markdown to Slack mrkdwn format
if (sourceMode == NotifyTemplateRenderMode.Markdown)
{
// Slack uses similar markdown but with some differences
// Convert **bold** to *bold* for Slack
body = Regex.Replace(body, @"\*\*(.+?)\*\*", "*$1*");
}
return body;
}
private static string ConvertToTeams(string body, NotifyTemplateRenderMode sourceMode)
{
// Teams uses Adaptive Cards or MessageCard format
// For simple conversion, wrap in basic card structure
if (sourceMode == NotifyTemplateRenderMode.Markdown ||
sourceMode == NotifyTemplateRenderMode.PlainText)
{
var card = new JsonObject
{
["@type"] = "MessageCard",
["@context"] = "http://schema.org/extensions",
["summary"] = "Notification",
["sections"] = new JsonArray
{
new JsonObject
{
["text"] = body
}
}
};
return card.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
}
return body;
}
private static string ConvertToEmail(string body, NotifyTemplateRenderMode sourceMode)
{
if (sourceMode == NotifyTemplateRenderMode.Markdown)
{
// Basic Markdown to HTML conversion for email
return ConvertMarkdownToHtml(body);
}
if (sourceMode == NotifyTemplateRenderMode.PlainText)
{
// Wrap plain text in basic HTML structure
return $"<html><body><pre>{HttpUtility.HtmlEncode(body)}</pre></body></html>";
}
return body;
}
private static string ConvertMarkdownToHtml(string markdown)
{
var html = new StringBuilder(markdown);
// Headers
html.Replace("\n### ", "\n<h3>");
html.Replace("\n## ", "\n<h2>");
html.Replace("\n# ", "\n<h1>");
// Bold
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*\*(.+?)\*\*", "<strong>$1</strong>"));
// Italic
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*(.+?)\*", "<em>$1</em>"));
// Code
html = new StringBuilder(Regex.Replace(html.ToString(), @"`(.+?)`", "<code>$1</code>"));
// Links
html = new StringBuilder(Regex.Replace(html.ToString(), @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>"));
// Line breaks
html.Replace("\n\n", "</p><p>");
html.Replace("\n", "<br/>");
return $"<html><body><p>{html}</p></body></html>";
}
private static string AppendProvenanceLink(string body, NotifyTemplate template, string baseUrl)
{
var provenanceUrl = $"{baseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
return template.RenderMode switch
{
NotifyTemplateRenderMode.Markdown => $"{body}\n\n---\n_Template: [{template.Key}]({provenanceUrl})_",
NotifyTemplateRenderMode.Html => $"{body}<hr/><p><small>Template: <a href=\"{provenanceUrl}\">{template.Key}</a></small></p>",
NotifyTemplateRenderMode.PlainText => $"{body}\n\n---\nTemplate: {template.Key} ({provenanceUrl})",
_ => body
};
}
[GeneratedRegex(@"\{\{([^#/}]+)\}\}", RegexOptions.Compiled)]
private static partial Regex PlaceholderRegex();
[GeneratedRegex(@"\{\{#each\s+([^}]+)\}\}(.*?)\{\{/each\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex EachBlockRegex();
[GeneratedRegex(@"\{\{#if\s+([^}]+)\}\}(.*?)\{\{/if\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex IfBlockRegex();
[GeneratedRegex(@"\{\{else\}\}(.*)", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex ElseBlockRegex();
}

View File

@@ -0,0 +1,201 @@
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Default implementation of ILocalizationResolver with hierarchical fallback chain.
/// </summary>
public sealed class DefaultLocalizationResolver : ILocalizationResolver
{
private const string DefaultLocale = "en-us";
private const string DefaultLanguage = "en";
private readonly INotifyLocalizationRepository _repository;
private readonly ILogger<DefaultLocalizationResolver> _logger;
public DefaultLocalizationResolver(
INotifyLocalizationRepository repository,
ILogger<DefaultLocalizationResolver> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<LocalizedString?> ResolveAsync(
string tenantId,
string bundleKey,
string stringKey,
string locale,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
ArgumentException.ThrowIfNullOrWhiteSpace(stringKey);
locale = NormalizeLocale(locale);
var fallbackChain = BuildFallbackChain(locale);
foreach (var tryLocale in fallbackChain)
{
var bundle = await _repository.GetByKeyAndLocaleAsync(
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
if (bundle is null)
{
continue;
}
var value = bundle.GetString(stringKey);
if (value is not null)
{
_logger.LogDebug(
"Resolved string '{StringKey}' from bundle '{BundleKey}' locale '{ResolvedLocale}' (requested: {RequestedLocale})",
stringKey, bundleKey, tryLocale, locale);
return new LocalizedString
{
Value = value,
ResolvedLocale = tryLocale,
RequestedLocale = locale,
FallbackChain = fallbackChain
};
}
}
// Try the default bundle
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
.ConfigureAwait(false);
if (defaultBundle is not null)
{
var value = defaultBundle.GetString(stringKey);
if (value is not null)
{
_logger.LogDebug(
"Resolved string '{StringKey}' from default bundle '{BundleKey}' locale '{ResolvedLocale}'",
stringKey, bundleKey, defaultBundle.Locale);
return new LocalizedString
{
Value = value,
ResolvedLocale = defaultBundle.Locale,
RequestedLocale = locale,
FallbackChain = fallbackChain.Append(defaultBundle.Locale).Distinct().ToArray()
};
}
}
_logger.LogWarning(
"String '{StringKey}' not found in bundle '{BundleKey}' for any locale in chain: {FallbackChain}",
stringKey, bundleKey, string.Join(" -> ", fallbackChain));
return null;
}
public async Task<IReadOnlyDictionary<string, LocalizedString>> ResolveBatchAsync(
string tenantId,
string bundleKey,
IEnumerable<string> stringKeys,
string locale,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
ArgumentNullException.ThrowIfNull(stringKeys);
locale = NormalizeLocale(locale);
var fallbackChain = BuildFallbackChain(locale);
var keysToResolve = new HashSet<string>(stringKeys, StringComparer.Ordinal);
var results = new Dictionary<string, LocalizedString>(StringComparer.Ordinal);
// Load all bundles in the fallback chain
var bundles = new List<NotifyLocalizationBundle>();
foreach (var tryLocale in fallbackChain)
{
var bundle = await _repository.GetByKeyAndLocaleAsync(
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
if (bundle is not null)
{
bundles.Add(bundle);
}
}
// Add default bundle
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
.ConfigureAwait(false);
if (defaultBundle is not null && !bundles.Any(b => b.BundleId == defaultBundle.BundleId))
{
bundles.Add(defaultBundle);
}
// Resolve each key through the bundles
foreach (var key in keysToResolve)
{
foreach (var bundle in bundles)
{
var value = bundle.GetString(key);
if (value is not null)
{
results[key] = new LocalizedString
{
Value = value,
ResolvedLocale = bundle.Locale,
RequestedLocale = locale,
FallbackChain = fallbackChain
};
break;
}
}
}
return results;
}
/// <summary>
/// Builds a fallback chain for the given locale.
/// Example: "pt-br" -> ["pt-br", "pt", "en-us", "en"]
/// </summary>
private static IReadOnlyList<string> BuildFallbackChain(string locale)
{
var chain = new List<string> { locale };
// Add language-only fallback (e.g., "pt" from "pt-br")
var dashIndex = locale.IndexOf('-');
if (dashIndex > 0)
{
var languageOnly = locale[..dashIndex];
if (!chain.Contains(languageOnly, StringComparer.OrdinalIgnoreCase))
{
chain.Add(languageOnly);
}
}
// Add default locale if not already in chain
if (!chain.Contains(DefaultLocale, StringComparer.OrdinalIgnoreCase))
{
chain.Add(DefaultLocale);
}
// Add default language if not already in chain
if (!chain.Contains(DefaultLanguage, StringComparer.OrdinalIgnoreCase))
{
chain.Add(DefaultLanguage);
}
return chain;
}
private static string NormalizeLocale(string? locale)
{
if (string.IsNullOrWhiteSpace(locale))
{
return DefaultLocale;
}
return locale.ToLowerInvariant().Trim();
}
}

View File

@@ -0,0 +1,15 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Template renderer with support for render options, format conversion, and redaction.
/// </summary>
public interface INotifyTemplateRenderer
{
/// <summary>
/// Renders a template with the given payload and options.
/// </summary>
string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null);
}

View File

@@ -0,0 +1,102 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Application-level service for managing versioned templates with localization support.
/// </summary>
public interface INotifyTemplateService
{
/// <summary>
/// Gets a template by key and locale, falling back to the default locale if not found.
/// </summary>
Task<NotifyTemplate?> GetByKeyAsync(
string tenantId,
string key,
string locale,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific template by ID.
/// </summary>
Task<NotifyTemplate?> GetByIdAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all templates for a tenant, optionally filtered.
/// </summary>
Task<IReadOnlyList<NotifyTemplate>> ListAsync(
string tenantId,
string? keyPrefix = null,
string? locale = null,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates or updates a template with version tracking.
/// </summary>
Task<NotifyTemplate> UpsertAsync(
NotifyTemplate template,
string updatedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a template.
/// </summary>
Task DeleteAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// Renders a template preview with sample payload (no persistence).
/// </summary>
Task<TemplatePreviewResult> PreviewAsync(
NotifyTemplate template,
JsonNode? samplePayload,
TemplateRenderOptions? options = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a template preview render.
/// </summary>
public sealed record TemplatePreviewResult
{
public required string RenderedBody { get; init; }
public required string? RenderedSubject { get; init; }
public required NotifyTemplateRenderMode RenderMode { get; init; }
public required NotifyDeliveryFormat Format { get; init; }
public IReadOnlyList<string> RedactedFields { get; init; } = [];
public string? ProvenanceLink { get; init; }
}
/// <summary>
/// Options for template rendering.
/// </summary>
public sealed record TemplateRenderOptions
{
/// <summary>
/// Fields to redact from the output (dot-notation paths).
/// </summary>
public IReadOnlySet<string>? RedactionAllowlist { get; init; }
/// <summary>
/// Whether to include provenance links in output.
/// </summary>
public bool IncludeProvenance { get; init; } = true;
/// <summary>
/// Base URL for provenance links.
/// </summary>
public string? ProvenanceBaseUrl { get; init; }
/// <summary>
/// Target format override.
/// </summary>
public NotifyDeliveryFormat? FormatOverride { get; init; }
}

View File

@@ -0,0 +1,273 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Default implementation of INotifyTemplateService with locale fallback and version tracking.
/// </summary>
public sealed class NotifyTemplateService : INotifyTemplateService
{
private const string DefaultLocale = "en-us";
private readonly INotifyTemplateRepository _repository;
private readonly INotifyTemplateRenderer _renderer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifyTemplateService> _logger;
public NotifyTemplateService(
INotifyTemplateRepository repository,
INotifyTemplateRenderer renderer,
TimeProvider timeProvider,
ILogger<NotifyTemplateService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<NotifyTemplate?> GetByKeyAsync(
string tenantId,
string key,
string locale,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(key);
locale = NormalizeLocale(locale);
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
// Filter by key
var matching = allTemplates.Where(t => t.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
// Filter by channel type if specified
if (channelType.HasValue)
{
matching = matching.Where(t => t.ChannelType == channelType.Value);
}
var candidates = matching.ToArray();
// Try exact locale match
var exactMatch = candidates.FirstOrDefault(t =>
t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase));
if (exactMatch is not null)
{
return exactMatch;
}
// Try language-only match (e.g., "en" from "en-us")
var languageCode = locale.Split('-')[0];
var languageMatch = candidates.FirstOrDefault(t =>
t.Locale.StartsWith(languageCode, StringComparison.OrdinalIgnoreCase));
if (languageMatch is not null)
{
_logger.LogDebug("Template {Key} not found for locale {Locale}, using {FallbackLocale}.",
key, locale, languageMatch.Locale);
return languageMatch;
}
// Fall back to default locale
var defaultMatch = candidates.FirstOrDefault(t =>
t.Locale.Equals(DefaultLocale, StringComparison.OrdinalIgnoreCase));
if (defaultMatch is not null)
{
_logger.LogDebug("Template {Key} not found for locale {Locale}, using default locale.",
key, locale);
return defaultMatch;
}
// Return any available template for the key
return candidates.FirstOrDefault();
}
public Task<NotifyTemplate?> GetByIdAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
return _repository.GetAsync(tenantId, templateId, cancellationToken);
}
public async Task<IReadOnlyList<NotifyTemplate>> ListAsync(
string tenantId,
string? keyPrefix = null,
string? locale = null,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
IEnumerable<NotifyTemplate> filtered = allTemplates;
if (!string.IsNullOrWhiteSpace(keyPrefix))
{
filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(locale))
{
var normalizedLocale = NormalizeLocale(locale);
filtered = filtered.Where(t => t.Locale.Equals(normalizedLocale, StringComparison.OrdinalIgnoreCase));
}
if (channelType.HasValue)
{
filtered = filtered.Where(t => t.ChannelType == channelType.Value);
}
return filtered.OrderBy(t => t.Key).ThenBy(t => t.Locale).ToArray();
}
public async Task<NotifyTemplate> UpsertAsync(
NotifyTemplate template,
string updatedBy,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentException.ThrowIfNullOrWhiteSpace(updatedBy);
var now = _timeProvider.GetUtcNow();
// Check for existing template to preserve creation metadata
var existing = await _repository.GetAsync(template.TenantId, template.TemplateId, cancellationToken)
.ConfigureAwait(false);
var updatedTemplate = NotifyTemplate.Create(
templateId: template.TemplateId,
tenantId: template.TenantId,
channelType: template.ChannelType,
key: template.Key,
locale: template.Locale,
body: template.Body,
renderMode: template.RenderMode,
format: template.Format,
description: template.Description,
metadata: template.Metadata,
createdBy: existing?.CreatedBy ?? updatedBy,
createdAt: existing?.CreatedAt ?? now,
updatedBy: updatedBy,
updatedAt: now);
await _repository.UpsertAsync(updatedTemplate, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Template {TemplateId} (key={Key}, locale={Locale}) upserted by {UpdatedBy}.",
updatedTemplate.TemplateId, updatedTemplate.Key, updatedTemplate.Locale, updatedBy);
return updatedTemplate;
}
public async Task DeleteAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
await _repository.DeleteAsync(tenantId, templateId, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Template {TemplateId} deleted from tenant {TenantId}.", templateId, tenantId);
}
public Task<TemplatePreviewResult> PreviewAsync(
NotifyTemplate template,
JsonNode? samplePayload,
TemplateRenderOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
options ??= new TemplateRenderOptions();
// Apply redaction to payload if allowlist is specified
var redactedFields = new List<string>();
var processedPayload = samplePayload;
if (options.RedactionAllowlist is { Count: > 0 })
{
processedPayload = ApplyRedaction(samplePayload, options.RedactionAllowlist, redactedFields);
}
// Render body
var renderedBody = _renderer.Render(template, processedPayload, options);
// Render subject if present in metadata
string? renderedSubject = null;
if (template.Metadata.TryGetValue("subject", out var subjectTemplate))
{
var subjectTemplateObj = NotifyTemplate.Create(
templateId: "subject-preview",
tenantId: template.TenantId,
channelType: template.ChannelType,
key: "subject",
locale: template.Locale,
body: subjectTemplate);
renderedSubject = _renderer.Render(subjectTemplateObj, processedPayload, options);
}
// Build provenance link if requested
string? provenanceLink = null;
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
{
provenanceLink = $"{options.ProvenanceBaseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
}
var result = new TemplatePreviewResult
{
RenderedBody = renderedBody,
RenderedSubject = renderedSubject,
RenderMode = template.RenderMode,
Format = options.FormatOverride ?? template.Format,
RedactedFields = redactedFields,
ProvenanceLink = provenanceLink
};
return Task.FromResult(result);
}
private static JsonNode? ApplyRedaction(JsonNode? payload, IReadOnlySet<string> allowlist, List<string> redactedFields)
{
if (payload is not JsonObject obj)
{
return payload;
}
var result = new JsonObject();
foreach (var (key, value) in obj)
{
if (allowlist.Contains(key))
{
result[key] = value?.DeepClone();
}
else
{
result[key] = "[REDACTED]";
redactedFields.Add(key);
}
}
return result;
}
private static string NormalizeLocale(string? locale)
{
return string.IsNullOrWhiteSpace(locale) ? DefaultLocale : locale.ToLowerInvariant();
}
}

View File

@@ -121,12 +121,12 @@ public sealed class AttestationTemplateSeeder : IHostedService
var rulesElement = doc.RootElement.GetProperty("rules");
var channels = channelsElement.EnumerateArray()
.Select(ToChannel)
.Select(el => ToChannel(el, tenant))
.ToArray();
foreach (var channel in channels)
{
await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false);
await channelRepository.UpsertAsync(channel, cancellationToken).ConfigureAwait(false);
}
foreach (var rule in rulesElement.EnumerateArray())
@@ -162,7 +162,7 @@ public sealed class AttestationTemplateSeeder : IHostedService
description: "Seeded attestation routing rule.");
}
private static NotifyChannel ToChannel(JsonElement element)
private static NotifyChannel ToChannel(JsonElement element, string tenantOverride)
{
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
@@ -178,7 +178,7 @@ public sealed class AttestationTemplateSeeder : IHostedService
return NotifyChannel.Create(
channelId: channelId,
tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap",
tenantId: tenantOverride,
name: name,
type: type,
config: config,

View File

@@ -121,12 +121,12 @@ public sealed class RiskTemplateSeeder : IHostedService
var rulesElement = doc.RootElement.GetProperty("rules");
var channels = channelsElement.EnumerateArray()
.Select(ToChannel)
.Select(el => ToChannel(el, tenant))
.ToArray();
foreach (var channel in channels)
{
await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false);
await channelRepository.UpsertAsync(channel, cancellationToken).ConfigureAwait(false);
}
foreach (var rule in rulesElement.EnumerateArray())
@@ -164,7 +164,7 @@ public sealed class RiskTemplateSeeder : IHostedService
description: "Seeded risk routing rule.");
}
private static NotifyChannel ToChannel(JsonElement element)
private static NotifyChannel ToChannel(JsonElement element, string tenantOverride)
{
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
@@ -180,7 +180,7 @@ public sealed class RiskTemplateSeeder : IHostedService
return NotifyChannel.Create(
channelId: channelId,
tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap",
tenantId: tenantOverride,
name: name,
type: type,
config: config,

View File

@@ -11,5 +11,7 @@
<ItemGroup>
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
<ProjectReference Include="../StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,190 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Channel adapter for CLI-based notification delivery.
/// Executes a configured command-line tool with notification payload as input.
/// Useful for custom integrations and local testing.
/// </summary>
public sealed class CliChannelAdapter : INotifyChannelAdapter
{
private readonly ILogger<CliChannelAdapter> _logger;
private readonly TimeSpan _commandTimeout;
public CliChannelAdapter(ILogger<CliChannelAdapter> logger, TimeSpan? commandTimeout = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_commandTimeout = commandTimeout ?? TimeSpan.FromSeconds(30);
}
public NotifyChannelType ChannelType => NotifyChannelType.Cli;
public async Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(rendered);
var command = channel.Config?.Endpoint;
if (string.IsNullOrWhiteSpace(command))
{
return ChannelDispatchResult.Fail("CLI command not configured in endpoint", shouldRetry: false);
}
// Parse command and arguments
var (executable, arguments) = ParseCommand(command);
if (string.IsNullOrWhiteSpace(executable))
{
return ChannelDispatchResult.Fail("Invalid CLI command format", shouldRetry: false);
}
// Build JSON payload to send via stdin
var payload = new
{
bodyHash = rendered.BodyHash,
channel = rendered.ChannelType.ToString(),
target = rendered.Target,
title = rendered.Title,
body = rendered.Body,
summary = rendered.Summary,
textBody = rendered.TextBody,
format = rendered.Format.ToString(),
locale = rendered.Locale,
timestamp = DateTimeOffset.UtcNow.ToString("O"),
channelConfig = new
{
channelId = channel.ChannelId,
name = channel.Name,
properties = channel.Config?.Properties
}
};
var jsonPayload = JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_commandTimeout);
var startInfo = new ProcessStartInfo
{
FileName = executable,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardInputEncoding = Encoding.UTF8,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
// Add environment variables from channel config
if (channel.Config?.Properties is not null)
{
foreach (var kv in channel.Config.Properties)
{
if (kv.Key.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var envVar = kv.Key[4..];
startInfo.EnvironmentVariables[envVar] = kv.Value;
}
}
}
using var process = new Process { StartInfo = startInfo };
_logger.LogDebug("Starting CLI command: {Executable} {Arguments}", executable, arguments);
process.Start();
// Write payload to stdin
await process.StandardInput.WriteAsync(jsonPayload).ConfigureAwait(false);
await process.StandardInput.FlushAsync().ConfigureAwait(false);
process.StandardInput.Close();
// Read output streams
var outputTask = process.StandardOutput.ReadToEndAsync(cts.Token);
var errorTask = process.StandardError.ReadToEndAsync(cts.Token);
await process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
var stdout = await outputTask.ConfigureAwait(false);
var stderr = await errorTask.ConfigureAwait(false);
if (process.ExitCode == 0)
{
_logger.LogInformation(
"CLI command executed successfully. Exit code: 0. Output: {Output}",
stdout.Length > 500 ? stdout[..500] + "..." : stdout);
return ChannelDispatchResult.Ok(process.ExitCode);
}
_logger.LogWarning(
"CLI command failed with exit code {ExitCode}. Stderr: {Stderr}",
process.ExitCode,
stderr.Length > 500 ? stderr[..500] + "..." : stderr);
// Non-zero exit codes are typically not retryable
return ChannelDispatchResult.Fail(
$"Exit code {process.ExitCode}: {stderr}",
process.ExitCode,
shouldRetry: false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
_logger.LogWarning("CLI command timed out after {Timeout}", _commandTimeout);
return ChannelDispatchResult.Fail($"Command timeout after {_commandTimeout.TotalSeconds}s", shouldRetry: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "CLI command execution failed: {Message}", ex.Message);
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: false);
}
}
private static (string executable, string arguments) ParseCommand(string command)
{
command = command.Trim();
if (string.IsNullOrEmpty(command))
return (string.Empty, string.Empty);
// Handle quoted executable paths
if (command.StartsWith('"'))
{
var endQuote = command.IndexOf('"', 1);
if (endQuote > 0)
{
var exe = command[1..endQuote];
var args = command.Length > endQuote + 1 ? command[(endQuote + 1)..].TrimStart() : string.Empty;
return (exe, args);
}
}
// Simple space-separated
var spaceIndex = command.IndexOf(' ');
if (spaceIndex > 0)
{
return (command[..spaceIndex], command[(spaceIndex + 1)..].TrimStart());
}
return (command, string.Empty);
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Channel adapter for email delivery. Requires SMTP configuration.
/// </summary>
public sealed class EmailChannelAdapter : INotifyChannelAdapter
{
private readonly ILogger<EmailChannelAdapter> _logger;
public EmailChannelAdapter(ILogger<EmailChannelAdapter> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public NotifyChannelType ChannelType => NotifyChannelType.Email;
public Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(rendered);
var target = channel.Config?.Target ?? rendered.Target;
if (string.IsNullOrWhiteSpace(target))
{
return Task.FromResult(ChannelDispatchResult.Fail(
"Email recipient not configured",
shouldRetry: false));
}
// Email delivery requires SMTP integration which depends on environment config.
// For now, log the intent and return success for dev/test scenarios.
// Production deployments should integrate with an SMTP relay or email service.
_logger.LogInformation(
"Email delivery queued: to={Recipient}, subject={Subject}, format={Format}",
target,
rendered.Title,
rendered.Format);
// In a real implementation, this would:
// 1. Resolve SMTP settings from channel.Config.SecretRef
// 2. Build and send the email via SmtpClient or a service like SendGrid
// 3. Return actual success/failure based on delivery
return Task.FromResult(ChannelDispatchResult.Ok());
}
}

View File

@@ -0,0 +1,51 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Sends rendered notifications through a specific channel type.
/// </summary>
public interface INotifyChannelAdapter
{
/// <summary>
/// The channel type this adapter handles.
/// </summary>
NotifyChannelType ChannelType { get; }
/// <summary>
/// Sends a rendered notification through the channel.
/// </summary>
/// <param name="channel">The channel configuration.</param>
/// <param name="rendered">The rendered notification content.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The dispatch result with status and any error details.</returns>
Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken);
}
/// <summary>
/// Result of a channel dispatch attempt.
/// </summary>
public sealed record ChannelDispatchResult
{
public required bool Success { get; init; }
public int? StatusCode { get; init; }
public string? Reason { get; init; }
public bool ShouldRetry { get; init; }
public static ChannelDispatchResult Ok(int? statusCode = null) => new()
{
Success = true,
StatusCode = statusCode
};
public static ChannelDispatchResult Fail(string reason, int? statusCode = null, bool shouldRetry = true) => new()
{
Success = false,
StatusCode = statusCode,
Reason = reason,
ShouldRetry = shouldRetry
};
}

View File

@@ -0,0 +1,156 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Channel adapter for in-app inbox notifications.
/// Stores notifications in the database for users to retrieve via API or WebSocket.
/// </summary>
public sealed class InAppInboxChannelAdapter : INotifyChannelAdapter
{
private readonly IInAppInboxStore _inboxStore;
private readonly ILogger<InAppInboxChannelAdapter> _logger;
public InAppInboxChannelAdapter(IInAppInboxStore inboxStore, ILogger<InAppInboxChannelAdapter> logger)
{
_inboxStore = inboxStore ?? throw new ArgumentNullException(nameof(inboxStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public NotifyChannelType ChannelType => NotifyChannelType.InAppInbox;
public async Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(rendered);
var userId = rendered.Target;
if (string.IsNullOrWhiteSpace(userId))
{
// Try to get from channel config
userId = channel.Config?.Target;
}
if (string.IsNullOrWhiteSpace(userId))
{
return ChannelDispatchResult.Fail("Target user ID not specified", shouldRetry: false);
}
var tenantId = channel.Config?.Properties.GetValueOrDefault("tenantId") ?? channel.TenantId;
var messageId = Guid.NewGuid().ToString("N");
var inboxMessage = new InAppInboxMessage
{
MessageId = messageId,
TenantId = tenantId,
UserId = userId,
Title = rendered.Title ?? "Notification",
Body = rendered.Body ?? string.Empty,
Summary = rendered.Summary,
Category = channel.Config?.Properties.GetValueOrDefault("category") ?? "general",
Priority = DeterminePriority(rendered),
Metadata = null,
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DetermineExpiry(channel),
SourceChannel = channel.ChannelId,
DeliveryId = messageId
};
try
{
await _inboxStore.StoreAsync(inboxMessage, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"In-app inbox message stored for user {UserId}. MessageId: {MessageId}",
userId,
inboxMessage.MessageId);
return ChannelDispatchResult.Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to store in-app inbox message for user {UserId}", userId);
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
}
}
private static InAppInboxPriority DeterminePriority(NotifyDeliveryRendered rendered)
{
if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true ||
rendered.Title?.Contains("urgent", StringComparison.OrdinalIgnoreCase) == true)
return InAppInboxPriority.Critical;
if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true ||
rendered.Title?.Contains("important", StringComparison.OrdinalIgnoreCase) == true)
return InAppInboxPriority.High;
if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true)
return InAppInboxPriority.Normal;
return InAppInboxPriority.Low;
}
private static DateTimeOffset? DetermineExpiry(NotifyChannel channel)
{
var ttlStr = channel.Config?.Properties.GetValueOrDefault("ttl");
if (!string.IsNullOrEmpty(ttlStr) && int.TryParse(ttlStr, out var ttlHours))
{
return DateTimeOffset.UtcNow.AddHours(ttlHours);
}
// Default 30 day expiry
return DateTimeOffset.UtcNow.AddDays(30);
}
}
/// <summary>
/// Storage interface for in-app inbox messages.
/// </summary>
public interface IInAppInboxStore
{
Task StoreAsync(InAppInboxMessage message, CancellationToken cancellationToken = default);
Task<IReadOnlyList<InAppInboxMessage>> GetForUserAsync(string tenantId, string userId, int limit = 50, CancellationToken cancellationToken = default);
Task<InAppInboxMessage?> GetAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
}
/// <summary>
/// In-app inbox message model.
/// </summary>
public sealed record InAppInboxMessage
{
public required string MessageId { get; init; }
public required string TenantId { get; init; }
public required string UserId { get; init; }
public required string Title { get; init; }
public required string Body { get; init; }
public string? Summary { get; init; }
public required string Category { get; init; }
public InAppInboxPriority Priority { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset? ReadAt { get; set; }
public bool IsRead => ReadAt.HasValue;
public string? SourceChannel { get; init; }
public string? DeliveryId { get; init; }
}
/// <summary>
/// Priority levels for in-app inbox messages.
/// </summary>
public enum InAppInboxPriority
{
Low,
Normal,
High,
Critical
}

View File

@@ -0,0 +1,101 @@
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Adapter that bridges IInAppInboxStore to INotifyInboxRepository.
/// </summary>
public sealed class MongoInboxStoreAdapter : IInAppInboxStore
{
private readonly INotifyInboxRepository _repository;
public MongoInboxStoreAdapter(INotifyInboxRepository repository)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
public async Task StoreAsync(InAppInboxMessage message, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
var repoMessage = new NotifyInboxMessage
{
MessageId = message.MessageId,
TenantId = message.TenantId,
UserId = message.UserId,
Title = message.Title,
Body = message.Body,
Summary = message.Summary,
Category = message.Category,
Priority = (int)message.Priority,
Metadata = message.Metadata,
CreatedAt = message.CreatedAt,
ExpiresAt = message.ExpiresAt,
ReadAt = message.ReadAt,
SourceChannel = message.SourceChannel,
DeliveryId = message.DeliveryId
};
await _repository.StoreAsync(repoMessage, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<InAppInboxMessage>> GetForUserAsync(
string tenantId,
string userId,
int limit = 50,
CancellationToken cancellationToken = default)
{
var repoMessages = await _repository.GetForUserAsync(tenantId, userId, limit, cancellationToken).ConfigureAwait(false);
return repoMessages.Select(MapToInboxMessage).ToList();
}
public async Task<InAppInboxMessage?> GetAsync(
string tenantId,
string messageId,
CancellationToken cancellationToken = default)
{
var repoMessage = await _repository.GetAsync(tenantId, messageId, cancellationToken).ConfigureAwait(false);
return repoMessage is null ? null : MapToInboxMessage(repoMessage);
}
public Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
{
return _repository.MarkReadAsync(tenantId, messageId, cancellationToken);
}
public Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
{
return _repository.MarkAllReadAsync(tenantId, userId, cancellationToken);
}
public Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
{
return _repository.DeleteAsync(tenantId, messageId, cancellationToken);
}
public Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
{
return _repository.GetUnreadCountAsync(tenantId, userId, cancellationToken);
}
private static InAppInboxMessage MapToInboxMessage(NotifyInboxMessage repo)
{
return new InAppInboxMessage
{
MessageId = repo.MessageId,
TenantId = repo.TenantId,
UserId = repo.UserId,
Title = repo.Title,
Body = repo.Body,
Summary = repo.Summary,
Category = repo.Category,
Priority = (InAppInboxPriority)repo.Priority,
Metadata = repo.Metadata,
CreatedAt = repo.CreatedAt,
ExpiresAt = repo.ExpiresAt,
ReadAt = repo.ReadAt,
SourceChannel = repo.SourceChannel,
DeliveryId = repo.DeliveryId
};
}
}

View File

@@ -0,0 +1,140 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Channel adapter for OpsGenie incident management integration.
/// Uses the OpsGenie Alert API v2.
/// </summary>
public sealed class OpsGenieChannelAdapter : INotifyChannelAdapter
{
private const string DefaultOpsGenieApiUrl = "https://api.opsgenie.com/v2/alerts";
private readonly HttpClient _httpClient;
private readonly ILogger<OpsGenieChannelAdapter> _logger;
public OpsGenieChannelAdapter(HttpClient httpClient, ILogger<OpsGenieChannelAdapter> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public NotifyChannelType ChannelType => NotifyChannelType.OpsGenie;
public async Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(rendered);
// OpsGenie API key should be stored via SecretRef (resolved externally)
// or provided in Properties as "api_key"
var apiKey = channel.Config?.Properties.GetValueOrDefault("api_key");
if (string.IsNullOrWhiteSpace(apiKey))
{
return ChannelDispatchResult.Fail("OpsGenie API key not configured in properties", shouldRetry: false);
}
var endpoint = channel.Config?.Endpoint ?? DefaultOpsGenieApiUrl;
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
{
return ChannelDispatchResult.Fail($"Invalid OpsGenie endpoint: {endpoint}", shouldRetry: false);
}
// Build OpsGenie Alert API v2 payload
var priority = DeterminePriority(rendered);
var payload = new
{
message = rendered.Title ?? "StellaOps Notification",
alias = rendered.BodyHash ?? Guid.NewGuid().ToString("N"),
description = rendered.Body,
priority = priority,
source = "StellaOps Notifier",
tags = new[] { "stellaops", "notification" },
details = new Dictionary<string, string>
{
["channel"] = channel.ChannelId,
["target"] = rendered.Target ?? string.Empty,
["summary"] = rendered.Summary ?? string.Empty,
["locale"] = rendered.Locale ?? "en-US"
},
entity = channel.Config?.Properties.GetValueOrDefault("entity") ?? string.Empty,
note = $"Sent via StellaOps Notifier at {DateTimeOffset.UtcNow:O}"
};
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
request.Content = JsonContent.Create(payload, options: new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
request.Headers.Authorization = new AuthenticationHeaderValue("GenieKey", apiKey);
request.Headers.Add("X-StellaOps-Notifier", "1.0");
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var statusCode = (int)response.StatusCode;
if (response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"OpsGenie alert sent successfully to {Endpoint}. Status: {StatusCode}",
endpoint,
statusCode);
return ChannelDispatchResult.Ok(statusCode);
}
var shouldRetry = statusCode >= 500 || statusCode == 429;
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogWarning(
"OpsGenie delivery to {Endpoint} failed with status {StatusCode}. Error: {Error}. Retry: {ShouldRetry}.",
endpoint,
statusCode,
errorContent,
shouldRetry);
return ChannelDispatchResult.Fail(
$"HTTP {statusCode}: {errorContent}",
statusCode,
shouldRetry);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "OpsGenie delivery to {Endpoint} failed with network error.", endpoint);
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
_logger.LogWarning(ex, "OpsGenie delivery to {Endpoint} timed out.", endpoint);
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
}
}
private static string DeterminePriority(NotifyDeliveryRendered rendered)
{
// Map notification priority to OpsGenie priority (P1-P5)
if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true)
return "P1";
if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true)
return "P2";
if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true)
return "P3";
if (rendered.Title?.Contains("info", StringComparison.OrdinalIgnoreCase) == true)
return "P4";
return "P3"; // Default to medium priority
}
}

View File

@@ -0,0 +1,141 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Channel adapter for PagerDuty incident management integration.
/// Uses the PagerDuty Events API v2 for incident creation and updates.
/// </summary>
public sealed class PagerDutyChannelAdapter : INotifyChannelAdapter
{
private const string DefaultPagerDutyApiUrl = "https://events.pagerduty.com/v2/enqueue";
private readonly HttpClient _httpClient;
private readonly ILogger<PagerDutyChannelAdapter> _logger;
public PagerDutyChannelAdapter(HttpClient httpClient, ILogger<PagerDutyChannelAdapter> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public NotifyChannelType ChannelType => NotifyChannelType.PagerDuty;
public async Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(rendered);
// PagerDuty routing key should be stored via SecretRef (resolved externally)
// or provided in Properties as "routing_key"
var routingKey = channel.Config?.Properties.GetValueOrDefault("routing_key");
if (string.IsNullOrWhiteSpace(routingKey))
{
return ChannelDispatchResult.Fail("PagerDuty routing key not configured in properties", shouldRetry: false);
}
var endpoint = channel.Config?.Endpoint ?? DefaultPagerDutyApiUrl;
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
{
return ChannelDispatchResult.Fail($"Invalid PagerDuty endpoint: {endpoint}", shouldRetry: false);
}
// Build PagerDuty Events API v2 payload
var severity = DetermineSeverity(rendered);
var payload = new
{
routing_key = routingKey,
event_action = "trigger",
dedup_key = rendered.BodyHash ?? Guid.NewGuid().ToString("N"),
payload = new
{
summary = rendered.Title ?? "StellaOps Notification",
source = "StellaOps Notifier",
severity = severity,
timestamp = DateTimeOffset.UtcNow.ToString("O"),
custom_details = new
{
body = rendered.Body,
summary = rendered.Summary,
channel = channel.ChannelId,
target = rendered.Target
}
},
client = "StellaOps",
client_url = channel.Config?.Properties.GetValueOrDefault("client_url") ?? string.Empty
};
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
request.Content = JsonContent.Create(payload, options: new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
request.Headers.Add("X-StellaOps-Notifier", "1.0");
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var statusCode = (int)response.StatusCode;
if (response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"PagerDuty event sent successfully to {Endpoint}. Status: {StatusCode}",
endpoint,
statusCode);
return ChannelDispatchResult.Ok(statusCode);
}
var shouldRetry = statusCode >= 500 || statusCode == 429;
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogWarning(
"PagerDuty delivery to {Endpoint} failed with status {StatusCode}. Error: {Error}. Retry: {ShouldRetry}.",
endpoint,
statusCode,
errorContent,
shouldRetry);
return ChannelDispatchResult.Fail(
$"HTTP {statusCode}: {errorContent}",
statusCode,
shouldRetry);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "PagerDuty delivery to {Endpoint} failed with network error.", endpoint);
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
_logger.LogWarning(ex, "PagerDuty delivery to {Endpoint} timed out.", endpoint);
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
}
}
private static string DetermineSeverity(NotifyDeliveryRendered rendered)
{
// Map notification priority to PagerDuty severity
// Priority can be embedded in metadata or parsed from title
if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true)
return "critical";
if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true)
return "error";
if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true)
return "warning";
return "info";
}
}

View File

@@ -0,0 +1,107 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Channel adapter for Slack webhook delivery.
/// </summary>
public sealed class SlackChannelAdapter : INotifyChannelAdapter
{
private readonly HttpClient _httpClient;
private readonly ILogger<SlackChannelAdapter> _logger;
public SlackChannelAdapter(HttpClient httpClient, ILogger<SlackChannelAdapter> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
public async Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(rendered);
var endpoint = channel.Config?.Endpoint;
if (string.IsNullOrWhiteSpace(endpoint))
{
return ChannelDispatchResult.Fail("Slack webhook URL not configured", shouldRetry: false);
}
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
{
return ChannelDispatchResult.Fail($"Invalid Slack webhook URL: {endpoint}", shouldRetry: false);
}
// Build Slack message payload
var slackPayload = new
{
channel = channel.Config?.Target,
text = rendered.Title,
blocks = new object[]
{
new
{
type = "section",
text = new
{
type = "mrkdwn",
text = rendered.Body
}
}
}
};
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
request.Content = JsonContent.Create(slackPayload, options: new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var statusCode = (int)response.StatusCode;
if (response.IsSuccessStatusCode)
{
_logger.LogInformation(
"Slack delivery to channel {Target} succeeded.",
channel.Config?.Target ?? "(default)");
return ChannelDispatchResult.Ok(statusCode);
}
var shouldRetry = statusCode >= 500 || statusCode == 429;
_logger.LogWarning(
"Slack delivery failed with status {StatusCode}. Retry: {ShouldRetry}.",
statusCode,
shouldRetry);
return ChannelDispatchResult.Fail(
$"HTTP {statusCode}",
statusCode,
shouldRetry);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Slack delivery failed with network error.");
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
_logger.LogWarning(ex, "Slack delivery timed out.");
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
}
}
}

View File

@@ -0,0 +1,105 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Channel adapter for webhook (HTTP POST) delivery with retry support.
/// </summary>
public sealed class WebhookChannelAdapter : INotifyChannelAdapter
{
private readonly HttpClient _httpClient;
private readonly ILogger<WebhookChannelAdapter> _logger;
public WebhookChannelAdapter(HttpClient httpClient, ILogger<WebhookChannelAdapter> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
public async Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(rendered);
var endpoint = channel.Config?.Endpoint;
if (string.IsNullOrWhiteSpace(endpoint))
{
return ChannelDispatchResult.Fail("Webhook endpoint not configured", shouldRetry: false);
}
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
{
return ChannelDispatchResult.Fail($"Invalid webhook endpoint: {endpoint}", shouldRetry: false);
}
var payload = new
{
channel = channel.ChannelId,
target = rendered.Target,
title = rendered.Title,
body = rendered.Body,
summary = rendered.Summary,
format = rendered.Format.ToString().ToLowerInvariant(),
locale = rendered.Locale,
timestamp = DateTimeOffset.UtcNow
};
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
request.Content = JsonContent.Create(payload, options: new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
// Add HMAC signature header if secret is available (placeholder for KMS integration)
request.Headers.Add("X-StellaOps-Notifier", "1.0");
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var statusCode = (int)response.StatusCode;
if (response.IsSuccessStatusCode)
{
_logger.LogInformation(
"Webhook delivery to {Endpoint} succeeded with status {StatusCode}.",
endpoint,
statusCode);
return ChannelDispatchResult.Ok(statusCode);
}
var shouldRetry = statusCode >= 500 || statusCode == 429;
_logger.LogWarning(
"Webhook delivery to {Endpoint} failed with status {StatusCode}. Retry: {ShouldRetry}.",
endpoint,
statusCode,
shouldRetry);
return ChannelDispatchResult.Fail(
$"HTTP {statusCode}",
statusCode,
shouldRetry);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Webhook delivery to {Endpoint} failed with network error.", endpoint);
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
_logger.LogWarning(ex, "Webhook delivery to {Endpoint} timed out.", endpoint);
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
}
}
}

View File

@@ -0,0 +1,300 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Default implementation of the correlation engine.
/// </summary>
public sealed class DefaultCorrelationEngine : ICorrelationEngine
{
private readonly ICorrelationKeyEvaluator _keyEvaluator;
private readonly INotifyThrottler _throttler;
private readonly IQuietHoursEvaluator _quietHoursEvaluator;
private readonly CorrelationKeyConfig _config;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DefaultCorrelationEngine> _logger;
// In-memory incident store (in production, would use a repository)
private readonly ConcurrentDictionary<string, NotifyIncident> _incidents = new();
public DefaultCorrelationEngine(
ICorrelationKeyEvaluator keyEvaluator,
INotifyThrottler throttler,
IQuietHoursEvaluator quietHoursEvaluator,
IOptions<CorrelationKeyConfig> config,
TimeProvider timeProvider,
ILogger<DefaultCorrelationEngine> logger)
{
_keyEvaluator = keyEvaluator ?? throw new ArgumentNullException(nameof(keyEvaluator));
_throttler = throttler ?? throw new ArgumentNullException(nameof(throttler));
_quietHoursEvaluator = quietHoursEvaluator ?? throw new ArgumentNullException(nameof(quietHoursEvaluator));
_config = config?.Value ?? new CorrelationKeyConfig();
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CorrelationResult> ProcessAsync(
NotifyEvent @event,
NotifyRule rule,
NotifyRuleAction action,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(@event);
ArgumentNullException.ThrowIfNull(rule);
ArgumentNullException.ThrowIfNull(action);
var tenantId = @event.Tenant;
// 1. Check maintenance window
var maintenanceResult = await _quietHoursEvaluator.IsInMaintenanceAsync(tenantId, cancellationToken)
.ConfigureAwait(false);
if (maintenanceResult.IsInMaintenance)
{
_logger.LogDebug(
"Event {EventId} suppressed due to maintenance window: {Reason}",
@event.EventId, maintenanceResult.MaintenanceReason);
return new CorrelationResult
{
Decision = CorrelationDecision.Maintenance,
Reason = maintenanceResult.MaintenanceReason
};
}
// 2. Check quiet hours (per channel if action specifies)
var quietHoursResult = await _quietHoursEvaluator.IsInQuietHoursAsync(
tenantId, action.Channel, cancellationToken).ConfigureAwait(false);
if (quietHoursResult.IsInQuietHours)
{
_logger.LogDebug(
"Event {EventId} suppressed due to quiet hours: {Reason}",
@event.EventId, quietHoursResult.Reason);
return new CorrelationResult
{
Decision = CorrelationDecision.QuietHours,
Reason = quietHoursResult.Reason,
QuietHoursEndsAt = quietHoursResult.QuietHoursEndsAt
};
}
// 3. Compute correlation key
var correlationKey = _keyEvaluator.EvaluateDefaultKey(@event);
// 4. Get or create incident
var (incident, isNew) = await GetOrCreateIncidentInternalAsync(
tenantId, correlationKey, @event.Kind, @event, cancellationToken).ConfigureAwait(false);
// 5. Check if incident is already acknowledged
if (incident.Status == NotifyIncidentStatus.Acknowledged)
{
_logger.LogDebug(
"Event {EventId} suppressed - incident {IncidentId} already acknowledged",
@event.EventId, incident.IncidentId);
return new CorrelationResult
{
Decision = CorrelationDecision.Acknowledged,
Reason = "Incident already acknowledged",
CorrelationKey = correlationKey,
IncidentId = incident.IncidentId,
IsNewIncident = false
};
}
// 6. Check throttling (if action has throttle configured)
if (action.Throttle is { } throttle && throttle > TimeSpan.Zero)
{
var throttleKey = $"{rule.RuleId}:{action.ActionId}:{correlationKey}";
var isThrottled = await _throttler.IsThrottledAsync(
tenantId, throttleKey, throttle, cancellationToken).ConfigureAwait(false);
if (isThrottled)
{
_logger.LogDebug(
"Event {EventId} throttled: key={ThrottleKey}, window={Throttle}",
@event.EventId, throttleKey, throttle);
return new CorrelationResult
{
Decision = CorrelationDecision.Throttled,
Reason = $"Throttled for {throttle}",
CorrelationKey = correlationKey,
IncidentId = incident.IncidentId,
IsNewIncident = isNew,
ThrottledUntil = _timeProvider.GetUtcNow().Add(throttle)
};
}
}
// 7. If this is a new event added to an existing incident within the correlation window,
// and it's not the first event, suppress delivery (already notified)
if (!isNew && incident.EventCount > 1)
{
var windowEnd = incident.FirstEventAt.Add(_config.CorrelationWindow);
if (_timeProvider.GetUtcNow() < windowEnd)
{
_logger.LogDebug(
"Event {EventId} correlated to existing incident {IncidentId} within window",
@event.EventId, incident.IncidentId);
return new CorrelationResult
{
Decision = CorrelationDecision.Correlated,
Reason = "Event correlated to existing incident",
CorrelationKey = correlationKey,
IncidentId = incident.IncidentId,
IsNewIncident = false
};
}
}
// 8. Proceed with delivery
_logger.LogDebug(
"Event {EventId} approved for delivery: incident={IncidentId}, isNew={IsNew}",
@event.EventId, incident.IncidentId, isNew);
return new CorrelationResult
{
Decision = CorrelationDecision.Deliver,
CorrelationKey = correlationKey,
IncidentId = incident.IncidentId,
IsNewIncident = isNew
};
}
public Task<NotifyIncident> GetOrCreateIncidentAsync(
string tenantId,
string correlationKey,
string kind,
NotifyEvent @event,
CancellationToken cancellationToken = default)
{
var (incident, _) = GetOrCreateIncidentInternalAsync(
tenantId, correlationKey, kind, @event, cancellationToken).GetAwaiter().GetResult();
return Task.FromResult(incident);
}
private Task<(NotifyIncident Incident, bool IsNew)> GetOrCreateIncidentInternalAsync(
string tenantId,
string correlationKey,
string kind,
NotifyEvent @event,
CancellationToken cancellationToken)
{
var incidentKey = $"{tenantId}:{correlationKey}";
var now = _timeProvider.GetUtcNow();
// Check if existing incident is within correlation window
if (_incidents.TryGetValue(incidentKey, out var existing))
{
var windowEnd = existing.FirstEventAt.Add(_config.CorrelationWindow);
if (now < windowEnd && existing.Status == NotifyIncidentStatus.Open)
{
// Add event to existing incident
var updated = existing with
{
EventCount = existing.EventCount + 1,
LastEventAt = now,
EventIds = existing.EventIds.Add(@event.EventId),
UpdatedAt = now
};
_incidents[incidentKey] = updated;
return Task.FromResult((updated, false));
}
}
// Create new incident
var incident = new NotifyIncident
{
IncidentId = Guid.NewGuid().ToString("N"),
TenantId = tenantId,
CorrelationKey = correlationKey,
Kind = kind,
Status = NotifyIncidentStatus.Open,
EventCount = 1,
FirstEventAt = now,
LastEventAt = now,
EventIds = [@event.EventId],
CreatedAt = now,
UpdatedAt = now
};
_incidents[incidentKey] = incident;
return Task.FromResult((incident, true));
}
public Task<NotifyIncident> AcknowledgeIncidentAsync(
string tenantId,
string incidentId,
string acknowledgedBy,
CancellationToken cancellationToken = default)
{
var incident = _incidents.Values.FirstOrDefault(i =>
i.TenantId == tenantId && i.IncidentId == incidentId);
if (incident is null)
{
throw new InvalidOperationException($"Incident {incidentId} not found");
}
var now = _timeProvider.GetUtcNow();
var updated = incident with
{
Status = NotifyIncidentStatus.Acknowledged,
AcknowledgedAt = now,
AcknowledgedBy = acknowledgedBy,
UpdatedAt = now
};
var key = $"{tenantId}:{incident.CorrelationKey}";
_incidents[key] = updated;
_logger.LogInformation(
"Incident {IncidentId} acknowledged by {AcknowledgedBy}",
incidentId, acknowledgedBy);
return Task.FromResult(updated);
}
public Task<NotifyIncident> ResolveIncidentAsync(
string tenantId,
string incidentId,
string resolvedBy,
string? resolutionNote = null,
CancellationToken cancellationToken = default)
{
var incident = _incidents.Values.FirstOrDefault(i =>
i.TenantId == tenantId && i.IncidentId == incidentId);
if (incident is null)
{
throw new InvalidOperationException($"Incident {incidentId} not found");
}
var now = _timeProvider.GetUtcNow();
var updated = incident with
{
Status = NotifyIncidentStatus.Resolved,
ResolvedAt = now,
ResolvedBy = resolvedBy,
ResolutionNote = resolutionNote,
UpdatedAt = now
};
var key = $"{tenantId}:{incident.CorrelationKey}";
_incidents[key] = updated;
_logger.LogInformation(
"Incident {IncidentId} resolved by {ResolvedBy}: {ResolutionNote}",
incidentId, resolvedBy, resolutionNote);
return Task.FromResult(updated);
}
}

View File

@@ -0,0 +1,125 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Default implementation of correlation key evaluator using template expressions.
/// </summary>
public sealed partial class DefaultCorrelationKeyEvaluator : ICorrelationKeyEvaluator
{
private static readonly Regex PlaceholderPattern = PlaceholderRegex();
private readonly CorrelationKeyConfig _config;
public DefaultCorrelationKeyEvaluator(IOptions<CorrelationKeyConfig> config)
{
_config = config?.Value ?? new CorrelationKeyConfig();
}
public string EvaluateKey(NotifyEvent @event, string expression)
{
ArgumentNullException.ThrowIfNull(@event);
ArgumentException.ThrowIfNullOrWhiteSpace(expression);
return PlaceholderPattern.Replace(expression, match =>
{
var path = match.Groups[1].Value.Trim();
return ResolveValue(@event, path) ?? string.Empty;
});
}
public string EvaluateDefaultKey(NotifyEvent @event)
{
ArgumentNullException.ThrowIfNull(@event);
// Check for kind-specific expression
var expression = _config.DefaultExpression;
foreach (var (kindPattern, kindExpression) in _config.KindExpressions)
{
if (MatchesKindPattern(@event.Kind, kindPattern))
{
expression = kindExpression;
break;
}
}
return EvaluateKey(@event, expression);
}
private static string? ResolveValue(NotifyEvent @event, string path)
{
// Built-in event properties
return path.ToLowerInvariant() switch
{
"eventid" => @event.EventId.ToString(),
"kind" => @event.Kind,
"tenant" => @event.Tenant,
"actor" => @event.Actor,
"ts" => @event.Ts.ToString("o"),
"version" => @event.Version,
_ when path.StartsWith("payload.", StringComparison.OrdinalIgnoreCase) =>
ResolvePayloadPath(@event.Payload, path[8..]),
_ when path.StartsWith("attributes.", StringComparison.OrdinalIgnoreCase) =>
ResolveAttributesPath(@event.Attributes, path[11..]),
_ => ResolvePayloadPath(@event.Payload, path) // Fallback to payload
};
}
private static string? ResolvePayloadPath(JsonNode? payload, string path)
{
if (payload is null || string.IsNullOrWhiteSpace(path))
{
return null;
}
var segments = path.Split('.');
var current = payload;
foreach (var segment in segments)
{
if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next))
{
current = next;
}
else if (current is JsonArray arr && int.TryParse(segment, out var index) && index >= 0 && index < arr.Count)
{
current = arr[index];
}
else
{
return null;
}
}
return current?.ToString();
}
private static string? ResolveAttributesPath(IReadOnlyDictionary<string, string>? attributes, string key)
{
if (attributes is null)
{
return null;
}
return attributes.TryGetValue(key, out var value) ? value : null;
}
private static bool MatchesKindPattern(string kind, string pattern)
{
// Support wildcard patterns like "scan.*" or "attestation.*"
if (pattern.EndsWith(".*", StringComparison.Ordinal))
{
var prefix = pattern[..^2];
return kind.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
return kind.Equals(pattern, StringComparison.OrdinalIgnoreCase);
}
[GeneratedRegex(@"\{\{([^}]+)\}\}", RegexOptions.Compiled)]
private static partial Regex PlaceholderRegex();
}

View File

@@ -0,0 +1,221 @@
using Cronos;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Default implementation of quiet hours evaluator using cron expressions.
/// </summary>
public sealed class DefaultQuietHoursEvaluator : IQuietHoursEvaluator
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<DefaultQuietHoursEvaluator> _logger;
private readonly INotifyQuietHoursRepository? _quietHoursRepository;
private readonly INotifyMaintenanceWindowRepository? _maintenanceWindowRepository;
private readonly INotifyOperatorOverrideRepository? _operatorOverrideRepository;
// In-memory fallback for testing
private readonly List<NotifyQuietHoursSchedule> _schedules = [];
private readonly List<NotifyMaintenanceWindow> _maintenanceWindows = [];
public DefaultQuietHoursEvaluator(
TimeProvider timeProvider,
ILogger<DefaultQuietHoursEvaluator> logger,
INotifyQuietHoursRepository? quietHoursRepository = null,
INotifyMaintenanceWindowRepository? maintenanceWindowRepository = null,
INotifyOperatorOverrideRepository? operatorOverrideRepository = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_quietHoursRepository = quietHoursRepository;
_maintenanceWindowRepository = maintenanceWindowRepository;
_operatorOverrideRepository = operatorOverrideRepository;
}
public async Task<QuietHoursCheckResult> IsInQuietHoursAsync(
string tenantId,
string? channelId = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var now = _timeProvider.GetUtcNow();
// Check for active bypass override
if (_operatorOverrideRepository is not null)
{
var overrides = await _operatorOverrideRepository.ListActiveAsync(
tenantId, now, NotifyOverrideType.BypassQuietHours, channelId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (overrides.Count > 0)
{
_logger.LogDebug(
"Quiet hours bypassed by operator override for tenant {TenantId}: override={OverrideId}",
tenantId, overrides[0].OverrideId);
return new QuietHoursCheckResult
{
IsInQuietHours = false,
Reason = $"Bypassed by operator override: {overrides[0].Reason ?? overrides[0].OverrideId}"
};
}
}
// Find applicable schedules for this tenant
IEnumerable<NotifyQuietHoursSchedule> applicableSchedules;
if (_quietHoursRepository is not null)
{
var schedules = await _quietHoursRepository.ListEnabledAsync(tenantId, channelId, cancellationToken).ConfigureAwait(false);
applicableSchedules = schedules;
}
else
{
applicableSchedules = _schedules
.Where(s => s.TenantId == tenantId && s.Enabled)
.Where(s => channelId is null || s.ChannelId is null || s.ChannelId == channelId);
}
foreach (var schedule in applicableSchedules)
{
if (IsInSchedule(schedule, now, out var endsAt))
{
_logger.LogDebug(
"Quiet hours active for tenant {TenantId}: schedule={ScheduleId}, endsAt={EndsAt}",
tenantId, schedule.ScheduleId, endsAt);
return new QuietHoursCheckResult
{
IsInQuietHours = true,
QuietHoursScheduleId = schedule.ScheduleId,
QuietHoursEndsAt = endsAt,
Reason = $"Quiet hours: {schedule.Name}"
};
}
}
return new QuietHoursCheckResult
{
IsInQuietHours = false
};
}
public async Task<MaintenanceCheckResult> IsInMaintenanceAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var now = _timeProvider.GetUtcNow();
// Check for active bypass override
if (_operatorOverrideRepository is not null)
{
var overrides = await _operatorOverrideRepository.ListActiveAsync(
tenantId, now, NotifyOverrideType.BypassMaintenance, cancellationToken: cancellationToken).ConfigureAwait(false);
if (overrides.Count > 0)
{
_logger.LogDebug(
"Maintenance window bypassed by operator override for tenant {TenantId}: override={OverrideId}",
tenantId, overrides[0].OverrideId);
return new MaintenanceCheckResult
{
IsInMaintenance = false,
MaintenanceReason = $"Bypassed by operator override: {overrides[0].Reason ?? overrides[0].OverrideId}"
};
}
}
// Find active maintenance windows
NotifyMaintenanceWindow? activeWindow;
if (_maintenanceWindowRepository is not null)
{
var windows = await _maintenanceWindowRepository.GetActiveAsync(tenantId, now, cancellationToken).ConfigureAwait(false);
activeWindow = windows.FirstOrDefault();
}
else
{
activeWindow = _maintenanceWindows
.Where(w => w.TenantId == tenantId && w.SuppressNotifications)
.FirstOrDefault(w => w.IsActiveAt(now));
}
if (activeWindow is not null)
{
_logger.LogDebug(
"Maintenance window active for tenant {TenantId}: window={WindowId}, endsAt={EndsAt}",
tenantId, activeWindow.WindowId, activeWindow.EndsAt);
return new MaintenanceCheckResult
{
IsInMaintenance = true,
MaintenanceWindowId = activeWindow.WindowId,
MaintenanceEndsAt = activeWindow.EndsAt,
MaintenanceReason = activeWindow.Reason
};
}
return new MaintenanceCheckResult
{
IsInMaintenance = false
};
}
/// <summary>
/// Adds a quiet hours schedule (for configuration/testing).
/// </summary>
public void AddSchedule(NotifyQuietHoursSchedule schedule)
{
ArgumentNullException.ThrowIfNull(schedule);
_schedules.Add(schedule);
}
/// <summary>
/// Adds a maintenance window (for configuration/testing).
/// </summary>
public void AddMaintenanceWindow(NotifyMaintenanceWindow window)
{
ArgumentNullException.ThrowIfNull(window);
_maintenanceWindows.Add(window);
}
private bool IsInSchedule(NotifyQuietHoursSchedule schedule, DateTimeOffset now, out DateTimeOffset? endsAt)
{
endsAt = null;
try
{
var timeZone = TimeZoneInfo.FindSystemTimeZoneById(schedule.TimeZone);
var localNow = TimeZoneInfo.ConvertTime(now, timeZone);
var cron = CronExpression.Parse(schedule.CronExpression);
// Look back for the most recent occurrence
var searchStart = localNow.AddDays(-1);
var lastOccurrence = cron.GetNextOccurrence(searchStart.DateTime, timeZone, inclusive: true);
if (lastOccurrence.HasValue)
{
var occurrenceOffset = new DateTimeOffset(lastOccurrence.Value, timeZone.GetUtcOffset(lastOccurrence.Value));
var windowEnd = occurrenceOffset.Add(schedule.Duration);
if (now >= occurrenceOffset && now < windowEnd)
{
endsAt = windowEnd;
return true;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to evaluate quiet hours schedule {ScheduleId} for tenant {TenantId}",
schedule.ScheduleId, schedule.TenantId);
}
return false;
}
}

View File

@@ -0,0 +1,102 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Engine for correlating events, managing incidents, and applying throttling/quiet hours.
/// </summary>
public interface ICorrelationEngine
{
/// <summary>
/// Processes an event through correlation, throttling, and quiet hours evaluation.
/// </summary>
/// <param name="event">The event to process.</param>
/// <param name="rule">The matched rule.</param>
/// <param name="action">The action to potentially execute.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The correlation result indicating whether to proceed with delivery.</returns>
Task<CorrelationResult> ProcessAsync(
NotifyEvent @event,
NotifyRule rule,
NotifyRuleAction action,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets or creates an incident for the given correlation key.
/// </summary>
Task<NotifyIncident> GetOrCreateIncidentAsync(
string tenantId,
string correlationKey,
string kind,
NotifyEvent @event,
CancellationToken cancellationToken = default);
/// <summary>
/// Acknowledges an incident.
/// </summary>
Task<NotifyIncident> AcknowledgeIncidentAsync(
string tenantId,
string incidentId,
string acknowledgedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves an incident.
/// </summary>
Task<NotifyIncident> ResolveIncidentAsync(
string tenantId,
string incidentId,
string resolvedBy,
string? resolutionNote = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of correlation processing.
/// </summary>
public sealed record CorrelationResult
{
public required CorrelationDecision Decision { get; init; }
public string? Reason { get; init; }
public string? CorrelationKey { get; init; }
public string? IncidentId { get; init; }
public bool IsNewIncident { get; init; }
public DateTimeOffset? ThrottledUntil { get; init; }
public DateTimeOffset? QuietHoursEndsAt { get; init; }
}
/// <summary>
/// Decision made by the correlation engine.
/// </summary>
public enum CorrelationDecision
{
/// <summary>
/// Proceed with delivery.
/// </summary>
Deliver,
/// <summary>
/// Suppress due to throttling.
/// </summary>
Throttled,
/// <summary>
/// Suppress due to quiet hours.
/// </summary>
QuietHours,
/// <summary>
/// Suppress due to maintenance window.
/// </summary>
Maintenance,
/// <summary>
/// Suppress and add to existing incident.
/// </summary>
Correlated,
/// <summary>
/// Suppress due to incident already acknowledged.
/// </summary>
Acknowledged
}

View File

@@ -0,0 +1,44 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Evaluates correlation keys from event payloads using configurable expressions.
/// </summary>
public interface ICorrelationKeyEvaluator
{
/// <summary>
/// Extracts a correlation key from an event based on the configured expression.
/// </summary>
/// <param name="event">The event to correlate.</param>
/// <param name="expression">The key expression (e.g., "kind:{{kind}}|target:{{payload.target}}").</param>
/// <returns>The computed correlation key.</returns>
string EvaluateKey(NotifyEvent @event, string expression);
/// <summary>
/// Extracts a correlation key using the default expression for the event kind.
/// </summary>
string EvaluateDefaultKey(NotifyEvent @event);
}
/// <summary>
/// Configuration for correlation key expressions per event kind.
/// </summary>
public sealed class CorrelationKeyConfig
{
/// <summary>
/// Default expression used when no kind-specific expression is defined.
/// </summary>
public string DefaultExpression { get; set; } = "{{tenant}}:{{kind}}";
/// <summary>
/// Kind-specific expressions (key = event kind pattern, value = expression).
/// </summary>
public Dictionary<string, string> KindExpressions { get; set; } = new();
/// <summary>
/// Correlation window duration for grouping events.
/// </summary>
public TimeSpan CorrelationWindow { get; set; } = TimeSpan.FromMinutes(15);
}

View File

@@ -0,0 +1,41 @@
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Throttling service for rate-limiting notifications.
/// </summary>
public interface INotifyThrottler
{
/// <summary>
/// Checks if a notification should be throttled based on the key and window.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="throttleKey">The unique key for throttling (e.g., action + correlation key).</param>
/// <param name="window">The throttle window duration.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if throttled (should not send), false if allowed.</returns>
Task<bool> IsThrottledAsync(
string tenantId,
string throttleKey,
TimeSpan window,
CancellationToken cancellationToken = default);
/// <summary>
/// Records a notification as sent, establishing the throttle marker.
/// </summary>
Task RecordSentAsync(
string tenantId,
string throttleKey,
TimeSpan window,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a throttle check with additional context.
/// </summary>
public sealed record ThrottleCheckResult
{
public required bool IsThrottled { get; init; }
public DateTimeOffset? ThrottledUntil { get; init; }
public DateTimeOffset? LastSentAt { get; init; }
public int SuppressedCount { get; init; }
}

View File

@@ -0,0 +1,44 @@
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Evaluates whether notifications should be suppressed due to quiet hours or maintenance windows.
/// </summary>
public interface IQuietHoursEvaluator
{
/// <summary>
/// Checks if the current time falls within a quiet hours period for the tenant.
/// </summary>
Task<QuietHoursCheckResult> IsInQuietHoursAsync(
string tenantId,
string? channelId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if notifications should be suppressed due to an active maintenance window.
/// </summary>
Task<MaintenanceCheckResult> IsInMaintenanceAsync(
string tenantId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a quiet hours check.
/// </summary>
public sealed record QuietHoursCheckResult
{
public required bool IsInQuietHours { get; init; }
public string? QuietHoursScheduleId { get; init; }
public DateTimeOffset? QuietHoursEndsAt { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// Result of a maintenance window check.
/// </summary>
public sealed record MaintenanceCheckResult
{
public required bool IsInMaintenance { get; init; }
public string? MaintenanceWindowId { get; init; }
public DateTimeOffset? MaintenanceEndsAt { get; init; }
public string? MaintenanceReason { get; init; }
}

View File

@@ -0,0 +1,74 @@
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Throttler implementation using the lock repository for distributed throttling.
/// </summary>
public sealed class LockBasedThrottler : INotifyThrottler
{
private readonly INotifyLockRepository _lockRepository;
private readonly ILogger<LockBasedThrottler> _logger;
public LockBasedThrottler(
INotifyLockRepository lockRepository,
ILogger<LockBasedThrottler> logger)
{
_lockRepository = lockRepository ?? throw new ArgumentNullException(nameof(lockRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> IsThrottledAsync(
string tenantId,
string throttleKey,
TimeSpan window,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(throttleKey);
if (window <= TimeSpan.Zero)
{
return false;
}
var lockKey = BuildThrottleKey(throttleKey);
// Try to acquire the lock - if we can't, it means we're throttled
var acquired = await _lockRepository.TryAcquireAsync(
tenantId,
lockKey,
"throttle",
window,
cancellationToken).ConfigureAwait(false);
if (!acquired)
{
_logger.LogDebug(
"Notification throttled: tenant={TenantId}, key={ThrottleKey}, window={Window}",
tenantId, throttleKey, window);
return true;
}
// We acquired the lock, so we're not throttled
// Note: The lock will automatically expire after the window
return false;
}
public Task RecordSentAsync(
string tenantId,
string throttleKey,
TimeSpan window,
CancellationToken cancellationToken = default)
{
// The lock was already acquired in IsThrottledAsync, which also serves as the marker
// This method exists for cases where throttle check and send are separate operations
return Task.CompletedTask;
}
private static string BuildThrottleKey(string key)
{
return $"throttle|{key}";
}
}

View File

@@ -0,0 +1,38 @@
using System.Collections.Immutable;
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Represents a correlated incident grouping multiple related events.
/// </summary>
public sealed record NotifyIncident
{
public required string IncidentId { get; init; }
public required string TenantId { get; init; }
public required string CorrelationKey { get; init; }
public required string Kind { get; init; }
public required NotifyIncidentStatus Status { get; init; }
public required int EventCount { get; init; }
public required DateTimeOffset FirstEventAt { get; init; }
public required DateTimeOffset LastEventAt { get; init; }
public DateTimeOffset? AcknowledgedAt { get; init; }
public string? AcknowledgedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public string? ResolvedBy { get; init; }
public string? ResolutionNote { get; init; }
public ImmutableArray<Guid> EventIds { get; init; } = [];
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Status of an incident through its lifecycle.
/// </summary>
public enum NotifyIncidentStatus
{
Open,
Acknowledged,
Resolved,
Suppressed
}

View File

@@ -0,0 +1,186 @@
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Processing;
namespace StellaOps.Notifier.Worker.Digest;
/// <summary>
/// Default implementation of the digest generator.
/// </summary>
public sealed class DefaultDigestGenerator : IDigestGenerator
{
private readonly INotifyDeliveryRepository _deliveryRepository;
private readonly INotifyTemplateRepository _templateRepository;
private readonly INotifyTemplateRenderer _templateRenderer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DefaultDigestGenerator> _logger;
public DefaultDigestGenerator(
INotifyDeliveryRepository deliveryRepository,
INotifyTemplateRepository templateRepository,
INotifyTemplateRenderer templateRenderer,
TimeProvider timeProvider,
ILogger<DefaultDigestGenerator> logger)
{
_deliveryRepository = deliveryRepository ?? throw new ArgumentNullException(nameof(deliveryRepository));
_templateRepository = templateRepository ?? throw new ArgumentNullException(nameof(templateRepository));
_templateRenderer = templateRenderer ?? throw new ArgumentNullException(nameof(templateRenderer));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<NotifyDigest> GenerateAsync(
DigestSchedule schedule,
DateTimeOffset periodStart,
DateTimeOffset periodEnd,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(schedule);
_logger.LogDebug(
"Generating digest for schedule {ScheduleId}: period {PeriodStart} to {PeriodEnd}",
schedule.ScheduleId, periodStart, periodEnd);
// Query deliveries for the period
var result = await _deliveryRepository.QueryAsync(
tenantId: schedule.TenantId,
since: periodStart,
status: null, // All statuses
limit: 1000,
cancellationToken: cancellationToken).ConfigureAwait(false);
// Filter to relevant event kinds if specified
var deliveries = result.Items.AsEnumerable();
if (!schedule.EventKinds.IsDefaultOrEmpty)
{
var kindSet = schedule.EventKinds.ToHashSet(StringComparer.OrdinalIgnoreCase);
deliveries = deliveries.Where(d => kindSet.Contains(d.Kind));
}
// Filter to period
deliveries = deliveries.Where(d =>
d.CreatedAt >= periodStart && d.CreatedAt < periodEnd);
var deliveryList = deliveries.ToList();
// Compute event kind counts
var kindCounts = deliveryList
.GroupBy(d => d.Kind, StringComparer.OrdinalIgnoreCase)
.ToImmutableDictionary(
g => g.Key,
g => g.Count(),
StringComparer.OrdinalIgnoreCase);
var eventIds = deliveryList
.Select(d => d.EventId)
.Distinct()
.ToImmutableArray();
var now = _timeProvider.GetUtcNow();
var digest = new NotifyDigest
{
DigestId = Guid.NewGuid().ToString("N"),
TenantId = schedule.TenantId,
DigestKey = schedule.DigestKey,
ScheduleId = schedule.ScheduleId,
Period = schedule.Period,
EventCount = deliveryList.Count,
EventIds = eventIds,
EventKindCounts = kindCounts,
PeriodStart = periodStart,
PeriodEnd = periodEnd,
GeneratedAt = now,
Status = deliveryList.Count > 0 ? NotifyDigestStatus.Ready : NotifyDigestStatus.Skipped,
Metadata = schedule.Metadata
};
_logger.LogInformation(
"Generated digest {DigestId} for schedule {ScheduleId}: {EventCount} events, {UniqueEvents} unique, {KindCount} kinds",
digest.DigestId, schedule.ScheduleId, deliveryList.Count, eventIds.Length, kindCounts.Count);
return digest;
}
public async Task<string> FormatAsync(
NotifyDigest digest,
string templateId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(digest);
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
var template = await _templateRepository.GetAsync(
digest.TenantId, templateId, cancellationToken).ConfigureAwait(false);
if (template is null)
{
_logger.LogWarning(
"Digest template {TemplateId} not found for tenant {TenantId}",
templateId, digest.TenantId);
return FormatDefaultDigest(digest);
}
var payload = BuildDigestPayload(digest);
return _templateRenderer.Render(template, payload);
}
private static JsonObject BuildDigestPayload(NotifyDigest digest)
{
var kindCountsArray = new JsonArray();
foreach (var (kind, count) in digest.EventKindCounts)
{
kindCountsArray.Add(new JsonObject
{
["kind"] = kind,
["count"] = count
});
}
return new JsonObject
{
["digestId"] = digest.DigestId,
["tenantId"] = digest.TenantId,
["digestKey"] = digest.DigestKey,
["scheduleId"] = digest.ScheduleId,
["period"] = digest.Period.ToString(),
["eventCount"] = digest.EventCount,
["uniqueEventCount"] = digest.EventIds.Length,
["kindCounts"] = kindCountsArray,
["periodStart"] = digest.PeriodStart.ToString("o"),
["periodEnd"] = digest.PeriodEnd.ToString("o"),
["generatedAt"] = digest.GeneratedAt.ToString("o")
};
}
private static string FormatDefaultDigest(NotifyDigest digest)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine($"## Notification Digest");
sb.AppendLine();
sb.AppendLine($"**Period:** {digest.PeriodStart:g} to {digest.PeriodEnd:g}");
sb.AppendLine($"**Total Events:** {digest.EventCount}");
sb.AppendLine();
if (digest.EventKindCounts.Count > 0)
{
sb.AppendLine("### Event Summary");
sb.AppendLine();
foreach (var (kind, count) in digest.EventKindCounts.OrderByDescending(kv => kv.Value))
{
sb.AppendLine($"- **{kind}**: {count}");
}
}
else
{
sb.AppendLine("*No events in this period.*");
}
return sb.ToString();
}
}

View File

@@ -0,0 +1,252 @@
using System.Collections.Concurrent;
using Cronos;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Channels;
namespace StellaOps.Notifier.Worker.Digest;
/// <summary>
/// Default implementation of the digest schedule runner.
/// </summary>
public sealed class DigestScheduleRunner : IDigestScheduleRunner
{
private readonly IDigestGenerator _digestGenerator;
private readonly INotifyChannelRepository _channelRepository;
private readonly IReadOnlyDictionary<NotifyChannelType, INotifyChannelAdapter> _channelAdapters;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DigestScheduleRunner> _logger;
// In-memory schedule store (in production, would use a repository)
private readonly ConcurrentDictionary<string, DigestSchedule> _schedules = new();
private readonly ConcurrentDictionary<string, DateTimeOffset> _lastRunTimes = new();
public DigestScheduleRunner(
IDigestGenerator digestGenerator,
INotifyChannelRepository channelRepository,
IEnumerable<INotifyChannelAdapter> channelAdapters,
TimeProvider timeProvider,
ILogger<DigestScheduleRunner> logger)
{
_digestGenerator = digestGenerator ?? throw new ArgumentNullException(nameof(digestGenerator));
_channelRepository = channelRepository ?? throw new ArgumentNullException(nameof(channelRepository));
_channelAdapters = BuildAdapterMap(channelAdapters);
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<int> ProcessDueDigestsAsync(CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var processed = 0;
foreach (var schedule in _schedules.Values.Where(s => s.Enabled))
{
cancellationToken.ThrowIfCancellationRequested();
try
{
if (IsDue(schedule, now))
{
await ProcessScheduleAsync(schedule, now, cancellationToken).ConfigureAwait(false);
processed++;
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to process digest schedule {ScheduleId} for tenant {TenantId}",
schedule.ScheduleId, schedule.TenantId);
}
}
return processed;
}
public DateTimeOffset? GetNextScheduledTime(DigestSchedule schedule, DateTimeOffset? after = null)
{
ArgumentNullException.ThrowIfNull(schedule);
var referenceTime = after ?? _timeProvider.GetUtcNow();
try
{
var timeZone = TimeZoneInfo.FindSystemTimeZoneById(schedule.TimeZone);
if (!string.IsNullOrWhiteSpace(schedule.CronExpression))
{
var cron = CronExpression.Parse(schedule.CronExpression);
var next = cron.GetNextOccurrence(referenceTime.UtcDateTime, timeZone);
return next.HasValue
? new DateTimeOffset(next.Value, timeZone.GetUtcOffset(next.Value))
: null;
}
// Default period-based scheduling
return schedule.Period switch
{
DigestPeriod.Hourly => referenceTime.AddHours(1).Date.AddHours(referenceTime.Hour + 1),
DigestPeriod.Daily => referenceTime.Date.AddDays(1).AddHours(9), // 9 AM next day
DigestPeriod.Weekly => GetNextWeekday(referenceTime, DayOfWeek.Monday).AddHours(9),
_ => null
};
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to calculate next scheduled time for {ScheduleId}",
schedule.ScheduleId);
return null;
}
}
/// <summary>
/// Registers a digest schedule.
/// </summary>
public void RegisterSchedule(DigestSchedule schedule)
{
ArgumentNullException.ThrowIfNull(schedule);
_schedules[schedule.ScheduleId] = schedule;
_logger.LogInformation(
"Registered digest schedule {ScheduleId} for tenant {TenantId}",
schedule.ScheduleId, schedule.TenantId);
}
/// <summary>
/// Unregisters a digest schedule.
/// </summary>
public void UnregisterSchedule(string scheduleId)
{
_schedules.TryRemove(scheduleId, out _);
_lastRunTimes.TryRemove(scheduleId, out _);
}
private bool IsDue(DigestSchedule schedule, DateTimeOffset now)
{
// Check if we've run recently
if (_lastRunTimes.TryGetValue(schedule.ScheduleId, out var lastRun))
{
var minInterval = schedule.Period switch
{
DigestPeriod.Hourly => TimeSpan.FromMinutes(55),
DigestPeriod.Daily => TimeSpan.FromHours(23),
DigestPeriod.Weekly => TimeSpan.FromDays(6.5),
_ => TimeSpan.FromHours(1)
};
if (now - lastRun < minInterval)
{
return false;
}
}
var nextScheduled = GetNextScheduledTime(schedule, _lastRunTimes.GetValueOrDefault(schedule.ScheduleId));
return nextScheduled.HasValue && now >= nextScheduled.Value;
}
private async Task ProcessScheduleAsync(
DigestSchedule schedule,
DateTimeOffset now,
CancellationToken cancellationToken)
{
_logger.LogDebug("Processing digest schedule {ScheduleId}", schedule.ScheduleId);
// Calculate period
var (periodStart, periodEnd) = CalculatePeriod(schedule, now);
// Generate digest
var digest = await _digestGenerator.GenerateAsync(
schedule, periodStart, periodEnd, cancellationToken).ConfigureAwait(false);
// Record run time
_lastRunTimes[schedule.ScheduleId] = now;
// Skip if no events
if (digest.Status == NotifyDigestStatus.Skipped || digest.EventCount == 0)
{
_logger.LogDebug(
"Skipping empty digest {DigestId} for schedule {ScheduleId}",
digest.DigestId, schedule.ScheduleId);
return;
}
// Format content
var content = await _digestGenerator.FormatAsync(
digest, schedule.TemplateId, cancellationToken).ConfigureAwait(false);
// Get channel and send
var channel = await _channelRepository.GetAsync(
schedule.TenantId, schedule.ChannelId, cancellationToken).ConfigureAwait(false);
if (channel is null)
{
_logger.LogWarning(
"Channel {ChannelId} not found for digest schedule {ScheduleId}",
schedule.ChannelId, schedule.ScheduleId);
return;
}
if (!_channelAdapters.TryGetValue(channel.Type, out var adapter))
{
_logger.LogWarning(
"No adapter found for channel type {ChannelType}",
channel.Type);
return;
}
var rendered = NotifyDeliveryRendered.Create(
channelType: channel.Type,
format: NotifyDeliveryFormat.Json,
target: channel.Config?.Target ?? string.Empty,
title: $"Notification Digest: {schedule.Name}",
body: content,
locale: "en-us");
var result = await adapter.SendAsync(channel, rendered, cancellationToken).ConfigureAwait(false);
if (result.Success)
{
_logger.LogInformation(
"Sent digest {DigestId} via channel {ChannelId}: {EventCount} events",
digest.DigestId, schedule.ChannelId, digest.EventCount);
}
else
{
_logger.LogWarning(
"Failed to send digest {DigestId}: {Reason}",
digest.DigestId, result.Reason);
}
}
private static (DateTimeOffset Start, DateTimeOffset End) CalculatePeriod(
DigestSchedule schedule,
DateTimeOffset now)
{
return schedule.Period switch
{
DigestPeriod.Hourly => (now.AddHours(-1), now),
DigestPeriod.Daily => (now.Date.AddDays(-1), now.Date),
DigestPeriod.Weekly => (now.Date.AddDays(-7), now.Date),
_ => (now.AddHours(-1), now)
};
}
private static DateTimeOffset GetNextWeekday(DateTimeOffset from, DayOfWeek target)
{
var daysUntil = ((int)target - (int)from.DayOfWeek + 7) % 7;
if (daysUntil == 0) daysUntil = 7;
return from.Date.AddDays(daysUntil);
}
private static IReadOnlyDictionary<NotifyChannelType, INotifyChannelAdapter> BuildAdapterMap(
IEnumerable<INotifyChannelAdapter> adapters)
{
var builder = new Dictionary<NotifyChannelType, INotifyChannelAdapter>();
foreach (var adapter in adapters)
{
builder[adapter.ChannelType] = adapter;
}
return builder;
}
}

View File

@@ -0,0 +1,40 @@
namespace StellaOps.Notifier.Worker.Digest;
/// <summary>
/// Generates notification digests from accumulated events.
/// </summary>
public interface IDigestGenerator
{
/// <summary>
/// Generates a digest for the given schedule and time period.
/// </summary>
Task<NotifyDigest> GenerateAsync(
DigestSchedule schedule,
DateTimeOffset periodStart,
DateTimeOffset periodEnd,
CancellationToken cancellationToken = default);
/// <summary>
/// Formats a digest into renderable content using the specified template.
/// </summary>
Task<string> FormatAsync(
NotifyDigest digest,
string templateId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Manages digest schedule execution and delivery.
/// </summary>
public interface IDigestScheduleRunner
{
/// <summary>
/// Checks all schedules and generates/sends digests that are due.
/// </summary>
Task<int> ProcessDueDigestsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets the next scheduled time for a digest.
/// </summary>
DateTimeOffset? GetNextScheduledTime(DigestSchedule schedule, DateTimeOffset? after = null);
}

View File

@@ -0,0 +1,68 @@
using System.Collections.Immutable;
namespace StellaOps.Notifier.Worker.Digest;
/// <summary>
/// Represents a compiled digest summarizing multiple events for batch delivery.
/// </summary>
public sealed record NotifyDigest
{
public required string DigestId { get; init; }
public required string TenantId { get; init; }
public required string DigestKey { get; init; }
public required string ScheduleId { get; init; }
public required DigestPeriod Period { get; init; }
public required int EventCount { get; init; }
public required ImmutableArray<Guid> EventIds { get; init; }
public required ImmutableDictionary<string, int> EventKindCounts { get; init; }
public required DateTimeOffset PeriodStart { get; init; }
public required DateTimeOffset PeriodEnd { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public NotifyDigestStatus Status { get; init; } = NotifyDigestStatus.Pending;
public DateTimeOffset? SentAt { get; init; }
public string? RenderedContent { get; init; }
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Status of a digest through its lifecycle.
/// </summary>
public enum NotifyDigestStatus
{
Pending,
Generating,
Ready,
Sent,
Failed,
Skipped
}
/// <summary>
/// Digest delivery period/frequency.
/// </summary>
public enum DigestPeriod
{
Hourly,
Daily,
Weekly,
Custom
}
/// <summary>
/// Configuration for a digest schedule.
/// </summary>
public sealed record DigestSchedule
{
public required string ScheduleId { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public required string DigestKey { get; init; }
public required DigestPeriod Period { get; init; }
public string? CronExpression { get; init; }
public required string TimeZone { get; init; }
public required string ChannelId { get; init; }
public required string TemplateId { get; init; }
public ImmutableArray<string> EventKinds { get; init; } = [];
public bool Enabled { get; init; } = true;
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
}

View File

@@ -0,0 +1,507 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Channels;
namespace StellaOps.Notifier.Worker.Escalation;
/// <summary>
/// Default implementation of the escalation engine.
/// </summary>
public sealed class DefaultEscalationEngine : IEscalationEngine
{
private readonly INotifyEscalationPolicyRepository _policyRepository;
private readonly INotifyEscalationStateRepository _stateRepository;
private readonly INotifyChannelRepository _channelRepository;
private readonly IOnCallResolver _onCallResolver;
private readonly IEnumerable<INotifyChannelAdapter> _channelAdapters;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DefaultEscalationEngine> _logger;
public DefaultEscalationEngine(
INotifyEscalationPolicyRepository policyRepository,
INotifyEscalationStateRepository stateRepository,
INotifyChannelRepository channelRepository,
IOnCallResolver onCallResolver,
IEnumerable<INotifyChannelAdapter> channelAdapters,
TimeProvider timeProvider,
ILogger<DefaultEscalationEngine> logger)
{
_policyRepository = policyRepository ?? throw new ArgumentNullException(nameof(policyRepository));
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
_channelRepository = channelRepository ?? throw new ArgumentNullException(nameof(channelRepository));
_onCallResolver = onCallResolver ?? throw new ArgumentNullException(nameof(onCallResolver));
_channelAdapters = channelAdapters ?? throw new ArgumentNullException(nameof(channelAdapters));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<NotifyEscalationState> StartEscalationAsync(
string tenantId,
string incidentId,
string policyId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(incidentId);
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
// Check if escalation already exists for this incident
var existingState = await _stateRepository.GetByIncidentAsync(tenantId, incidentId, cancellationToken).ConfigureAwait(false);
if (existingState is not null && existingState.Status == NotifyEscalationStatus.Active)
{
_logger.LogDebug("Escalation already active for incident {IncidentId}", incidentId);
return existingState;
}
var policy = await _policyRepository.GetAsync(tenantId, policyId, cancellationToken).ConfigureAwait(false);
if (policy is null)
{
throw new InvalidOperationException($"Escalation policy {policyId} not found.");
}
if (!policy.Enabled)
{
throw new InvalidOperationException($"Escalation policy {policyId} is disabled.");
}
var now = _timeProvider.GetUtcNow();
var firstLevel = policy.Levels.FirstOrDefault();
var nextEscalationAt = firstLevel is not null ? now.Add(firstLevel.EscalateAfter) : (DateTimeOffset?)null;
var state = NotifyEscalationState.Create(
stateId: Guid.NewGuid().ToString("N"),
tenantId: tenantId,
incidentId: incidentId,
policyId: policyId,
currentLevel: 0,
repeatIteration: 0,
status: NotifyEscalationStatus.Active,
nextEscalationAt: nextEscalationAt,
createdAt: now);
await _stateRepository.UpsertAsync(state, cancellationToken).ConfigureAwait(false);
// Notify first level immediately
if (firstLevel is not null)
{
await NotifyLevelAsync(tenantId, state, policy, firstLevel, cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation(
"Started escalation {StateId} for incident {IncidentId} with policy {PolicyId}",
state.StateId, incidentId, policyId);
return state;
}
public async Task<EscalationProcessResult> ProcessPendingEscalationsAsync(
string tenantId,
int batchSize = 100,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var now = _timeProvider.GetUtcNow();
var pendingStates = await _stateRepository.ListDueForEscalationAsync(tenantId, now, batchSize, cancellationToken).ConfigureAwait(false);
var processed = 0;
var escalated = 0;
var exhausted = 0;
var errors = 0;
var errorMessages = new List<string>();
foreach (var state in pendingStates)
{
try
{
var policy = await _policyRepository.GetAsync(tenantId, state.PolicyId, cancellationToken).ConfigureAwait(false);
if (policy is null || !policy.Enabled)
{
_logger.LogWarning("Policy {PolicyId} not found or disabled for escalation {StateId}", state.PolicyId, state.StateId);
continue;
}
var result = await ProcessEscalationAsync(tenantId, state, policy, now, cancellationToken).ConfigureAwait(false);
processed++;
if (result.Escalated)
{
escalated++;
}
else if (result.Exhausted)
{
exhausted++;
}
}
catch (Exception ex)
{
errors++;
errorMessages.Add($"State {state.StateId}: {ex.Message}");
_logger.LogError(ex, "Error processing escalation {StateId}", state.StateId);
}
}
return new EscalationProcessResult
{
Processed = processed,
Escalated = escalated,
Exhausted = exhausted,
Errors = errors,
ErrorMessages = errorMessages.Count > 0 ? errorMessages : null
};
}
public async Task<NotifyEscalationState?> AcknowledgeAsync(
string tenantId,
string stateIdOrIncidentId,
string acknowledgedBy,
CancellationToken cancellationToken = default)
{
var state = await FindStateAsync(tenantId, stateIdOrIncidentId, cancellationToken).ConfigureAwait(false);
if (state is null)
{
return null;
}
if (state.Status != NotifyEscalationStatus.Active)
{
_logger.LogDebug("Escalation {StateId} is not active, cannot acknowledge", state.StateId);
return state;
}
var now = _timeProvider.GetUtcNow();
await _stateRepository.AcknowledgeAsync(tenantId, state.StateId, acknowledgedBy, now, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Escalation {StateId} acknowledged by {AcknowledgedBy}",
state.StateId, acknowledgedBy);
return await _stateRepository.GetAsync(tenantId, state.StateId, cancellationToken).ConfigureAwait(false);
}
public async Task<NotifyEscalationState?> ResolveAsync(
string tenantId,
string stateIdOrIncidentId,
string resolvedBy,
CancellationToken cancellationToken = default)
{
var state = await FindStateAsync(tenantId, stateIdOrIncidentId, cancellationToken).ConfigureAwait(false);
if (state is null)
{
return null;
}
if (state.Status == NotifyEscalationStatus.Resolved)
{
return state;
}
var now = _timeProvider.GetUtcNow();
await _stateRepository.ResolveAsync(tenantId, state.StateId, resolvedBy, now, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Escalation {StateId} resolved by {ResolvedBy}",
state.StateId, resolvedBy);
return await _stateRepository.GetAsync(tenantId, state.StateId, cancellationToken).ConfigureAwait(false);
}
public async Task<NotifyEscalationState?> GetStateForIncidentAsync(
string tenantId,
string incidentId,
CancellationToken cancellationToken = default)
{
return await _stateRepository.GetByIncidentAsync(tenantId, incidentId, cancellationToken).ConfigureAwait(false);
}
private async Task<NotifyEscalationState?> FindStateAsync(
string tenantId,
string stateIdOrIncidentId,
CancellationToken cancellationToken)
{
// Try by state ID first
var state = await _stateRepository.GetAsync(tenantId, stateIdOrIncidentId, cancellationToken).ConfigureAwait(false);
if (state is not null)
{
return state;
}
// Try by incident ID
return await _stateRepository.GetByIncidentAsync(tenantId, stateIdOrIncidentId, cancellationToken).ConfigureAwait(false);
}
private async Task<(bool Escalated, bool Exhausted)> ProcessEscalationAsync(
string tenantId,
NotifyEscalationState state,
NotifyEscalationPolicy policy,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var nextLevel = state.CurrentLevel + 1;
var iteration = state.RepeatIteration;
if (nextLevel >= policy.Levels.Length)
{
// Reached end of levels
if (policy.RepeatEnabled && (policy.RepeatCount is null || iteration < policy.RepeatCount))
{
// Repeat from first level
nextLevel = 0;
iteration++;
}
else
{
// Exhausted all levels and repeats
await _stateRepository.UpdateLevelAsync(
tenantId,
state.StateId,
state.CurrentLevel,
iteration,
null, // No next escalation
new NotifyEscalationAttempt(state.CurrentLevel, iteration, now, ImmutableArray<string>.Empty, true),
cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Escalation {StateId} exhausted all levels", state.StateId);
return (false, true);
}
}
var level = policy.Levels[nextLevel];
var nextEscalationAt = now.Add(level.EscalateAfter);
// Notify targets at this level
var notifiedTargets = await NotifyLevelAsync(tenantId, state, policy, level, cancellationToken).ConfigureAwait(false);
var attempt = new NotifyEscalationAttempt(
nextLevel,
iteration,
now,
notifiedTargets.ToImmutableArray(),
notifiedTargets.Count > 0);
await _stateRepository.UpdateLevelAsync(
tenantId,
state.StateId,
nextLevel,
iteration,
nextEscalationAt,
attempt,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Escalation {StateId} advanced to level {Level} iteration {Iteration}, notified {TargetCount} targets",
state.StateId, nextLevel, iteration, notifiedTargets.Count);
return (true, false);
}
private async Task<List<string>> NotifyLevelAsync(
string tenantId,
NotifyEscalationState state,
NotifyEscalationPolicy policy,
NotifyEscalationLevel level,
CancellationToken cancellationToken)
{
var notifiedTargets = new List<string>();
foreach (var target in level.Targets)
{
try
{
var notified = await NotifyTargetAsync(tenantId, state, target, cancellationToken).ConfigureAwait(false);
if (notified)
{
notifiedTargets.Add($"{target.Type}:{target.TargetId}");
}
// If NotifyAll is false, stop after first successful notification
if (!level.NotifyAll && notified)
{
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to notify target {TargetType}:{TargetId}", target.Type, target.TargetId);
}
}
return notifiedTargets;
}
private async Task<bool> NotifyTargetAsync(
string tenantId,
NotifyEscalationState state,
NotifyEscalationTarget target,
CancellationToken cancellationToken)
{
switch (target.Type)
{
case NotifyEscalationTargetType.OnCallSchedule:
var resolution = await _onCallResolver.ResolveAsync(tenantId, target.TargetId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (resolution.OnCallUsers.IsDefaultOrEmpty)
{
_logger.LogWarning("No on-call user found for schedule {ScheduleId}", target.TargetId);
return false;
}
var notifiedAny = false;
foreach (var user in resolution.OnCallUsers)
{
if (await NotifyUserAsync(tenantId, state, user, target.ChannelOverride, cancellationToken).ConfigureAwait(false))
{
notifiedAny = true;
}
}
return notifiedAny;
case NotifyEscalationTargetType.User:
// For user targets, we'd need a user repository to get contact info
// For now, log and return false
_logger.LogDebug("User target notification not yet implemented: {UserId}", target.TargetId);
return false;
case NotifyEscalationTargetType.Channel:
// Send directly to a channel
return await SendToChannelAsync(tenantId, state, target.TargetId, cancellationToken).ConfigureAwait(false);
case NotifyEscalationTargetType.ExternalService:
// Would call PagerDuty/OpsGenie adapters
_logger.LogDebug("External service target notification not yet implemented: {ServiceId}", target.TargetId);
return false;
case NotifyEscalationTargetType.InAppInbox:
// Would send to in-app inbox
_logger.LogDebug("In-app inbox notification not yet implemented");
return false;
default:
_logger.LogWarning("Unknown escalation target type: {TargetType}", target.Type);
return false;
}
}
private async Task<bool> NotifyUserAsync(
string tenantId,
NotifyEscalationState state,
NotifyOnCallParticipant user,
string? channelOverride,
CancellationToken cancellationToken)
{
// Prefer channel override if specified
if (!string.IsNullOrWhiteSpace(channelOverride))
{
return await SendToChannelAsync(tenantId, state, channelOverride, cancellationToken).ConfigureAwait(false);
}
// Try contact methods in order
foreach (var method in user.ContactMethods.OrderBy(m => m.Priority))
{
if (!method.Enabled) continue;
// Map contact method to channel type
var channelType = method.Type switch
{
NotifyContactMethodType.Email => NotifyChannelType.Email,
NotifyContactMethodType.Slack => NotifyChannelType.Slack,
NotifyContactMethodType.Teams => NotifyChannelType.Teams,
NotifyContactMethodType.Webhook => NotifyChannelType.Webhook,
_ => NotifyChannelType.Custom
};
var adapter = _channelAdapters.FirstOrDefault(a => a.ChannelType == channelType);
if (adapter is not null)
{
// Create a minimal rendered notification for the escalation
var format = channelType switch
{
NotifyChannelType.Email => NotifyDeliveryFormat.Email,
NotifyChannelType.Slack => NotifyDeliveryFormat.Slack,
NotifyChannelType.Teams => NotifyDeliveryFormat.Teams,
NotifyChannelType.Webhook => NotifyDeliveryFormat.Webhook,
NotifyChannelType.PagerDuty => NotifyDeliveryFormat.PagerDuty,
NotifyChannelType.OpsGenie => NotifyDeliveryFormat.OpsGenie,
NotifyChannelType.Cli => NotifyDeliveryFormat.Cli,
NotifyChannelType.InAppInbox => NotifyDeliveryFormat.InAppInbox,
_ => NotifyDeliveryFormat.Json
};
var rendered = NotifyDeliveryRendered.Create(
channelType,
format,
method.Address,
$"Escalation: Incident {state.IncidentId}",
$"Incident {state.IncidentId} requires attention. Escalation level: {state.CurrentLevel + 1}");
// Get default channel config
var channels = await _channelRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
var channel = channels.FirstOrDefault(c => c.Type == channelType);
if (channel is not null)
{
var result = await adapter.SendAsync(channel, rendered, cancellationToken).ConfigureAwait(false);
if (result.Success)
{
_logger.LogDebug("Notified user {UserId} via {ContactMethod}", user.UserId, method.Type);
return true;
}
}
}
}
// Fallback to email if available
if (!string.IsNullOrWhiteSpace(user.Email))
{
_logger.LogDebug("Would send email to {Email} for user {UserId}", user.Email, user.UserId);
return true; // Assume success for now
}
return false;
}
private async Task<bool> SendToChannelAsync(
string tenantId,
NotifyEscalationState state,
string channelId,
CancellationToken cancellationToken)
{
var channel = await _channelRepository.GetAsync(tenantId, channelId, cancellationToken).ConfigureAwait(false);
if (channel is null)
{
_logger.LogWarning("Channel {ChannelId} not found for escalation", channelId);
return false;
}
var adapter = _channelAdapters.FirstOrDefault(a => a.ChannelType == channel.Type);
if (adapter is null)
{
_logger.LogWarning("No adapter found for channel type {ChannelType}", channel.Type);
return false;
}
var channelFormat = channel.Type switch
{
NotifyChannelType.Email => NotifyDeliveryFormat.Email,
NotifyChannelType.Slack => NotifyDeliveryFormat.Slack,
NotifyChannelType.Teams => NotifyDeliveryFormat.Teams,
NotifyChannelType.Webhook => NotifyDeliveryFormat.Webhook,
NotifyChannelType.PagerDuty => NotifyDeliveryFormat.PagerDuty,
NotifyChannelType.OpsGenie => NotifyDeliveryFormat.OpsGenie,
NotifyChannelType.Cli => NotifyDeliveryFormat.Cli,
NotifyChannelType.InAppInbox => NotifyDeliveryFormat.InAppInbox,
_ => NotifyDeliveryFormat.Json
};
var rendered = NotifyDeliveryRendered.Create(
channel.Type,
channelFormat,
channel.Config.Target ?? channel.Config.Endpoint ?? string.Empty,
$"Escalation: Incident {state.IncidentId}",
$"Incident {state.IncidentId} requires attention. Escalation level: {state.CurrentLevel + 1}. Policy: {state.PolicyId}");
var result = await adapter.SendAsync(channel, rendered, cancellationToken).ConfigureAwait(false);
return result.Success;
}
}

View File

@@ -0,0 +1,221 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Worker.Escalation;
/// <summary>
/// Default implementation of on-call schedule resolution.
/// </summary>
public sealed class DefaultOnCallResolver : IOnCallResolver
{
private readonly INotifyOnCallScheduleRepository? _scheduleRepository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DefaultOnCallResolver> _logger;
public DefaultOnCallResolver(
TimeProvider timeProvider,
ILogger<DefaultOnCallResolver> logger,
INotifyOnCallScheduleRepository? scheduleRepository = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_scheduleRepository = scheduleRepository;
}
public async Task<NotifyOnCallResolution> ResolveAsync(
string tenantId,
string scheduleId,
DateTimeOffset? evaluationTime = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(scheduleId);
if (_scheduleRepository is null)
{
_logger.LogWarning("On-call schedule repository not available");
return new NotifyOnCallResolution(scheduleId, evaluationTime ?? _timeProvider.GetUtcNow(), ImmutableArray<NotifyOnCallParticipant>.Empty);
}
var schedule = await _scheduleRepository.GetAsync(tenantId, scheduleId, cancellationToken).ConfigureAwait(false);
if (schedule is null)
{
_logger.LogWarning("On-call schedule {ScheduleId} not found for tenant {TenantId}", scheduleId, tenantId);
return new NotifyOnCallResolution(scheduleId, evaluationTime ?? _timeProvider.GetUtcNow(), ImmutableArray<NotifyOnCallParticipant>.Empty);
}
return ResolveAt(schedule, evaluationTime ?? _timeProvider.GetUtcNow());
}
public NotifyOnCallResolution ResolveAt(
NotifyOnCallSchedule schedule,
DateTimeOffset evaluationTime)
{
ArgumentNullException.ThrowIfNull(schedule);
// Check for active override first
var activeOverride = schedule.Overrides
.FirstOrDefault(o => o.IsActiveAt(evaluationTime));
if (activeOverride is not null)
{
// Find the participant matching the override user ID
var overrideUser = schedule.Layers
.SelectMany(l => l.Participants)
.FirstOrDefault(p => p.UserId == activeOverride.UserId);
if (overrideUser is not null)
{
_logger.LogDebug(
"On-call resolved from override {OverrideId} for schedule {ScheduleId}: user={UserId}",
activeOverride.OverrideId, schedule.ScheduleId, activeOverride.UserId);
return new NotifyOnCallResolution(
schedule.ScheduleId,
evaluationTime,
ImmutableArray.Create(overrideUser),
sourceOverride: activeOverride.OverrideId);
}
// Override user not in participants - create a minimal participant
var minimalParticipant = NotifyOnCallParticipant.Create(activeOverride.UserId);
return new NotifyOnCallResolution(
schedule.ScheduleId,
evaluationTime,
ImmutableArray.Create(minimalParticipant),
sourceOverride: activeOverride.OverrideId);
}
// No override - find highest priority active layer
var activeLayer = FindActiveLayer(schedule, evaluationTime);
if (activeLayer is null || activeLayer.Participants.IsDefaultOrEmpty)
{
_logger.LogDebug("No active on-call layer found for schedule {ScheduleId} at {EvaluationTime}",
schedule.ScheduleId, evaluationTime);
return new NotifyOnCallResolution(schedule.ScheduleId, evaluationTime, ImmutableArray<NotifyOnCallParticipant>.Empty);
}
// Calculate who is on-call based on rotation
var onCallUser = CalculateRotationUser(activeLayer, evaluationTime, schedule.TimeZone);
if (onCallUser is null)
{
_logger.LogDebug("No on-call user found in rotation for layer {LayerId}", activeLayer.LayerId);
return new NotifyOnCallResolution(schedule.ScheduleId, evaluationTime, ImmutableArray<NotifyOnCallParticipant>.Empty);
}
_logger.LogDebug(
"On-call resolved from layer {LayerId} for schedule {ScheduleId}: user={UserId}",
activeLayer.LayerId, schedule.ScheduleId, onCallUser.UserId);
return new NotifyOnCallResolution(
schedule.ScheduleId,
evaluationTime,
ImmutableArray.Create(onCallUser),
sourceLayer: activeLayer.LayerId);
}
private NotifyOnCallLayer? FindActiveLayer(NotifyOnCallSchedule schedule, DateTimeOffset evaluationTime)
{
// Order layers by priority (higher priority first)
var orderedLayers = schedule.Layers.OrderByDescending(l => l.Priority);
foreach (var layer in orderedLayers)
{
if (IsLayerActiveAt(layer, evaluationTime, schedule.TimeZone))
{
return layer;
}
}
// If no layer matches restrictions, return highest priority layer
return schedule.Layers.OrderByDescending(l => l.Priority).FirstOrDefault();
}
private bool IsLayerActiveAt(NotifyOnCallLayer layer, DateTimeOffset evaluationTime, string timeZone)
{
if (layer.Restrictions is null || layer.Restrictions.TimeRanges.IsDefaultOrEmpty)
{
return true; // No restrictions = always active
}
try
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZone);
var localTime = TimeZoneInfo.ConvertTime(evaluationTime, tz);
foreach (var range in layer.Restrictions.TimeRanges)
{
var isTimeInRange = IsTimeInRange(localTime.TimeOfDay, range.StartTime, range.EndTime);
if (layer.Restrictions.Type == NotifyRestrictionType.DailyRestriction)
{
if (isTimeInRange) return true;
}
else if (layer.Restrictions.Type == NotifyRestrictionType.WeeklyRestriction)
{
if (range.DayOfWeek == localTime.DayOfWeek && isTimeInRange)
{
return true;
}
}
}
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to evaluate layer restrictions for layer {LayerId}", layer.LayerId);
return true; // On error, assume layer is active
}
}
private static bool IsTimeInRange(TimeSpan current, TimeOnly start, TimeOnly end)
{
var currentTimeOnly = TimeOnly.FromTimeSpan(current);
if (start <= end)
{
return currentTimeOnly >= start && currentTimeOnly < end;
}
// Handles overnight ranges (e.g., 22:00 - 06:00)
return currentTimeOnly >= start || currentTimeOnly < end;
}
private NotifyOnCallParticipant? CalculateRotationUser(
NotifyOnCallLayer layer,
DateTimeOffset evaluationTime,
string timeZone)
{
if (layer.Participants.IsDefaultOrEmpty)
{
return null;
}
var participantCount = layer.Participants.Length;
if (participantCount == 1)
{
return layer.Participants[0];
}
// Calculate rotation index based on time since rotation start
var rotationStart = layer.RotationStartsAt;
var elapsed = evaluationTime - rotationStart;
if (elapsed < TimeSpan.Zero)
{
// Evaluation time is before rotation start - return first participant
return layer.Participants[0];
}
var rotationCount = (long)(elapsed / layer.RotationInterval);
var currentIndex = (int)(rotationCount % participantCount);
return layer.Participants[currentIndex];
}
}

View File

@@ -0,0 +1,64 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Escalation;
/// <summary>
/// Processes escalation state and triggers notifications at appropriate levels.
/// </summary>
public interface IEscalationEngine
{
/// <summary>
/// Starts escalation for an incident.
/// </summary>
Task<NotifyEscalationState> StartEscalationAsync(
string tenantId,
string incidentId,
string policyId,
CancellationToken cancellationToken = default);
/// <summary>
/// Processes pending escalations and advances to next level if needed.
/// </summary>
Task<EscalationProcessResult> ProcessPendingEscalationsAsync(
string tenantId,
int batchSize = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Acknowledges an escalation.
/// </summary>
Task<NotifyEscalationState?> AcknowledgeAsync(
string tenantId,
string stateIdOrIncidentId,
string acknowledgedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves an escalation.
/// </summary>
Task<NotifyEscalationState?> ResolveAsync(
string tenantId,
string stateIdOrIncidentId,
string resolvedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current escalation state for an incident.
/// </summary>
Task<NotifyEscalationState?> GetStateForIncidentAsync(
string tenantId,
string incidentId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of processing escalations.
/// </summary>
public sealed record EscalationProcessResult
{
public required int Processed { get; init; }
public required int Escalated { get; init; }
public required int Exhausted { get; init; }
public required int Errors { get; init; }
public IReadOnlyList<string>? ErrorMessages { get; init; }
}

View File

@@ -0,0 +1,25 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Escalation;
/// <summary>
/// Resolves who is currently on-call for a given schedule.
/// </summary>
public interface IOnCallResolver
{
/// <summary>
/// Resolves the current on-call user(s) for a schedule.
/// </summary>
Task<NotifyOnCallResolution> ResolveAsync(
string tenantId,
string scheduleId,
DateTimeOffset? evaluationTime = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves the current on-call user(s) for a schedule at a specific time.
/// </summary>
NotifyOnCallResolution ResolveAt(
NotifyOnCallSchedule schedule,
DateTimeOffset evaluationTime);
}

View File

@@ -16,4 +16,14 @@ public sealed class NotifierWorkerOptions
/// Default TTL for idempotency reservations when actions do not specify a throttle.
/// </summary>
public TimeSpan DefaultIdempotencyTtl { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Poll interval for the dispatch worker when no pending deliveries are found.
/// </summary>
public TimeSpan DispatchPollInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Maximum number of pending deliveries to process in a single dispatch batch.
/// </summary>
public int DispatchBatchSize { get; set; } = 10;
}

View File

@@ -5,7 +5,7 @@ using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Processing;
internal sealed class DefaultNotifyRuleEvaluator : INotifyRuleEvaluator
public sealed class DefaultNotifyRuleEvaluator : INotifyRuleEvaluator
{
private static readonly IDictionary<string, int> SeverityRank = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{

View File

@@ -0,0 +1,18 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Processing;
/// <summary>
/// Renders notification templates with event payload data.
/// </summary>
public interface INotifyTemplateRenderer
{
/// <summary>
/// Renders a template body using the provided data context.
/// </summary>
/// <param name="template">The template containing the body pattern.</param>
/// <param name="payload">The event payload data to interpolate.</param>
/// <returns>The rendered string.</returns>
string Render(NotifyTemplate template, JsonNode? payload);
}

View File

@@ -0,0 +1,288 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Channels;
using StellaOps.Notifier.Worker.Options;
namespace StellaOps.Notifier.Worker.Processing;
/// <summary>
/// Background worker that picks up pending deliveries, renders templates, and dispatches through channels.
/// </summary>
public sealed class NotifierDispatchWorker : BackgroundService
{
private readonly INotifyDeliveryRepository _deliveryRepository;
private readonly INotifyTemplateRepository _templateRepository;
private readonly INotifyChannelRepository _channelRepository;
private readonly INotifyTemplateRenderer _templateRenderer;
private readonly IReadOnlyDictionary<NotifyChannelType, INotifyChannelAdapter> _channelAdapters;
private readonly NotifierWorkerOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifierDispatchWorker> _logger;
private readonly string _workerId;
public NotifierDispatchWorker(
INotifyDeliveryRepository deliveryRepository,
INotifyTemplateRepository templateRepository,
INotifyChannelRepository channelRepository,
INotifyTemplateRenderer templateRenderer,
IEnumerable<INotifyChannelAdapter> channelAdapters,
IOptions<NotifierWorkerOptions> options,
TimeProvider timeProvider,
ILogger<NotifierDispatchWorker> logger)
{
_deliveryRepository = deliveryRepository ?? throw new ArgumentNullException(nameof(deliveryRepository));
_templateRepository = templateRepository ?? throw new ArgumentNullException(nameof(templateRepository));
_channelRepository = channelRepository ?? throw new ArgumentNullException(nameof(channelRepository));
_templateRenderer = templateRenderer ?? throw new ArgumentNullException(nameof(templateRenderer));
_channelAdapters = BuildAdapterMap(channelAdapters);
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_workerId = $"notifier-dispatch-{Environment.MachineName}-{Guid.NewGuid():N}";
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Notifier dispatch worker {WorkerId} started.", _workerId);
var pollInterval = _options.DispatchPollInterval > TimeSpan.Zero
? _options.DispatchPollInterval
: TimeSpan.FromSeconds(5);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var processed = await ProcessPendingDeliveriesAsync(stoppingToken).ConfigureAwait(false);
if (processed == 0)
{
await Task.Delay(pollInterval, stoppingToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception in dispatch worker loop.");
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken).ConfigureAwait(false);
}
}
_logger.LogInformation("Notifier dispatch worker {WorkerId} stopping.", _workerId);
}
private async Task<int> ProcessPendingDeliveriesAsync(CancellationToken cancellationToken)
{
// Query for pending deliveries across all tenants (simplified - production would partition)
var result = await _deliveryRepository.QueryAsync(
tenantId: "tenant-sample", // In production, would iterate tenants
since: null,
status: "pending",
limit: _options.DispatchBatchSize > 0 ? _options.DispatchBatchSize : 10,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (result.Items.Count == 0)
{
return 0;
}
var processed = 0;
foreach (var delivery in result.Items)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await ProcessDeliveryAsync(delivery, cancellationToken).ConfigureAwait(false);
processed++;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process delivery {DeliveryId}.", delivery.DeliveryId);
}
}
return processed;
}
private async Task ProcessDeliveryAsync(NotifyDelivery delivery, CancellationToken cancellationToken)
{
var tenantId = delivery.TenantId;
// Look up channel from metadata
if (!delivery.Metadata.TryGetValue("channel", out var channelId) || string.IsNullOrWhiteSpace(channelId))
{
await MarkDeliveryFailedAsync(delivery, "Channel reference missing in delivery metadata", cancellationToken)
.ConfigureAwait(false);
return;
}
var channel = await _channelRepository.GetAsync(tenantId, channelId, cancellationToken).ConfigureAwait(false);
if (channel is null)
{
await MarkDeliveryFailedAsync(delivery, $"Channel {channelId} not found", cancellationToken)
.ConfigureAwait(false);
return;
}
// Look up template from metadata
delivery.Metadata.TryGetValue("template", out var templateKey);
delivery.Metadata.TryGetValue("locale", out var locale);
locale ??= "en-us";
NotifyTemplate? template = null;
if (!string.IsNullOrWhiteSpace(templateKey))
{
// GetAsync uses templateId, so we look up by the template reference from metadata
template = await _templateRepository.GetAsync(tenantId, templateKey, cancellationToken)
.ConfigureAwait(false);
}
// Build rendered content
NotifyDeliveryRendered rendered;
if (template is not null)
{
// Create a payload from the delivery kind and metadata
var payload = BuildPayloadFromDelivery(delivery);
var renderedBody = _templateRenderer.Render(template, payload);
var subject = template.Metadata.TryGetValue("subject", out var subj)
? _templateRenderer.Render(
NotifyTemplate.Create(
templateId: "subject-inline",
tenantId: tenantId,
channelType: template.ChannelType,
key: "subject",
locale: locale,
body: subj),
payload)
: $"Notification: {delivery.Kind}";
rendered = NotifyDeliveryRendered.Create(
channelType: channel.Type,
format: template.Format,
target: channel.Config?.Target ?? string.Empty,
title: subject,
body: renderedBody,
locale: locale);
}
else
{
// Fallback rendering without template
rendered = NotifyDeliveryRendered.Create(
channelType: channel.Type,
format: NotifyDeliveryFormat.Json,
target: channel.Config?.Target ?? string.Empty,
title: $"Notification: {delivery.Kind}",
body: $"Event {delivery.EventId} triggered rule {delivery.RuleId}",
locale: locale);
}
// Dispatch through channel adapter
if (!_channelAdapters.TryGetValue(channel.Type, out var adapter))
{
await MarkDeliveryFailedAsync(delivery, $"No adapter for channel type {channel.Type}", cancellationToken)
.ConfigureAwait(false);
return;
}
var dispatchResult = await adapter.SendAsync(channel, rendered, cancellationToken).ConfigureAwait(false);
// Update delivery with result
var attempt = new NotifyDeliveryAttempt(
timestamp: _timeProvider.GetUtcNow(),
status: dispatchResult.Success ? NotifyDeliveryAttemptStatus.Succeeded : NotifyDeliveryAttemptStatus.Failed,
statusCode: dispatchResult.StatusCode,
reason: dispatchResult.Reason);
var newStatus = dispatchResult.Success
? NotifyDeliveryStatus.Sent
: (dispatchResult.ShouldRetry ? NotifyDeliveryStatus.Pending : NotifyDeliveryStatus.Failed);
var updatedDelivery = NotifyDelivery.Create(
deliveryId: delivery.DeliveryId,
tenantId: delivery.TenantId,
ruleId: delivery.RuleId,
actionId: delivery.ActionId,
eventId: delivery.EventId,
kind: delivery.Kind,
status: newStatus,
statusReason: dispatchResult.Reason,
rendered: rendered,
attempts: delivery.Attempts.Add(attempt),
metadata: delivery.Metadata,
createdAt: delivery.CreatedAt,
sentAt: dispatchResult.Success ? _timeProvider.GetUtcNow() : delivery.SentAt,
completedAt: newStatus == NotifyDeliveryStatus.Sent || newStatus == NotifyDeliveryStatus.Failed
? _timeProvider.GetUtcNow()
: null);
await _deliveryRepository.UpdateAsync(updatedDelivery, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Delivery {DeliveryId} dispatched via {ChannelType}: {Status}",
delivery.DeliveryId,
channel.Type,
newStatus);
}
private async Task MarkDeliveryFailedAsync(
NotifyDelivery delivery,
string reason,
CancellationToken cancellationToken)
{
var failedDelivery = NotifyDelivery.Create(
deliveryId: delivery.DeliveryId,
tenantId: delivery.TenantId,
ruleId: delivery.RuleId,
actionId: delivery.ActionId,
eventId: delivery.EventId,
kind: delivery.Kind,
status: NotifyDeliveryStatus.Failed,
statusReason: reason,
attempts: delivery.Attempts,
metadata: delivery.Metadata,
createdAt: delivery.CreatedAt,
completedAt: _timeProvider.GetUtcNow());
await _deliveryRepository.UpdateAsync(failedDelivery, cancellationToken).ConfigureAwait(false);
_logger.LogWarning("Delivery {DeliveryId} marked failed: {Reason}", delivery.DeliveryId, reason);
}
private static JsonObject BuildPayloadFromDelivery(NotifyDelivery delivery)
{
var payload = new JsonObject
{
["eventId"] = delivery.EventId.ToString(),
["kind"] = delivery.Kind,
["ruleId"] = delivery.RuleId,
["actionId"] = delivery.ActionId
};
foreach (var (key, value) in delivery.Metadata)
{
payload[key] = value;
}
return payload;
}
private static IReadOnlyDictionary<NotifyChannelType, INotifyChannelAdapter> BuildAdapterMap(
IEnumerable<INotifyChannelAdapter> adapters)
{
var builder = ImmutableDictionary.CreateBuilder<NotifyChannelType, INotifyChannelAdapter>();
foreach (var adapter in adapters)
{
builder[adapter.ChannelType] = adapter;
}
return builder.ToImmutable();
}
}

View File

@@ -0,0 +1,100 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Processing;
/// <summary>
/// Simple Handlebars-like template renderer supporting {{property}} and {{#each}} blocks.
/// </summary>
public sealed partial class SimpleTemplateRenderer : INotifyTemplateRenderer
{
private static readonly Regex PlaceholderPattern = PlaceholderRegex();
private static readonly Regex EachBlockPattern = EachBlockRegex();
public string Render(NotifyTemplate template, JsonNode? payload)
{
ArgumentNullException.ThrowIfNull(template);
var body = template.Body;
if (string.IsNullOrWhiteSpace(body))
{
return string.Empty;
}
// Process {{#each}} blocks first
body = ProcessEachBlocks(body, payload);
// Then substitute simple placeholders
body = SubstitutePlaceholders(body, payload);
return body;
}
private static string ProcessEachBlocks(string body, JsonNode? payload)
{
return EachBlockPattern.Replace(body, match =>
{
var collectionPath = match.Groups[1].Value.Trim();
var innerTemplate = match.Groups[2].Value;
var collection = ResolvePath(payload, collectionPath);
if (collection is not JsonObject obj)
{
return string.Empty;
}
var results = new List<string>();
foreach (var (key, value) in obj)
{
var itemResult = innerTemplate
.Replace("{{@key}}", key)
.Replace("{{this}}", value?.ToString() ?? string.Empty);
results.Add(itemResult);
}
return string.Join(string.Empty, results);
});
}
private static string SubstitutePlaceholders(string body, JsonNode? payload)
{
return PlaceholderPattern.Replace(body, match =>
{
var path = match.Groups[1].Value.Trim();
var resolved = ResolvePath(payload, path);
return resolved?.ToString() ?? string.Empty;
});
}
private static JsonNode? ResolvePath(JsonNode? root, string path)
{
if (root is null || string.IsNullOrWhiteSpace(path))
{
return null;
}
var segments = path.Split('.');
var current = root;
foreach (var segment in segments)
{
if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next))
{
current = next;
}
else
{
return null;
}
}
return current;
}
[GeneratedRegex(@"\{\{([^#/}]+)\}\}", RegexOptions.Compiled)]
private static partial Regex PlaceholderRegex();
[GeneratedRegex(@"\{\{#each\s+([^}]+)\}\}(.*?)\{\{/each\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex EachBlockRegex();
}

View File

@@ -2,10 +2,11 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Policy;
using StellaOps.Notify.Engine;
using StellaOps.AirGap.Policy;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Storage.Mongo;
using StellaOps.Notifier.Worker.Channels;
using StellaOps.Notifier.Worker.Options;
using StellaOps.Notifier.Worker.Processing;
@@ -25,10 +26,10 @@ builder.Logging.AddSimpleConsole(options =>
builder.Services.Configure<NotifierWorkerOptions>(builder.Configuration.GetSection("notifier:worker"));
builder.Services.AddSingleton(TimeProvider.System);
var mongoSection = builder.Configuration.GetSection("notifier:storage:mongo");
builder.Services.AddNotifyMongoStorage(mongoSection);
builder.Services.AddAirGapEgressPolicy(builder.Configuration);
var mongoSection = builder.Configuration.GetSection("notifier:storage:mongo");
builder.Services.AddNotifyMongoStorage(mongoSection);
builder.Services.AddAirGapEgressPolicy(builder.Configuration);
builder.Services.AddNotifyEventQueue(builder.Configuration, "notifier:queue");
builder.Services.AddHealthChecks().AddNotifyQueueHealthCheck();
@@ -38,4 +39,19 @@ builder.Services.AddSingleton<NotifierEventProcessor>();
builder.Services.AddHostedService<MongoInitializationHostedService>();
builder.Services.AddHostedService<NotifierEventWorker>();
// Template rendering
builder.Services.AddSingleton<INotifyTemplateRenderer, SimpleTemplateRenderer>();
// Channel adapters with HttpClient for webhook/Slack
builder.Services.AddHttpClient<WebhookChannelAdapter>();
builder.Services.AddHttpClient<SlackChannelAdapter>();
builder.Services.AddSingleton<INotifyChannelAdapter, WebhookChannelAdapter>(sp =>
sp.GetRequiredService<WebhookChannelAdapter>());
builder.Services.AddSingleton<INotifyChannelAdapter, SlackChannelAdapter>(sp =>
sp.GetRequiredService<SlackChannelAdapter>());
builder.Services.AddSingleton<INotifyChannelAdapter, EmailChannelAdapter>();
// Dispatch worker for rendering and sending notifications
builder.Services.AddHostedService<NotifierDispatchWorker>();
await builder.Build().RunAsync().ConfigureAwait(false);

View File

@@ -0,0 +1,649 @@
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.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Correlation;
namespace StellaOps.Notifier.Worker.Simulation;
/// <summary>
/// Default implementation of the notification simulation engine.
/// Dry-runs rules against events to preview what actions would be triggered.
/// </summary>
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<DefaultNotifySimulationEngine> _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<DefaultNotifySimulationEngine> 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<NotifySimulationResult> 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<SimulatedEventResult>();
var ruleSummaries = new Dictionary<string, RuleSummaryBuilder>(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<SimulatedEventResult> SimulateSingleEventAsync(
string tenantId,
JsonObject eventPayload,
IEnumerable<string>? 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<string, RuleSummaryBuilder>(StringComparer.Ordinal);
return await SimulateEventAsync(@event, rules, channels, request, evaluationTime, ruleSummaries, cancellationToken)
.ConfigureAwait(false);
}
private async Task<SimulatedEventResult> SimulateEventAsync(
NotifyEvent @event,
IReadOnlyList<NotifyRule> rules,
IReadOnlyDictionary<string, NotifyChannel> channels,
NotifySimulationRequest request,
DateTimeOffset evaluationTime,
Dictionary<string, RuleSummaryBuilder> ruleSummaries,
CancellationToken cancellationToken)
{
var matches = new List<SimulatedRuleMatch>();
var nonMatches = new List<SimulatedRuleNonMatch>();
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<ImmutableArray<SimulatedActionResult>> EvaluateActionsAsync(
NotifyEvent @event,
NotifyRule rule,
ImmutableArray<NotifyRuleAction> actions,
IReadOnlyDictionary<string, NotifyChannel> channels,
NotifySimulationRequest request,
DateTimeOffset evaluationTime,
CancellationToken cancellationToken)
{
var results = new List<SimulatedActionResult>();
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 isThrottled = await _throttler.IsThrottledAsync(
@event.Tenant, throttleKey, throttleWindow, cancellationToken).ConfigureAwait(false);
if (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.IsInQuietHoursAsync(
@event.Tenant, channelId, cancellationToken).ConfigureAwait(false);
if (quietHoursResult.IsInQuietHours)
{
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<string> BuildMatchExplanations(NotifyRule rule, NotifyEvent @event)
{
var explanations = new List<string>();
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<NotifyRule> FilterRules(
IReadOnlyList<NotifyRule> rules,
ImmutableArray<string> 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<IReadOnlyDictionary<string, NotifyChannel>> LoadChannelsAsync(
string tenantId,
IReadOnlyList<NotifyRule> 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<string, NotifyChannel>(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<NotifyEvent> ConvertAuditEntriesToEvents(
IReadOnlyList<NotifyAuditEntryDocument> auditEntries,
DateTimeOffset periodStart,
DateTimeOffset periodEnd,
ImmutableArray<string> eventKinds)
{
var kindSet = eventKinds.IsDefaultOrEmpty
? null
: eventKinds.ToHashSet(StringComparer.OrdinalIgnoreCase);
var events = new List<NotifyEvent>();
foreach (var entry in auditEntries)
{
// Skip entries outside the period
if (entry.Timestamp < periodStart || entry.Timestamp >= periodEnd)
{
continue;
}
// Try to extract event info from the audit entry's action or payload
// Audit entries may not contain full event data, so we reconstruct what we can
var eventKind = ExtractEventKindFromAuditEntry(entry);
if (string.IsNullOrWhiteSpace(eventKind))
{
continue;
}
// Filter by event kind if specified
if (kindSet is not null && !kindSet.Contains(eventKind))
{
continue;
}
var eventId = ExtractEventIdFromAuditEntry(entry);
var @event = NotifyEvent.Create(
eventId: eventId,
kind: eventKind,
tenant: entry.TenantId,
ts: entry.Timestamp,
payload: TryParsePayloadFromBson(entry.Payload));
events.Add(@event);
}
return events;
}
private static string? ExtractEventKindFromAuditEntry(NotifyAuditEntryDocument 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.Payload is { } payload)
{
if (payload.TryGetValue("Kind", out var kindValue) || payload.TryGetValue("kind", out kindValue))
{
return kindValue.AsString;
}
}
return null;
}
private static Guid ExtractEventIdFromAuditEntry(NotifyAuditEntryDocument entry)
{
// Try to extract event ID from payload
if (entry.Payload is { } payload)
{
if (payload.TryGetValue("EventId", out var eventIdValue) || payload.TryGetValue("eventId", out eventIdValue))
{
if (Guid.TryParse(eventIdValue.ToString(), out var id))
{
return id;
}
}
}
// Try entity ID
if (Guid.TryParse(entry.EntityId, out var entityId))
{
return entityId;
}
return Guid.NewGuid();
}
private static JsonNode? TryParsePayloadFromBson(MongoDB.Bson.BsonDocument? payload)
{
if (payload is null || payload.IsBsonNull)
{
return null;
}
try
{
// Use MongoDB.Bson.BsonExtensionMethods.ToJson extension method
var json = MongoDB.Bson.BsonExtensionMethods.ToJson(payload);
return JsonNode.Parse(json);
}
catch
{
return null;
}
}
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<string, string>.Empty;
if (payload.TryGetPropertyValue("attributes", out var attrNode) && attrNode is JsonObject attrObj)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(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<string, int> _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()
};
}
}
}

View File

@@ -0,0 +1,35 @@
namespace StellaOps.Notifier.Worker.Simulation;
/// <summary>
/// Engine for simulating notification rules against historical events.
/// Allows dry-run testing of rules before enabling them in production.
/// </summary>
public interface INotifySimulationEngine
{
/// <summary>
/// Runs a simulation against historical events.
/// </summary>
/// <param name="request">The simulation request parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The simulation result with matched actions and explanations.</returns>
Task<NotifySimulationResult> SimulateAsync(
NotifySimulationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Simulates a single event against the current rules.
/// Useful for real-time what-if analysis.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="eventPayload">The event payload to simulate.</param>
/// <param name="ruleIds">Optional specific rule IDs to test.</param>
/// <param name="evaluationTimestamp">Timestamp for throttle/quiet hours evaluation.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The simulated event result.</returns>
Task<SimulatedEventResult> SimulateSingleEventAsync(
string tenantId,
System.Text.Json.Nodes.JsonObject eventPayload,
IEnumerable<string>? ruleIds = null,
DateTimeOffset? evaluationTimestamp = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,156 @@
using System.Collections.Immutable;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Simulation;
/// <summary>
/// Represents the result of a notification rule simulation.
/// </summary>
public sealed record NotifySimulationResult
{
public required string SimulationId { get; init; }
public required string TenantId { get; init; }
public required DateTimeOffset SimulatedAt { get; init; }
public required int EventsEvaluated { get; init; }
public required int RulesEvaluated { get; init; }
public required int TotalMatches { get; init; }
public required int TotalActions { get; init; }
public required ImmutableArray<SimulatedEventResult> EventResults { get; init; }
public required ImmutableArray<SimulatedRuleSummary> RuleSummaries { get; init; }
public TimeSpan Duration { get; init; }
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Result of simulating rules against a single event.
/// </summary>
public sealed record SimulatedEventResult
{
public required Guid EventId { get; init; }
public required string Kind { get; init; }
public required DateTimeOffset EventTimestamp { get; init; }
public required int MatchedRules { get; init; }
public required int TriggeredActions { get; init; }
public required ImmutableArray<SimulatedRuleMatch> Matches { get; init; }
public required ImmutableArray<SimulatedRuleNonMatch> NonMatches { get; init; }
}
/// <summary>
/// Details of a rule that matched during simulation.
/// </summary>
public sealed record SimulatedRuleMatch
{
public required string RuleId { get; init; }
public required string RuleName { get; init; }
public required int Priority { get; init; }
public required DateTimeOffset MatchedAt { get; init; }
public required ImmutableArray<SimulatedActionResult> Actions { get; init; }
public required ImmutableArray<string> MatchExplanations { get; init; }
}
/// <summary>
/// Details of a rule that did not match during simulation.
/// </summary>
public sealed record SimulatedRuleNonMatch
{
public required string RuleId { get; init; }
public required string RuleName { get; init; }
public required string Reason { get; init; }
public required string Explanation { get; init; }
}
/// <summary>
/// Result of a simulated action (what would have happened).
/// </summary>
public sealed record SimulatedActionResult
{
public required string ActionId { get; init; }
public required string ChannelId { get; init; }
public required NotifyChannelType ChannelType { get; init; }
public required string? TemplateId { get; init; }
public required bool WouldDeliver { get; init; }
public required string DeliveryExplanation { get; init; }
public string? ThrottleReason { get; init; }
public string? QuietHoursReason { get; init; }
public string? ChannelBlockReason { get; init; }
}
/// <summary>
/// Summary of how a rule performed across all simulated events.
/// </summary>
public sealed record SimulatedRuleSummary
{
public required string RuleId { get; init; }
public required string RuleName { get; init; }
public required int MatchCount { get; init; }
public required int ActionCount { get; init; }
public required ImmutableDictionary<string, int> NonMatchReasons { get; init; }
}
/// <summary>
/// Request parameters for running a simulation.
/// </summary>
public sealed record NotifySimulationRequest
{
/// <summary>
/// Tenant ID to simulate for.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Start of the time range to query historical events.
/// </summary>
public required DateTimeOffset PeriodStart { get; init; }
/// <summary>
/// End of the time range to query historical events.
/// </summary>
public required DateTimeOffset PeriodEnd { get; init; }
/// <summary>
/// Optional: specific rule IDs to simulate. If empty, all enabled rules are used.
/// </summary>
public ImmutableArray<string> RuleIds { get; init; } = [];
/// <summary>
/// Optional: filter to specific event kinds.
/// </summary>
public ImmutableArray<string> EventKinds { get; init; } = [];
/// <summary>
/// Maximum number of events to evaluate.
/// </summary>
public int MaxEvents { get; init; } = 1000;
/// <summary>
/// Whether to include non-match details in results.
/// </summary>
public bool IncludeNonMatches { get; init; } = true;
/// <summary>
/// Whether to evaluate throttling rules.
/// </summary>
public bool EvaluateThrottling { get; init; } = true;
/// <summary>
/// Whether to evaluate quiet hours.
/// </summary>
public bool EvaluateQuietHours { get; init; } = true;
/// <summary>
/// Timestamp to use for throttle/quiet hours evaluation (defaults to now).
/// </summary>
public DateTimeOffset? EvaluationTimestamp { get; init; }
}
/// <summary>
/// Status of a simulation run.
/// </summary>
public enum NotifySimulationStatus
{
Pending,
Running,
Completed,
Failed,
Cancelled
}

View File

@@ -10,6 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cronos" Version="0.10.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-rc.2.25502.107" />

View File

@@ -0,0 +1,294 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.StormBreaker;
/// <summary>
/// Default implementation of storm breaker using in-memory tracking.
/// </summary>
public sealed class DefaultStormBreaker : IStormBreaker
{
private readonly StormBreakerConfig _config;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DefaultStormBreaker> _logger;
// In-memory storm tracking (keyed by storm key)
private readonly ConcurrentDictionary<string, StormTracker> _storms = new();
public DefaultStormBreaker(
IOptions<StormBreakerConfig> config,
TimeProvider timeProvider,
ILogger<DefaultStormBreaker> logger)
{
_config = config?.Value ?? new StormBreakerConfig();
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<StormDetectionResult> DetectAsync(
string tenantId,
NotifyEvent @event,
NotifyRule rule,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(@event);
ArgumentNullException.ThrowIfNull(rule);
if (!_config.Enabled)
{
return Task.FromResult(new StormDetectionResult
{
Decision = StormDecision.DeliverNormally,
Reason = "Storm breaking disabled"
});
}
var stormKey = ComputeStormKey(tenantId, @event.Kind, rule.RuleId);
var now = _timeProvider.GetUtcNow();
var tracker = _storms.GetOrAdd(stormKey, _ => new StormTracker
{
StormKey = stormKey,
TenantId = tenantId,
EventKind = @event.Kind,
RuleId = rule.RuleId,
WindowStart = now
});
// Clean up old events outside the detection window
CleanupOldEvents(tracker, now);
var eventCount = tracker.EventTimestamps.Count;
// Check if we're in storm mode
if (eventCount >= _config.StormThreshold)
{
// Check if we should send a summary
var shouldSendSummary = tracker.LastSummaryAt is null ||
(now - tracker.LastSummaryAt.Value) >= _config.SummaryInterval;
if (shouldSendSummary)
{
_logger.LogInformation(
"Storm detected for {StormKey}: {EventCount} events in window, triggering summary",
stormKey, eventCount);
return Task.FromResult(new StormDetectionResult
{
Decision = StormDecision.SendSummary,
StormKey = stormKey,
Reason = $"Storm threshold ({_config.StormThreshold}) reached with {eventCount} events",
AccumulatedCount = eventCount,
Threshold = _config.StormThreshold,
WindowStart = tracker.WindowStart
});
}
_logger.LogDebug(
"Storm active for {StormKey}: {EventCount} events, summary sent at {LastSummaryAt}",
stormKey, eventCount, tracker.LastSummaryAt);
return Task.FromResult(new StormDetectionResult
{
Decision = StormDecision.SuppressedBySummary,
StormKey = stormKey,
Reason = $"Storm active, summary already sent at {tracker.LastSummaryAt}",
AccumulatedCount = eventCount,
Threshold = _config.StormThreshold,
WindowStart = tracker.WindowStart,
NextSummaryAt = tracker.LastSummaryAt?.Add(_config.SummaryInterval)
});
}
// Check if we're approaching storm threshold
if (eventCount >= _config.StormThreshold - 1)
{
_logger.LogDebug(
"Storm threshold approaching for {StormKey}: {EventCount} events",
stormKey, eventCount);
return Task.FromResult(new StormDetectionResult
{
Decision = StormDecision.SuppressAndAccumulate,
StormKey = stormKey,
Reason = $"Approaching storm threshold ({eventCount + 1}/{_config.StormThreshold})",
AccumulatedCount = eventCount,
Threshold = _config.StormThreshold,
WindowStart = tracker.WindowStart
});
}
// Normal delivery
return Task.FromResult(new StormDetectionResult
{
Decision = StormDecision.DeliverNormally,
StormKey = stormKey,
AccumulatedCount = eventCount,
Threshold = _config.StormThreshold,
WindowStart = tracker.WindowStart
});
}
public Task RecordEventAsync(
string tenantId,
NotifyEvent @event,
NotifyRule rule,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(@event);
ArgumentNullException.ThrowIfNull(rule);
var stormKey = ComputeStormKey(tenantId, @event.Kind, rule.RuleId);
var now = _timeProvider.GetUtcNow();
var tracker = _storms.GetOrAdd(stormKey, _ => new StormTracker
{
StormKey = stormKey,
TenantId = tenantId,
EventKind = @event.Kind,
RuleId = rule.RuleId,
WindowStart = now
});
// Add event timestamp
tracker.EventTimestamps.Add(now);
tracker.LastEventAt = now;
// Track sample event IDs
if (tracker.SampleEventIds.Count < _config.MaxSampleEvents)
{
tracker.SampleEventIds.Add(@event.EventId.ToString("N"));
}
_logger.LogDebug(
"Recorded event {EventId} for storm {StormKey}, count: {Count}",
@event.EventId, stormKey, tracker.EventTimestamps.Count);
return Task.CompletedTask;
}
public Task<StormSummary?> TriggerSummaryAsync(
string tenantId,
string stormKey,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(stormKey);
if (!_storms.TryGetValue(stormKey, out var tracker))
{
return Task.FromResult<StormSummary?>(null);
}
var now = _timeProvider.GetUtcNow();
CleanupOldEvents(tracker, now);
var summary = new StormSummary
{
SummaryId = Guid.NewGuid().ToString("N"),
StormKey = stormKey,
TenantId = tenantId,
EventCount = tracker.EventTimestamps.Count,
EventKind = tracker.EventKind,
RuleId = tracker.RuleId,
WindowStart = tracker.WindowStart,
WindowEnd = now,
SampleEventIds = tracker.SampleEventIds.ToArray(),
GeneratedAt = now
};
// Update tracker state
tracker.LastSummaryAt = now;
tracker.SummaryCount++;
// Reset window for next batch
tracker.WindowStart = now;
tracker.EventTimestamps.Clear();
tracker.SampleEventIds.Clear();
_logger.LogInformation(
"Generated storm summary {SummaryId} for {StormKey}: {EventCount} events",
summary.SummaryId, stormKey, summary.EventCount);
return Task.FromResult<StormSummary?>(summary);
}
public Task<IReadOnlyList<StormState>> GetActiveStormsAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var now = _timeProvider.GetUtcNow();
var activeStorms = new List<StormState>();
foreach (var tracker in _storms.Values)
{
if (tracker.TenantId != tenantId)
{
continue;
}
CleanupOldEvents(tracker, now);
if (tracker.EventTimestamps.Count == 0)
{
continue;
}
activeStorms.Add(new StormState
{
StormKey = tracker.StormKey,
TenantId = tracker.TenantId,
EventKind = tracker.EventKind,
RuleId = tracker.RuleId,
EventCount = tracker.EventTimestamps.Count,
WindowStart = tracker.WindowStart,
LastEventAt = tracker.LastEventAt,
LastSummaryAt = tracker.LastSummaryAt,
SummaryCount = tracker.SummaryCount
});
}
return Task.FromResult<IReadOnlyList<StormState>>(activeStorms);
}
private void CleanupOldEvents(StormTracker tracker, DateTimeOffset now)
{
var cutoff = now - _config.DetectionWindow;
tracker.EventTimestamps.RemoveAll(t => t < cutoff);
// Reset window if all events expired
if (tracker.EventTimestamps.Count == 0)
{
tracker.WindowStart = now;
tracker.SampleEventIds.Clear();
}
}
private static string ComputeStormKey(string tenantId, string eventKind, string ruleId)
{
return $"{tenantId}:{eventKind}:{ruleId}";
}
/// <summary>
/// Internal tracker for storm state.
/// </summary>
private sealed class StormTracker
{
public required string StormKey { get; init; }
public required string TenantId { get; init; }
public required string EventKind { get; init; }
public required string RuleId { get; init; }
public DateTimeOffset WindowStart { get; set; }
public DateTimeOffset LastEventAt { get; set; }
public DateTimeOffset? LastSummaryAt { get; set; }
public int SummaryCount { get; set; }
public List<DateTimeOffset> EventTimestamps { get; } = [];
public List<string> SampleEventIds { get; } = [];
}
}

View File

@@ -0,0 +1,253 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.StormBreaker;
/// <summary>
/// Storm breaker service that detects high-volume notification storms
/// and converts them to summary notifications to prevent recipient flooding.
/// </summary>
public interface IStormBreaker
{
/// <summary>
/// Evaluates an event to determine if it's part of a notification storm.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="event">The notification event.</param>
/// <param name="rule">The matched rule.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Storm detection result with decision and context.</returns>
Task<StormDetectionResult> DetectAsync(
string tenantId,
NotifyEvent @event,
NotifyRule rule,
CancellationToken cancellationToken = default);
/// <summary>
/// Records an event occurrence for storm tracking.
/// </summary>
Task RecordEventAsync(
string tenantId,
NotifyEvent @event,
NotifyRule rule,
CancellationToken cancellationToken = default);
/// <summary>
/// Triggers a summary notification for accumulated storm events.
/// </summary>
Task<StormSummary?> TriggerSummaryAsync(
string tenantId,
string stormKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets active storms for a tenant.
/// </summary>
Task<IReadOnlyList<StormState>> GetActiveStormsAsync(
string tenantId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of storm detection evaluation.
/// </summary>
public sealed record StormDetectionResult
{
/// <summary>
/// The decision made by the storm breaker.
/// </summary>
public required StormDecision Decision { get; init; }
/// <summary>
/// The unique key identifying this storm.
/// </summary>
public string? StormKey { get; init; }
/// <summary>
/// Human-readable reason for the decision.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Number of events accumulated in the current storm window.
/// </summary>
public int AccumulatedCount { get; init; }
/// <summary>
/// Threshold that triggered storm detection.
/// </summary>
public int Threshold { get; init; }
/// <summary>
/// When the storm window started.
/// </summary>
public DateTimeOffset? WindowStart { get; init; }
/// <summary>
/// When the next summary will be sent.
/// </summary>
public DateTimeOffset? NextSummaryAt { get; init; }
}
/// <summary>
/// Decision made by the storm breaker.
/// </summary>
public enum StormDecision
{
/// <summary>
/// No storm detected, deliver normally.
/// </summary>
DeliverNormally,
/// <summary>
/// Storm detected, suppress individual delivery and accumulate.
/// </summary>
SuppressAndAccumulate,
/// <summary>
/// Storm threshold reached, send summary notification.
/// </summary>
SendSummary,
/// <summary>
/// Storm already handled by recent summary, suppress.
/// </summary>
SuppressedBySummary
}
/// <summary>
/// Summary notification for a storm.
/// </summary>
public sealed record StormSummary
{
/// <summary>
/// Unique ID for this summary.
/// </summary>
public required string SummaryId { get; init; }
/// <summary>
/// The storm key this summary covers.
/// </summary>
public required string StormKey { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Number of events summarized.
/// </summary>
public required int EventCount { get; init; }
/// <summary>
/// Event kind being summarized.
/// </summary>
public required string EventKind { get; init; }
/// <summary>
/// Rule that triggered these events.
/// </summary>
public required string RuleId { get; init; }
/// <summary>
/// Start of the summary window.
/// </summary>
public required DateTimeOffset WindowStart { get; init; }
/// <summary>
/// End of the summary window.
/// </summary>
public required DateTimeOffset WindowEnd { get; init; }
/// <summary>
/// Sample event IDs (first N events).
/// </summary>
public IReadOnlyList<string> SampleEventIds { get; init; } = [];
/// <summary>
/// When this summary was generated.
/// </summary>
public required DateTimeOffset GeneratedAt { get; init; }
}
/// <summary>
/// Current state of an active storm.
/// </summary>
public sealed record StormState
{
/// <summary>
/// Unique key identifying this storm.
/// </summary>
public required string StormKey { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Event kind.
/// </summary>
public required string EventKind { get; init; }
/// <summary>
/// Rule ID.
/// </summary>
public required string RuleId { get; init; }
/// <summary>
/// Current event count in this storm.
/// </summary>
public required int EventCount { get; init; }
/// <summary>
/// When the storm window started.
/// </summary>
public required DateTimeOffset WindowStart { get; init; }
/// <summary>
/// When the last event occurred.
/// </summary>
public required DateTimeOffset LastEventAt { get; init; }
/// <summary>
/// When the last summary was sent.
/// </summary>
public DateTimeOffset? LastSummaryAt { get; init; }
/// <summary>
/// Number of summaries sent for this storm.
/// </summary>
public int SummaryCount { get; init; }
}
/// <summary>
/// Configuration for storm breaker behavior.
/// </summary>
public sealed record StormBreakerConfig
{
/// <summary>
/// Number of events in a window that triggers storm mode.
/// </summary>
public int StormThreshold { get; init; } = 10;
/// <summary>
/// Time window for counting events.
/// </summary>
public TimeSpan DetectionWindow { get; init; } = TimeSpan.FromMinutes(5);
/// <summary>
/// How often to send summary notifications during a storm.
/// </summary>
public TimeSpan SummaryInterval { get; init; } = TimeSpan.FromMinutes(15);
/// <summary>
/// Maximum number of sample event IDs to include in summary.
/// </summary>
public int MaxSampleEvents { get; init; } = 5;
/// <summary>
/// Whether storm breaking is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
}