Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
- Repository reports which findings opened and resolved per evaluation - Agent notifies on new and resolved findings and on instance unreachable/recovered transitions, with links to the web UI - Channels: e-mail (MailKit, HTML-encoded), Microsoft Teams Workflows webhook (Adaptive Card), generic JSON webhook signed with HMAC-SHA256 - Per-channel minimum severity (outages always sent), one message per batch, hourly cap per channel; a failing channel never stops monitoring - Tested with a real SMTP server (Mailpit) and the real agent pipeline Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
144 lines
5.3 KiB
C#
144 lines
5.3 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using SQLInsight.Core.Findings;
|
|
|
|
namespace SQLInsight.Notifications;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed partial class NotificationDispatcher(IEnumerable<INotificationChannel> channels, NotificationOptions options, TimeProvider time, ILogger<NotificationDispatcher> logger)
|
|
{
|
|
private readonly Dictionary<string, Queue<DateTimeOffset>> _sent = [];
|
|
private readonly Dictionary<string, int> _suppressed = [];
|
|
private readonly Lock _lock = new();
|
|
|
|
public async Task DispatchAsync(IReadOnlyList<Notification> 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<DateTimeOffset>();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Notifications suppressed by the hourly cap, per channel (for the UI and tests).</summary>
|
|
public IReadOnlyDictionary<string, int> Suppressed
|
|
{
|
|
get
|
|
{
|
|
lock (_lock)
|
|
{
|
|
return new Dictionary<string, int>(_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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds notifications from what the agent saw.
|
|
/// </summary>
|
|
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,
|
|
};
|
|
}
|