using Microsoft.Extensions.Logging; using SQLInsight.Core.Findings; namespace SQLInsight.Notifications; /// /// Sends notifications to every enabled channel: filtered by severity, one message per batch, and capped per hour so /// a bad night does not produce hundreds of mails. A failing channel is logged and never stops monitoring. /// public sealed partial class NotificationDispatcher(IEnumerable channels, NotificationOptions options, TimeProvider time, ILogger logger) { private readonly Dictionary> _sent = []; private readonly Dictionary _suppressed = []; private readonly Lock _lock = new(); public async Task DispatchAsync(IReadOnlyList notifications, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(notifications); foreach (var channel in channels) { var relevant = notifications.Where(n => IsRelevant(channel, n)).ToList(); if (relevant.Count == 0 || !TryReserve(channel.Name, relevant.Count)) { continue; } try { await channel.SendAsync(relevant, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { LogSendFailed(logger, ex, channel.Name, relevant.Count); } } } private bool IsRelevant(INotificationChannel channel, Notification n) => n.Kind switch { NotificationKind.FindingOpened => n.Severity >= channel.MinSeverity, NotificationKind.FindingResolved => options.NotifyResolved && n.Severity >= channel.MinSeverity, _ => true, }; private bool TryReserve(string channel, int count) { lock (_lock) { var now = time.GetUtcNow(); if (!_sent.TryGetValue(channel, out var sent)) { _sent[channel] = sent = new Queue(); } while (sent.Count > 0 && sent.Peek() < now.AddHours(-1)) { sent.Dequeue(); } if (sent.Count >= options.MaxMessagesPerHour) { _suppressed[channel] = _suppressed.GetValueOrDefault(channel) + count; LogSuppressed(logger, channel, count, options.MaxMessagesPerHour); return false; } sent.Enqueue(now); return true; } } /// Notifications suppressed by the hourly cap, per channel (for the UI and tests). public IReadOnlyDictionary Suppressed { get { lock (_lock) { return new Dictionary(_suppressed); } } } [LoggerMessage(Level = LogLevel.Error, Message = "Notification channel {Channel} failed to send {Count} notifications")] private static partial void LogSendFailed(ILogger logger, Exception exception, string channel, int count); [LoggerMessage(Level = LogLevel.Warning, Message = "Notification channel {Channel}: {Count} notifications suppressed, over {Max} messages per hour")] private static partial void LogSuppressed(ILogger logger, string channel, int count, int max); } /// /// Builds notifications from what the agent saw. /// public static class NotificationFactory { public static Notification Opened(string instance, long findingId, Finding finding, Uri? webUrl, DateTimeOffset at) { ArgumentNullException.ThrowIfNull(finding); return new Notification { Kind = NotificationKind.FindingOpened, Instance = instance, Title = finding.Title, Severity = finding.Severity, RuleId = finding.RuleId, Subject = finding.Subject, Summary = Texts.FirstSentence(finding.Explanation), Url = webUrl is null ? null : new Uri(webUrl, $"fund/{findingId}"), At = at, }; } public static Notification Resolved(string instance, long findingId, string ruleId, string title, Severity severity, Uri? webUrl, DateTimeOffset at) => new() { Kind = NotificationKind.FindingResolved, Instance = instance, Title = title, Severity = severity, RuleId = ruleId, Url = webUrl is null ? null : new Uri(webUrl, $"fund/{findingId}"), At = at, }; public static Notification Unreachable(string instance, string error, Uri? webUrl, DateTimeOffset at) => new() { Kind = NotificationKind.InstanceUnreachable, Instance = instance, Title = $"{instance} kan ikke nås", Severity = Severity.Critical, Summary = error, Url = webUrl is null ? null : new Uri(webUrl, $"instanser/{Uri.EscapeDataString(instance)}"), At = at, }; public static Notification Recovered(string instance, Uri? webUrl, DateTimeOffset at) => new() { Kind = NotificationKind.InstanceRecovered, Instance = instance, Title = $"{instance} svarer igen", Severity = Severity.Info, Url = webUrl is null ? null : new Uri(webUrl, $"instanser/{Uri.EscapeDataString(instance)}"), At = at, }; }