sqlinsight-pro/src/SQLInsight.Notifications/NotificationDispatcher.cs
Claude eac799a1f1
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
Fase 2: notifications by e-mail, Teams and webhook
- 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
2026-09-29 08:08:06 +00:00

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