From 25871f245fc9effd635c599dee88fee7cebdc942 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 2 Jul 2026 10:57:18 +0200 Subject: [PATCH 1/7] feat(bootstrap): add idempotent eager ConfigureLogging to SquidStdBootstrap --- .../Bootstrap/ISquidStdBootstrap.cs | 6 +++ .../Services/Bootstrap/SquidStdBootstrap.cs | 16 +++++++- .../AspNetCore/FakeSquidStdBootstrap.cs | 5 +++ .../SquidStdBootstrapLoggingTests.cs | 40 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapLoggingTests.cs 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/Bootstrap/SquidStdBootstrapLoggingTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapLoggingTests.cs new file mode 100644 index 00000000..48ffd2be --- /dev/null +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapLoggingTests.cs @@ -0,0 +1,40 @@ +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); + } +} From f5a9b0f7551480e7187653c1cc6863fdf93b3297 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 2 Jul 2026 11:02:17 +0200 Subject: [PATCH 2/7] test(bootstrap): assert ConfigureLogging then StartAsync builds logger once --- .../SquidStdBootstrapLoggingTests.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapLoggingTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapLoggingTests.cs index 48ffd2be..3a795b07 100644 --- a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapLoggingTests.cs +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapLoggingTests.cs @@ -37,4 +37,26 @@ public async Task ConfigureLogging_CalledTwice_ConfiguresLoggerOnce() 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(); + } + } } From 64944455ddebce26128b54158ef68af219f8cdd2 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 2 Jul 2026 11:05:12 +0200 Subject: [PATCH 3/7] feat(aspnetcore): add AddSquidStdSerilog to unify framework and SquidStd logging --- .../Extensions/SquidStdSerilogExtensions.cs | 41 ++++++++++++ .../SquidStd.AspNetCore.csproj | 1 + .../SquidStdSerilogExtensionsTests.cs | 65 +++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 src/SquidStd.AspNetCore/Extensions/SquidStdSerilogExtensions.cs create mode 100644 tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs 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/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/tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs new file mode 100644 index 00000000..3da2e84b --- /dev/null +++ b/tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SquidStd.AspNetCore.Extensions; +using SquidStd.Tests.Support; + +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.Equal("SerilogLoggerFactory", factory.GetType().Name); + } + + [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.NotEqual("SerilogLoggerFactory", factory.GetType().Name); + } + + [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); + } + + private static WebApplicationBuilder CreateBuilder(string contentRootPath) + { + var builder = WebApplication.CreateBuilder( + new WebApplicationOptions + { + ContentRootPath = contentRootPath, + EnvironmentName = Environments.Development + } + ); + + builder.WebHost.UseTestServer(); + + return builder; + } +} From b67b057da171f05ef8746b27aec9ab0a537c3b35 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 2 Jul 2026 11:14:38 +0200 Subject: [PATCH 4/7] test(aspnetcore): assert framework logs flow through SquidStd Serilog file sink --- .../SquidStdSerilogExtensionsTests.cs | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs index 3da2e84b..4c912fab 100644 --- a/tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs +++ b/tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs @@ -3,8 +3,10 @@ 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; @@ -22,7 +24,7 @@ public async Task AddSquidStdSerilog_RoutesFrameworkLoggingThroughSerilog() await using var app = builder.Build(); var factory = app.Services.GetRequiredService(); - Assert.Equal("SerilogLoggerFactory", factory.GetType().Name); + Assert.IsType(factory); } [Fact] @@ -35,7 +37,7 @@ public async Task UseSquidStd_WithoutAddSquidStdSerilog_KeepsDefaultLoggerFactor await using var app = builder.Build(); var factory = app.Services.GetRequiredService(); - Assert.NotEqual("SerilogLoggerFactory", factory.GetType().Name); + Assert.IsNotType(factory); } [Fact] @@ -48,6 +50,51 @@ public void AddSquidStdSerilog_WithoutUseSquidStd_Throws() 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>(); + logger.LogInformation("emitting {Marker}", marker); + 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( From b1bd101df221c07d0a79694b85f18a529c2fa96b Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 2 Jul 2026 11:16:55 +0200 Subject: [PATCH 5/7] docs(aspnetcore): document AddSquidStdSerilog unified logging opt-in --- docs/tutorials/aspnetcore-app.md | 15 +++++++++++++++ src/SquidStd.AspNetCore/README.md | 24 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/docs/tutorials/aspnetcore-app.md b/docs/tutorials/aspnetcore-app.md index 93f37afe..7a284b06 100644 --- a/docs/tutorials/aspnetcore-app.md +++ b/docs/tutorials/aspnetcore-app.md @@ -28,6 +28,21 @@ after `UseSquidStd`. [!code-csharp[](../../samples/SquidStd.Samples.AspNetCore/Program.cs#step-2)] +## 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. + ### 3. Build the app and map endpoints Build the app, expose the health checks with the standard `MapHealthChecks`, add a root endpoint, and run. 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) From cb33c24106fcf1f74c266aae7a293d38304d69d4 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 2 Jul 2026 11:18:05 +0200 Subject: [PATCH 6/7] docs(aspnetcore): move unified logging section after the numbered steps --- docs/tutorials/aspnetcore-app.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/tutorials/aspnetcore-app.md b/docs/tutorials/aspnetcore-app.md index 7a284b06..4b1faab2 100644 --- a/docs/tutorials/aspnetcore-app.md +++ b/docs/tutorials/aspnetcore-app.md @@ -28,6 +28,12 @@ after `UseSquidStd`. [!code-csharp[](../../samples/SquidStd.Samples.AspNetCore/Program.cs#step-2)] +### 3. Build the app and map endpoints + +Build the app, expose the health checks with the standard `MapHealthChecks`, add a root endpoint, and run. + +[!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`) @@ -43,12 +49,6 @@ builder.AddSquidStdHealthChecks(); Without this call the two loggers stay separate (two console formats). With it, `squidstd.yaml` drives all logging. -### 3. Build the app and map endpoints - -Build the app, expose the health checks with the standard `MapHealthChecks`, add a root endpoint, and run. - -[!code-csharp[](../../samples/SquidStd.Samples.AspNetCore/Program.cs#step-3)] - ## Run it ```bash From 9e16d2441aa0dc47c0bcf0a6d90e2590fb78df28 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 2 Jul 2026 11:22:31 +0200 Subject: [PATCH 7/7] test(aspnetcore): suppress logger performance analyzers in behavioral test --- .../SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs index 4c912fab..b9e7b217 100644 --- a/tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs +++ b/tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs @@ -69,7 +69,9 @@ public async Task AddSquidStdSerilog_RoutesFrameworkLogsToSquidStdFileSink() { 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(); }