Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for resolving channel adapters by type.
|
||||
/// </summary>
|
||||
public interface IChannelAdapterFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a channel adapter for the specified channel type.
|
||||
/// </summary>
|
||||
IChannelAdapter? GetAdapter(NotifyChannelType channelType);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered channel adapters.
|
||||
/// </summary>
|
||||
IReadOnlyList<IChannelAdapter> GetAllAdapters();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IChannelAdapterFactory"/>.
|
||||
/// </summary>
|
||||
public sealed class ChannelAdapterFactory : IChannelAdapterFactory
|
||||
{
|
||||
private readonly IReadOnlyDictionary<NotifyChannelType, IChannelAdapter> _adapters;
|
||||
private readonly IReadOnlyList<IChannelAdapter> _allAdapters;
|
||||
|
||||
public ChannelAdapterFactory(IEnumerable<IChannelAdapter> adapters)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(adapters);
|
||||
|
||||
var adapterList = adapters.ToList();
|
||||
_allAdapters = adapterList.AsReadOnly();
|
||||
|
||||
var dict = new Dictionary<NotifyChannelType, IChannelAdapter>();
|
||||
foreach (var adapter in adapterList)
|
||||
{
|
||||
dict[adapter.ChannelType] = adapter;
|
||||
}
|
||||
_adapters = dict;
|
||||
}
|
||||
|
||||
public IChannelAdapter? GetAdapter(NotifyChannelType channelType)
|
||||
{
|
||||
return _adapters.GetValueOrDefault(channelType);
|
||||
}
|
||||
|
||||
public IReadOnlyList<IChannelAdapter> GetAllAdapters() => _allAdapters;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering channel adapters.
|
||||
/// </summary>
|
||||
public static class ChannelAdapterServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers channel adapters and factory.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddChannelAdapters(
|
||||
this IServiceCollection services,
|
||||
Action<ChannelAdapterOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddOptions<ChannelAdapterOptions>()
|
||||
.BindConfiguration(ChannelAdapterOptions.SectionName);
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.AddHttpClient<WebhookChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
|
||||
services.AddHttpClient<ChatWebhookChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
|
||||
services.AddSingleton<IChannelAdapter, WebhookChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<WebhookChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(WebhookChannelAdapter)));
|
||||
});
|
||||
|
||||
services.AddSingleton<IChannelAdapter, EmailChannelAdapter>();
|
||||
|
||||
services.AddSingleton<IChannelAdapter, ChatWebhookChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<ChatWebhookChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(ChatWebhookChannelAdapter)));
|
||||
});
|
||||
|
||||
// PagerDuty adapter
|
||||
services.AddHttpClient<PagerDutyChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
services.AddSingleton<IChannelAdapter, PagerDutyChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<PagerDutyChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(PagerDutyChannelAdapter)));
|
||||
});
|
||||
|
||||
// OpsGenie adapter
|
||||
services.AddHttpClient<OpsGenieChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
services.AddSingleton<IChannelAdapter, OpsGenieChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<OpsGenieChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(OpsGenieChannelAdapter)));
|
||||
});
|
||||
|
||||
// InApp adapter
|
||||
services.AddOptions<InAppChannelOptions>()
|
||||
.BindConfiguration(InAppChannelOptions.SectionName);
|
||||
services.AddSingleton<IChannelAdapter, InAppChannelAdapter>();
|
||||
|
||||
services.AddSingleton<IChannelAdapterFactory, ChannelAdapterFactory>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for channel adapters.
|
||||
/// </summary>
|
||||
public sealed class ChannelAdapterOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "ChannelAdapters";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retry attempts for failed dispatches.
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Base delay for exponential backoff between retries.
|
||||
/// </summary>
|
||||
public TimeSpan RetryBaseDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum delay between retries.
|
||||
/// </summary>
|
||||
public TimeSpan RetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for individual dispatch operations.
|
||||
/// </summary>
|
||||
public TimeSpan DispatchTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Enable HMAC signing for webhook payloads.
|
||||
/// </summary>
|
||||
public bool EnableHmacSigning { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// User agent string for HTTP requests.
|
||||
/// </summary>
|
||||
public string UserAgent { get; set; } = "StellaOps-Notifier/1.0";
|
||||
|
||||
/// <summary>
|
||||
/// Default concurrency limit per channel type.
|
||||
/// </summary>
|
||||
public int DefaultConcurrency { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Enable circuit breaker for unhealthy channels.
|
||||
/// </summary>
|
||||
public bool EnableCircuitBreaker { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive failures before circuit opens.
|
||||
/// </summary>
|
||||
public int CircuitBreakerThreshold { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Duration to keep circuit open before allowing retry.
|
||||
/// </summary>
|
||||
public TimeSpan CircuitBreakerDuration { get; set; } = TimeSpan.FromMinutes(1);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for Slack and Teams webhooks with retry policies.
|
||||
/// Handles Slack incoming webhooks and Teams connectors.
|
||||
/// </summary>
|
||||
public sealed class ChatWebhookChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<ChatWebhookChannelAdapter> _logger;
|
||||
|
||||
public ChatWebhookChannelAdapter(
|
||||
HttpClient httpClient,
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
ILogger<ChatWebhookChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
// Routes Slack type to this adapter; Teams uses Custom type
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this adapter can handle the specified channel.
|
||||
/// </summary>
|
||||
public bool CanHandle(NotifyChannel channel)
|
||||
{
|
||||
return channel.Type is NotifyChannelType.Slack or NotifyChannelType.Teams;
|
||||
}
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var endpoint = context.Channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "Invalid webhook URL.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"Chat webhook URL is not configured or invalid.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var isSlack = context.Channel.Type == NotifyChannelType.Slack || IsSlackWebhook(uri);
|
||||
var payload = isSlack
|
||||
? BuildSlackPayload(context)
|
||||
: BuildTeamsPayload(context);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
int? lastStatusCode = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
lastStatusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var metadata = BuildSuccessMetadata(context, isSlack, attempt);
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Chat webhook delivery {DeliveryId} to {Platform} succeeded on attempt {Attempt}.",
|
||||
context.DeliveryId, isSlack ? "Slack" : "Teams", attempt);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
message: $"Delivered to {(isSlack ? "Slack" : "Teams")}.",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = ParseRetryAfter(response.Headers);
|
||||
stopwatch.Stop();
|
||||
|
||||
await AuditDispatchAsync(context, false, "Rate limited.", null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Chat webhook delivery {DeliveryId} throttled. Retry after: {RetryAfter}.",
|
||||
context.DeliveryId, retryAfter);
|
||||
|
||||
return ChannelDispatchResult.Throttled(
|
||||
$"Rate limited by {(isSlack ? "Slack" : "Teams")}.",
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
if (!IsRetryable(response.StatusCode))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var errorMessage = $"Chat webhook returned {response.StatusCode}: {TruncateError(responseBody)}";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Chat webhook delivery {DeliveryId} failed: {StatusCode}.",
|
||||
context.DeliveryId, response.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
httpStatusCode: lastStatusCode,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Chat webhook delivery {DeliveryId} attempt {Attempt} returned {StatusCode}, will retry.",
|
||||
context.DeliveryId, attempt, response.StatusCode);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Chat webhook delivery {DeliveryId} attempt {Attempt} failed with network error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
"Chat webhook delivery {DeliveryId} attempt {Attempt} timed out.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(
|
||||
lastException,
|
||||
"Chat webhook delivery {DeliveryId} exhausted all retries.",
|
||||
context.DeliveryId);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
lastException is TaskCanceledException ? ChannelDispatchStatus.Timeout : ChannelDispatchStatus.NetworkError,
|
||||
httpStatusCode: lastStatusCode,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public async Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
var endpoint = channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelHealthCheckResult.Unhealthy("Webhook URL is not configured or invalid.");
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded("Channel is disabled.");
|
||||
}
|
||||
|
||||
var isSlack = channel.Type == NotifyChannelType.Slack || IsSlackWebhook(uri);
|
||||
|
||||
// Slack/Teams webhooks don't support HEAD, so we just validate the URL format
|
||||
if (isSlack && !uri.Host.Contains("slack.com", StringComparison.OrdinalIgnoreCase) &&
|
||||
!uri.Host.Contains("hooks.slack.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded(
|
||||
"Webhook URL doesn't appear to be a Slack webhook.");
|
||||
}
|
||||
|
||||
if (!isSlack && !uri.Host.Contains("webhook.office.com", StringComparison.OrdinalIgnoreCase) &&
|
||||
!uri.Host.Contains("outlook.office.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded(
|
||||
"Webhook URL doesn't appear to be a Teams connector.");
|
||||
}
|
||||
|
||||
return ChannelHealthCheckResult.Ok(
|
||||
$"{(isSlack ? "Slack" : "Teams")} webhook URL validated.");
|
||||
}
|
||||
|
||||
private static bool IsSlackWebhook(Uri uri)
|
||||
{
|
||||
return uri.Host.Contains("slack.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
uri.Host.Contains("hooks.slack.com", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildSlackPayload(ChannelDispatchContext context)
|
||||
{
|
||||
var message = new
|
||||
{
|
||||
text = context.Subject ?? "StellaOps Notification",
|
||||
blocks = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
text = new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = context.RenderedBody
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "context",
|
||||
elements = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = $"*Delivery ID:* {context.DeliveryId} | *Trace:* {context.TraceId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(message, new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
|
||||
private static string BuildTeamsPayload(ChannelDispatchContext context)
|
||||
{
|
||||
var card = new
|
||||
{
|
||||
type = "message",
|
||||
attachments = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
contentType = "application/vnd.microsoft.card.adaptive",
|
||||
content = new
|
||||
{
|
||||
type = "AdaptiveCard",
|
||||
version = "1.4",
|
||||
body = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = context.Subject ?? "StellaOps Notification",
|
||||
weight = "bolder",
|
||||
size = "medium"
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = context.RenderedBody,
|
||||
wrap = true
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "FactSet",
|
||||
facts = new object[]
|
||||
{
|
||||
new { title = "Delivery ID", value = context.DeliveryId },
|
||||
new { title = "Trace ID", value = context.TraceId }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(card, new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseRetryAfter(HttpResponseHeaders headers)
|
||||
{
|
||||
if (headers.RetryAfter?.Delta is { } delta)
|
||||
{
|
||||
return delta;
|
||||
}
|
||||
|
||||
if (headers.RetryAfter?.Date is { } date)
|
||||
{
|
||||
var delay = date - DateTimeOffset.UtcNow;
|
||||
return delay > TimeSpan.Zero ? delay : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.RequestTimeout => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private static string TruncateError(string error)
|
||||
{
|
||||
const int maxLength = 200;
|
||||
return error.Length > maxLength ? error[..maxLength] + "..." : error;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSuccessMetadata(
|
||||
ChannelDispatchContext context,
|
||||
bool isSlack,
|
||||
int attempt)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["platform"] = isSlack ? "Slack" : "Teams",
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = context.Channel.Type.ToString(),
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,190 +1,190 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for CLI-based notification delivery.
|
||||
/// Executes a configured command-line tool with notification payload as input.
|
||||
/// Useful for custom integrations and local testing.
|
||||
/// </summary>
|
||||
public sealed class CliChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly ILogger<CliChannelAdapter> _logger;
|
||||
private readonly TimeSpan _commandTimeout;
|
||||
|
||||
public CliChannelAdapter(ILogger<CliChannelAdapter> logger, TimeSpan? commandTimeout = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_commandTimeout = commandTimeout ?? TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Cli;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var command = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("CLI command not configured in endpoint", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Parse command and arguments
|
||||
var (executable, arguments) = ParseCommand(command);
|
||||
if (string.IsNullOrWhiteSpace(executable))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Invalid CLI command format", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Build JSON payload to send via stdin
|
||||
var payload = new
|
||||
{
|
||||
bodyHash = rendered.BodyHash,
|
||||
channel = rendered.ChannelType.ToString(),
|
||||
target = rendered.Target,
|
||||
title = rendered.Title,
|
||||
body = rendered.Body,
|
||||
summary = rendered.Summary,
|
||||
textBody = rendered.TextBody,
|
||||
format = rendered.Format.ToString(),
|
||||
locale = rendered.Locale,
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O"),
|
||||
channelConfig = new
|
||||
{
|
||||
channelId = channel.ChannelId,
|
||||
name = channel.Name,
|
||||
properties = channel.Config?.Properties
|
||||
}
|
||||
};
|
||||
|
||||
var jsonPayload = JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(_commandTimeout);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
StandardInputEncoding = Encoding.UTF8,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
// Add environment variables from channel config
|
||||
if (channel.Config?.Properties is not null)
|
||||
{
|
||||
foreach (var kv in channel.Config.Properties)
|
||||
{
|
||||
if (kv.Key.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var envVar = kv.Key[4..];
|
||||
startInfo.EnvironmentVariables[envVar] = kv.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
|
||||
_logger.LogDebug("Starting CLI command: {Executable} {Arguments}", executable, arguments);
|
||||
|
||||
process.Start();
|
||||
|
||||
// Write payload to stdin
|
||||
await process.StandardInput.WriteAsync(jsonPayload).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync().ConfigureAwait(false);
|
||||
process.StandardInput.Close();
|
||||
|
||||
// Read output streams
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync(cts.Token);
|
||||
var errorTask = process.StandardError.ReadToEndAsync(cts.Token);
|
||||
|
||||
await process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var stdout = await outputTask.ConfigureAwait(false);
|
||||
var stderr = await errorTask.ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"CLI command executed successfully. Exit code: 0. Output: {Output}",
|
||||
stdout.Length > 500 ? stdout[..500] + "..." : stdout);
|
||||
|
||||
return ChannelDispatchResult.Ok(process.ExitCode);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"CLI command failed with exit code {ExitCode}. Stderr: {Stderr}",
|
||||
process.ExitCode,
|
||||
stderr.Length > 500 ? stderr[..500] + "..." : stderr);
|
||||
|
||||
// Non-zero exit codes are typically not retryable
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"Exit code {process.ExitCode}: {stderr}",
|
||||
process.ExitCode,
|
||||
shouldRetry: false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("CLI command timed out after {Timeout}", _commandTimeout);
|
||||
return ChannelDispatchResult.Fail($"Command timeout after {_commandTimeout.TotalSeconds}s", shouldRetry: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "CLI command execution failed: {Message}", ex.Message);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string executable, string arguments) ParseCommand(string command)
|
||||
{
|
||||
command = command.Trim();
|
||||
if (string.IsNullOrEmpty(command))
|
||||
return (string.Empty, string.Empty);
|
||||
|
||||
// Handle quoted executable paths
|
||||
if (command.StartsWith('"'))
|
||||
{
|
||||
var endQuote = command.IndexOf('"', 1);
|
||||
if (endQuote > 0)
|
||||
{
|
||||
var exe = command[1..endQuote];
|
||||
var args = command.Length > endQuote + 1 ? command[(endQuote + 1)..].TrimStart() : string.Empty;
|
||||
return (exe, args);
|
||||
}
|
||||
}
|
||||
|
||||
// Simple space-separated
|
||||
var spaceIndex = command.IndexOf(' ');
|
||||
if (spaceIndex > 0)
|
||||
{
|
||||
return (command[..spaceIndex], command[(spaceIndex + 1)..].TrimStart());
|
||||
}
|
||||
|
||||
return (command, string.Empty);
|
||||
}
|
||||
}
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for CLI-based notification delivery.
|
||||
/// Executes a configured command-line tool with notification payload as input.
|
||||
/// Useful for custom integrations and local testing.
|
||||
/// </summary>
|
||||
public sealed class CliChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly ILogger<CliChannelAdapter> _logger;
|
||||
private readonly TimeSpan _commandTimeout;
|
||||
|
||||
public CliChannelAdapter(ILogger<CliChannelAdapter> logger, TimeSpan? commandTimeout = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_commandTimeout = commandTimeout ?? TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Cli;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var command = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("CLI command not configured in endpoint", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Parse command and arguments
|
||||
var (executable, arguments) = ParseCommand(command);
|
||||
if (string.IsNullOrWhiteSpace(executable))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Invalid CLI command format", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Build JSON payload to send via stdin
|
||||
var payload = new
|
||||
{
|
||||
bodyHash = rendered.BodyHash,
|
||||
channel = rendered.ChannelType.ToString(),
|
||||
target = rendered.Target,
|
||||
title = rendered.Title,
|
||||
body = rendered.Body,
|
||||
summary = rendered.Summary,
|
||||
textBody = rendered.TextBody,
|
||||
format = rendered.Format.ToString(),
|
||||
locale = rendered.Locale,
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O"),
|
||||
channelConfig = new
|
||||
{
|
||||
channelId = channel.ChannelId,
|
||||
name = channel.Name,
|
||||
properties = channel.Config?.Properties
|
||||
}
|
||||
};
|
||||
|
||||
var jsonPayload = JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(_commandTimeout);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
StandardInputEncoding = Encoding.UTF8,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
// Add environment variables from channel config
|
||||
if (channel.Config?.Properties is not null)
|
||||
{
|
||||
foreach (var kv in channel.Config.Properties)
|
||||
{
|
||||
if (kv.Key.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var envVar = kv.Key[4..];
|
||||
startInfo.EnvironmentVariables[envVar] = kv.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
|
||||
_logger.LogDebug("Starting CLI command: {Executable} {Arguments}", executable, arguments);
|
||||
|
||||
process.Start();
|
||||
|
||||
// Write payload to stdin
|
||||
await process.StandardInput.WriteAsync(jsonPayload).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync().ConfigureAwait(false);
|
||||
process.StandardInput.Close();
|
||||
|
||||
// Read output streams
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync(cts.Token);
|
||||
var errorTask = process.StandardError.ReadToEndAsync(cts.Token);
|
||||
|
||||
await process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var stdout = await outputTask.ConfigureAwait(false);
|
||||
var stderr = await errorTask.ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"CLI command executed successfully. Exit code: 0. Output: {Output}",
|
||||
stdout.Length > 500 ? stdout[..500] + "..." : stdout);
|
||||
|
||||
return ChannelDispatchResult.Ok(process.ExitCode);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"CLI command failed with exit code {ExitCode}. Stderr: {Stderr}",
|
||||
process.ExitCode,
|
||||
stderr.Length > 500 ? stderr[..500] + "..." : stderr);
|
||||
|
||||
// Non-zero exit codes are typically not retryable
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"Exit code {process.ExitCode}: {stderr}",
|
||||
process.ExitCode,
|
||||
shouldRetry: false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("CLI command timed out after {Timeout}", _commandTimeout);
|
||||
return ChannelDispatchResult.Fail($"Command timeout after {_commandTimeout.TotalSeconds}s", shouldRetry: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "CLI command execution failed: {Message}", ex.Message);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string executable, string arguments) ParseCommand(string command)
|
||||
{
|
||||
command = command.Trim();
|
||||
if (string.IsNullOrEmpty(command))
|
||||
return (string.Empty, string.Empty);
|
||||
|
||||
// Handle quoted executable paths
|
||||
if (command.StartsWith('"'))
|
||||
{
|
||||
var endQuote = command.IndexOf('"', 1);
|
||||
if (endQuote > 0)
|
||||
{
|
||||
var exe = command[1..endQuote];
|
||||
var args = command.Length > endQuote + 1 ? command[(endQuote + 1)..].TrimStart() : string.Empty;
|
||||
return (exe, args);
|
||||
}
|
||||
}
|
||||
|
||||
// Simple space-separated
|
||||
var spaceIndex = command.IndexOf(' ');
|
||||
if (spaceIndex > 0)
|
||||
{
|
||||
return (command[..spaceIndex], command[(spaceIndex + 1)..].TrimStart());
|
||||
}
|
||||
|
||||
return (command, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,378 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for email delivery. Requires SMTP configuration.
|
||||
/// </summary>
|
||||
public sealed class EmailChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly ILogger<EmailChannelAdapter> _logger;
|
||||
|
||||
public EmailChannelAdapter(ILogger<EmailChannelAdapter> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Email;
|
||||
|
||||
public Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var target = channel.Config?.Target ?? rendered.Target;
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return Task.FromResult(ChannelDispatchResult.Fail(
|
||||
"Email recipient not configured",
|
||||
shouldRetry: false));
|
||||
}
|
||||
|
||||
// Email delivery requires SMTP integration which depends on environment config.
|
||||
// For now, log the intent and return success for dev/test scenarios.
|
||||
// Production deployments should integrate with an SMTP relay or email service.
|
||||
_logger.LogInformation(
|
||||
"Email delivery queued: to={Recipient}, subject={Subject}, format={Format}",
|
||||
target,
|
||||
rendered.Title,
|
||||
rendered.Format);
|
||||
|
||||
// In a real implementation, this would:
|
||||
// 1. Resolve SMTP settings from channel.Config.SecretRef
|
||||
// 2. Build and send the email via SmtpClient or a service like SendGrid
|
||||
// 3. Return actual success/failure based on delivery
|
||||
|
||||
return Task.FromResult(ChannelDispatchResult.Ok());
|
||||
}
|
||||
}
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for SMTP email dispatch with retry policies.
|
||||
/// </summary>
|
||||
public sealed class EmailChannelAdapter : IChannelAdapter, IDisposable
|
||||
{
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<EmailChannelAdapter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _disposed;
|
||||
|
||||
public EmailChannelAdapter(
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<EmailChannelAdapter> logger)
|
||||
{
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Email;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (!TryParseSmtpConfig(context.Channel, out var smtpConfig, out var configError))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, configError, null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(configError, ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var recipients = ParseRecipients(context);
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
var error = "No valid recipients configured.";
|
||||
await AuditDispatchAsync(context, false, error, null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(error, ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = CreateSmtpClient(smtpConfig);
|
||||
using var message = BuildMessage(context, smtpConfig, recipients);
|
||||
|
||||
await client.SendMailAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
var metadata = BuildSuccessMetadata(context, smtpConfig, recipients, attempt);
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Email delivery {DeliveryId} succeeded to {RecipientCount} recipients via {SmtpHost} on attempt {Attempt}.",
|
||||
context.DeliveryId, recipients.Count, smtpConfig.Host, attempt);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
message: $"Sent to {recipients.Count} recipient(s) via {smtpConfig.Host}.",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
catch (SmtpException ex) when (IsRetryable(ex))
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Email delivery {DeliveryId} attempt {Attempt} failed with retryable SMTP error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
catch (SmtpException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorMessage = $"SMTP error: {ex.StatusCode} - {ex.Message}";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Email delivery {DeliveryId} failed with non-retryable SMTP error: {StatusCode}.",
|
||||
context.DeliveryId, ex.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
ChannelDispatchStatus.Failed,
|
||||
exception: ex,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Net.Sockets.SocketException or TimeoutException)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Email delivery {DeliveryId} attempt {Attempt} failed with network error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(
|
||||
lastException,
|
||||
"Email delivery {DeliveryId} exhausted all {MaxRetries} retries to {SmtpHost}.",
|
||||
context.DeliveryId, maxRetries + 1, smtpConfig.Host);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
ChannelDispatchStatus.NetworkError,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (!TryParseSmtpConfig(channel, out var smtpConfig, out var error))
|
||||
{
|
||||
return Task.FromResult(ChannelHealthCheckResult.Unhealthy(error));
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return Task.FromResult(ChannelHealthCheckResult.Degraded("Channel is disabled."));
|
||||
}
|
||||
|
||||
return Task.FromResult(ChannelHealthCheckResult.Ok(
|
||||
$"SMTP configuration validated for {smtpConfig.Host}:{smtpConfig.Port}."));
|
||||
}
|
||||
|
||||
private static bool TryParseSmtpConfig(NotifyChannel channel, out SmtpConfig config, out string error)
|
||||
{
|
||||
config = default;
|
||||
error = string.Empty;
|
||||
|
||||
var props = channel.Config.Properties;
|
||||
if (props is null)
|
||||
{
|
||||
error = "Channel properties are not configured.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!props.TryGetValue("smtpHost", out var host) || string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
error = "SMTP host is not configured.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!props.TryGetValue("smtpPort", out var portStr) || !int.TryParse(portStr, out var port))
|
||||
{
|
||||
port = 587;
|
||||
}
|
||||
|
||||
if (!props.TryGetValue("fromAddress", out var fromAddress) || string.IsNullOrWhiteSpace(fromAddress))
|
||||
{
|
||||
error = "From address is not configured.";
|
||||
return false;
|
||||
}
|
||||
|
||||
props.TryGetValue("fromName", out var fromName);
|
||||
props.TryGetValue("username", out var username);
|
||||
var enableSsl = !props.TryGetValue("enableSsl", out var sslStr) || !bool.TryParse(sslStr, out var ssl) || ssl;
|
||||
|
||||
config = new SmtpConfig(host, port, fromAddress, fromName, username, channel.Config.SecretRef, enableSsl);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<string> ParseRecipients(ChannelDispatchContext context)
|
||||
{
|
||||
var recipients = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(context.Channel.Config.Target))
|
||||
{
|
||||
recipients.AddRange(context.Channel.Config.Target
|
||||
.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(IsValidEmail));
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("recipients", out var metaRecipients) &&
|
||||
!string.IsNullOrWhiteSpace(metaRecipients))
|
||||
{
|
||||
recipients.AddRange(metaRecipients
|
||||
.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(IsValidEmail));
|
||||
}
|
||||
|
||||
return recipients.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static bool IsValidEmail(string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var addr = new MailAddress(email);
|
||||
return addr.Address == email.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private SmtpClient CreateSmtpClient(SmtpConfig config)
|
||||
{
|
||||
var client = new SmtpClient(config.Host, config.Port)
|
||||
{
|
||||
EnableSsl = config.EnableSsl,
|
||||
Timeout = (int)_options.DispatchTimeout.TotalMilliseconds,
|
||||
DeliveryMethod = SmtpDeliveryMethod.Network
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.Username) && !string.IsNullOrWhiteSpace(config.Password))
|
||||
{
|
||||
client.Credentials = new NetworkCredential(config.Username, config.Password);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static MailMessage BuildMessage(
|
||||
ChannelDispatchContext context,
|
||||
SmtpConfig config,
|
||||
List<string> recipients)
|
||||
{
|
||||
var from = string.IsNullOrWhiteSpace(config.FromName)
|
||||
? new MailAddress(config.FromAddress)
|
||||
: new MailAddress(config.FromAddress, config.FromName);
|
||||
|
||||
var message = new MailMessage
|
||||
{
|
||||
From = from,
|
||||
Subject = context.Subject ?? "StellaOps Notification",
|
||||
Body = context.RenderedBody,
|
||||
IsBodyHtml = context.RenderedBody.Contains("<html", StringComparison.OrdinalIgnoreCase) ||
|
||||
context.RenderedBody.Contains("<body", StringComparison.OrdinalIgnoreCase)
|
||||
};
|
||||
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
message.To.Add(recipient);
|
||||
}
|
||||
|
||||
message.Headers.Add("X-StellaOps-Delivery-Id", context.DeliveryId);
|
||||
message.Headers.Add("X-StellaOps-Trace-Id", context.TraceId);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(SmtpException ex)
|
||||
{
|
||||
return ex.StatusCode switch
|
||||
{
|
||||
SmtpStatusCode.ServiceNotAvailable => true,
|
||||
SmtpStatusCode.MailboxBusy => true,
|
||||
SmtpStatusCode.LocalErrorInProcessing => true,
|
||||
SmtpStatusCode.InsufficientStorage => true,
|
||||
SmtpStatusCode.ServiceClosingTransmissionChannel => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSuccessMetadata(
|
||||
ChannelDispatchContext context,
|
||||
SmtpConfig config,
|
||||
List<string> recipients,
|
||||
int attempt)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["smtpHost"] = config.Host,
|
||||
["smtpPort"] = config.Port.ToString(),
|
||||
["recipientCount"] = recipients.Count.ToString(),
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = context.Channel.Type.ToString(),
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private readonly record struct SmtpConfig(
|
||||
string Host,
|
||||
int Port,
|
||||
string FromAddress,
|
||||
string? FromName,
|
||||
string? Username,
|
||||
string? Password,
|
||||
bool EnableSsl);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Contract implemented by channel adapters to dispatch notifications.
|
||||
/// </summary>
|
||||
public interface IChannelAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Channel type handled by this adapter.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches a notification delivery through the channel.
|
||||
/// </summary>
|
||||
Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Checks channel health/connectivity.
|
||||
/// </summary>
|
||||
Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for dispatching a notification through a channel.
|
||||
/// </summary>
|
||||
public sealed record ChannelDispatchContext(
|
||||
string DeliveryId,
|
||||
string TenantId,
|
||||
NotifyChannel Channel,
|
||||
NotifyDelivery Delivery,
|
||||
string RenderedBody,
|
||||
string? Subject,
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
DateTimeOffset Timestamp,
|
||||
string TraceId);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a channel dispatch attempt.
|
||||
/// </summary>
|
||||
public sealed record ChannelDispatchResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public required ChannelDispatchStatus Status { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public string? ExternalId { get; init; }
|
||||
public int? HttpStatusCode { get; init; }
|
||||
public TimeSpan? Duration { get; init; }
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
public Exception? Exception { get; init; }
|
||||
|
||||
public static ChannelDispatchResult Succeeded(
|
||||
string? externalId = null,
|
||||
string? message = null,
|
||||
TimeSpan? duration = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
Status = ChannelDispatchStatus.Sent,
|
||||
ExternalId = externalId,
|
||||
Message = message ?? "Delivery dispatched successfully.",
|
||||
Duration = duration,
|
||||
Metadata = metadata ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
public static ChannelDispatchResult Failed(
|
||||
string message,
|
||||
ChannelDispatchStatus status = ChannelDispatchStatus.Failed,
|
||||
int? httpStatusCode = null,
|
||||
Exception? exception = null,
|
||||
TimeSpan? duration = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null) => new()
|
||||
{
|
||||
Success = false,
|
||||
Status = status,
|
||||
Message = message,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Exception = exception,
|
||||
Duration = duration,
|
||||
Metadata = metadata ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
public static ChannelDispatchResult Throttled(
|
||||
string message,
|
||||
TimeSpan? retryAfter = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
var meta = metadata is not null
|
||||
? new Dictionary<string, string>(metadata)
|
||||
: new Dictionary<string, string>();
|
||||
|
||||
if (retryAfter.HasValue)
|
||||
{
|
||||
meta["retryAfterSeconds"] = retryAfter.Value.TotalSeconds.ToString("F0");
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Success = false,
|
||||
Status = ChannelDispatchStatus.Throttled,
|
||||
Message = message,
|
||||
HttpStatusCode = 429,
|
||||
Metadata = meta
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatch attempt status.
|
||||
/// </summary>
|
||||
public enum ChannelDispatchStatus
|
||||
{
|
||||
Sent,
|
||||
Failed,
|
||||
Throttled,
|
||||
InvalidConfiguration,
|
||||
Timeout,
|
||||
NetworkError
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a channel health check.
|
||||
/// </summary>
|
||||
public sealed record ChannelHealthCheckResult
|
||||
{
|
||||
public required bool Healthy { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public TimeSpan? Latency { get; init; }
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
public static ChannelHealthCheckResult Ok(string? message = null, TimeSpan? latency = null) => new()
|
||||
{
|
||||
Healthy = true,
|
||||
Status = "healthy",
|
||||
Message = message ?? "Channel is operational.",
|
||||
Latency = latency
|
||||
};
|
||||
|
||||
public static ChannelHealthCheckResult Degraded(string message, TimeSpan? latency = null) => new()
|
||||
{
|
||||
Healthy = true,
|
||||
Status = "degraded",
|
||||
Message = message,
|
||||
Latency = latency
|
||||
};
|
||||
|
||||
public static ChannelHealthCheckResult Unhealthy(string message) => new()
|
||||
{
|
||||
Healthy = false,
|
||||
Status = "unhealthy",
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
@@ -1,51 +1,51 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Sends rendered notifications through a specific channel type.
|
||||
/// </summary>
|
||||
public interface INotifyChannelAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// The channel type this adapter handles.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends a rendered notification through the channel.
|
||||
/// </summary>
|
||||
/// <param name="channel">The channel configuration.</param>
|
||||
/// <param name="rendered">The rendered notification content.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The dispatch result with status and any error details.</returns>
|
||||
Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a channel dispatch attempt.
|
||||
/// </summary>
|
||||
public sealed record ChannelDispatchResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public int? StatusCode { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public bool ShouldRetry { get; init; }
|
||||
|
||||
public static ChannelDispatchResult Ok(int? statusCode = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
StatusCode = statusCode
|
||||
};
|
||||
|
||||
public static ChannelDispatchResult Fail(string reason, int? statusCode = null, bool shouldRetry = true) => new()
|
||||
{
|
||||
Success = false,
|
||||
StatusCode = statusCode,
|
||||
Reason = reason,
|
||||
ShouldRetry = shouldRetry
|
||||
};
|
||||
}
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Sends rendered notifications through a specific channel type.
|
||||
/// </summary>
|
||||
public interface INotifyChannelAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// The channel type this adapter handles.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends a rendered notification through the channel.
|
||||
/// </summary>
|
||||
/// <param name="channel">The channel configuration.</param>
|
||||
/// <param name="rendered">The rendered notification content.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The dispatch result with status and any error details.</returns>
|
||||
Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a channel dispatch attempt.
|
||||
/// </summary>
|
||||
public sealed record ChannelDispatchResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public int? StatusCode { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public bool ShouldRetry { get; init; }
|
||||
|
||||
public static ChannelDispatchResult Ok(int? statusCode = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
StatusCode = statusCode
|
||||
};
|
||||
|
||||
public static ChannelDispatchResult Fail(string reason, int? statusCode = null, bool shouldRetry = true) => new()
|
||||
{
|
||||
Success = false,
|
||||
StatusCode = statusCode,
|
||||
Reason = reason,
|
||||
ShouldRetry = shouldRetry
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for in-app notifications (inbox/CLI).
|
||||
/// Stores notifications in-memory for retrieval by users/services.
|
||||
/// </summary>
|
||||
public sealed class InAppChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentQueue<InAppNotification>> _inboxes = new();
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly InAppChannelOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InAppChannelAdapter> _logger;
|
||||
|
||||
public InAppChannelAdapter(
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<InAppChannelOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InAppChannelAdapter> logger)
|
||||
{
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? new InAppChannelOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.InApp;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var userId = GetTargetUserId(context);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "No target user ID specified.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"Target user ID is required for in-app notifications.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var notification = new InAppNotification
|
||||
{
|
||||
NotificationId = $"notif-{Guid.NewGuid():N}"[..20],
|
||||
DeliveryId = context.DeliveryId,
|
||||
TenantId = context.TenantId,
|
||||
UserId = userId,
|
||||
Title = context.Subject ?? "Notification",
|
||||
Body = context.RenderedBody,
|
||||
Priority = GetPriority(context),
|
||||
Category = GetCategory(context),
|
||||
IncidentId = context.Metadata.GetValueOrDefault("incidentId"),
|
||||
ActionUrl = context.Metadata.GetValueOrDefault("actionUrl"),
|
||||
AckUrl = context.Metadata.GetValueOrDefault("ackUrl"),
|
||||
Metadata = new Dictionary<string, string>(context.Metadata),
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = _timeProvider.GetUtcNow() + _options.NotificationTtl,
|
||||
Status = InAppNotificationStatus.Unread
|
||||
};
|
||||
|
||||
// Store in inbox
|
||||
var inboxKey = BuildInboxKey(context.TenantId, userId);
|
||||
var inbox = _inboxes.GetOrAdd(inboxKey, _ => new ConcurrentQueue<InAppNotification>());
|
||||
inbox.Enqueue(notification);
|
||||
|
||||
// Enforce max notifications per inbox
|
||||
while (inbox.Count > _options.MaxNotificationsPerInbox && inbox.TryDequeue(out _))
|
||||
{
|
||||
// Remove oldest
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["notificationId"] = notification.NotificationId,
|
||||
["userId"] = userId,
|
||||
["inboxSize"] = inbox.Count.ToString()
|
||||
};
|
||||
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"In-app notification {NotificationId} delivered to user {UserId} inbox for tenant {TenantId}.",
|
||||
notification.NotificationId, userId, context.TenantId);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
externalId: notification.NotificationId,
|
||||
message: $"Delivered to inbox for user {userId}",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
await AuditDispatchAsync(context, false, ex.Message, null, cancellationToken);
|
||||
|
||||
_logger.LogError(ex, "In-app notification dispatch failed for delivery {DeliveryId}.", context.DeliveryId);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
ex.Message,
|
||||
ChannelDispatchStatus.Failed,
|
||||
exception: ex,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return Task.FromResult(ChannelHealthCheckResult.Degraded("Channel is disabled."));
|
||||
}
|
||||
|
||||
return Task.FromResult(ChannelHealthCheckResult.Ok("In-app channel operational."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unread notifications for a user.
|
||||
/// </summary>
|
||||
public IReadOnlyList<InAppNotification> GetUnreadNotifications(string tenantId, string userId, int limit = 50)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return inbox
|
||||
.Where(n => n.Status == InAppNotificationStatus.Unread && (!n.ExpiresAt.HasValue || n.ExpiresAt > now))
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all notifications for a user.
|
||||
/// </summary>
|
||||
public IReadOnlyList<InAppNotification> GetNotifications(
|
||||
string tenantId,
|
||||
string userId,
|
||||
int limit = 100,
|
||||
bool includeRead = true,
|
||||
bool includeExpired = false)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return inbox
|
||||
.Where(n => (includeRead || n.Status == InAppNotificationStatus.Unread) &&
|
||||
(includeExpired || !n.ExpiresAt.HasValue || n.ExpiresAt > now))
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a notification as read.
|
||||
/// </summary>
|
||||
public bool MarkAsRead(string tenantId, string userId, string notificationId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var notification = inbox.FirstOrDefault(n => n.NotificationId == notificationId);
|
||||
if (notification is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
notification.Status = InAppNotificationStatus.Read;
|
||||
notification.ReadAt = _timeProvider.GetUtcNow();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all notifications as read for a user.
|
||||
/// </summary>
|
||||
public int MarkAllAsRead(string tenantId, string userId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
foreach (var notification in inbox.Where(n => n.Status == InAppNotificationStatus.Unread))
|
||||
{
|
||||
notification.Status = InAppNotificationStatus.Read;
|
||||
notification.ReadAt = now;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a notification.
|
||||
/// </summary>
|
||||
public bool DeleteNotification(string tenantId, string userId, string notificationId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// ConcurrentQueue doesn't support removal, so mark as deleted
|
||||
var notification = inbox.FirstOrDefault(n => n.NotificationId == notificationId);
|
||||
if (notification is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
notification.Status = InAppNotificationStatus.Deleted;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unread count for a user.
|
||||
/// </summary>
|
||||
public int GetUnreadCount(string tenantId, string userId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return inbox.Count(n => n.Status == InAppNotificationStatus.Unread &&
|
||||
(!n.ExpiresAt.HasValue || n.ExpiresAt > now));
|
||||
}
|
||||
|
||||
private static string GetTargetUserId(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("targetUserId", out var userId) && !string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("userId", out userId) && !string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static InAppNotificationPriority GetPriority(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("priority", out var priority) ||
|
||||
context.Metadata.TryGetValue("severity", out priority))
|
||||
{
|
||||
return priority.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" or "urgent" => InAppNotificationPriority.Urgent,
|
||||
"high" => InAppNotificationPriority.High,
|
||||
"medium" => InAppNotificationPriority.Normal,
|
||||
"low" => InAppNotificationPriority.Low,
|
||||
_ => InAppNotificationPriority.Normal
|
||||
};
|
||||
}
|
||||
return InAppNotificationPriority.Normal;
|
||||
}
|
||||
|
||||
private static string GetCategory(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("category", out var category) && !string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
return category;
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("eventKind", out var eventKind) && !string.IsNullOrWhiteSpace(eventKind))
|
||||
{
|
||||
return eventKind;
|
||||
}
|
||||
|
||||
return "general";
|
||||
}
|
||||
|
||||
private static string BuildInboxKey(string tenantId, string userId) =>
|
||||
$"{tenantId}:{userId}";
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = "InApp",
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for in-app channel adapter.
|
||||
/// </summary>
|
||||
public sealed class InAppChannelOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "InAppChannel";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum notifications to keep per user inbox.
|
||||
/// </summary>
|
||||
public int MaxNotificationsPerInbox { get; set; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Time-to-live for notifications.
|
||||
/// </summary>
|
||||
public TimeSpan NotificationTtl { get; set; } = TimeSpan.FromDays(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An in-app notification stored in user's inbox.
|
||||
/// </summary>
|
||||
public sealed class InAppNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique notification ID.
|
||||
/// </summary>
|
||||
public required string NotificationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original delivery ID.
|
||||
/// </summary>
|
||||
public required string DeliveryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target user ID.
|
||||
/// </summary>
|
||||
public required string UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Notification title.
|
||||
/// </summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Notification body/content.
|
||||
/// </summary>
|
||||
public string? Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Priority level.
|
||||
/// </summary>
|
||||
public InAppNotificationPriority Priority { get; init; } = InAppNotificationPriority.Normal;
|
||||
|
||||
/// <summary>
|
||||
/// Notification category.
|
||||
/// </summary>
|
||||
public required string Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Related incident ID.
|
||||
/// </summary>
|
||||
public string? IncidentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL for main action.
|
||||
/// </summary>
|
||||
public string? ActionUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL for acknowledgment action.
|
||||
/// </summary>
|
||||
public string? AckUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Metadata { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// When created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status.
|
||||
/// </summary>
|
||||
public InAppNotificationStatus Status { get; set; } = InAppNotificationStatus.Unread;
|
||||
|
||||
/// <summary>
|
||||
/// When read.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReadAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-app notification status.
|
||||
/// </summary>
|
||||
public enum InAppNotificationStatus
|
||||
{
|
||||
Unread,
|
||||
Read,
|
||||
Actioned,
|
||||
Deleted
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-app notification priority.
|
||||
/// </summary>
|
||||
public enum InAppNotificationPriority
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Urgent
|
||||
}
|
||||
@@ -1,156 +1,156 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for in-app inbox notifications.
|
||||
/// Stores notifications in the database for users to retrieve via API or WebSocket.
|
||||
/// </summary>
|
||||
public sealed class InAppInboxChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly IInAppInboxStore _inboxStore;
|
||||
private readonly ILogger<InAppInboxChannelAdapter> _logger;
|
||||
|
||||
public InAppInboxChannelAdapter(IInAppInboxStore inboxStore, ILogger<InAppInboxChannelAdapter> logger)
|
||||
{
|
||||
_inboxStore = inboxStore ?? throw new ArgumentNullException(nameof(inboxStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.InAppInbox;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var userId = rendered.Target;
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
// Try to get from channel config
|
||||
userId = channel.Config?.Target;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Target user ID not specified", shouldRetry: false);
|
||||
}
|
||||
|
||||
var tenantId = channel.Config?.Properties.GetValueOrDefault("tenantId") ?? channel.TenantId;
|
||||
|
||||
var messageId = Guid.NewGuid().ToString("N");
|
||||
var inboxMessage = new InAppInboxMessage
|
||||
{
|
||||
MessageId = messageId,
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Title = rendered.Title ?? "Notification",
|
||||
Body = rendered.Body ?? string.Empty,
|
||||
Summary = rendered.Summary,
|
||||
Category = channel.Config?.Properties.GetValueOrDefault("category") ?? "general",
|
||||
Priority = DeterminePriority(rendered),
|
||||
Metadata = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DetermineExpiry(channel),
|
||||
SourceChannel = channel.ChannelId,
|
||||
DeliveryId = messageId
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _inboxStore.StoreAsync(inboxMessage, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"In-app inbox message stored for user {UserId}. MessageId: {MessageId}",
|
||||
userId,
|
||||
inboxMessage.MessageId);
|
||||
|
||||
return ChannelDispatchResult.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to store in-app inbox message for user {UserId}", userId);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static InAppInboxPriority DeterminePriority(NotifyDeliveryRendered rendered)
|
||||
{
|
||||
if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
rendered.Title?.Contains("urgent", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.Critical;
|
||||
|
||||
if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
rendered.Title?.Contains("important", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.High;
|
||||
|
||||
if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.Normal;
|
||||
|
||||
return InAppInboxPriority.Low;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? DetermineExpiry(NotifyChannel channel)
|
||||
{
|
||||
var ttlStr = channel.Config?.Properties.GetValueOrDefault("ttl");
|
||||
if (!string.IsNullOrEmpty(ttlStr) && int.TryParse(ttlStr, out var ttlHours))
|
||||
{
|
||||
return DateTimeOffset.UtcNow.AddHours(ttlHours);
|
||||
}
|
||||
|
||||
// Default 30 day expiry
|
||||
return DateTimeOffset.UtcNow.AddDays(30);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for in-app inbox messages.
|
||||
/// </summary>
|
||||
public interface IInAppInboxStore
|
||||
{
|
||||
Task StoreAsync(InAppInboxMessage message, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<InAppInboxMessage>> GetForUserAsync(string tenantId, string userId, int limit = 50, CancellationToken cancellationToken = default);
|
||||
Task<InAppInboxMessage?> GetAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-app inbox message model.
|
||||
/// </summary>
|
||||
public sealed record InAppInboxMessage
|
||||
{
|
||||
public required string MessageId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string UserId { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public required string Category { get; init; }
|
||||
public InAppInboxPriority Priority { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public DateTimeOffset? ReadAt { get; set; }
|
||||
public bool IsRead => ReadAt.HasValue;
|
||||
public string? SourceChannel { get; init; }
|
||||
public string? DeliveryId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Priority levels for in-app inbox messages.
|
||||
/// </summary>
|
||||
public enum InAppInboxPriority
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Critical
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for in-app inbox notifications.
|
||||
/// Stores notifications in the database for users to retrieve via API or WebSocket.
|
||||
/// </summary>
|
||||
public sealed class InAppInboxChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly IInAppInboxStore _inboxStore;
|
||||
private readonly ILogger<InAppInboxChannelAdapter> _logger;
|
||||
|
||||
public InAppInboxChannelAdapter(IInAppInboxStore inboxStore, ILogger<InAppInboxChannelAdapter> logger)
|
||||
{
|
||||
_inboxStore = inboxStore ?? throw new ArgumentNullException(nameof(inboxStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.InAppInbox;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var userId = rendered.Target;
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
// Try to get from channel config
|
||||
userId = channel.Config?.Target;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Target user ID not specified", shouldRetry: false);
|
||||
}
|
||||
|
||||
var tenantId = channel.Config?.Properties.GetValueOrDefault("tenantId") ?? channel.TenantId;
|
||||
|
||||
var messageId = Guid.NewGuid().ToString("N");
|
||||
var inboxMessage = new InAppInboxMessage
|
||||
{
|
||||
MessageId = messageId,
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Title = rendered.Title ?? "Notification",
|
||||
Body = rendered.Body ?? string.Empty,
|
||||
Summary = rendered.Summary,
|
||||
Category = channel.Config?.Properties.GetValueOrDefault("category") ?? "general",
|
||||
Priority = DeterminePriority(rendered),
|
||||
Metadata = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DetermineExpiry(channel),
|
||||
SourceChannel = channel.ChannelId,
|
||||
DeliveryId = messageId
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _inboxStore.StoreAsync(inboxMessage, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"In-app inbox message stored for user {UserId}. MessageId: {MessageId}",
|
||||
userId,
|
||||
inboxMessage.MessageId);
|
||||
|
||||
return ChannelDispatchResult.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to store in-app inbox message for user {UserId}", userId);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static InAppInboxPriority DeterminePriority(NotifyDeliveryRendered rendered)
|
||||
{
|
||||
if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
rendered.Title?.Contains("urgent", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.Critical;
|
||||
|
||||
if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
rendered.Title?.Contains("important", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.High;
|
||||
|
||||
if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.Normal;
|
||||
|
||||
return InAppInboxPriority.Low;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? DetermineExpiry(NotifyChannel channel)
|
||||
{
|
||||
var ttlStr = channel.Config?.Properties.GetValueOrDefault("ttl");
|
||||
if (!string.IsNullOrEmpty(ttlStr) && int.TryParse(ttlStr, out var ttlHours))
|
||||
{
|
||||
return DateTimeOffset.UtcNow.AddHours(ttlHours);
|
||||
}
|
||||
|
||||
// Default 30 day expiry
|
||||
return DateTimeOffset.UtcNow.AddDays(30);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for in-app inbox messages.
|
||||
/// </summary>
|
||||
public interface IInAppInboxStore
|
||||
{
|
||||
Task StoreAsync(InAppInboxMessage message, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<InAppInboxMessage>> GetForUserAsync(string tenantId, string userId, int limit = 50, CancellationToken cancellationToken = default);
|
||||
Task<InAppInboxMessage?> GetAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-app inbox message model.
|
||||
/// </summary>
|
||||
public sealed record InAppInboxMessage
|
||||
{
|
||||
public required string MessageId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string UserId { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public required string Category { get; init; }
|
||||
public InAppInboxPriority Priority { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public DateTimeOffset? ReadAt { get; set; }
|
||||
public bool IsRead => ReadAt.HasValue;
|
||||
public string? SourceChannel { get; init; }
|
||||
public string? DeliveryId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Priority levels for in-app inbox messages.
|
||||
/// </summary>
|
||||
public enum InAppInboxPriority
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Critical
|
||||
}
|
||||
|
||||
@@ -1,101 +1,101 @@
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter that bridges IInAppInboxStore to INotifyInboxRepository.
|
||||
/// </summary>
|
||||
public sealed class MongoInboxStoreAdapter : IInAppInboxStore
|
||||
{
|
||||
private readonly INotifyInboxRepository _repository;
|
||||
|
||||
public MongoInboxStoreAdapter(INotifyInboxRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
public async Task StoreAsync(InAppInboxMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
var repoMessage = new NotifyInboxMessage
|
||||
{
|
||||
MessageId = message.MessageId,
|
||||
TenantId = message.TenantId,
|
||||
UserId = message.UserId,
|
||||
Title = message.Title,
|
||||
Body = message.Body,
|
||||
Summary = message.Summary,
|
||||
Category = message.Category,
|
||||
Priority = (int)message.Priority,
|
||||
Metadata = message.Metadata,
|
||||
CreatedAt = message.CreatedAt,
|
||||
ExpiresAt = message.ExpiresAt,
|
||||
ReadAt = message.ReadAt,
|
||||
SourceChannel = message.SourceChannel,
|
||||
DeliveryId = message.DeliveryId
|
||||
};
|
||||
|
||||
await _repository.StoreAsync(repoMessage, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<InAppInboxMessage>> GetForUserAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var repoMessages = await _repository.GetForUserAsync(tenantId, userId, limit, cancellationToken).ConfigureAwait(false);
|
||||
return repoMessages.Select(MapToInboxMessage).ToList();
|
||||
}
|
||||
|
||||
public async Task<InAppInboxMessage?> GetAsync(
|
||||
string tenantId,
|
||||
string messageId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var repoMessage = await _repository.GetAsync(tenantId, messageId, cancellationToken).ConfigureAwait(false);
|
||||
return repoMessage is null ? null : MapToInboxMessage(repoMessage);
|
||||
}
|
||||
|
||||
public Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.MarkReadAsync(tenantId, messageId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.MarkAllReadAsync(tenantId, userId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.DeleteAsync(tenantId, messageId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.GetUnreadCountAsync(tenantId, userId, cancellationToken);
|
||||
}
|
||||
|
||||
private static InAppInboxMessage MapToInboxMessage(NotifyInboxMessage repo)
|
||||
{
|
||||
return new InAppInboxMessage
|
||||
{
|
||||
MessageId = repo.MessageId,
|
||||
TenantId = repo.TenantId,
|
||||
UserId = repo.UserId,
|
||||
Title = repo.Title,
|
||||
Body = repo.Body,
|
||||
Summary = repo.Summary,
|
||||
Category = repo.Category,
|
||||
Priority = (InAppInboxPriority)repo.Priority,
|
||||
Metadata = repo.Metadata,
|
||||
CreatedAt = repo.CreatedAt,
|
||||
ExpiresAt = repo.ExpiresAt,
|
||||
ReadAt = repo.ReadAt,
|
||||
SourceChannel = repo.SourceChannel,
|
||||
DeliveryId = repo.DeliveryId
|
||||
};
|
||||
}
|
||||
}
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter that bridges IInAppInboxStore to INotifyInboxRepository.
|
||||
/// </summary>
|
||||
public sealed class MongoInboxStoreAdapter : IInAppInboxStore
|
||||
{
|
||||
private readonly INotifyInboxRepository _repository;
|
||||
|
||||
public MongoInboxStoreAdapter(INotifyInboxRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
public async Task StoreAsync(InAppInboxMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
var repoMessage = new NotifyInboxMessage
|
||||
{
|
||||
MessageId = message.MessageId,
|
||||
TenantId = message.TenantId,
|
||||
UserId = message.UserId,
|
||||
Title = message.Title,
|
||||
Body = message.Body,
|
||||
Summary = message.Summary,
|
||||
Category = message.Category,
|
||||
Priority = (int)message.Priority,
|
||||
Metadata = message.Metadata,
|
||||
CreatedAt = message.CreatedAt,
|
||||
ExpiresAt = message.ExpiresAt,
|
||||
ReadAt = message.ReadAt,
|
||||
SourceChannel = message.SourceChannel,
|
||||
DeliveryId = message.DeliveryId
|
||||
};
|
||||
|
||||
await _repository.StoreAsync(repoMessage, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<InAppInboxMessage>> GetForUserAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var repoMessages = await _repository.GetForUserAsync(tenantId, userId, limit, cancellationToken).ConfigureAwait(false);
|
||||
return repoMessages.Select(MapToInboxMessage).ToList();
|
||||
}
|
||||
|
||||
public async Task<InAppInboxMessage?> GetAsync(
|
||||
string tenantId,
|
||||
string messageId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var repoMessage = await _repository.GetAsync(tenantId, messageId, cancellationToken).ConfigureAwait(false);
|
||||
return repoMessage is null ? null : MapToInboxMessage(repoMessage);
|
||||
}
|
||||
|
||||
public Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.MarkReadAsync(tenantId, messageId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.MarkAllReadAsync(tenantId, userId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.DeleteAsync(tenantId, messageId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.GetUnreadCountAsync(tenantId, userId, cancellationToken);
|
||||
}
|
||||
|
||||
private static InAppInboxMessage MapToInboxMessage(NotifyInboxMessage repo)
|
||||
{
|
||||
return new InAppInboxMessage
|
||||
{
|
||||
MessageId = repo.MessageId,
|
||||
TenantId = repo.TenantId,
|
||||
UserId = repo.UserId,
|
||||
Title = repo.Title,
|
||||
Body = repo.Body,
|
||||
Summary = repo.Summary,
|
||||
Category = repo.Category,
|
||||
Priority = (InAppInboxPriority)repo.Priority,
|
||||
Metadata = repo.Metadata,
|
||||
CreatedAt = repo.CreatedAt,
|
||||
ExpiresAt = repo.ExpiresAt,
|
||||
ReadAt = repo.ReadAt,
|
||||
SourceChannel = repo.SourceChannel,
|
||||
DeliveryId = repo.DeliveryId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,140 +1,572 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for OpsGenie incident management integration.
|
||||
/// Uses the OpsGenie Alert API v2.
|
||||
/// </summary>
|
||||
public sealed class OpsGenieChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private const string DefaultOpsGenieApiUrl = "https://api.opsgenie.com/v2/alerts";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<OpsGenieChannelAdapter> _logger;
|
||||
|
||||
public OpsGenieChannelAdapter(HttpClient httpClient, ILogger<OpsGenieChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.OpsGenie;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
// OpsGenie API key should be stored via SecretRef (resolved externally)
|
||||
// or provided in Properties as "api_key"
|
||||
var apiKey = channel.Config?.Properties.GetValueOrDefault("api_key");
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("OpsGenie API key not configured in properties", shouldRetry: false);
|
||||
}
|
||||
|
||||
var endpoint = channel.Config?.Endpoint ?? DefaultOpsGenieApiUrl;
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelDispatchResult.Fail($"Invalid OpsGenie endpoint: {endpoint}", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Build OpsGenie Alert API v2 payload
|
||||
var priority = DeterminePriority(rendered);
|
||||
var payload = new
|
||||
{
|
||||
message = rendered.Title ?? "StellaOps Notification",
|
||||
alias = rendered.BodyHash ?? Guid.NewGuid().ToString("N"),
|
||||
description = rendered.Body,
|
||||
priority = priority,
|
||||
source = "StellaOps Notifier",
|
||||
tags = new[] { "stellaops", "notification" },
|
||||
details = new Dictionary<string, string>
|
||||
{
|
||||
["channel"] = channel.ChannelId,
|
||||
["target"] = rendered.Target ?? string.Empty,
|
||||
["summary"] = rendered.Summary ?? string.Empty,
|
||||
["locale"] = rendered.Locale ?? "en-US"
|
||||
},
|
||||
entity = channel.Config?.Properties.GetValueOrDefault("entity") ?? string.Empty,
|
||||
note = $"Sent via StellaOps Notifier at {DateTimeOffset.UtcNow:O}"
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = JsonContent.Create(payload, options: new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("GenieKey", apiKey);
|
||||
request.Headers.Add("X-StellaOps-Notifier", "1.0");
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"OpsGenie alert sent successfully to {Endpoint}. Status: {StatusCode}",
|
||||
endpoint,
|
||||
statusCode);
|
||||
return ChannelDispatchResult.Ok(statusCode);
|
||||
}
|
||||
|
||||
var shouldRetry = statusCode >= 500 || statusCode == 429;
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogWarning(
|
||||
"OpsGenie delivery to {Endpoint} failed with status {StatusCode}. Error: {Error}. Retry: {ShouldRetry}.",
|
||||
endpoint,
|
||||
statusCode,
|
||||
errorContent,
|
||||
shouldRetry);
|
||||
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"HTTP {statusCode}: {errorContent}",
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "OpsGenie delivery to {Endpoint} failed with network error.", endpoint);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OpsGenie delivery to {Endpoint} timed out.", endpoint);
|
||||
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static string DeterminePriority(NotifyDeliveryRendered rendered)
|
||||
{
|
||||
// Map notification priority to OpsGenie priority (P1-P5)
|
||||
if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return "P1";
|
||||
if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return "P2";
|
||||
if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return "P3";
|
||||
if (rendered.Title?.Contains("info", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return "P4";
|
||||
|
||||
return "P3"; // Default to medium priority
|
||||
}
|
||||
}
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for OpsGenie Alert API v2.
|
||||
/// </summary>
|
||||
public sealed class OpsGenieChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private const string DefaultApiUrl = "https://api.opsgenie.com/v2/alerts";
|
||||
private const string EuApiUrl = "https://api.eu.opsgenie.com/v2/alerts";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<OpsGenieChannelAdapter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public OpsGenieChannelAdapter(
|
||||
HttpClient httpClient,
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<OpsGenieChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.OpsGenie;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!TryGetApiKey(context.Channel, out var apiKey))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "Missing OpsGenie API key.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"OpsGenie API key is not configured.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var baseUrl = GetApiUrl(context.Channel);
|
||||
var action = GetAlertAction(context);
|
||||
var alias = GetAlias(context);
|
||||
var requestUrl = BuildRequestUrl(baseUrl, action, alias);
|
||||
|
||||
var payload = BuildPayload(context, action, alias);
|
||||
var payloadJson = payload is not null ? JsonSerializer.Serialize(payload, JsonOptions) : null;
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
int? lastStatusCode = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(GetHttpMethod(action), requestUrl);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("GenieKey", apiKey);
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
|
||||
if (payloadJson is not null)
|
||||
{
|
||||
request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
lastStatusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var opsGenieResponse = JsonSerializer.Deserialize<OpsGenieResponse>(responseBody, JsonOptions);
|
||||
|
||||
stopwatch.Stop();
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["requestId"] = opsGenieResponse?.RequestId ?? string.Empty,
|
||||
["alias"] = alias ?? string.Empty,
|
||||
["action"] = action,
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"OpsGenie delivery {DeliveryId} succeeded with requestId={RequestId} on attempt {Attempt}.",
|
||||
context.DeliveryId, opsGenieResponse?.RequestId, attempt);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
externalId: opsGenieResponse?.RequestId,
|
||||
message: $"OpsGenie {action} succeeded",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = ParseRetryAfter(response.Headers);
|
||||
stopwatch.Stop();
|
||||
await AuditDispatchAsync(context, false, "Rate limited by OpsGenie.", null, cancellationToken);
|
||||
|
||||
return ChannelDispatchResult.Throttled(
|
||||
"Rate limited by OpsGenie.",
|
||||
retryAfter ?? TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
if (!IsRetryable(response.StatusCode))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var errorMessage = $"OpsGenie returned {response.StatusCode}: {errorBody}";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"OpsGenie delivery {DeliveryId} failed with non-retryable status {StatusCode}.",
|
||||
context.DeliveryId, response.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
httpStatusCode: lastStatusCode,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"OpsGenie delivery {DeliveryId} attempt {Attempt} returned {StatusCode}, will retry.",
|
||||
context.DeliveryId, attempt, response.StatusCode);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(ex, "OpsGenie delivery {DeliveryId} attempt {Attempt} failed.", context.DeliveryId, attempt);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug("OpsGenie delivery {DeliveryId} attempt {Attempt} timed out.", context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(lastException, "OpsGenie delivery {DeliveryId} exhausted all retries.", context.DeliveryId);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
lastException is TaskCanceledException ? ChannelDispatchStatus.Timeout : ChannelDispatchStatus.NetworkError,
|
||||
httpStatusCode: lastStatusCode,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public async Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
if (!TryGetApiKey(channel, out _))
|
||||
{
|
||||
return ChannelHealthCheckResult.Unhealthy("OpsGenie API key is not configured.");
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded("Channel is disabled.");
|
||||
}
|
||||
|
||||
return ChannelHealthCheckResult.Ok("OpsGenie channel configured.");
|
||||
}
|
||||
|
||||
private static bool TryGetApiKey(NotifyChannel channel, out string apiKey)
|
||||
{
|
||||
apiKey = string.Empty;
|
||||
|
||||
if (channel.Config.Properties.TryGetValue("apiKey", out var key) && !string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
apiKey = key;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GetApiUrl(NotifyChannel channel)
|
||||
{
|
||||
if (channel.Config.Properties.TryGetValue("apiUrl", out var url) && !string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
if (channel.Config.Properties.TryGetValue("region", out var region) &&
|
||||
region.Equals("eu", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EuApiUrl;
|
||||
}
|
||||
|
||||
return DefaultApiUrl;
|
||||
}
|
||||
|
||||
private static string GetAlertAction(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("opsgenie.action", out var action) && !string.IsNullOrWhiteSpace(action))
|
||||
{
|
||||
return action.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("incident.resolved", out var resolved) && resolved == "true")
|
||||
{
|
||||
return "close";
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("incident.acknowledged", out var acked) && acked == "true")
|
||||
{
|
||||
return "acknowledge";
|
||||
}
|
||||
|
||||
return "create";
|
||||
}
|
||||
|
||||
private static string? GetAlias(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("opsgenie.alias", out var alias) && !string.IsNullOrWhiteSpace(alias))
|
||||
{
|
||||
return alias;
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("incidentId", out var incidentId) && !string.IsNullOrWhiteSpace(incidentId))
|
||||
{
|
||||
return $"stellaops-{context.TenantId}-{incidentId}";
|
||||
}
|
||||
|
||||
return $"stellaops-{context.DeliveryId}";
|
||||
}
|
||||
|
||||
private static string BuildRequestUrl(string baseUrl, string action, string? alias)
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"create" => baseUrl,
|
||||
"acknowledge" => $"{baseUrl}/{Uri.EscapeDataString(alias ?? "")}/acknowledge?identifierType=alias",
|
||||
"close" => $"{baseUrl}/{Uri.EscapeDataString(alias ?? "")}/close?identifierType=alias",
|
||||
"note" => $"{baseUrl}/{Uri.EscapeDataString(alias ?? "")}/notes?identifierType=alias",
|
||||
_ => baseUrl
|
||||
};
|
||||
}
|
||||
|
||||
private static HttpMethod GetHttpMethod(string action)
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"create" => HttpMethod.Post,
|
||||
_ => HttpMethod.Post
|
||||
};
|
||||
}
|
||||
|
||||
private object? BuildPayload(ChannelDispatchContext context, string action, string? alias)
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"create" => BuildCreatePayload(context, alias),
|
||||
"acknowledge" => BuildAcknowledgePayload(context),
|
||||
"close" => BuildClosePayload(context),
|
||||
"note" => BuildNotePayload(context),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private OpsGenieCreateAlert BuildCreatePayload(ChannelDispatchContext context, string? alias)
|
||||
{
|
||||
var message = context.Subject ?? "StellaOps Alert";
|
||||
var priority = GetPriority(context);
|
||||
|
||||
return new OpsGenieCreateAlert
|
||||
{
|
||||
Message = message,
|
||||
Alias = alias,
|
||||
Description = context.RenderedBody,
|
||||
Priority = priority,
|
||||
Source = $"StellaOps/{context.TenantId}",
|
||||
Entity = context.Metadata.GetValueOrDefault("component"),
|
||||
Tags = BuildTags(context),
|
||||
Details = BuildDetails(context),
|
||||
User = "StellaOps Notifier",
|
||||
Note = $"Created by StellaOps delivery {context.DeliveryId}"
|
||||
};
|
||||
}
|
||||
|
||||
private static OpsGenieAcknowledge BuildAcknowledgePayload(ChannelDispatchContext context)
|
||||
{
|
||||
return new OpsGenieAcknowledge
|
||||
{
|
||||
User = context.Metadata.GetValueOrDefault("actor") ?? "StellaOps",
|
||||
Source = $"StellaOps/{context.TenantId}",
|
||||
Note = context.Metadata.GetValueOrDefault("comment") ?? "Acknowledged via StellaOps"
|
||||
};
|
||||
}
|
||||
|
||||
private static OpsGenieClose BuildClosePayload(ChannelDispatchContext context)
|
||||
{
|
||||
return new OpsGenieClose
|
||||
{
|
||||
User = context.Metadata.GetValueOrDefault("actor") ?? "StellaOps",
|
||||
Source = $"StellaOps/{context.TenantId}",
|
||||
Note = context.Metadata.GetValueOrDefault("comment") ?? "Closed via StellaOps"
|
||||
};
|
||||
}
|
||||
|
||||
private static OpsGenieNote BuildNotePayload(ChannelDispatchContext context)
|
||||
{
|
||||
return new OpsGenieNote
|
||||
{
|
||||
User = context.Metadata.GetValueOrDefault("actor") ?? "StellaOps",
|
||||
Source = $"StellaOps/{context.TenantId}",
|
||||
Note = context.RenderedBody ?? "Note added via StellaOps"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetPriority(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("severity", out var sev))
|
||||
{
|
||||
return sev.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" => "P1",
|
||||
"high" => "P2",
|
||||
"medium" => "P3",
|
||||
"low" => "P4",
|
||||
_ => "P3"
|
||||
};
|
||||
}
|
||||
return "P3";
|
||||
}
|
||||
|
||||
private static List<string>? BuildTags(ChannelDispatchContext context)
|
||||
{
|
||||
var tags = new List<string> { "stellaops" };
|
||||
|
||||
if (context.Metadata.TryGetValue("tags", out var tagStr) && !string.IsNullOrWhiteSpace(tagStr))
|
||||
{
|
||||
tags.AddRange(tagStr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("severity", out var severity) && !string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
tags.Add($"severity:{severity}");
|
||||
}
|
||||
|
||||
return tags.Count > 0 ? tags : null;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string>? BuildDetails(ChannelDispatchContext context)
|
||||
{
|
||||
var details = new Dictionary<string, string>
|
||||
{
|
||||
["tenantId"] = context.TenantId,
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
foreach (var (key, value) in context.Metadata)
|
||||
{
|
||||
if (!key.StartsWith("opsgenie.", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(value) &&
|
||||
!details.ContainsKey(key))
|
||||
{
|
||||
details[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return details.Count > 0 ? details : null;
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseRetryAfter(HttpResponseHeaders headers)
|
||||
{
|
||||
if (headers.RetryAfter?.Delta is { } delta)
|
||||
{
|
||||
return delta;
|
||||
}
|
||||
if (headers.RetryAfter?.Date is { } date)
|
||||
{
|
||||
var delay = date - DateTimeOffset.UtcNow;
|
||||
return delay > TimeSpan.Zero ? delay : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.RequestTimeout => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = "OpsGenie",
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
|
||||
// OpsGenie API DTOs
|
||||
private sealed class OpsGenieCreateAlert
|
||||
{
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; init; }
|
||||
|
||||
[JsonPropertyName("alias")]
|
||||
public string? Alias { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public string? Priority { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("entity")]
|
||||
public string? Entity { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public List<string>? Tags { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public Dictionary<string, string>? Details { get; init; }
|
||||
|
||||
[JsonPropertyName("user")]
|
||||
public string? User { get; init; }
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
public string? Note { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OpsGenieAcknowledge
|
||||
{
|
||||
[JsonPropertyName("user")]
|
||||
public string? User { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
public string? Note { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OpsGenieClose
|
||||
{
|
||||
[JsonPropertyName("user")]
|
||||
public string? User { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
public string? Note { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OpsGenieNote
|
||||
{
|
||||
[JsonPropertyName("user")]
|
||||
public string? User { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
public required string Note { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OpsGenieResponse
|
||||
{
|
||||
[JsonPropertyName("result")]
|
||||
public string? Result { get; init; }
|
||||
|
||||
[JsonPropertyName("took")]
|
||||
public double Took { get; init; }
|
||||
|
||||
[JsonPropertyName("requestId")]
|
||||
public string? RequestId { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,141 +1,527 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for PagerDuty incident management integration.
|
||||
/// Uses the PagerDuty Events API v2 for incident creation and updates.
|
||||
/// </summary>
|
||||
public sealed class PagerDutyChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private const string DefaultPagerDutyApiUrl = "https://events.pagerduty.com/v2/enqueue";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<PagerDutyChannelAdapter> _logger;
|
||||
|
||||
public PagerDutyChannelAdapter(HttpClient httpClient, ILogger<PagerDutyChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.PagerDuty;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
// PagerDuty routing key should be stored via SecretRef (resolved externally)
|
||||
// or provided in Properties as "routing_key"
|
||||
var routingKey = channel.Config?.Properties.GetValueOrDefault("routing_key");
|
||||
if (string.IsNullOrWhiteSpace(routingKey))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("PagerDuty routing key not configured in properties", shouldRetry: false);
|
||||
}
|
||||
|
||||
var endpoint = channel.Config?.Endpoint ?? DefaultPagerDutyApiUrl;
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelDispatchResult.Fail($"Invalid PagerDuty endpoint: {endpoint}", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Build PagerDuty Events API v2 payload
|
||||
var severity = DetermineSeverity(rendered);
|
||||
var payload = new
|
||||
{
|
||||
routing_key = routingKey,
|
||||
event_action = "trigger",
|
||||
dedup_key = rendered.BodyHash ?? Guid.NewGuid().ToString("N"),
|
||||
payload = new
|
||||
{
|
||||
summary = rendered.Title ?? "StellaOps Notification",
|
||||
source = "StellaOps Notifier",
|
||||
severity = severity,
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O"),
|
||||
custom_details = new
|
||||
{
|
||||
body = rendered.Body,
|
||||
summary = rendered.Summary,
|
||||
channel = channel.ChannelId,
|
||||
target = rendered.Target
|
||||
}
|
||||
},
|
||||
client = "StellaOps",
|
||||
client_url = channel.Config?.Properties.GetValueOrDefault("client_url") ?? string.Empty
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = JsonContent.Create(payload, options: new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
});
|
||||
|
||||
request.Headers.Add("X-StellaOps-Notifier", "1.0");
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"PagerDuty event sent successfully to {Endpoint}. Status: {StatusCode}",
|
||||
endpoint,
|
||||
statusCode);
|
||||
return ChannelDispatchResult.Ok(statusCode);
|
||||
}
|
||||
|
||||
var shouldRetry = statusCode >= 500 || statusCode == 429;
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogWarning(
|
||||
"PagerDuty delivery to {Endpoint} failed with status {StatusCode}. Error: {Error}. Retry: {ShouldRetry}.",
|
||||
endpoint,
|
||||
statusCode,
|
||||
errorContent,
|
||||
shouldRetry);
|
||||
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"HTTP {statusCode}: {errorContent}",
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "PagerDuty delivery to {Endpoint} failed with network error.", endpoint);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "PagerDuty delivery to {Endpoint} timed out.", endpoint);
|
||||
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static string DetermineSeverity(NotifyDeliveryRendered rendered)
|
||||
{
|
||||
// Map notification priority to PagerDuty severity
|
||||
// Priority can be embedded in metadata or parsed from title
|
||||
if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return "critical";
|
||||
if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return "error";
|
||||
if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return "warning";
|
||||
|
||||
return "info";
|
||||
}
|
||||
}
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for PagerDuty Events API v2.
|
||||
/// </summary>
|
||||
public sealed class PagerDutyChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private const string DefaultEventsApiUrl = "https://events.pagerduty.com/v2/enqueue";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<PagerDutyChannelAdapter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public PagerDutyChannelAdapter(
|
||||
HttpClient httpClient,
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PagerDutyChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.PagerDuty;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!TryGetRoutingKey(context.Channel, out var routingKey))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "Missing PagerDuty routing key.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"PagerDuty routing key is not configured.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var eventsUrl = GetEventsApiUrl(context.Channel);
|
||||
var eventAction = GetEventAction(context);
|
||||
var dedupKey = GetDedupKey(context);
|
||||
|
||||
var payload = BuildPayload(context, routingKey, eventAction, dedupKey);
|
||||
var payloadJson = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
int? lastStatusCode = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, eventsUrl);
|
||||
request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
lastStatusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var pagerDutyResponse = JsonSerializer.Deserialize<PagerDutyResponse>(responseBody, JsonOptions);
|
||||
|
||||
stopwatch.Stop();
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["dedupKey"] = pagerDutyResponse?.DedupKey ?? dedupKey ?? string.Empty,
|
||||
["status"] = pagerDutyResponse?.Status ?? "success",
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"PagerDuty delivery {DeliveryId} succeeded with dedup_key={DedupKey} on attempt {Attempt}.",
|
||||
context.DeliveryId, pagerDutyResponse?.DedupKey, attempt);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
externalId: pagerDutyResponse?.DedupKey,
|
||||
message: $"PagerDuty event created: {pagerDutyResponse?.Status}",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = ParseRetryAfter(response.Headers);
|
||||
stopwatch.Stop();
|
||||
await AuditDispatchAsync(context, false, "Rate limited by PagerDuty.", null, cancellationToken);
|
||||
|
||||
return ChannelDispatchResult.Throttled(
|
||||
"Rate limited by PagerDuty.",
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
if (!IsRetryable(response.StatusCode))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var errorMessage = $"PagerDuty returned {response.StatusCode}: {errorBody}";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"PagerDuty delivery {DeliveryId} failed with non-retryable status {StatusCode}.",
|
||||
context.DeliveryId, response.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
httpStatusCode: lastStatusCode,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"PagerDuty delivery {DeliveryId} attempt {Attempt} returned {StatusCode}, will retry.",
|
||||
context.DeliveryId, attempt, response.StatusCode);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(ex, "PagerDuty delivery {DeliveryId} attempt {Attempt} failed.", context.DeliveryId, attempt);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug("PagerDuty delivery {DeliveryId} attempt {Attempt} timed out.", context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(lastException, "PagerDuty delivery {DeliveryId} exhausted all retries.", context.DeliveryId);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
lastException is TaskCanceledException ? ChannelDispatchStatus.Timeout : ChannelDispatchStatus.NetworkError,
|
||||
httpStatusCode: lastStatusCode,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public async Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
if (!TryGetRoutingKey(channel, out _))
|
||||
{
|
||||
return ChannelHealthCheckResult.Unhealthy("PagerDuty routing key is not configured.");
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded("Channel is disabled.");
|
||||
}
|
||||
|
||||
// PagerDuty doesn't have a health endpoint, just verify config
|
||||
return ChannelHealthCheckResult.Ok("PagerDuty channel configured.");
|
||||
}
|
||||
|
||||
private static bool TryGetRoutingKey(NotifyChannel channel, out string routingKey)
|
||||
{
|
||||
routingKey = string.Empty;
|
||||
|
||||
if (channel.Config.Properties.TryGetValue("routingKey", out var key) && !string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
routingKey = key;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (channel.Config.Properties.TryGetValue("integrationKey", out key) && !string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
routingKey = key;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GetEventsApiUrl(NotifyChannel channel)
|
||||
{
|
||||
if (channel.Config.Properties.TryGetValue("eventsApiUrl", out var url) && !string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
return DefaultEventsApiUrl;
|
||||
}
|
||||
|
||||
private static string GetEventAction(ChannelDispatchContext context)
|
||||
{
|
||||
// Check metadata for explicit action
|
||||
if (context.Metadata.TryGetValue("pagerduty.action", out var action) && !string.IsNullOrWhiteSpace(action))
|
||||
{
|
||||
return action.ToLowerInvariant();
|
||||
}
|
||||
|
||||
// Check delivery metadata for incident state transitions
|
||||
if (context.Metadata.TryGetValue("incident.resolved", out var resolved) && resolved == "true")
|
||||
{
|
||||
return "resolve";
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("incident.acknowledged", out var acked) && acked == "true")
|
||||
{
|
||||
return "acknowledge";
|
||||
}
|
||||
|
||||
return "trigger";
|
||||
}
|
||||
|
||||
private static string? GetDedupKey(ChannelDispatchContext context)
|
||||
{
|
||||
// Try explicit dedup key
|
||||
if (context.Metadata.TryGetValue("pagerduty.dedupKey", out var key) && !string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return key;
|
||||
}
|
||||
|
||||
// Use incident ID if available
|
||||
if (context.Metadata.TryGetValue("incidentId", out var incidentId) && !string.IsNullOrWhiteSpace(incidentId))
|
||||
{
|
||||
return $"stellaops-{context.TenantId}-{incidentId}";
|
||||
}
|
||||
|
||||
// Use delivery ID as fallback
|
||||
return $"stellaops-{context.DeliveryId}";
|
||||
}
|
||||
|
||||
private PagerDutyEvent BuildPayload(
|
||||
ChannelDispatchContext context,
|
||||
string routingKey,
|
||||
string eventAction,
|
||||
string? dedupKey)
|
||||
{
|
||||
var severity = GetSeverity(context);
|
||||
var summary = context.Subject ?? "StellaOps Alert";
|
||||
var source = GetSource(context);
|
||||
var component = context.Metadata.GetValueOrDefault("component");
|
||||
var group = context.Metadata.GetValueOrDefault("group");
|
||||
var eventClass = context.Metadata.GetValueOrDefault("class");
|
||||
|
||||
return new PagerDutyEvent
|
||||
{
|
||||
RoutingKey = routingKey,
|
||||
EventAction = eventAction,
|
||||
DedupKey = dedupKey,
|
||||
Payload = new PagerDutyPayload
|
||||
{
|
||||
Summary = summary,
|
||||
Source = source,
|
||||
Severity = severity,
|
||||
Timestamp = context.Timestamp.ToString("O"),
|
||||
Component = component,
|
||||
Group = group,
|
||||
Class = eventClass,
|
||||
CustomDetails = BuildCustomDetails(context)
|
||||
},
|
||||
Links = BuildLinks(context),
|
||||
Client = "StellaOps",
|
||||
ClientUrl = context.Metadata.GetValueOrDefault("stellaops.url")
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetSeverity(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("severity", out var sev))
|
||||
{
|
||||
return sev.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" => "critical",
|
||||
"high" => "error",
|
||||
"medium" => "warning",
|
||||
"low" => "info",
|
||||
_ => "warning"
|
||||
};
|
||||
}
|
||||
return "warning";
|
||||
}
|
||||
|
||||
private static string GetSource(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("source", out var source) && !string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
return source;
|
||||
}
|
||||
return $"stellaops-{context.TenantId}";
|
||||
}
|
||||
|
||||
private static Dictionary<string, object>? BuildCustomDetails(ChannelDispatchContext context)
|
||||
{
|
||||
var details = new Dictionary<string, object>
|
||||
{
|
||||
["tenantId"] = context.TenantId,
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
// Add relevant metadata
|
||||
foreach (var (key, value) in context.Metadata)
|
||||
{
|
||||
if (!key.StartsWith("pagerduty.", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
details[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Include rendered body as description
|
||||
if (!string.IsNullOrWhiteSpace(context.RenderedBody))
|
||||
{
|
||||
details["description"] = context.RenderedBody;
|
||||
}
|
||||
|
||||
return details.Count > 0 ? details : null;
|
||||
}
|
||||
|
||||
private static List<PagerDutyLink>? BuildLinks(ChannelDispatchContext context)
|
||||
{
|
||||
var links = new List<PagerDutyLink>();
|
||||
|
||||
if (context.Metadata.TryGetValue("incidentUrl", out var incidentUrl) && !string.IsNullOrWhiteSpace(incidentUrl))
|
||||
{
|
||||
links.Add(new PagerDutyLink { Href = incidentUrl, Text = "View Incident" });
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("ackUrl", out var ackUrl) && !string.IsNullOrWhiteSpace(ackUrl))
|
||||
{
|
||||
links.Add(new PagerDutyLink { Href = ackUrl, Text = "Acknowledge" });
|
||||
}
|
||||
|
||||
return links.Count > 0 ? links : null;
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseRetryAfter(HttpResponseHeaders headers)
|
||||
{
|
||||
if (headers.RetryAfter?.Delta is { } delta)
|
||||
{
|
||||
return delta;
|
||||
}
|
||||
if (headers.RetryAfter?.Date is { } date)
|
||||
{
|
||||
var delay = date - DateTimeOffset.UtcNow;
|
||||
return delay > TimeSpan.Zero ? delay : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.RequestTimeout => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = "PagerDuty",
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
|
||||
// PagerDuty API DTOs
|
||||
private sealed class PagerDutyEvent
|
||||
{
|
||||
[JsonPropertyName("routing_key")]
|
||||
public required string RoutingKey { get; init; }
|
||||
|
||||
[JsonPropertyName("event_action")]
|
||||
public required string EventAction { get; init; }
|
||||
|
||||
[JsonPropertyName("dedup_key")]
|
||||
public string? DedupKey { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public PagerDutyPayload? Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("links")]
|
||||
public List<PagerDutyLink>? Links { get; init; }
|
||||
|
||||
[JsonPropertyName("client")]
|
||||
public string? Client { get; init; }
|
||||
|
||||
[JsonPropertyName("client_url")]
|
||||
public string? ClientUrl { get; init; }
|
||||
}
|
||||
|
||||
private sealed class PagerDutyPayload
|
||||
{
|
||||
[JsonPropertyName("summary")]
|
||||
public required string Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public required string Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public string? Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("component")]
|
||||
public string? Component { get; init; }
|
||||
|
||||
[JsonPropertyName("group")]
|
||||
public string? Group { get; init; }
|
||||
|
||||
[JsonPropertyName("class")]
|
||||
public string? Class { get; init; }
|
||||
|
||||
[JsonPropertyName("custom_details")]
|
||||
public Dictionary<string, object>? CustomDetails { get; init; }
|
||||
}
|
||||
|
||||
private sealed class PagerDutyLink
|
||||
{
|
||||
[JsonPropertyName("href")]
|
||||
public required string Href { get; init; }
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string? Text { get; init; }
|
||||
}
|
||||
|
||||
private sealed class PagerDutyResponse
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
|
||||
[JsonPropertyName("dedup_key")]
|
||||
public string? DedupKey { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,107 +1,107 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for Slack webhook delivery.
|
||||
/// </summary>
|
||||
public sealed class SlackChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<SlackChannelAdapter> _logger;
|
||||
|
||||
public SlackChannelAdapter(HttpClient httpClient, ILogger<SlackChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var endpoint = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Slack webhook URL not configured", shouldRetry: false);
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelDispatchResult.Fail($"Invalid Slack webhook URL: {endpoint}", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Build Slack message payload
|
||||
var slackPayload = new
|
||||
{
|
||||
channel = channel.Config?.Target,
|
||||
text = rendered.Title,
|
||||
blocks = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
text = new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = rendered.Body
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = JsonContent.Create(slackPayload, options: new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Slack delivery to channel {Target} succeeded.",
|
||||
channel.Config?.Target ?? "(default)");
|
||||
return ChannelDispatchResult.Ok(statusCode);
|
||||
}
|
||||
|
||||
var shouldRetry = statusCode >= 500 || statusCode == 429;
|
||||
_logger.LogWarning(
|
||||
"Slack delivery failed with status {StatusCode}. Retry: {ShouldRetry}.",
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"HTTP {statusCode}",
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Slack delivery failed with network error.");
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Slack delivery timed out.");
|
||||
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for Slack webhook delivery.
|
||||
/// </summary>
|
||||
public sealed class SlackChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<SlackChannelAdapter> _logger;
|
||||
|
||||
public SlackChannelAdapter(HttpClient httpClient, ILogger<SlackChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var endpoint = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Slack webhook URL not configured", shouldRetry: false);
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelDispatchResult.Fail($"Invalid Slack webhook URL: {endpoint}", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Build Slack message payload
|
||||
var slackPayload = new
|
||||
{
|
||||
channel = channel.Config?.Target,
|
||||
text = rendered.Title,
|
||||
blocks = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
text = new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = rendered.Body
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = JsonContent.Create(slackPayload, options: new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Slack delivery to channel {Target} succeeded.",
|
||||
channel.Config?.Target ?? "(default)");
|
||||
return ChannelDispatchResult.Ok(statusCode);
|
||||
}
|
||||
|
||||
var shouldRetry = statusCode >= 500 || statusCode == 429;
|
||||
_logger.LogWarning(
|
||||
"Slack delivery failed with status {StatusCode}. Retry: {ShouldRetry}.",
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"HTTP {statusCode}",
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Slack delivery failed with network error.");
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Slack delivery timed out.");
|
||||
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,128 +1,352 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for webhook (HTTP POST) delivery with retry support and HMAC signing.
|
||||
/// </summary>
|
||||
public sealed class WebhookChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IWebhookSecurityService? _securityService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<WebhookChannelAdapter> _logger;
|
||||
|
||||
public WebhookChannelAdapter(
|
||||
HttpClient httpClient,
|
||||
ILogger<WebhookChannelAdapter> logger,
|
||||
IWebhookSecurityService? securityService = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_securityService = securityService;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var endpoint = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Webhook endpoint not configured", shouldRetry: false);
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelDispatchResult.Fail($"Invalid webhook endpoint: {endpoint}", shouldRetry: false);
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
channel = channel.ChannelId,
|
||||
target = rendered.Target,
|
||||
title = rendered.Title,
|
||||
body = rendered.Body,
|
||||
summary = rendered.Summary,
|
||||
format = rendered.Format.ToString().ToLowerInvariant(),
|
||||
locale = rendered.Locale,
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
var payloadJson = JsonSerializer.Serialize(payload, jsonOptions);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
|
||||
|
||||
// Add version header
|
||||
request.Headers.Add("X-StellaOps-Notifier", "1.0");
|
||||
|
||||
// Add HMAC signature if security service is available
|
||||
if (_securityService is not null)
|
||||
{
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
var signature = _securityService.SignPayload(
|
||||
channel.TenantId,
|
||||
channel.ChannelId,
|
||||
payloadBytes,
|
||||
timestamp);
|
||||
request.Headers.Add("X-StellaOps-Signature", signature);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Webhook delivery to {Endpoint} succeeded with status {StatusCode}.",
|
||||
endpoint,
|
||||
statusCode);
|
||||
return ChannelDispatchResult.Ok(statusCode);
|
||||
}
|
||||
|
||||
var shouldRetry = statusCode >= 500 || statusCode == 429;
|
||||
_logger.LogWarning(
|
||||
"Webhook delivery to {Endpoint} failed with status {StatusCode}. Retry: {ShouldRetry}.",
|
||||
endpoint,
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"HTTP {statusCode}",
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Webhook delivery to {Endpoint} failed with network error.", endpoint);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Webhook delivery to {Endpoint} timed out.", endpoint);
|
||||
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for generic HTTP webhook dispatch with retry policies.
|
||||
/// </summary>
|
||||
public sealed class WebhookChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<WebhookChannelAdapter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public WebhookChannelAdapter(
|
||||
HttpClient httpClient,
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<WebhookChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var endpoint = context.Channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "Invalid endpoint configuration.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"Webhook endpoint is not configured or invalid.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
int? lastStatusCode = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = BuildRequest(context, uri);
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
lastStatusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var metadata = BuildSuccessMetadata(context, response, attempt);
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Webhook delivery {DeliveryId} succeeded to {Endpoint} on attempt {Attempt} in {Duration}ms.",
|
||||
context.DeliveryId, endpoint, attempt, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
message: $"Delivered to {uri.Host} with status {response.StatusCode}.",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = ParseRetryAfter(response.Headers);
|
||||
stopwatch.Stop();
|
||||
|
||||
await AuditDispatchAsync(context, false, "Rate limited by endpoint.", null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook delivery {DeliveryId} throttled by {Endpoint}. Retry after: {RetryAfter}.",
|
||||
context.DeliveryId, endpoint, retryAfter);
|
||||
|
||||
return ChannelDispatchResult.Throttled(
|
||||
$"Rate limited by {uri.Host}.",
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
if (!IsRetryable(response.StatusCode))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorMessage = $"Webhook returned non-retryable status {response.StatusCode}.";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook delivery {DeliveryId} failed with non-retryable status {StatusCode}.",
|
||||
context.DeliveryId, response.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
httpStatusCode: lastStatusCode,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Webhook delivery {DeliveryId} attempt {Attempt} returned {StatusCode}, will retry.",
|
||||
context.DeliveryId, attempt, response.StatusCode);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Webhook delivery {DeliveryId} attempt {Attempt} failed with network error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
"Webhook delivery {DeliveryId} attempt {Attempt} timed out.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(
|
||||
lastException,
|
||||
"Webhook delivery {DeliveryId} exhausted all {MaxRetries} retries to {Endpoint}.",
|
||||
context.DeliveryId, maxRetries + 1, endpoint);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
lastException is TaskCanceledException ? ChannelDispatchStatus.Timeout : ChannelDispatchStatus.NetworkError,
|
||||
httpStatusCode: lastStatusCode,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public async Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
var endpoint = channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelHealthCheckResult.Unhealthy("Webhook endpoint is not configured or invalid.");
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded("Channel is disabled.");
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Head, uri);
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.MethodNotAllowed)
|
||||
{
|
||||
return ChannelHealthCheckResult.Ok(
|
||||
$"Endpoint responded with {response.StatusCode}.",
|
||||
stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
return ChannelHealthCheckResult.Degraded(
|
||||
$"Endpoint returned {response.StatusCode}.",
|
||||
stopwatch.Elapsed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_logger.LogDebug(ex, "Webhook health check failed for channel {ChannelId}.", channel.ChannelId);
|
||||
return ChannelHealthCheckResult.Unhealthy($"Connection failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildRequest(ChannelDispatchContext context, Uri uri)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = new StringContent(context.RenderedBody, Encoding.UTF8, "application/json");
|
||||
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
request.Headers.Add("X-StellaOps-Delivery-Id", context.DeliveryId);
|
||||
request.Headers.Add("X-StellaOps-Trace-Id", context.TraceId);
|
||||
request.Headers.Add("X-StellaOps-Timestamp", context.Timestamp.ToString("O"));
|
||||
|
||||
if (_options.EnableHmacSigning && TryGetHmacSecret(context.Channel, out var secret))
|
||||
{
|
||||
var signature = ComputeHmacSignature(context.RenderedBody, secret);
|
||||
request.Headers.Add("X-StellaOps-Signature", $"sha256={signature}");
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private static bool TryGetHmacSecret(NotifyChannel channel, out string secret)
|
||||
{
|
||||
secret = string.Empty;
|
||||
if (channel.Config.Properties.TryGetValue("hmacSecret", out var s) && !string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
secret = s;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ComputeHmacSignature(string body, string secret)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseRetryAfter(HttpResponseHeaders headers)
|
||||
{
|
||||
if (headers.RetryAfter?.Delta is { } delta)
|
||||
{
|
||||
return delta;
|
||||
}
|
||||
|
||||
if (headers.RetryAfter?.Date is { } date)
|
||||
{
|
||||
var delay = date - DateTimeOffset.UtcNow;
|
||||
return delay > TimeSpan.Zero ? delay : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.RequestTimeout => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSuccessMetadata(
|
||||
ChannelDispatchContext context,
|
||||
HttpResponseMessage response,
|
||||
int attempt)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = context.Channel.Config.Endpoint ?? string.Empty,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString(),
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = context.Channel.Type.ToString(),
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user