Structured logging treats log events as data rather than strings. Instead of interpolating values into a message and losing them, you preserve each value as a named property that can be filtered, queried, and aggregated. Serilog is the most popular structured logging library in .NET, and it integrates cleanly with the built-in ILogger infrastructure.

Setup

Install the core packages:

dotnet add package Serilog.AspNetCore

Configure Serilog in Program.cs:

Program.cs
builder.Host.UseSerilog((context, configuration) =>
{
    configuration
        .ReadFrom.Configuration(context.Configuration)
        .Enrich.FromLogContext()
        .Enrich.WithMachineName()
        .Enrich.WithThreadId()
        .WriteTo.Console()
        .WriteTo.Seq("http://localhost:5341");
});

This replaces the default logging provider with Serilog and sets up two sinks — console and Seq (a structured log server).

Message Templates

The key concept in Serilog is the message template. Properties are captured by name, not by position:

Example.cs
_logger.LogInformation(
    "Order {OrderId} placed by {CustomerEmail} for {Total:C}",
    order.Id,
    order.CustomerEmail,
    order.Total);

This produces a log event with three named properties: OrderId, CustomerEmail, and Total. You can search for all logs where OrderId == "abc-123" without parsing strings.

Important: Never use string interpolation with Serilog. This destroys the structured data:

Example.cs
// Wrong — loses structure
_logger.LogInformation($"Order {order.Id} placed by {order.CustomerEmail}");

// Correct — preserves structure
_logger.LogInformation("Order {OrderId} placed by {CustomerEmail}",
    order.Id, order.CustomerEmail);

Destructuring Objects

To capture a complex object as a structured property, prefix the placeholder with @:

Example.cs
_logger.LogInformation("Processing request {@Request}", new
{
    OrderId = order.Id,
    ItemCount = order.Items.Count,
    Total = order.Total
});

Without @, Serilog calls ToString() on the object. With @, it serialises the object's properties into the log event.

Enrichers

Enrichers add properties to every log event automatically. Some are built-in, others come from NuGet packages:

Example.cs
configuration
    .Enrich.FromLogContext()
    .Enrich.WithMachineName()       // Serilog.Enrichers.Environment
    .Enrich.WithThreadId()          // Serilog.Enrichers.Thread
    .Enrich.WithProperty("Application", "OrderService");

FromLogContext is particularly useful — it lets you push properties onto a scope:

Example.cs
using (LogContext.PushProperty("CorrelationId", correlationId))
{
    _logger.LogInformation("Starting order processing");
    await ProcessOrderAsync(order);
    _logger.LogInformation("Order processing complete");
}
// Both log events above will have CorrelationId attached

Configuration via appsettings.json

Keep your logging configuration outside code for flexibility:

appsettings.json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft.AspNetCore": "Warning",
        "Microsoft.EntityFrameworkCore": "Warning",
        "System": "Warning"
      }
    },
    "WriteTo": [
      { "Name": "Console" },
      {
        "Name": "Seq",
        "Args": { "serverUrl": "http://localhost:5341" }
      }
    ]
  }
}

The Override section is essential — without it, framework-level logs flood your output at the Information level.

Request Logging Middleware

Serilog provides a single-line request log that replaces the multiple log events ASP.NET Core produces per request:

Program.cs
app.UseSerilogRequestLogging(options =>
{
    options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
    {
        diagnosticContext.Set("UserId",
            httpContext.User.FindFirst("sub")?.Value ?? "anonymous");
        diagnosticContext.Set("ClientIp",
            httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown");
    };
});

This produces one log event per request with the status code, elapsed time, and any custom properties you add.

Filtering

Use filter expressions to control what gets logged to specific sinks:

Example.cs
configuration
    .WriteTo.Console()
    .WriteTo.Logger(lc => lc
        .Filter.ByIncludingOnly(e =>
            e.Level >= LogEventLevel.Error)
        .WriteTo.File("errors.log", rollingInterval: RollingInterval.Day));

This sends everything to the console but only errors and above to the file sink.

Practical Tips

Use LogWarning for things that might need attention. Use LogError with an exception parameter when catching exceptions:

Example.cs
try
{
    await ProcessPaymentAsync(payment);
}
catch (PaymentException ex)
{
    _logger.LogError(ex, "Payment failed for Order {OrderId}", order.Id);
    throw;
}

Passing the exception as the first argument ensures Serilog captures the full stack trace as a structured property rather than just the message string.

Serilog turns logs from an afterthought into a first-class observability tool. Combined with a structured log server like Seq or an ELK stack, you get searchable, filterable insight into exactly what your application is doing.