Files
git.stella-ops.org/src/Notify/StellaOps.Notify.WebService/Services/NotifyChannelHealthService.cs
2025-10-28 15:10:40 +02:00

183 lines
6.3 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using StellaOps.Notify.WebService.Contracts;
namespace StellaOps.Notify.WebService.Services;
internal interface INotifyChannelHealthService
{
Task<ChannelHealthResponse> CheckAsync(
string tenantId,
NotifyChannel channel,
string traceId,
CancellationToken cancellationToken);
}
internal sealed class NotifyChannelHealthService : INotifyChannelHealthService
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifyChannelHealthService> _logger;
private readonly IReadOnlyDictionary<NotifyChannelType, INotifyChannelHealthProvider> _providers;
public NotifyChannelHealthService(
TimeProvider timeProvider,
ILogger<NotifyChannelHealthService> logger,
IEnumerable<INotifyChannelHealthProvider> providers)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_providers = BuildProviderMap(providers ?? Array.Empty<INotifyChannelHealthProvider>(), _logger);
}
public async Task<ChannelHealthResponse> CheckAsync(
string tenantId,
NotifyChannel channel,
string traceId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
cancellationToken.ThrowIfCancellationRequested();
var target = ResolveTarget(channel);
var timestamp = _timeProvider.GetUtcNow();
var context = new ChannelHealthContext(
tenantId,
channel,
target,
timestamp,
traceId);
ChannelHealthResult? providerResult = null;
var providerName = "fallback";
if (_providers.TryGetValue(channel.Type, out var provider))
{
try
{
providerResult = await provider.CheckAsync(context, cancellationToken).ConfigureAwait(false);
providerName = provider.GetType().FullName ?? provider.GetType().Name;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Notify channel health provider {Provider} failed for tenant {TenantId}, channel {ChannelId} ({ChannelType}).",
provider.GetType().FullName,
tenantId,
channel.ChannelId,
channel.Type);
providerResult = new ChannelHealthResult(
ChannelHealthStatus.Degraded,
"Channel health provider threw an exception. See logs for details.",
new Dictionary<string, string>(StringComparer.Ordinal));
}
}
var metadata = MergeMetadata(context, providerName, providerResult?.Metadata);
var status = providerResult?.Status ?? ChannelHealthStatus.Healthy;
var message = providerResult?.Message ?? "Channel metadata returned without provider-specific diagnostics.";
var response = new ChannelHealthResponse(
tenantId,
channel.ChannelId,
status,
message,
timestamp,
traceId,
metadata);
_logger.LogInformation(
"Notify channel health generated for tenant {TenantId}, channel {ChannelId} ({ChannelType}) using provider {Provider}.",
tenantId,
channel.ChannelId,
channel.Type,
providerName);
return response;
}
private static IReadOnlyDictionary<NotifyChannelType, INotifyChannelHealthProvider> BuildProviderMap(
IEnumerable<INotifyChannelHealthProvider> providers,
ILogger logger)
{
var map = new Dictionary<NotifyChannelType, INotifyChannelHealthProvider>();
foreach (var provider in providers)
{
if (provider is null)
{
continue;
}
if (map.TryGetValue(provider.ChannelType, out var existing))
{
logger?.LogWarning(
"Multiple Notify channel health providers registered for {ChannelType}. Keeping {ExistingProvider} and ignoring {NewProvider}.",
provider.ChannelType,
existing.GetType().FullName,
provider.GetType().FullName);
continue;
}
map[provider.ChannelType] = provider;
}
return map;
}
private static string ResolveTarget(NotifyChannel channel)
{
var target = channel.Config.Target ?? channel.Config.Endpoint;
if (string.IsNullOrWhiteSpace(target))
{
return channel.Name;
}
return target;
}
private static IReadOnlyDictionary<string, string> MergeMetadata(
ChannelHealthContext context,
string providerName,
IReadOnlyDictionary<string, string>? providerMetadata)
{
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["channelType"] = context.Channel.Type.ToString().ToLowerInvariant(),
["target"] = context.Target,
["previewProvider"] = providerName,
["traceId"] = context.TraceId,
["channelEnabled"] = context.Channel.Enabled.ToString()
};
foreach (var label in context.Channel.Labels)
{
metadata[$"label.{label.Key}"] = label.Value;
}
if (providerMetadata is not null)
{
foreach (var pair in providerMetadata)
{
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
{
continue;
}
metadata[pair.Key.Trim()] = pair.Value;
}
}
return metadata;
}
}