5 Serilog Best Practices For Better Structured Logging

Most .NET applications I review still write logs using string interpolation, something like logger.LogInformation($”User {id} logged in”). This works fine until you need to search or filter logs at scale, and then it turns into a real problem.

Serilog solves this by treating a log message as structured data instead of plain text. You keep the message template and the values separate, rather than baking values directly into a string. This lets you query logs by property, not just by matching text.

I have used Serilog on production .NET services for a few years now. Over that time, a handful of practices have made a genuine difference to how useful the logs turn out to be during an incident. Here are five of them.

Configure Serilog From appsettings.json, Not Code

You can set up Serilog two ways in ASP.NET Core: the fluent API in code, or the configuration system reading from appsettings.json. The fluent API looks clean at first, but it hardcodes your sinks and enrichers directly into the startup code.

Every time you want to add a sink, change a minimum log level, or point to a different Seq server, you end up touching code and redeploying. The configuration system avoids this problem entirely. Install the Serilog.Settings.Configuration package, then read the configuration straight from your configuration providers:

builder.Host.UseSerilog((context, loggerConfig) =>
    loggerConfig.ReadFrom.Configuration(context.Configuration));

This one line tells Serilog to build its entire configuration, sinks, enrichers, and minimum levels, from whatever is in your configuration providers. Since ASP.NET Core configuration also picks up environment variables and services like Azure App Configuration, you get environment specific logging without touching code or redeploying.

Here is what the appsettings side typically looks like:

{
  "Serilog": {
    "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.Seq"],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Information"
      }
    },
    "WriteTo": [
      { "Name": "Console" },
      {
        "Name": "Seq",
        "Args": { "serverUrl": "http://localhost:5341" }
      }
    ],
    "Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"]
  }
}

This writes logs to both the console and a local Seq instance, and it overrides the Microsoft namespace to Information level so framework noise from routing and model binding does not drown out your own log entries. The three enrichers here, FromLogContext, WithMachineName, and WithThreadId, add extra properties to every log event automatically, so you do not need to pass them manually at every call site.

One thing to watch for: if you leave FromLogContext out of the Enrich array, any properties you push later using LogContext.PushProperty, including the correlation ID pattern covered further down, will silently not appear in your logs. This is a common mistake, and it is easy to miss because there is no error, the property is just absent from the output.

Turn On Serilog Request Logging

ASP.NET Core already logs a lot about the request pipeline through its own logging providers, but the output is verbose and scattered across many lines per request. The Serilog.AspNetCore package gives you a single, compact log entry per HTTP request instead. Install the package and add one line to your pipeline:

app.UseSerilogRequestLogging();

This middleware should sit early in the pipeline, ideally right after exception handling middleware, so it wraps the rest of the request and can measure the full processing time accurately. The SourceContext for these logs is Serilog.AspNetCore.RequestLoggingMiddleware, worth remembering if you ever need to filter these entries separately or set a different minimum level for them.

A typical structured log entry from this middleware looks like this:

{
  "@t": "2023-12-16T00:00:00.0000000Z",
  "@mt": "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms",
  "@m": "HTTP POST /api/users responded 409 in 24.7928 ms",
  "@i": "37aa1435",
  "@r": ["24.7928"],
  "@tr": "61a449a8606fdb64e88d6c64b7b7354e",
  "@sp": "163ed90674cb12f6",
  "ConnectionId": "0HMVSP0L8FVEN",
  "CorrelationId": "0HMVSP0L8FVEN:0000000B",
  "Elapsed": 24.792778,
  "RequestId": "0HMVSP0L8FVEN:0000000B",
  "RequestMethod": "POST",
  "RequestPath": "/api/users",
  "SourceContext": "Serilog.AspNetCore.RequestLoggingMiddleware",
  "StatusCode": 409
}

Notice how RequestMethod, RequestPath, StatusCode, and Elapsed all show up as separate properties rather than being embedded in free text. In Seq or Application Insights, you can filter on StatusCode above 500 or sort by Elapsed to find your slowest endpoints, without writing a single regular expression against a message string.

A practical trade-off worth knowing: this middleware only logs once per request, after the whole pipeline finishes. If a request hangs or the process crashes mid-request, you will not get this log line at all. For that failure mode you still need timeouts and health checks elsewhere, request logging alone will not catch it.

Enrich Every Log With a Correlation ID

When something goes wrong in production, the first question is usually simple: show every log line for this one request. Without a correlation ID, you are stuck grepping around timestamps and hoping nothing else logged at the same second.

Serilog’s LogContext lets you push a property that automatically attaches to every log event created within that scope, regardless of how deep the call stack goes. Here is a middleware that reads a correlation ID from an incoming header, falling back to the built in TraceIdentifier if the header is missing:

public class RequestContextLoggingMiddleware
{
    private const string CorrelationIdHeaderName = "X-Correlation-Id";
    private readonly RequestDelegate _next;
 
    public RequestContextLoggingMiddleware(RequestDelegate next)
    {
        _next = next;
    }
 
    public Task Invoke(HttpContext context)
    {
        string correlationId = GetCorrelationId(context);
 
        using (LogContext.PushProperty("CorrelationId", correlationId))
        {
            return _next.Invoke(context);
        }
    }
 
    private static string GetCorrelationId(HttpContext context)
    {
        context.Request.Headers.TryGetValue(
            CorrelationIdHeaderName, out StringValues correlationId);
 
        return correlationId.FirstOrDefault() ?? context.TraceIdentifier;
    }
}

The middleware reads a custom X-Correlation-Id header if the caller sent one, which matters once you have multiple services calling each other and you want a single ID to follow the request across all of them. If no header is present, it falls back to context.TraceIdentifier, so a standalone request still gets a usable ID. Everything logged inside the using block, effectively the rest of the request pipeline, automatically picks up this CorrelationId property.

Wrap the middleware registration in an extension method so it reads cleanly from Program.cs:

public static IApplicationBuilder UseRequestContextLogging(
    this IApplicationBuilder app)
{
    app.UseMiddleware<RequestContextLoggingMiddleware>();
 
    return app;
}

Middleware order matters a lot here. If you want the correlation ID attached to logs produced further down the pipeline, including the request logging middleware from the previous section, this one needs to run before them. Registering it in the wrong order is a common mistake: the middleware works fine, tests pass, but half your logs are still missing the CorrelationId property because they ran before the LogContext scope was set up.

Log the Events That Actually Matter

Logging everything is nearly as unhelpful as logging nothing, it just moves the noise problem into your log storage bill. I try to be selective and log events that tell a story: request start and completion, validation failures, and business level failures rather than low level exceptions.

I generally prefer the Result pattern over throwing exceptions for expected failure cases like validation errors or business rule violations. Exceptions are expensive to throw and are meant for genuinely unanticipated situations, not routine control flow such as a duplicate email address on signup. That said, you still need a global exception handler for the failures you did not anticipate.

If your application uses the CQRS pattern with MediatR, a pipeline behavior is a clean way to add this logging once, centrally, instead of sprinkling logging calls through every handler:

internal sealed class RequestLoggingPipelineBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : class
    where TResponse : Result
{
    private readonly ILogger _logger;
 
    public RequestLoggingPipelineBehavior(ILogger logger)
    {
        _logger = logger;
    }
 
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        string requestName = typeof(TRequest).Name;
 
        _logger.LogInformation(
            "Processing request {RequestName}", requestName);
 
        TResponse result = await next();
 
        if (result.IsSuccess)
        {
            _logger.LogInformation(
                "Completed request {RequestName}", requestName);
        }
        else
        {
            using (LogContext.PushProperty("Error", result.Error, true))
            {
                _logger.LogError(
                    "Completed request {RequestName} with error", requestName);
            }
        }
 
        return result;
    }
}

This behavior wraps every request handled through MediatR. It logs when processing starts, then logs either a success or an error depending on the Result returned by the handler. On error, it pushes the Error object itself onto the LogContext with destructuring enabled, the true parameter, so the object gets serialized as a proper JSON structure rather than a flattened ToString output.

That distinction matters in Seq, because you can filter and group errors by their Code or Type property instead of matching substrings in a message. One limitation worth calling out: this pattern only covers requests that actually go through MediatR. Background jobs, event handlers outside that pipeline, or anything triggered from a message queue need their own logging setup.

Run Seq Locally for Searching and Filtering

Structured logs are only as useful as your ability to query them. Console output and flat text files do not give you real search, they give you scrolling. Seq is a self hosted log server built specifically for structured data, and it is free for local development and single user use.

You can get an instance running in a couple of minutes with Docker Compose:

version: '3.4'
 
services:
  seq:
    image: datalust/seq:latest
    container_name: seq
    environment:
      - ACCEPT_EULA=Y
    ports:
      - 5341:5341
      - 8081:80

This exposes the ingestion API on port 5341, matching the serverUrl in the Serilog configuration shown earlier, and the web UI on port 8081. Once Serilog is writing to this instance, you can filter logs using Seq’s query language, for example StatusCode >= 500 or CorrelationId = ‘some-id’, and it shows the exact structured properties attached to each event.

Filtering structured logs by property inside Seq
Filtering structured logs by property inside Seq

This is where the earlier investment in structured properties and correlation ID enrichment actually pays off, you get a proper timeline of a single request instead of hunting through flat text. For production, you would typically point the Seq sink at a hosted or centrally deployed instance, or swap it for Application Insights or another managed sink, since a Docker container on a developer laptop obviously will not scale to production traffic.

Seq also supports retention policies and alerting rules if you decide to run it in a real environment, but setting those up is a separate exercise from what is covered here.

Closing Thoughts

None of these five practices are complicated on their own. What makes the difference is applying them consistently across a codebase: configuring Serilog through appsettings rather than hardcoding it, always attaching a correlation ID, and being deliberate about which events actually deserve a log line.

Structured logging pays off exactly when you need it most, in the middle of a production incident when you are trying to reconstruct what a specific request did. Text logs and grep get you partway there. Structured logs with a proper correlation ID and a tool like Seq get you the rest of the way, without turning debugging into an archaeology exercise.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading