Plain text logs work fine until you have more than a handful of requests a day, and then every incident starts with someone grepping through gigabytes of unstructured text trying to reconstruct what happened. Structured logging fixes this by treating log entries as data — queryable fields, not sentences. Serilog is still the most common way to get there in .NET, and it works the same way in .NET 10 as it always has.

Key takeaways
  • Log with message templates and named properties, not string interpolation, so each field stays queryable.
  • Bootstrap a two-stage logger: a minimal one that catches startup failures, then the fully configured one built from appsettings.json.
  • Enrich every log line with request-scoped context (correlation ID, environment) once, centrally, instead of repeating it at every call site.
  • Route dev logs to the console and production logs to a queryable sink (Seq, Elasticsearch, or an OpenTelemetry collector) — console logs alone don't scale past local development.

Message templates, not string interpolation

This is the one habit that makes or breaks structured logging. It's tempting to write _logger.LogInformation($"Order {orderId} submitted") — but that collapses the order ID into the message text, losing it as a queryable field. Use the template form instead:

csharp
// Wrong: values baked into the string, not queryable
_logger.LogInformation($"Order {orderId} submitted for {customerId}");

// Right: named properties, indexed as structured fields
_logger.LogInformation("Order {OrderId} submitted for {CustomerId}", orderId, customerId);

With the second form, a sink like Seq or Elasticsearch indexes OrderId and CustomerId as their own fields — you can filter on OrderId = '8f14e45f-...' directly instead of running a regex over message text.

Bootstrapping Serilog

Configure a minimal logger first, before the host is built, so startup failures (a bad connection string, a missing config value) get logged instead of silently crashing before logging is available.

csharp
Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateBootstrapLogger();

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

    builder.Host.UseSerilog((context, services, configuration) => configuration
        .ReadFrom.Configuration(context.Configuration)
        .ReadFrom.Services(services)
        .Enrich.FromLogContext());

    var app = builder.Build();
    app.UseSerilogRequestLogging(); // one structured log line per HTTP request
    app.Run();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
    Log.CloseAndFlush();
}

Configuration-driven sinks

Keep sink configuration in appsettings.json so dev, staging, and production can point at different destinations without a code change.

json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": { "Microsoft.AspNetCore": "Warning" }
    },
    "WriteTo": [
      { "Name": "Console" },
      {
        "Name": "Seq",
        "Args": { "serverUrl": "http://seq:5341" }
      }
    ],
    "Enrich": ["FromLogContext", "WithMachineName", "WithEnvironmentName"]
  }
}

Enriching with request-scoped context

Push a correlation ID onto every log line for the duration of a request, once, in middleware — not repeated as a parameter at every call site downstream.

csharp
app.Use(async (context, next) =>
{
    var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
        ?? Guid.NewGuid().ToString();

    using (LogContext.PushProperty("CorrelationId", correlationId))
    {
        context.Response.Headers["X-Correlation-Id"] = correlationId;
        await next();
    }
});

Every log statement emitted further down the pipeline — in controllers, handlers, repositories — automatically carries CorrelationId without threading it through method signatures.

Watch forMessage templates make it easy to accidentally log sensitive data as a structured field, where it's now indexed and searchable in your logging backend, not just buried in scrollback. Treat property names in log templates with the same care as database column names — no raw passwords, tokens, or full payment details.

Where OpenTelemetry fits

Serilog remains the most ergonomic way to write structured logs in .NET code. Where it increasingly plugs in is as a log provider feeding an OpenTelemetry pipeline (via the Serilog.Sinks.OpenTelemetry package), so logs, traces, and metrics land in the same collector and correlate by trace ID. You don't have to choose one over the other — keep writing logs the Serilog way and let OpenTelemetry handle export and correlation across signals.

Need help with this in production?

astrivo helps teams design, build, and modernize systems like this. Let's talk about your context.