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 CheckAsync( string tenantId, NotifyChannel channel, string traceId, CancellationToken cancellationToken); } internal sealed class NotifyChannelHealthService : INotifyChannelHealthService { private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private readonly IReadOnlyDictionary _providers; public NotifyChannelHealthService( TimeProvider timeProvider, ILogger logger, IEnumerable providers) { _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _providers = BuildProviderMap(providers ?? Array.Empty(), _logger); } public async Task 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(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 BuildProviderMap( IEnumerable providers, ILogger logger) { var map = new Dictionary(); 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 MergeMetadata( ChannelHealthContext context, string providerName, IReadOnlyDictionary? providerMetadata) { var metadata = new Dictionary(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; } }