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();
+ }
+ }
+}