Serilog (Optional)

Decide when Serilog earns its place, wire it behind ILogger with AddSerilog, configure sinks and levels, and avoid its traps.

Syntaxbuilder.Services.AddSerilog((services, lc) => lc .ReadFrom.Configuration(builder.Configuration) .ReadFrom.Services(services) .Enrich.FromLogContext() .WriteTo.Console(new CompactJsonFormatter())); app.UseSerilogRequestLogging();

Serilog is a logging library with its own pipeline: enrichers, filters, sinks and its own template parser. In ASP.NET Core it plugs in behind ILogger<T>, so application code keeps logging through the Microsoft abstraction and only Program.cs knows Serilog exists. Keep it that way: no static Log.Information calls or Serilog.ILogger parameters in services, so the choice can be reversed.

When teams choose it

NeedBuilt-in ILogger + OpenTelemetrySerilog
Ship logs over OTLPBuilt-in exporterSerilog.Sinks.OpenTelemetry
Rolling filesNo file provider in the boxSerilog.Sinks.File
Seq, Elasticsearch, SplunkVia a CollectorDirect sinks
One summary event per requestHTTP logging middlewareUseSerilogRequestLogging
Levels from configurationLogging:LogLevelSerilog:MinimumLevel

For a new service exporting OTLP, the built-in stack is enough and is one dependency fewer. Choose Serilog when the team already runs Seq or file shipping, needs a specific sink, or wants its request logging and enrichers. The package to reference is Serilog.AspNetCore (10.0.0 for .NET 10), which brings the console, file and configuration support.

Wiring and the configuration trap

builder.Services.AddSerilog((services, lc) => ...) replaces the logger factory: events go to Serilog sinks, and providers added through builder.Logging are bypassed. ReadFrom.Configuration reads the Serilog section, and the Logging:LogLevel section is ignored. An operator who sets Logging__LogLevel__Default=Debug during an incident sees no change; the key is Serilog__MinimumLevel__Default, and per-namespace levels live under MinimumLevel:Override. Enrich.FromLogContext() is required for BeginScope and LogContext.PushProperty properties to appear; the trace and span ids are captured on every event by Serilog itself. Two-stage initialisation, a bootstrap logger created before the host is built, captures exceptions thrown while building the host, which otherwise vanish because no logger exists yet.

Templates and destructuring

Serilog templates are compatible with ILogger templates and add operators: {@Order} destructures an object into a structured property, {$Order} forces ToString(). Through ILogger the @ still works, because Serilog parses the template. Destructuring an entity serialises every public property, including customer email, address and whatever the navigation properties reach; destructure small DTOs or register Destructure.ByTransforming<T>.

Production failure modes

  • File sinks in containers: the file dies with the container and fills its writable layer. Write compact JSON to stdout and let the platform ship it.
  • Buffered sinks lose the last events on exit unless the logger is flushed: Log.CloseAndFlushAsync() in a finally.
  • Request logging plus framework request logs at Information doubles volume: override Microsoft.AspNetCore to Warning.
  • Two pipelines configured (Serilog and builder.Logging) with only one actually used, so changes to the other silently do nothing.

Example

Example · csharp
using Serilog;
using Serilog.Formatting.Compact;

// Stage 1: a bootstrap logger so failures while building the host are recorded.
Log.Logger = new LoggerConfiguration()
    .WriteTo.Console(new RenderedCompactJsonFormatter())
    .CreateBootstrapLogger();

try
{
    var builder = WebApplication.CreateBuilder(args);

    // Stage 2: the real pipeline, from configuration plus services, replacing the bootstrap logger.
    builder.Services.AddSerilog((services, configuration) => configuration
        .ReadFrom.Configuration(builder.Configuration)
        .ReadFrom.Services(services)
        .Enrich.FromLogContext()
        .Enrich.WithProperty("Service", "orders-api")
        .WriteTo.Console(new RenderedCompactJsonFormatter()));

    builder.Services.AddSingleton<OrderService>();

    var app = builder.Build();

    // One event per request with method, path, status and elapsed time.
    app.UseSerilogRequestLogging();

    app.MapPost("/orders", (PlaceOrder order, OrderService orders) =>
    {
        var orderId = orders.Place(order);
        return TypedResults.Created($"/orders/{orderId}", new { orderId });
    });

    await app.RunAsync();
}
catch (Exception ex) when (ex is not HostAbortedException)
{
    // HostAbortedException is thrown on purpose by tooling such as dotnet ef; it is not a crash.
    Log.Fatal(ex, "orders-api terminated unexpectedly");
}
finally
{
    await Log.CloseAndFlushAsync();
}

public sealed record PlaceOrder(string CustomerId, string Sku, int Quantity, decimal Total);

// Application code depends only on Microsoft.Extensions.Logging.
public sealed class OrderService(ILogger<OrderService> logger)
{
    private int _nextId = 10_480;

    public int Place(PlaceOrder order)
    {
        var orderId = Interlocked.Increment(ref _nextId);
        logger.LogInformation("Order {OrderId} placed: {Quantity} x {Sku} for {Total}",
            orderId, order.Quantity, order.Sku, order.Total);
        return orderId;
    }
}

When to use it

  • Keep an existing Seq-based log search working while migrating a service to .NET 10, with ILogger<T> everywhere in application code.
  • Write exactly one log event per request with status, elapsed time and OrderId, at Warning when it takes longer than one second.
  • Record a missing connection string that crashes the host during startup, before the configured logging pipeline exists.

More examples

The Serilog section in appsettings.json

The override keys are category prefixes, like the built-in rules, and environment variables follow the usual double-underscore form: <code>Serilog__MinimumLevel__Override__Microsoft.AspNetCore=Information</code>. Keeping the sinks in code and only levels and properties in configuration avoids the common surprise of the same event written twice because a sink was declared in both places. The leftover <code>Logging</code> section is harmless but misleading, which is why the comment says so.

Example · json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft.AspNetCore": "Warning",
        "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
        "System.Net.Http.HttpClient": "Warning"
      }
    },
    // Sinks configured here would be added to the ones in code; keep them in one place.
    "Enrich": [ "FromLogContext" ],
    "Properties": {
      "Application": "orders-api"
    }
  },
  // Ignored once Serilog owns the pipeline; kept only for tools that read it.
  "Logging": {
    "LogLevel": {
      "Default": "Information"
    }
  }
}

Request logging with extra properties and smarter levels

<code>GetLevel</code> keeps probe noise at Verbose, which the default minimum filters out, and promotes slow requests to Warning so they stand out without a separate alert rule. <code>IDiagnosticContext</code> lets a handler add properties to the one summary event instead of writing its own line, so each request is exactly one event with everything on it. Raise <code>Microsoft.AspNetCore</code> to Warning so the framework's own per-request lines do not duplicate it.

Example · csharp
using Serilog;
using Serilog.Events;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog(lc => lc.Enrich.FromLogContext().WriteTo.Console());
var app = builder.Build();

app.UseSerilogRequestLogging(options =>
{
    options.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0} ms";
    options.GetLevel = (context, elapsedMs, exception) =>
        exception is not null || context.Response.StatusCode >= 500 ? LogEventLevel.Error
        : context.Request.Path.StartsWithSegments("/health") ? LogEventLevel.Verbose
        : elapsedMs > 1000 ? LogEventLevel.Warning
        : LogEventLevel.Information;
    options.EnrichDiagnosticContext = (diagnostics, context) =>
    {
        diagnostics.Set("ClientIp", context.Connection.RemoteIpAddress?.ToString());
        diagnostics.Set("Route", context.GetEndpoint()?.DisplayName);
    };
});

app.MapGet("/health/live", () => Results.Ok());
app.MapGet("/orders/{id:int}", (int id, IDiagnosticContext diagnostics) =>
{
    diagnostics.Set("OrderId", id); // lands on the single request event
    return Results.Ok(new { id, status = "shipped" });
});
app.Run();

Pitfall: destructuring an entity

<code>{@Customer}</code> asks Serilog to serialise the object, and by default it takes every public property, which here is three pieces of personal data. <code>ByTransforming</code> centralises the decision so a careless log line elsewhere cannot leak them, and the depth and collection limits stop an entity graph from producing megabyte-sized events. Better still, log the identifiers you need as separate properties.

Example · csharp
using Serilog;

public sealed class Customer
{
    public required string Id { get; init; }
    public required string Email { get; init; }
    public required string FullName { get; init; }
    public required string Phone { get; init; }
    public string Tier { get; init; } = "standard";
}

public static class LoggingSetup
{
    public static LoggerConfiguration Configure(LoggerConfiguration lc) => lc
        // Whatever {@Customer} is logged anywhere, only these fields leave the process.
        .Destructure.ByTransforming<Customer>(c => new { c.Id, c.Tier })
        .Destructure.ToMaximumDepth(4)
        .Destructure.ToMaximumCollectionCount(20)
        .Enrich.FromLogContext()
        .WriteTo.Console();
}

public sealed class CustomerUpdates(ILogger<CustomerUpdates> logger)
{
    public void Updated(Customer customer)
    {
        // Without the transform this would write the email, name and phone number.
        logger.LogInformation("Customer updated {@Customer}", customer);
    }
}

Discussion

  • Be the first to comment on this lesson.