Skip to content
Merged
15 changes: 15 additions & 0 deletions docs/tutorials/aspnetcore-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions src/SquidStd.AspNetCore/Extensions/SquidStdSerilogExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using DryIoc;
using Microsoft.AspNetCore.Builder;
using Serilog;
using SquidStd.Core.Interfaces.Bootstrap;

namespace SquidStd.AspNetCore.Extensions;

/// <summary>
/// Extension methods that route ASP.NET Core and SquidStd logging through a single Serilog pipeline.
/// </summary>
public static class SquidStdSerilogExtensions
{
/// <param name="builder">ASP.NET Core application builder.</param>
extension(WebApplicationBuilder builder)
{
/// <summary>
/// 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 <c>UseSquidStd</c> and before <c>Build</c>.
/// </summary>
/// <returns>The same builder for chaining.</returns>
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<ISquidStdBootstrap>();
bootstrap.ConfigureLogging();

builder.Host.UseSerilog(Log.Logger, dispose: false);

return builder;
}
}
}
24 changes: 24 additions & 0 deletions src/SquidStd.AspNetCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

<ItemGroup>
<PackageReference Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0"/>
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0"/>
</ItemGroup>

<ItemGroup>
Expand Down
6 changes: 6 additions & 0 deletions src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ public interface ISquidStdBootstrap : IAsyncDisposable
/// <returns>The resolved service instance.</returns>
TService Resolve<TService>();

/// <summary>
/// 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.
/// </summary>
void ConfigureLogging();

/// <summary>
/// Starts services, waits until cancellation, and then stops services.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,20 @@ public TService Resolve<TService>()
return Container.Resolve<TService>();
}

/// <inheritdoc />
public void ConfigureLogging()
{
ThrowIfDisposed();

if (_loggerConfigured)
{
return;
}

Container.Resolve<IConfigManagerService>().Load();
ConfigureLogger();
}

/// <inheritdoc />
public async Task RunAsync(CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -224,7 +238,7 @@ public static SquidStdBootstrap Create(SquidStdOptions options, IContainer conta

private void ConfigureLogger()
{
if (!Container.IsRegistered<SquidStdLoggerOptions>())
if (_loggerConfigured || !Container.IsRegistered<SquidStdLoggerOptions>())
{
return;
}
Expand Down
5 changes: 5 additions & 0 deletions tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -37,6 +39,9 @@ public ValueTask DisposeAsync()
public TService Resolve<TService>()
=> Container.Resolve<TService>();

public void ConfigureLogging()
=> ConfigureLoggingCount++;

public async Task RunAsync(CancellationToken cancellationToken = default)
=> await StartAsync(cancellationToken);

Expand Down
114 changes: 114 additions & 0 deletions tests/SquidStd.Tests/AspNetCore/SquidStdSerilogExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -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<ILoggerFactory>();
Assert.IsType<SerilogLoggerFactory>(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<ILoggerFactory>();
Assert.IsNotType<SerilogLoggerFactory>(factory);
}

[Fact]
public void AddSquidStdSerilog_WithoutUseSquidStd_Throws()
{
using var temp = new TempDirectory();
var builder = CreateBuilder(temp.Path);

var ex = Assert.Throws<InvalidOperationException>(() => 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");

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note test

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.
File.WriteAllText(
Path.Combine(temp.Path, "app.yaml"),

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note test

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.
"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<ILogger<SquidStdSerilogExtensionsTests>>();
#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<ILoggerFactory>();
Assert.IsType<SerilogLoggerFactory>(factory);
}

private static WebApplicationBuilder CreateBuilder(string contentRootPath)
{
var builder = WebApplication.CreateBuilder(
new WebApplicationOptions
{
ContentRootPath = contentRootPath,
EnvironmentName = Environments.Development
}
);

builder.WebHost.UseTestServer();

return builder;
}
}
62 changes: 62 additions & 0 deletions tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapLoggingTests.cs
Original file line number Diff line number Diff line change
@@ -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<SquidStdLoggerOptions>());
Assert.NotNull(bootstrap.Container.Resolve<SerilogILogger>());
}

[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<SerilogILogger>();

bootstrap.ConfigureLogging();
var second = bootstrap.Container.Resolve<SerilogILogger>();

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<SerilogILogger>();

await bootstrap.StartAsync();
try
{
var afterStart = bootstrap.Container.Resolve<SerilogILogger>();
Assert.Same(eager, afterStart);
}
finally
{
await bootstrap.StopAsync();
}
}
}
Loading