up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,295 +1,295 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Dispatch;
|
||||
|
||||
/// <summary>
|
||||
/// Background worker that dispatches pending notification deliveries.
|
||||
/// </summary>
|
||||
public sealed class DeliveryDispatchWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly NotifierWorkerOptions _options;
|
||||
private readonly ILogger<DeliveryDispatchWorker> _logger;
|
||||
private readonly string _workerId;
|
||||
|
||||
public DeliveryDispatchWorker(
|
||||
IServiceProvider services,
|
||||
IOptions<NotifierWorkerOptions> options,
|
||||
ILogger<DeliveryDispatchWorker> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_workerId = $"dispatch-{Environment.MachineName}-{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("DeliveryDispatchWorker {WorkerId} starting.", _workerId);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessPendingDeliveriesAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "DeliveryDispatchWorker {WorkerId} encountered error.", _workerId);
|
||||
}
|
||||
|
||||
var interval = _options.DispatchInterval > TimeSpan.Zero
|
||||
? _options.DispatchInterval
|
||||
: TimeSpan.FromSeconds(5);
|
||||
|
||||
await Task.Delay(interval, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation("DeliveryDispatchWorker {WorkerId} stopping.", _workerId);
|
||||
}
|
||||
|
||||
private async Task ProcessPendingDeliveriesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
|
||||
var deliveryRepository = scope.ServiceProvider.GetService<INotifyDeliveryRepository>();
|
||||
var templateRepository = scope.ServiceProvider.GetService<INotifyTemplateRepository>();
|
||||
var channelRepository = scope.ServiceProvider.GetService<INotifyChannelRepository>();
|
||||
var renderer = scope.ServiceProvider.GetService<INotifyTemplateRenderer>();
|
||||
var dispatchers = scope.ServiceProvider.GetServices<INotifyChannelDispatcher>().ToArray();
|
||||
|
||||
if (deliveryRepository is null || templateRepository is null ||
|
||||
channelRepository is null || renderer is null || dispatchers.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("Required services not registered; skipping dispatch cycle.");
|
||||
return;
|
||||
}
|
||||
|
||||
var batchSize = _options.DispatchBatchSize > 0 ? _options.DispatchBatchSize : 100;
|
||||
var pendingDeliveries = await deliveryRepository
|
||||
.ListPendingAsync(batchSize, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (pendingDeliveries.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Processing {Count} pending deliveries.", pendingDeliveries.Count);
|
||||
|
||||
var channelCache = new Dictionary<(string TenantId, string ChannelId), NotifyChannel?>();
|
||||
var templateCache = new Dictionary<(string TenantId, string TemplateId), NotifyTemplate?>();
|
||||
var dispatcherMap = dispatchers
|
||||
.SelectMany(d => d.SupportedTypes.Select(t => (Type: t, Dispatcher: d)))
|
||||
.ToDictionary(x => x.Type, x => x.Dispatcher);
|
||||
|
||||
foreach (var delivery in pendingDeliveries)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessDeliveryAsync(
|
||||
delivery,
|
||||
deliveryRepository,
|
||||
templateRepository,
|
||||
channelRepository,
|
||||
renderer,
|
||||
dispatcherMap,
|
||||
channelCache,
|
||||
templateCache,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to process delivery {DeliveryId} for tenant {TenantId}.",
|
||||
delivery.DeliveryId,
|
||||
delivery.TenantId);
|
||||
|
||||
await MarkDeliveryFailedAsync(
|
||||
deliveryRepository,
|
||||
delivery,
|
||||
ex.Message,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessDeliveryAsync(
|
||||
NotifyDelivery delivery,
|
||||
INotifyDeliveryRepository deliveryRepository,
|
||||
INotifyTemplateRepository templateRepository,
|
||||
INotifyChannelRepository channelRepository,
|
||||
INotifyTemplateRenderer renderer,
|
||||
Dictionary<NotifyChannelType, INotifyChannelDispatcher> dispatcherMap,
|
||||
Dictionary<(string, string), NotifyChannel?> channelCache,
|
||||
Dictionary<(string, string), NotifyTemplate?> templateCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Get channel
|
||||
var channelId = delivery.Metadata.GetValueOrDefault("channel") ?? string.Empty;
|
||||
var channelKey = (delivery.TenantId, channelId);
|
||||
|
||||
if (!channelCache.TryGetValue(channelKey, out var channel))
|
||||
{
|
||||
channel = await channelRepository
|
||||
.GetAsync(delivery.TenantId, channelId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
channelCache[channelKey] = channel;
|
||||
}
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Channel {ChannelId} not found for delivery {DeliveryId}.",
|
||||
channelId,
|
||||
delivery.DeliveryId);
|
||||
await MarkDeliveryFailedAsync(deliveryRepository, delivery, "Channel not found", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get template
|
||||
var templateId = delivery.Metadata.GetValueOrDefault("template") ?? string.Empty;
|
||||
var templateKey = (delivery.TenantId, templateId);
|
||||
|
||||
if (!templateCache.TryGetValue(templateKey, out var template))
|
||||
{
|
||||
template = await templateRepository
|
||||
.GetAsync(delivery.TenantId, templateId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
templateCache[templateKey] = template;
|
||||
}
|
||||
|
||||
if (template is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Template {TemplateId} not found for delivery {DeliveryId}.",
|
||||
templateId,
|
||||
delivery.DeliveryId);
|
||||
await MarkDeliveryFailedAsync(deliveryRepository, delivery, "Template not found", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get dispatcher
|
||||
if (!dispatcherMap.TryGetValue(channel.Type, out var dispatcher))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No dispatcher for channel type {ChannelType} (delivery {DeliveryId}).",
|
||||
channel.Type,
|
||||
delivery.DeliveryId);
|
||||
await MarkDeliveryFailedAsync(deliveryRepository, delivery, $"No dispatcher for {channel.Type}", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build event from delivery metadata for rendering
|
||||
var notifyEvent = RebuildEventForRendering(delivery);
|
||||
|
||||
// Render template
|
||||
var content = await renderer.RenderAsync(template, notifyEvent, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Dispatch
|
||||
var result = await dispatcher.DispatchAsync(channel, content, delivery, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update delivery status
|
||||
var attempt = new NotifyDeliveryAttempt(
|
||||
timestamp: DateTimeOffset.UtcNow,
|
||||
status: result.Success ? NotifyDeliveryAttemptStatus.Succeeded : NotifyDeliveryAttemptStatus.Failed,
|
||||
reason: result.ErrorMessage);
|
||||
|
||||
var completedAt = result.Success || !result.IsRetryable ? DateTimeOffset.UtcNow : delivery.CompletedAt;
|
||||
|
||||
var updatedDelivery = CloneDelivery(
|
||||
delivery,
|
||||
result.Status,
|
||||
result.ErrorMessage,
|
||||
delivery.Attempts.Add(attempt),
|
||||
completedAt);
|
||||
|
||||
await deliveryRepository.UpdateAsync(updatedDelivery, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Delivery {DeliveryId} {Status}: {Message}",
|
||||
delivery.DeliveryId,
|
||||
result.Status,
|
||||
result.Success ? "dispatched successfully" : result.ErrorMessage);
|
||||
}
|
||||
|
||||
private static NotifyEvent RebuildEventForRendering(NotifyDelivery delivery)
|
||||
{
|
||||
// Reconstruct a minimal event for template rendering
|
||||
return NotifyEvent.Create(
|
||||
eventId: delivery.EventId,
|
||||
kind: delivery.Kind,
|
||||
tenant: delivery.TenantId,
|
||||
ts: delivery.CreatedAt,
|
||||
payload: System.Text.Json.Nodes.JsonNode.Parse(
|
||||
System.Text.Json.JsonSerializer.Serialize(delivery.Metadata)) as System.Text.Json.Nodes.JsonObject
|
||||
?? new System.Text.Json.Nodes.JsonObject(),
|
||||
version: "1");
|
||||
}
|
||||
|
||||
private async Task MarkDeliveryFailedAsync(
|
||||
INotifyDeliveryRepository repository,
|
||||
NotifyDelivery delivery,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attempt = new NotifyDeliveryAttempt(
|
||||
timestamp: DateTimeOffset.UtcNow,
|
||||
status: NotifyDeliveryAttemptStatus.Failed,
|
||||
reason: errorMessage);
|
||||
|
||||
var updated = CloneDelivery(
|
||||
delivery,
|
||||
NotifyDeliveryStatus.Failed,
|
||||
errorMessage,
|
||||
delivery.Attempts.Add(attempt),
|
||||
delivery.CompletedAt ?? DateTimeOffset.UtcNow);
|
||||
|
||||
try
|
||||
{
|
||||
await repository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update delivery {DeliveryId} status.", delivery.DeliveryId);
|
||||
}
|
||||
}
|
||||
|
||||
private static NotifyDelivery CloneDelivery(
|
||||
NotifyDelivery source,
|
||||
NotifyDeliveryStatus status,
|
||||
string? statusReason,
|
||||
ImmutableArray<NotifyDeliveryAttempt> attempts,
|
||||
DateTimeOffset? completedAt)
|
||||
{
|
||||
return NotifyDelivery.Create(
|
||||
source.DeliveryId,
|
||||
source.TenantId,
|
||||
source.RuleId,
|
||||
source.ActionId,
|
||||
source.EventId,
|
||||
source.Kind,
|
||||
status,
|
||||
statusReason,
|
||||
source.Rendered,
|
||||
attempts,
|
||||
source.Metadata,
|
||||
source.CreatedAt,
|
||||
source.SentAt,
|
||||
completedAt);
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Dispatch;
|
||||
|
||||
/// <summary>
|
||||
/// Background worker that dispatches pending notification deliveries.
|
||||
/// </summary>
|
||||
public sealed class DeliveryDispatchWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly NotifierWorkerOptions _options;
|
||||
private readonly ILogger<DeliveryDispatchWorker> _logger;
|
||||
private readonly string _workerId;
|
||||
|
||||
public DeliveryDispatchWorker(
|
||||
IServiceProvider services,
|
||||
IOptions<NotifierWorkerOptions> options,
|
||||
ILogger<DeliveryDispatchWorker> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_workerId = $"dispatch-{Environment.MachineName}-{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("DeliveryDispatchWorker {WorkerId} starting.", _workerId);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessPendingDeliveriesAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "DeliveryDispatchWorker {WorkerId} encountered error.", _workerId);
|
||||
}
|
||||
|
||||
var interval = _options.DispatchInterval > TimeSpan.Zero
|
||||
? _options.DispatchInterval
|
||||
: TimeSpan.FromSeconds(5);
|
||||
|
||||
await Task.Delay(interval, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation("DeliveryDispatchWorker {WorkerId} stopping.", _workerId);
|
||||
}
|
||||
|
||||
private async Task ProcessPendingDeliveriesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
|
||||
var deliveryRepository = scope.ServiceProvider.GetService<INotifyDeliveryRepository>();
|
||||
var templateRepository = scope.ServiceProvider.GetService<INotifyTemplateRepository>();
|
||||
var channelRepository = scope.ServiceProvider.GetService<INotifyChannelRepository>();
|
||||
var renderer = scope.ServiceProvider.GetService<INotifyTemplateRenderer>();
|
||||
var dispatchers = scope.ServiceProvider.GetServices<INotifyChannelDispatcher>().ToArray();
|
||||
|
||||
if (deliveryRepository is null || templateRepository is null ||
|
||||
channelRepository is null || renderer is null || dispatchers.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("Required services not registered; skipping dispatch cycle.");
|
||||
return;
|
||||
}
|
||||
|
||||
var batchSize = _options.DispatchBatchSize > 0 ? _options.DispatchBatchSize : 100;
|
||||
var pendingDeliveries = await deliveryRepository
|
||||
.ListPendingAsync(batchSize, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (pendingDeliveries.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Processing {Count} pending deliveries.", pendingDeliveries.Count);
|
||||
|
||||
var channelCache = new Dictionary<(string TenantId, string ChannelId), NotifyChannel?>();
|
||||
var templateCache = new Dictionary<(string TenantId, string TemplateId), NotifyTemplate?>();
|
||||
var dispatcherMap = dispatchers
|
||||
.SelectMany(d => d.SupportedTypes.Select(t => (Type: t, Dispatcher: d)))
|
||||
.ToDictionary(x => x.Type, x => x.Dispatcher);
|
||||
|
||||
foreach (var delivery in pendingDeliveries)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessDeliveryAsync(
|
||||
delivery,
|
||||
deliveryRepository,
|
||||
templateRepository,
|
||||
channelRepository,
|
||||
renderer,
|
||||
dispatcherMap,
|
||||
channelCache,
|
||||
templateCache,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to process delivery {DeliveryId} for tenant {TenantId}.",
|
||||
delivery.DeliveryId,
|
||||
delivery.TenantId);
|
||||
|
||||
await MarkDeliveryFailedAsync(
|
||||
deliveryRepository,
|
||||
delivery,
|
||||
ex.Message,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessDeliveryAsync(
|
||||
NotifyDelivery delivery,
|
||||
INotifyDeliveryRepository deliveryRepository,
|
||||
INotifyTemplateRepository templateRepository,
|
||||
INotifyChannelRepository channelRepository,
|
||||
INotifyTemplateRenderer renderer,
|
||||
Dictionary<NotifyChannelType, INotifyChannelDispatcher> dispatcherMap,
|
||||
Dictionary<(string, string), NotifyChannel?> channelCache,
|
||||
Dictionary<(string, string), NotifyTemplate?> templateCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Get channel
|
||||
var channelId = delivery.Metadata.GetValueOrDefault("channel") ?? string.Empty;
|
||||
var channelKey = (delivery.TenantId, channelId);
|
||||
|
||||
if (!channelCache.TryGetValue(channelKey, out var channel))
|
||||
{
|
||||
channel = await channelRepository
|
||||
.GetAsync(delivery.TenantId, channelId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
channelCache[channelKey] = channel;
|
||||
}
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Channel {ChannelId} not found for delivery {DeliveryId}.",
|
||||
channelId,
|
||||
delivery.DeliveryId);
|
||||
await MarkDeliveryFailedAsync(deliveryRepository, delivery, "Channel not found", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get template
|
||||
var templateId = delivery.Metadata.GetValueOrDefault("template") ?? string.Empty;
|
||||
var templateKey = (delivery.TenantId, templateId);
|
||||
|
||||
if (!templateCache.TryGetValue(templateKey, out var template))
|
||||
{
|
||||
template = await templateRepository
|
||||
.GetAsync(delivery.TenantId, templateId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
templateCache[templateKey] = template;
|
||||
}
|
||||
|
||||
if (template is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Template {TemplateId} not found for delivery {DeliveryId}.",
|
||||
templateId,
|
||||
delivery.DeliveryId);
|
||||
await MarkDeliveryFailedAsync(deliveryRepository, delivery, "Template not found", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get dispatcher
|
||||
if (!dispatcherMap.TryGetValue(channel.Type, out var dispatcher))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No dispatcher for channel type {ChannelType} (delivery {DeliveryId}).",
|
||||
channel.Type,
|
||||
delivery.DeliveryId);
|
||||
await MarkDeliveryFailedAsync(deliveryRepository, delivery, $"No dispatcher for {channel.Type}", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build event from delivery metadata for rendering
|
||||
var notifyEvent = RebuildEventForRendering(delivery);
|
||||
|
||||
// Render template
|
||||
var content = await renderer.RenderAsync(template, notifyEvent, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Dispatch
|
||||
var result = await dispatcher.DispatchAsync(channel, content, delivery, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update delivery status
|
||||
var attempt = new NotifyDeliveryAttempt(
|
||||
timestamp: DateTimeOffset.UtcNow,
|
||||
status: result.Success ? NotifyDeliveryAttemptStatus.Succeeded : NotifyDeliveryAttemptStatus.Failed,
|
||||
reason: result.ErrorMessage);
|
||||
|
||||
var completedAt = result.Success || !result.IsRetryable ? DateTimeOffset.UtcNow : delivery.CompletedAt;
|
||||
|
||||
var updatedDelivery = CloneDelivery(
|
||||
delivery,
|
||||
result.Status,
|
||||
result.ErrorMessage,
|
||||
delivery.Attempts.Add(attempt),
|
||||
completedAt);
|
||||
|
||||
await deliveryRepository.UpdateAsync(updatedDelivery, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Delivery {DeliveryId} {Status}: {Message}",
|
||||
delivery.DeliveryId,
|
||||
result.Status,
|
||||
result.Success ? "dispatched successfully" : result.ErrorMessage);
|
||||
}
|
||||
|
||||
private static NotifyEvent RebuildEventForRendering(NotifyDelivery delivery)
|
||||
{
|
||||
// Reconstruct a minimal event for template rendering
|
||||
return NotifyEvent.Create(
|
||||
eventId: delivery.EventId,
|
||||
kind: delivery.Kind,
|
||||
tenant: delivery.TenantId,
|
||||
ts: delivery.CreatedAt,
|
||||
payload: System.Text.Json.Nodes.JsonNode.Parse(
|
||||
System.Text.Json.JsonSerializer.Serialize(delivery.Metadata)) as System.Text.Json.Nodes.JsonObject
|
||||
?? new System.Text.Json.Nodes.JsonObject(),
|
||||
version: "1");
|
||||
}
|
||||
|
||||
private async Task MarkDeliveryFailedAsync(
|
||||
INotifyDeliveryRepository repository,
|
||||
NotifyDelivery delivery,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attempt = new NotifyDeliveryAttempt(
|
||||
timestamp: DateTimeOffset.UtcNow,
|
||||
status: NotifyDeliveryAttemptStatus.Failed,
|
||||
reason: errorMessage);
|
||||
|
||||
var updated = CloneDelivery(
|
||||
delivery,
|
||||
NotifyDeliveryStatus.Failed,
|
||||
errorMessage,
|
||||
delivery.Attempts.Add(attempt),
|
||||
delivery.CompletedAt ?? DateTimeOffset.UtcNow);
|
||||
|
||||
try
|
||||
{
|
||||
await repository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update delivery {DeliveryId} status.", delivery.DeliveryId);
|
||||
}
|
||||
}
|
||||
|
||||
private static NotifyDelivery CloneDelivery(
|
||||
NotifyDelivery source,
|
||||
NotifyDeliveryStatus status,
|
||||
string? statusReason,
|
||||
ImmutableArray<NotifyDeliveryAttempt> attempts,
|
||||
DateTimeOffset? completedAt)
|
||||
{
|
||||
return NotifyDelivery.Create(
|
||||
source.DeliveryId,
|
||||
source.TenantId,
|
||||
source.RuleId,
|
||||
source.ActionId,
|
||||
source.EventId,
|
||||
source.Kind,
|
||||
status,
|
||||
statusReason,
|
||||
source.Rendered,
|
||||
attempts,
|
||||
source.Metadata,
|
||||
source.CreatedAt,
|
||||
source.SentAt,
|
||||
completedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +1,87 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Dispatch;
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches rendered notifications to channels.
|
||||
/// </summary>
|
||||
public interface INotifyChannelDispatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Dispatches a notification to the specified channel.
|
||||
/// </summary>
|
||||
/// <param name="channel">Target channel configuration.</param>
|
||||
/// <param name="content">Rendered notification content.</param>
|
||||
/// <param name="delivery">Delivery record for tracking.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Dispatch result with status and details.</returns>
|
||||
Task<NotifyDispatchResult> DispatchAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyRenderedContent content,
|
||||
NotifyDelivery delivery,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the channel types supported by this dispatcher.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<NotifyChannelType> SupportedTypes { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a notification dispatch attempt.
|
||||
/// </summary>
|
||||
public sealed class NotifyDispatchResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the dispatch succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resulting delivery status.
|
||||
/// </summary>
|
||||
public required NotifyDeliveryStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an optional error message if dispatch failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the error is retryable.
|
||||
/// </summary>
|
||||
public bool IsRetryable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of retry attempts made.
|
||||
/// </summary>
|
||||
public int AttemptCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an optional external reference (e.g., message ID from the channel).
|
||||
/// </summary>
|
||||
public string? ExternalReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful dispatch result.
|
||||
/// </summary>
|
||||
public static NotifyDispatchResult Succeeded(int attempts = 1, string? externalReference = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
Status = NotifyDeliveryStatus.Delivered,
|
||||
AttemptCount = attempts,
|
||||
ExternalReference = externalReference
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed dispatch result.
|
||||
/// </summary>
|
||||
public static NotifyDispatchResult Failed(string errorMessage, bool isRetryable = false, int attempts = 1) => new()
|
||||
{
|
||||
Success = false,
|
||||
Status = isRetryable ? NotifyDeliveryStatus.Pending : NotifyDeliveryStatus.Failed,
|
||||
ErrorMessage = errorMessage,
|
||||
IsRetryable = isRetryable,
|
||||
AttemptCount = attempts
|
||||
};
|
||||
}
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Dispatch;
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches rendered notifications to channels.
|
||||
/// </summary>
|
||||
public interface INotifyChannelDispatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Dispatches a notification to the specified channel.
|
||||
/// </summary>
|
||||
/// <param name="channel">Target channel configuration.</param>
|
||||
/// <param name="content">Rendered notification content.</param>
|
||||
/// <param name="delivery">Delivery record for tracking.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Dispatch result with status and details.</returns>
|
||||
Task<NotifyDispatchResult> DispatchAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyRenderedContent content,
|
||||
NotifyDelivery delivery,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the channel types supported by this dispatcher.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<NotifyChannelType> SupportedTypes { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a notification dispatch attempt.
|
||||
/// </summary>
|
||||
public sealed class NotifyDispatchResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the dispatch succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resulting delivery status.
|
||||
/// </summary>
|
||||
public required NotifyDeliveryStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an optional error message if dispatch failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the error is retryable.
|
||||
/// </summary>
|
||||
public bool IsRetryable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of retry attempts made.
|
||||
/// </summary>
|
||||
public int AttemptCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an optional external reference (e.g., message ID from the channel).
|
||||
/// </summary>
|
||||
public string? ExternalReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful dispatch result.
|
||||
/// </summary>
|
||||
public static NotifyDispatchResult Succeeded(int attempts = 1, string? externalReference = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
Status = NotifyDeliveryStatus.Delivered,
|
||||
AttemptCount = attempts,
|
||||
ExternalReference = externalReference
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed dispatch result.
|
||||
/// </summary>
|
||||
public static NotifyDispatchResult Failed(string errorMessage, bool isRetryable = false, int attempts = 1) => new()
|
||||
{
|
||||
Success = false,
|
||||
Status = isRetryable ? NotifyDeliveryStatus.Pending : NotifyDeliveryStatus.Failed,
|
||||
ErrorMessage = errorMessage,
|
||||
IsRetryable = isRetryable,
|
||||
AttemptCount = attempts
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Dispatch;
|
||||
|
||||
/// <summary>
|
||||
/// Renders notification templates using event payload data.
|
||||
/// </summary>
|
||||
public interface INotifyTemplateRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a template with the provided event payload.
|
||||
/// </summary>
|
||||
/// <param name="template">The template to render.</param>
|
||||
/// <param name="notifyEvent">The event containing payload data.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Rendered content with body and optional subject.</returns>
|
||||
Task<NotifyRenderedContent> RenderAsync(
|
||||
NotifyTemplate template,
|
||||
NotifyEvent notifyEvent,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents rendered notification content.
|
||||
/// </summary>
|
||||
public sealed class NotifyRenderedContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the rendered body content.
|
||||
/// </summary>
|
||||
public required string Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional rendered subject (for email).
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content hash for deduplication.
|
||||
/// </summary>
|
||||
public required string BodyHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the render format (Markdown, Html, Json, PlainText).
|
||||
/// </summary>
|
||||
public required NotifyDeliveryFormat Format { get; init; }
|
||||
}
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Dispatch;
|
||||
|
||||
/// <summary>
|
||||
/// Renders notification templates using event payload data.
|
||||
/// </summary>
|
||||
public interface INotifyTemplateRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a template with the provided event payload.
|
||||
/// </summary>
|
||||
/// <param name="template">The template to render.</param>
|
||||
/// <param name="notifyEvent">The event containing payload data.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Rendered content with body and optional subject.</returns>
|
||||
Task<NotifyRenderedContent> RenderAsync(
|
||||
NotifyTemplate template,
|
||||
NotifyEvent notifyEvent,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents rendered notification content.
|
||||
/// </summary>
|
||||
public sealed class NotifyRenderedContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the rendered body content.
|
||||
/// </summary>
|
||||
public required string Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional rendered subject (for email).
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content hash for deduplication.
|
||||
/// </summary>
|
||||
public required string BodyHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the render format (Markdown, Html, Json, PlainText).
|
||||
/// </summary>
|
||||
public required NotifyDeliveryFormat Format { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,200 +1,200 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Dispatch;
|
||||
|
||||
/// <summary>
|
||||
/// Simple template renderer using Handlebars-style {{variable}} substitution.
|
||||
/// Supports dot notation for nested properties and {{#each}} for iteration.
|
||||
/// </summary>
|
||||
public sealed partial class SimpleTemplateRenderer : INotifyTemplateRenderer
|
||||
{
|
||||
private readonly ILogger<SimpleTemplateRenderer> _logger;
|
||||
private readonly HashSet<string> _redactedKeys;
|
||||
|
||||
public SimpleTemplateRenderer(ILogger<SimpleTemplateRenderer> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_redactedKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"secret", "password", "token", "key", "apikey", "api_key", "credential"
|
||||
};
|
||||
}
|
||||
|
||||
public Task<NotifyRenderedContent> RenderAsync(
|
||||
NotifyTemplate template,
|
||||
NotifyEvent notifyEvent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
ArgumentNullException.ThrowIfNull(notifyEvent);
|
||||
|
||||
var context = BuildContext(notifyEvent);
|
||||
var body = RenderTemplate(template.Body, context);
|
||||
|
||||
string? subject = null;
|
||||
if (template.Metadata.TryGetValue("subject", out var subjectTemplate) &&
|
||||
!string.IsNullOrWhiteSpace(subjectTemplate))
|
||||
{
|
||||
subject = RenderTemplate(subjectTemplate, context);
|
||||
}
|
||||
|
||||
var bodyHash = ComputeHash(body);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Rendered template {TemplateId} for event {EventId}: {BodyLength} chars, hash={BodyHash}",
|
||||
template.TemplateId,
|
||||
notifyEvent.EventId,
|
||||
body.Length,
|
||||
bodyHash);
|
||||
|
||||
return Task.FromResult(new NotifyRenderedContent
|
||||
{
|
||||
Body = body,
|
||||
Subject = subject,
|
||||
BodyHash = bodyHash,
|
||||
Format = template.Format
|
||||
});
|
||||
}
|
||||
|
||||
private Dictionary<string, object?> BuildContext(NotifyEvent notifyEvent)
|
||||
{
|
||||
var context = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["eventId"] = notifyEvent.EventId.ToString(),
|
||||
["kind"] = notifyEvent.Kind,
|
||||
["tenant"] = notifyEvent.Tenant,
|
||||
["timestamp"] = notifyEvent.Ts.ToString("O"),
|
||||
["actor"] = notifyEvent.Actor,
|
||||
["version"] = notifyEvent.Version,
|
||||
};
|
||||
|
||||
if (notifyEvent.Payload is JsonObject payload)
|
||||
{
|
||||
FlattenJson(payload, context, string.Empty);
|
||||
}
|
||||
|
||||
foreach (var (key, value) in notifyEvent.Attributes)
|
||||
{
|
||||
context[$"attr.{key}"] = RedactIfSensitive(key, value);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private void FlattenJson(JsonObject obj, Dictionary<string, object?> context, string prefix)
|
||||
{
|
||||
foreach (var property in obj)
|
||||
{
|
||||
var key = string.IsNullOrEmpty(prefix) ? property.Key : $"{prefix}.{property.Key}";
|
||||
|
||||
if (property.Value is JsonObject nested)
|
||||
{
|
||||
FlattenJson(nested, context, key);
|
||||
}
|
||||
else if (property.Value is JsonArray array)
|
||||
{
|
||||
context[key] = array;
|
||||
}
|
||||
else
|
||||
{
|
||||
var value = property.Value?.GetValue<object>();
|
||||
context[key] = RedactIfSensitive(property.Key, value?.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string? RedactIfSensitive(string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return value;
|
||||
|
||||
foreach (var redacted in _redactedKeys)
|
||||
{
|
||||
if (key.Contains(redacted, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "[REDACTED]";
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private string RenderTemplate(string template, Dictionary<string, object?> context)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template)) return string.Empty;
|
||||
|
||||
var result = template;
|
||||
|
||||
// Handle {{#each collection}}...{{/each}} blocks
|
||||
result = EachBlockRegex().Replace(result, match =>
|
||||
{
|
||||
var collectionName = match.Groups[1].Value.Trim();
|
||||
var innerTemplate = match.Groups[2].Value;
|
||||
|
||||
if (!context.TryGetValue(collectionName, out var collection) || collection is not JsonArray array)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var item in array)
|
||||
{
|
||||
var itemContext = new Dictionary<string, object?>(context, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["this"] = item?.ToString()
|
||||
};
|
||||
|
||||
if (item is JsonObject itemObj)
|
||||
{
|
||||
foreach (var prop in itemObj)
|
||||
{
|
||||
itemContext[$"@{prop.Key}"] = prop.Value?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(RenderSimpleVariables(innerTemplate, itemContext));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
});
|
||||
|
||||
// Handle simple {{variable}} substitution
|
||||
result = RenderSimpleVariables(result, context);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string RenderSimpleVariables(string template, Dictionary<string, object?> context)
|
||||
{
|
||||
return VariableRegex().Replace(template, match =>
|
||||
{
|
||||
var key = match.Groups[1].Value.Trim();
|
||||
|
||||
if (context.TryGetValue(key, out var value) && value is not null)
|
||||
{
|
||||
return value.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
// Return empty string for missing variables
|
||||
return string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{\{#each\s+(\w+(?:\.\w+)*)\s*\}\}(.*?)\{\{/each\}\}", RegexOptions.Singleline)]
|
||||
private static partial Regex EachBlockRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{([^}]+)\}\}")]
|
||||
private static partial Regex VariableRegex();
|
||||
}
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Dispatch;
|
||||
|
||||
/// <summary>
|
||||
/// Simple template renderer using Handlebars-style {{variable}} substitution.
|
||||
/// Supports dot notation for nested properties and {{#each}} for iteration.
|
||||
/// </summary>
|
||||
public sealed partial class SimpleTemplateRenderer : INotifyTemplateRenderer
|
||||
{
|
||||
private readonly ILogger<SimpleTemplateRenderer> _logger;
|
||||
private readonly HashSet<string> _redactedKeys;
|
||||
|
||||
public SimpleTemplateRenderer(ILogger<SimpleTemplateRenderer> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_redactedKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"secret", "password", "token", "key", "apikey", "api_key", "credential"
|
||||
};
|
||||
}
|
||||
|
||||
public Task<NotifyRenderedContent> RenderAsync(
|
||||
NotifyTemplate template,
|
||||
NotifyEvent notifyEvent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
ArgumentNullException.ThrowIfNull(notifyEvent);
|
||||
|
||||
var context = BuildContext(notifyEvent);
|
||||
var body = RenderTemplate(template.Body, context);
|
||||
|
||||
string? subject = null;
|
||||
if (template.Metadata.TryGetValue("subject", out var subjectTemplate) &&
|
||||
!string.IsNullOrWhiteSpace(subjectTemplate))
|
||||
{
|
||||
subject = RenderTemplate(subjectTemplate, context);
|
||||
}
|
||||
|
||||
var bodyHash = ComputeHash(body);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Rendered template {TemplateId} for event {EventId}: {BodyLength} chars, hash={BodyHash}",
|
||||
template.TemplateId,
|
||||
notifyEvent.EventId,
|
||||
body.Length,
|
||||
bodyHash);
|
||||
|
||||
return Task.FromResult(new NotifyRenderedContent
|
||||
{
|
||||
Body = body,
|
||||
Subject = subject,
|
||||
BodyHash = bodyHash,
|
||||
Format = template.Format
|
||||
});
|
||||
}
|
||||
|
||||
private Dictionary<string, object?> BuildContext(NotifyEvent notifyEvent)
|
||||
{
|
||||
var context = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["eventId"] = notifyEvent.EventId.ToString(),
|
||||
["kind"] = notifyEvent.Kind,
|
||||
["tenant"] = notifyEvent.Tenant,
|
||||
["timestamp"] = notifyEvent.Ts.ToString("O"),
|
||||
["actor"] = notifyEvent.Actor,
|
||||
["version"] = notifyEvent.Version,
|
||||
};
|
||||
|
||||
if (notifyEvent.Payload is JsonObject payload)
|
||||
{
|
||||
FlattenJson(payload, context, string.Empty);
|
||||
}
|
||||
|
||||
foreach (var (key, value) in notifyEvent.Attributes)
|
||||
{
|
||||
context[$"attr.{key}"] = RedactIfSensitive(key, value);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private void FlattenJson(JsonObject obj, Dictionary<string, object?> context, string prefix)
|
||||
{
|
||||
foreach (var property in obj)
|
||||
{
|
||||
var key = string.IsNullOrEmpty(prefix) ? property.Key : $"{prefix}.{property.Key}";
|
||||
|
||||
if (property.Value is JsonObject nested)
|
||||
{
|
||||
FlattenJson(nested, context, key);
|
||||
}
|
||||
else if (property.Value is JsonArray array)
|
||||
{
|
||||
context[key] = array;
|
||||
}
|
||||
else
|
||||
{
|
||||
var value = property.Value?.GetValue<object>();
|
||||
context[key] = RedactIfSensitive(property.Key, value?.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string? RedactIfSensitive(string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return value;
|
||||
|
||||
foreach (var redacted in _redactedKeys)
|
||||
{
|
||||
if (key.Contains(redacted, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "[REDACTED]";
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private string RenderTemplate(string template, Dictionary<string, object?> context)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template)) return string.Empty;
|
||||
|
||||
var result = template;
|
||||
|
||||
// Handle {{#each collection}}...{{/each}} blocks
|
||||
result = EachBlockRegex().Replace(result, match =>
|
||||
{
|
||||
var collectionName = match.Groups[1].Value.Trim();
|
||||
var innerTemplate = match.Groups[2].Value;
|
||||
|
||||
if (!context.TryGetValue(collectionName, out var collection) || collection is not JsonArray array)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var item in array)
|
||||
{
|
||||
var itemContext = new Dictionary<string, object?>(context, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["this"] = item?.ToString()
|
||||
};
|
||||
|
||||
if (item is JsonObject itemObj)
|
||||
{
|
||||
foreach (var prop in itemObj)
|
||||
{
|
||||
itemContext[$"@{prop.Key}"] = prop.Value?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(RenderSimpleVariables(innerTemplate, itemContext));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
});
|
||||
|
||||
// Handle simple {{variable}} substitution
|
||||
result = RenderSimpleVariables(result, context);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string RenderSimpleVariables(string template, Dictionary<string, object?> context)
|
||||
{
|
||||
return VariableRegex().Replace(template, match =>
|
||||
{
|
||||
var key = match.Groups[1].Value.Trim();
|
||||
|
||||
if (context.TryGetValue(key, out var value) && value is not null)
|
||||
{
|
||||
return value.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
// Return empty string for missing variables
|
||||
return string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{\{#each\s+(\w+(?:\.\w+)*)\s*\}\}(.*?)\{\{/each\}\}", RegexOptions.Singleline)]
|
||||
private static partial Regex EachBlockRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{([^}]+)\}\}")]
|
||||
private static partial Regex VariableRegex();
|
||||
}
|
||||
|
||||
@@ -1,198 +1,198 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Dispatch;
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches notifications to webhook endpoints (Slack, generic webhook).
|
||||
/// </summary>
|
||||
public sealed class WebhookChannelDispatcher : INotifyChannelDispatcher
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<WebhookChannelDispatcher> _logger;
|
||||
|
||||
private static readonly IReadOnlyCollection<NotifyChannelType> Types = new[]
|
||||
{
|
||||
NotifyChannelType.Slack,
|
||||
NotifyChannelType.Webhook,
|
||||
NotifyChannelType.Custom
|
||||
};
|
||||
|
||||
public WebhookChannelDispatcher(HttpClient httpClient, ILogger<WebhookChannelDispatcher> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<NotifyChannelType> SupportedTypes => Types;
|
||||
|
||||
public async Task<NotifyDispatchResult> DispatchAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyRenderedContent content,
|
||||
NotifyDelivery delivery,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
ArgumentNullException.ThrowIfNull(delivery);
|
||||
|
||||
var endpoint = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Channel {ChannelId} has invalid endpoint: {Endpoint}",
|
||||
channel.ChannelId,
|
||||
endpoint ?? "(null)");
|
||||
return NotifyDispatchResult.Failed("Invalid webhook endpoint configuration");
|
||||
}
|
||||
|
||||
var payload = BuildPayload(channel, content, delivery);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Dispatching to {ChannelType} channel {ChannelId} at {Endpoint}",
|
||||
channel.Type,
|
||||
channel.ChannelId,
|
||||
uri.Host);
|
||||
|
||||
const int maxAttempts = 3;
|
||||
var attempt = 0;
|
||||
Exception? lastException = null;
|
||||
|
||||
while (attempt < maxAttempts)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Successfully dispatched delivery {DeliveryId} to {ChannelId} (attempt {Attempt})",
|
||||
delivery.DeliveryId,
|
||||
channel.ChannelId,
|
||||
attempt);
|
||||
|
||||
return NotifyDispatchResult.Succeeded(attempt);
|
||||
}
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook dispatch failed for {DeliveryId}: {StatusCode} {ReasonPhrase}. Response: {Response}",
|
||||
delivery.DeliveryId,
|
||||
statusCode,
|
||||
response.ReasonPhrase,
|
||||
responseBody.Length > 500 ? responseBody[..500] + "..." : responseBody);
|
||||
|
||||
var isRetryable = IsRetryableStatusCode(response.StatusCode);
|
||||
if (!isRetryable || attempt >= maxAttempts)
|
||||
{
|
||||
return NotifyDispatchResult.Failed(
|
||||
$"HTTP {statusCode}: {response.ReasonPhrase}",
|
||||
isRetryable,
|
||||
attempt);
|
||||
}
|
||||
|
||||
// Exponential backoff before retry
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"HTTP request failed for {DeliveryId} (attempt {Attempt}/{MaxAttempts})",
|
||||
delivery.DeliveryId,
|
||||
attempt,
|
||||
maxAttempts);
|
||||
|
||||
if (attempt >= maxAttempts)
|
||||
{
|
||||
return NotifyDispatchResult.Failed(ex.Message, isRetryable: true, attempt);
|
||||
}
|
||||
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
// Timeout
|
||||
lastException = ex;
|
||||
_logger.LogWarning(
|
||||
"Request timeout for {DeliveryId} (attempt {Attempt}/{MaxAttempts})",
|
||||
delivery.DeliveryId,
|
||||
attempt,
|
||||
maxAttempts);
|
||||
|
||||
if (attempt >= maxAttempts)
|
||||
{
|
||||
return NotifyDispatchResult.Failed("Request timeout", isRetryable: true, attempt);
|
||||
}
|
||||
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return NotifyDispatchResult.Failed(
|
||||
lastException?.Message ?? "Max retry attempts exceeded",
|
||||
isRetryable: true,
|
||||
attempt);
|
||||
}
|
||||
|
||||
private static string BuildPayload(NotifyChannel channel, NotifyRenderedContent content, NotifyDelivery delivery)
|
||||
{
|
||||
if (channel.Type == NotifyChannelType.Slack)
|
||||
{
|
||||
// Slack webhook format
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
text = content.Body,
|
||||
channel = channel.Config?.Target,
|
||||
});
|
||||
}
|
||||
|
||||
// Generic webhook format
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
deliveryId = delivery.DeliveryId,
|
||||
eventId = delivery.EventId,
|
||||
kind = delivery.Kind,
|
||||
tenant = delivery.TenantId,
|
||||
body = content.Body,
|
||||
subject = content.Subject,
|
||||
bodyHash = content.BodyHash,
|
||||
format = content.Format.ToString(),
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O")
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsRetryableStatusCode(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.TooManyRequests => true,
|
||||
HttpStatusCode.InternalServerError => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Dispatch;
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches notifications to webhook endpoints (Slack, generic webhook).
|
||||
/// </summary>
|
||||
public sealed class WebhookChannelDispatcher : INotifyChannelDispatcher
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<WebhookChannelDispatcher> _logger;
|
||||
|
||||
private static readonly IReadOnlyCollection<NotifyChannelType> Types = new[]
|
||||
{
|
||||
NotifyChannelType.Slack,
|
||||
NotifyChannelType.Webhook,
|
||||
NotifyChannelType.Custom
|
||||
};
|
||||
|
||||
public WebhookChannelDispatcher(HttpClient httpClient, ILogger<WebhookChannelDispatcher> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<NotifyChannelType> SupportedTypes => Types;
|
||||
|
||||
public async Task<NotifyDispatchResult> DispatchAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyRenderedContent content,
|
||||
NotifyDelivery delivery,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
ArgumentNullException.ThrowIfNull(delivery);
|
||||
|
||||
var endpoint = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Channel {ChannelId} has invalid endpoint: {Endpoint}",
|
||||
channel.ChannelId,
|
||||
endpoint ?? "(null)");
|
||||
return NotifyDispatchResult.Failed("Invalid webhook endpoint configuration");
|
||||
}
|
||||
|
||||
var payload = BuildPayload(channel, content, delivery);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Dispatching to {ChannelType} channel {ChannelId} at {Endpoint}",
|
||||
channel.Type,
|
||||
channel.ChannelId,
|
||||
uri.Host);
|
||||
|
||||
const int maxAttempts = 3;
|
||||
var attempt = 0;
|
||||
Exception? lastException = null;
|
||||
|
||||
while (attempt < maxAttempts)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Successfully dispatched delivery {DeliveryId} to {ChannelId} (attempt {Attempt})",
|
||||
delivery.DeliveryId,
|
||||
channel.ChannelId,
|
||||
attempt);
|
||||
|
||||
return NotifyDispatchResult.Succeeded(attempt);
|
||||
}
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook dispatch failed for {DeliveryId}: {StatusCode} {ReasonPhrase}. Response: {Response}",
|
||||
delivery.DeliveryId,
|
||||
statusCode,
|
||||
response.ReasonPhrase,
|
||||
responseBody.Length > 500 ? responseBody[..500] + "..." : responseBody);
|
||||
|
||||
var isRetryable = IsRetryableStatusCode(response.StatusCode);
|
||||
if (!isRetryable || attempt >= maxAttempts)
|
||||
{
|
||||
return NotifyDispatchResult.Failed(
|
||||
$"HTTP {statusCode}: {response.ReasonPhrase}",
|
||||
isRetryable,
|
||||
attempt);
|
||||
}
|
||||
|
||||
// Exponential backoff before retry
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"HTTP request failed for {DeliveryId} (attempt {Attempt}/{MaxAttempts})",
|
||||
delivery.DeliveryId,
|
||||
attempt,
|
||||
maxAttempts);
|
||||
|
||||
if (attempt >= maxAttempts)
|
||||
{
|
||||
return NotifyDispatchResult.Failed(ex.Message, isRetryable: true, attempt);
|
||||
}
|
||||
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
// Timeout
|
||||
lastException = ex;
|
||||
_logger.LogWarning(
|
||||
"Request timeout for {DeliveryId} (attempt {Attempt}/{MaxAttempts})",
|
||||
delivery.DeliveryId,
|
||||
attempt,
|
||||
maxAttempts);
|
||||
|
||||
if (attempt >= maxAttempts)
|
||||
{
|
||||
return NotifyDispatchResult.Failed("Request timeout", isRetryable: true, attempt);
|
||||
}
|
||||
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return NotifyDispatchResult.Failed(
|
||||
lastException?.Message ?? "Max retry attempts exceeded",
|
||||
isRetryable: true,
|
||||
attempt);
|
||||
}
|
||||
|
||||
private static string BuildPayload(NotifyChannel channel, NotifyRenderedContent content, NotifyDelivery delivery)
|
||||
{
|
||||
if (channel.Type == NotifyChannelType.Slack)
|
||||
{
|
||||
// Slack webhook format
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
text = content.Body,
|
||||
channel = channel.Config?.Target,
|
||||
});
|
||||
}
|
||||
|
||||
// Generic webhook format
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
deliveryId = delivery.DeliveryId,
|
||||
eventId = delivery.EventId,
|
||||
kind = delivery.Kind,
|
||||
tenant = delivery.TenantId,
|
||||
body = content.Body,
|
||||
subject = content.Subject,
|
||||
bodyHash = content.BodyHash,
|
||||
format = content.Format.ToString(),
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O")
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsRetryableStatusCode(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.TooManyRequests => true,
|
||||
HttpStatusCode.InternalServerError => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user