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,
};
}