Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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