diff --git a/src/Host/BravoApplicationBuilder.cs b/src/Host/BravoApplicationBuilder.cs index 3aba7e39..1ab481d7 100644 --- a/src/Host/BravoApplicationBuilder.cs +++ b/src/Host/BravoApplicationBuilder.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.EventLog; using Sqlbi.Bravo.Infrastructure.Configuration.Settings; +using Sqlbi.Bravo.Infrastructure.Diagnostics; using Sqlbi.Bravo.Infrastructure.Services; namespace Sqlbi.Bravo.Host; @@ -81,6 +82,8 @@ private static void ConfigureLogging(WebApplicationBuilder builder) builder.Logging.AddEventSourceLogger(); builder.Logging.AddEventLog(); builder.Logging.AddFilter((level) => level >= LogLevel.Warning); + builder.Logging.AddDiagnosticMessages(); + builder.Logging.AddFilter((level) => level >= LogLevel.Warning); #if DEBUG builder.Logging.AddConsole(); builder.Logging.AddDebug(); diff --git a/src/Infrastructure/Diagnostics/DiagnosticMessageLogger.cs b/src/Infrastructure/Diagnostics/DiagnosticMessageLogger.cs new file mode 100644 index 00000000..563521bd --- /dev/null +++ b/src/Infrastructure/Diagnostics/DiagnosticMessageLogger.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Sqlbi.Bravo.Models; + +namespace Sqlbi.Bravo.Infrastructure.Diagnostics; + +internal sealed class DiagnosticMessageLogger(string categoryName) : ILogger +{ + private readonly string _categoryName = categoryName; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var content = formatter(state, exception); + + if (exception is not null) + content = $"{content}{Environment.NewLine}{exception}"; + + try + { + AppEnvironment.AddDiagnostics(DiagnosticMessageType.Text, _categoryName, content, GetSeverity(logLevel)); + } + catch (Exception) + { + // A sink failure does not propagate to the caller of ILogger.Log. + } + } + + private static DiagnosticMessageSeverity GetSeverity(LogLevel logLevel) + { + return logLevel switch + { + LogLevel.Critical or LogLevel.Error => DiagnosticMessageSeverity.Error, + LogLevel.Warning => DiagnosticMessageSeverity.Warning, + _ => DiagnosticMessageSeverity.None, + }; + } +} + +internal static class DiagnosticMessageLoggerExtensions +{ + public static ILoggingBuilder AddDiagnosticMessages(this ILoggingBuilder builder) + { + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + return builder; + } +} diff --git a/src/Infrastructure/Diagnostics/DiagnosticMessageLoggerProvider.cs b/src/Infrastructure/Diagnostics/DiagnosticMessageLoggerProvider.cs new file mode 100644 index 00000000..1cdae225 --- /dev/null +++ b/src/Infrastructure/Diagnostics/DiagnosticMessageLoggerProvider.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Logging; + +namespace Sqlbi.Bravo.Infrastructure.Diagnostics; + +/// +/// Writes log records to , the channel the user interface polls. +/// +[ProviderAlias("Diagnostics")] +internal sealed class DiagnosticMessageLoggerProvider : ILoggerProvider +{ + public ILogger CreateLogger(string categoryName) => new DiagnosticMessageLogger(categoryName); + + public void Dispose() + { + } +} diff --git a/test/Bravo.Tests/Infrastructure/Diagnostics/DiagnosticMessageLoggerProviderTests.cs b/test/Bravo.Tests/Infrastructure/Diagnostics/DiagnosticMessageLoggerProviderTests.cs new file mode 100644 index 00000000..a339ef5d --- /dev/null +++ b/test/Bravo.Tests/Infrastructure/Diagnostics/DiagnosticMessageLoggerProviderTests.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using Sqlbi.Bravo.Infrastructure; +using Sqlbi.Bravo.Infrastructure.Diagnostics; +using Sqlbi.Bravo.Models; +using Xunit; + +namespace Bravo.Tests.Infrastructure.Diagnostics; + +public class DiagnosticMessageLoggerProviderTests +{ + [Theory] + [InlineData(LogLevel.Critical, DiagnosticMessageSeverity.Error)] + [InlineData(LogLevel.Error, DiagnosticMessageSeverity.Error)] + [InlineData(LogLevel.Warning, DiagnosticMessageSeverity.Warning)] + [InlineData(LogLevel.Information, DiagnosticMessageSeverity.None)] + public void Log_MapsTheLevelToTheMessageSeverity(LogLevel logLevel, DiagnosticMessageSeverity expected) + { + var category = $"{nameof(Log_MapsTheLevelToTheMessageSeverity)}.{logLevel}"; + using var provider = new DiagnosticMessageLoggerProvider(); + var logger = provider.CreateLogger(category); + + logger.Log(logLevel, default, "the message", null, (state, _) => state); + + var message = FindMessage(category); + Assert.Equal(expected, message.Severity); + Assert.Equal(DiagnosticMessageType.Text, message.Type); + Assert.Equal("the message", message.Content); + } + + [Fact] + public void Log_AppendsTheExceptionToTheContent() + { + var category = nameof(Log_AppendsTheExceptionToTheContent); + var exception = new InvalidOperationException("policies unavailable"); + using var provider = new DiagnosticMessageLoggerProvider(); + var logger = provider.CreateLogger(category); + + logger.LogError(exception, "the message"); + + var message = FindMessage(category); + Assert.StartsWith("the message", message.Content); + Assert.Contains("policies unavailable", message.Content); + } + + private static DiagnosticMessage FindMessage(string category) + { + var message = AppEnvironment.Diagnostics.Values.SingleOrDefault((m) => m.Name == $"[HOST] {category}"); + + Assert.NotNull(message); + Assert.NotNull(message.Content); + + return message; + } +}