diff --git a/docs/tutorials/aspnetcore-app.md b/docs/tutorials/aspnetcore-app.md index 93f37afe..4b1faab2 100644 --- a/docs/tutorials/aspnetcore-app.md +++ b/docs/tutorials/aspnetcore-app.md @@ -34,6 +34,21 @@ Build the app, expose the health checks with the standard `MapHealthChecks`, add [!code-csharp[](../../samples/SquidStd.Samples.AspNetCore/Program.cs#step-3)] +## Unified Serilog logging + +Add `AddSquidStdSerilog()` to send ASP.NET Core framework logs (Kestrel, `Microsoft.Hosting.Lifetime`) +through the same Serilog logger SquidStd configures from `squidstd.yaml`, giving a single format and a +single configuration source: + +```csharp +builder.UseSquidStd(options => options.ConfigName = "squidstd"); +builder.AddSquidStdSerilog(); +builder.AddSquidStdHealthChecks(); +``` + +Without this call the two loggers stay separate (two console formats). With it, `squidstd.yaml` drives +all logging. + ## Run it ```bash diff --git a/src/SquidStd.AspNetCore/Extensions/SquidStdSerilogExtensions.cs b/src/SquidStd.AspNetCore/Extensions/SquidStdSerilogExtensions.cs new file mode 100644 index 00000000..5298bbf6 --- /dev/null +++ b/src/SquidStd.AspNetCore/Extensions/SquidStdSerilogExtensions.cs @@ -0,0 +1,41 @@ +using DryIoc; +using Microsoft.AspNetCore.Builder; +using Serilog; +using SquidStd.Core.Interfaces.Bootstrap; + +namespace SquidStd.AspNetCore.Extensions; + +/// +/// Extension methods that route ASP.NET Core and SquidStd logging through a single Serilog pipeline. +/// +public static class SquidStdSerilogExtensions +{ + /// ASP.NET Core application builder. + extension(WebApplicationBuilder builder) + { + /// + /// Configures SquidStd's Serilog logger eagerly (from the SquidStd YAML configuration) and routes + /// ASP.NET Core framework logging through it, so framework and SquidStd logs share one config and + /// one format. Must be called after UseSquidStd and before Build. + /// + /// The same builder for chaining. + public WebApplicationBuilder AddSquidStdSerilog() + { + ArgumentNullException.ThrowIfNull(builder); + + if (!builder.Host.Properties.TryGetValue( + SquidStdAspNetCoreBuilderExtensions.ContainerPropertyKey, + out var value) || value is not IContainer container) + { + throw new InvalidOperationException("AddSquidStdSerilog must be called after UseSquidStd."); + } + + var bootstrap = container.Resolve(); + bootstrap.ConfigureLogging(); + + builder.Host.UseSerilog(Log.Logger, dispose: false); + + return builder; + } + } +} diff --git a/src/SquidStd.AspNetCore/README.md b/src/SquidStd.AspNetCore/README.md index bf097116..aca9dbe6 100644 --- a/src/SquidStd.AspNetCore/README.md +++ b/src/SquidStd.AspNetCore/README.md @@ -51,6 +51,30 @@ Each registered `IHealthCheck` appears as its own entry in the report. Check nam | `SquidStdHostedService` | Hosted service bridging SquidStd service lifecycle to the host. | | `SquidStdHealthChecksExtensions` | `AddSquidStdHealthChecks(...)` — bridge to ASP.NET Core health checks. | +## Unified logging (opt-in) + +By default the SquidStd Serilog logger (configured from the `logger` section of `squidstd.yaml`) and the +ASP.NET Core framework logger run as two separate pipelines, producing two console formats. Call +`AddSquidStdSerilog()` after `UseSquidStd()` to route framework logging through SquidStd's Serilog logger, +so everything shares one configuration and one format: + +```csharp +builder.UseSquidStd(options => options.ConfigName = "squidstd"); +builder.AddSquidStdSerilog(); +``` + +The logger is driven entirely by `squidstd.yaml` (keys are PascalCase and case-sensitive): + +```yaml +logger: + MinimumLevel: Information # None disables all logging, framework included + EnableConsole: true + EnableFile: false + LogDirectory: logs + FileName: squidstd-.log + RollingInterval: Day +``` + ## Related - Tutorial: [Build an ASP.NET Core app](https://tgiachi.github.io/squid-std/tutorials/aspnetcore-app.html) diff --git a/src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj b/src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj index 994d0dbb..2a3ea3f6 100644 --- a/src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj +++ b/src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj @@ -13,6 +13,7 @@ + diff --git a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs index 857851bf..3a573dab 100644 --- a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs +++ b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs @@ -39,6 +39,12 @@ public interface ISquidStdBootstrap : IAsyncDisposable /// The resolved service instance. TService Resolve(); + /// + /// Loads configuration and configures the Serilog logger immediately, if not already configured. + /// Idempotent: subsequent calls are no-ops. Lets ASP.NET hosts wire Serilog before the host starts. + /// + void ConfigureLogging(); + /// /// Starts services, waits until cancellation, and then stops services. /// diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index d8ab9057..53fadb3e 100644 --- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -120,6 +120,20 @@ public TService Resolve() return Container.Resolve(); } + /// + public void ConfigureLogging() + { + ThrowIfDisposed(); + + if (_loggerConfigured) + { + return; + } + + Container.Resolve().Load(); + ConfigureLogger(); + } + /// public async Task RunAsync(CancellationToken cancellationToken = default) { @@ -224,7 +238,7 @@ public static SquidStdBootstrap Create(SquidStdOptions options, IContainer conta private void ConfigureLogger() { - if (!Container.IsRegistered()) + if (_loggerConfigured || !Container.IsRegistered()) { return; } diff --git a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs index 17489326..e400a581 100644 --- a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs +++ b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs @@ -10,6 +10,8 @@ internal sealed class FakeSquidStdBootstrap : ISquidStdBootstrap public int StopCount { get; private set; } + public int ConfigureLoggingCount { get; private set; } + public SquidStdOptions Options { get; } = new(); public IContainer Container { get; } = new Container(); @@ -37,6 +39,9 @@ public ValueTask DisposeAsync() public TService Resolve() => Container.Resolve(); + public void ConfigureLogging() + => ConfigureLoggingCount++; + public async Task RunAsync(CancellationToken cancellationToken = default) => await StartAsync(cancellationToken); diff --git a/tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs new file mode 100644 index 00000000..b9e7b217 --- /dev/null +++ b/tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs @@ -0,0 +1,114 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Serilog.Extensions.Logging; +using SquidStd.AspNetCore.Extensions; +using SquidStd.Tests.Support; +using SerilogLog = Serilog.Log; + +namespace SquidStd.Tests.AspNetCore; + +[Collection(SerilogEventSinkCollection.Name)] +public class SquidStdSerilogExtensionsTests +{ + [Fact] + public async Task AddSquidStdSerilog_RoutesFrameworkLoggingThroughSerilog() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + builder.UseSquidStd(options => options.ConfigName = "app"); + builder.AddSquidStdSerilog(); + + await using var app = builder.Build(); + + var factory = app.Services.GetRequiredService(); + Assert.IsType(factory); + } + + [Fact] + public async Task UseSquidStd_WithoutAddSquidStdSerilog_KeepsDefaultLoggerFactory() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + builder.UseSquidStd(options => options.ConfigName = "app"); + + await using var app = builder.Build(); + + var factory = app.Services.GetRequiredService(); + Assert.IsNotType(factory); + } + + [Fact] + public void AddSquidStdSerilog_WithoutUseSquidStd_Throws() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + + var ex = Assert.Throws(() => builder.AddSquidStdSerilog()); + Assert.Equal("AddSquidStdSerilog must be called after UseSquidStd.", ex.Message); + } + + [Fact] + public async Task AddSquidStdSerilog_RoutesFrameworkLogsToSquidStdFileSink() + { + using var temp = new TempDirectory(); + var logDir = Path.Combine(temp.Path, "logs"); + File.WriteAllText( + Path.Combine(temp.Path, "app.yaml"), + "logger:\n MinimumLevel: Information\n EnableConsole: false\n EnableFile: true\n LogDirectory: logs\n FileName: app-.log\n RollingInterval: Day\n"); + + const string marker = "framework-log-marker-a1b2c3"; + + var builder = CreateBuilder(temp.Path); + builder.UseSquidStd(options => options.ConfigName = "app"); + builder.AddSquidStdSerilog(); + + await using (var app = builder.Build()) + { + await app.StartAsync(); + var logger = app.Services.GetRequiredService>(); +#pragma warning disable CA1848, CA1873 // LoggerMessage delegates are overkill for a one-shot test log. + logger.LogInformation("emitting {Marker}", marker); +#pragma warning restore CA1848, CA1873 + await app.StopAsync(); + } + + await SerilogLog.CloseAndFlushAsync(); + + var logFile = Directory.EnumerateFiles(logDir, "app-*.log").First(); + var contents = await File.ReadAllTextAsync(logFile); + Assert.Contains(marker, contents, StringComparison.Ordinal); + } + + [Fact] + public async Task AddSquidStdSerilog_CalledTwice_DoesNotThrow() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + builder.UseSquidStd(options => options.ConfigName = "app"); + + builder.AddSquidStdSerilog(); + builder.AddSquidStdSerilog(); + + await using var app = builder.Build(); + var factory = app.Services.GetRequiredService(); + Assert.IsType(factory); + } + + private static WebApplicationBuilder CreateBuilder(string contentRootPath) + { + var builder = WebApplication.CreateBuilder( + new WebApplicationOptions + { + ContentRootPath = contentRootPath, + EnvironmentName = Environments.Development + } + ); + + builder.WebHost.UseTestServer(); + + return builder; + } +} diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapLoggingTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapLoggingTests.cs new file mode 100644 index 00000000..3a795b07 --- /dev/null +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapLoggingTests.cs @@ -0,0 +1,62 @@ +using DryIoc; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Tests.Support; +using SerilogILogger = Serilog.ILogger; + +namespace SquidStd.Tests.Bootstrap; + +[Collection(SerilogEventSinkCollection.Name)] +public class SquidStdBootstrapLoggingTests +{ + [Fact] + public async Task ConfigureLogging_LoadsConfigurationAndRegistersLoggerEagerly() + { + using var temp = new TempDirectory(); + await using var bootstrap = + SquidStdBootstrap.Create(new() { ConfigName = "app", RootDirectory = temp.Path }); + + bootstrap.ConfigureLogging(); + + Assert.NotNull(bootstrap.Container.Resolve()); + Assert.NotNull(bootstrap.Container.Resolve()); + } + + [Fact] + public async Task ConfigureLogging_CalledTwice_ConfiguresLoggerOnce() + { + using var temp = new TempDirectory(); + await using var bootstrap = + SquidStdBootstrap.Create(new() { ConfigName = "app", RootDirectory = temp.Path }); + + bootstrap.ConfigureLogging(); + var first = bootstrap.Container.Resolve(); + + bootstrap.ConfigureLogging(); + var second = bootstrap.Container.Resolve(); + + Assert.Same(first, second); + } + + [Fact] + public async Task ConfigureLogging_ThenStartAsync_DoesNotRebuildLogger() + { + using var temp = new TempDirectory(); + await using var bootstrap = + SquidStdBootstrap.Create(new() { ConfigName = "app", RootDirectory = temp.Path }); + + bootstrap.ConfigureLogging(); + var eager = bootstrap.Container.Resolve(); + + await bootstrap.StartAsync(); + try + { + var afterStart = bootstrap.Container.Resolve(); + Assert.Same(eager, afterStart); + } + finally + { + await bootstrap.StopAsync(); + } + } +}