Structured Logging Architecture with Serilog in ASP.NET Core

Most ASP.NET Core projects start their logging story the same way: someone adds Serilog, wires up a console sink and a file sink, and calls it done. That works fine until the app grows into three services, a background worker, and a message queue, and suddenly nobody can answer "what happened to this request?" without grepping through five different log files on five different machines.

Diagram illustrating structured logging ASP.NET Core Serilog architecture with sinks and correlation IDs

This article is about avoiding that outcome. It shows how to design a structured logging pipeline (a defined flow of log events from creation to storage and querying) that stays coherent as the system grows, using Serilog as the logging library and ASP.NET Core as the host. The examples target .NET 8 or later with Serilog.AspNetCore 8.0 or later.

Structured logging itself just means writing log events as data (key-value pairs) instead of formatted strings. Instead of "User 42 logged in from 10.0.0.5", you write a message template with properties: "User {UserId} logged in from {IpAddress}". The difference sounds cosmetic until you try to query a million log lines and realize plain text can't be filtered or aggregated reliably, while structured properties can.

Key Takeaways

  • Write log events with message templates, not string interpolation, so properties stay queryable.
  • Add context such as machine name, environment and tenant ID with enrichers instead of relying on developers to remember it.
  • Generate or accept a correlation ID at the edge of each service, push it into LogContext, and forward it on every outgoing HTTP call and queue message.
  • Send events to sinks that fit each environment, and keep sink URLs and level overrides in configuration.

The Problem

Plain-text logging fails at scale for a specific reason: it optimizes for a human reading one line at a time, not for a system searching millions of lines for a pattern. When an ASP.NET Core Web API (a service exposing HTTP endpoints built on the ASP.NET Core framework) only writes formatted strings to a file, you lose the ability to ask "show me every failed order for tenant X in the last hour" without writing regex against inconsistent formatting.

The problem compounds in a microservices architecture, where a single user request might touch an API gateway, an orders service, a payments service, and a background job triggered by a message on a queue. Each of those might log independently, with its own timestamp format, its own idea of what a "request ID" looks like, and no shared identifier connecting them.

When something breaks, you end up stitching timelines together by hand from timestamps that may not even be in the same timezone.

The architectural goal, then, is threefold:

  • Emit logs as structured data with consistent property names.
  • Attach contextual metadata (enrichers) automatically rather than by convention.
  • Propagate a single correlation ID, an identifier that ties together all log events belonging to one logical operation, across every service and every hop that operation touches.

Core Concepts

A few Serilog-specific terms are worth pinning down before going further, because the architecture is built entirely out of them.

A sink is a destination for log events: the console, a rolling file, Seq (a structured log server built for querying Serilog-style events), Elasticsearch, or a cloud logging service. Serilog can write to multiple sinks simultaneously from the same pipeline, which matters because different sinks serve different purposes. Console output suits local development, a file sink works as a durable local fallback, and a centralized sink like Seq or Elasticsearch lets you query across services in production.

An enricher attaches additional properties to every log event that passes through the pipeline, without the calling code needing to specify them each time. Built-in enrichers can add machine name, process ID, or thread ID. Custom enrichers are where the real architectural value shows up, because they add tenant ID, authenticated user ID, or similar context consistently on every log line.

A message template is the format string passed to a logging call, such as "Order {OrderId} shipped to {City}". Serilog parses the template into a structured event with named properties (OrderId, City) rather than immediately interpolating them into a flat string. That is what allows sinks like Seq or Elasticsearch to index and query on those properties individually.

Log context (LogContext in Serilog) is a mechanism for pushing properties onto an ambient scope. Those properties automatically attach to every log event written within that scope, which makes it the primary tool for carrying a correlation ID through a request without threading it manually through every method signature.

A correlation ID and the related trace ID / span ID (identifiers from distributed tracing, where a trace ID represents an entire end-to-end operation and a span ID represents one step within it) serve the same underlying purpose. Each gives you a single value to search on that reconstructs the full path of a request across services. You can implement a simple custom correlation ID, or align with OpenTelemetry's tracing conventions if you already use it. That trade-off is covered later.

How It Works

Picture the pipeline as a straight line with branches at the end. A log call happens somewhere in application code: a controller, a service class, a background job. Serilog first checks the event against the minimum level. An event below that level, such as a Debug event when the minimum is Information, is discarded immediately, before any enrichment work happens.

Events that pass that check go through the enrichment stage, where every registered enricher adds its properties: machine name, environment, the correlation ID from LogContext, and a tenant ID if one is available. Any configured filters run next. What survives is handed to every configured sink at once. Serilog doesn't route different events to different sinks by default; it broadcasts the same enriched event to all of them, though you can restrict an individual sink to a minimum level or a filter.

For a correlation ID specifically, the mechanics in a single service look like this. Incoming middleware reads an X-Correlation-ID header if a valid one is present, or generates a new GUID if not. It pushes the value into LogContext and adds it to the response headers. Every log statement written during that request, anywhere in the call stack, then carries that property automatically, because it is read from the ambient log context rather than passed explicitly.

When that service calls a downstream service over HTTP, it forwards the same header. The downstream service's own middleware repeats the process, so the same correlation ID appears in the logs of every service that touched the request.

For message-queue-triggered work, the same idea applies. The correlation ID is serialized into the message metadata when the message is published, and the consumer reads it back out and pushes it into its own LogContext before processing.

Prerequisites

The examples below need three NuGet packages. Serilog.AspNetCore brings in the hosting integration, the console sink and configuration support (Serilog.Settings.Configuration). Serilog.Sinks.Seq provides the Seq sink. Serilog.Enrichers.Environment provides WithMachineName().

bash
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Seq
dotnet add package Serilog.Enrichers.Environment

The snippets include only the using directives for Serilog itself, because the standard ASP.NET Core project templates enable implicit usings. If you have disabled ImplicitUsings, add the namespaces the compiler reports as missing, typically Microsoft.AspNetCore.Http, System.Net.Http and System.Threading.Tasks.

You also need a Seq instance to send events to, or you can remove the Seq sink line and keep only the console sink while following along.

For local testing, Seq can be run as a container:

bash
docker run --name seq -d -p 5341:80 -e ACCEPT_EULA=Y datalust/seq

Then open http://localhost:5341 to query your logs. This is meant for local development only, since it runs without authentication. Seq's setup options change between versions, so check the current Seq Docker documentation before using it anywhere else.

Implementation

Start with the core Serilog setup in Program.cs. This example targets a typical ASP.NET Core Web API. It configures Serilog to write to the console (for local development) and to Seq (for centralized querying), with enrichment applied globally. It also creates a bootstrap logger first, so that failures during host startup, such as a broken configuration file, are still logged.

csharp
using Serilog;
using Serilog.Core;
using Serilog.Events;

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

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

    builder.Services.AddControllers();
    builder.Services.AddHttpContextAccessor();
    builder.Services.AddSingleton<ILogEventEnricher, TenantEnricher>();
    builder.Services.AddTransient<CorrelationIdHandler>();
    builder.Services.AddHttpClient("payments")
        .AddHttpMessageHandler<CorrelationIdHandler>();

    builder.Services.AddSerilog((services, configuration) =>
    {
        configuration
            .MinimumLevel.Information()
            .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
            .Enrich.FromLogContext()
            .Enrich.WithMachineName()
            .Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
            .ReadFrom.Services(services)
            .WriteTo.Console()
            .WriteTo.Seq(builder.Configuration["Seq:ServerUrl"] ?? "http://localhost:5341")
            .ReadFrom.Configuration(builder.Configuration);
    });

    var app = builder.Build();

    app.UseMiddleware<CorrelationIdMiddleware>();
    app.UseSerilogRequestLogging();

    app.MapControllers();
    await app.RunAsync();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Host terminated unexpectedly");
}
finally
{
    await Log.CloseAndFlushAsync();
}

A few things are worth explaining here rather than just copying. AddSerilog() is the current way to register Serilog in ASP.NET Core. builder.Host.UseSerilog() still works and behaves similarly, so existing projects don't need to change.

MinimumLevel.Override for the Microsoft.AspNetCore namespace suppresses the very chatty framework-level Information logs that would otherwise drown out application logs. It is a common override, not a mandatory one, so tune it per project.

Enrich.FromLogContext() is what makes the correlation ID (pushed later by middleware) appear on every log event. Without it, anything pushed into LogContext is silently ignored. UseSerilogRequestLogging() adds a single structured log line per HTTP request with method, path, status code and elapsed time, which is a cheap way to get request-level observability without writing that logic yourself.

The order of these two middleware lines matters. CorrelationIdMiddleware must be registered before UseSerilogRequestLogging(), so the request logging middleware runs inside the LogContext scope and its summary line carries the CorrelationId. If you reverse them, the summary line is written outside that scope and silently loses the property, while everything logged during the request still has it.

ReadFrom.Services(services) tells Serilog to use the enrichers registered in dependency injection as ILogEventEnricher, which is how TenantEnricher (shown below) gets attached without being resolved by hand. It also picks up any other Serilog components you register the same way, such as filters or sinks, so register only what you want in the pipeline.

ReadFrom.Configuration() is called last on purpose. Values in appsettings.json are applied after the code defaults, so configuration can override the minimum levels set in code.

It also means sinks can be defined in two places. If you list sinks in the Serilog:WriteTo array of appsettings.json (common in project templates), Serilog adds them on top of the ones written in C#, and every event is written twice. Choose one approach: either define the sinks in code as shown here, or define them all in configuration and remove the WriteTo lines from Program.cs.

The finally block uses CloseAndFlushAsync() (Serilog 3.1 or later) so that buffered events, especially those queued for a network sink like Seq, are sent before the process exits, without blocking a thread while it waits.

Application code doesn't call Serilog directly. It injects the standard ILogger<T>, and Serilog receives those events as the logging provider. The same setup works with Minimal APIs: replace AddControllers() and MapControllers() with your endpoint mappings, and everything else stays the same.

Next, the correlation ID middleware:

csharp
using Serilog.Context;

public class CorrelationIdMiddleware
{
    public const string HeaderName = "X-Correlation-ID";
    public const string ItemKey = "CorrelationId";
    private const int MaxLength = 64;

    private readonly RequestDelegate _next;

    public CorrelationIdMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var incoming = context.Request.Headers[HeaderName].ToString();
        var correlationId = IsValid(incoming) ? incoming : Guid.NewGuid().ToString();

        context.Items[ItemKey] = correlationId;

        context.Response.OnStarting(() =>
        {
            context.Response.Headers[HeaderName] = correlationId;
            return Task.CompletedTask;
        });

        using (LogContext.PushProperty("CorrelationId", correlationId))
        {
            await _next(context);
        }
    }

    private static bool IsValid(string value)
    {
        if (string.IsNullOrWhiteSpace(value) || value.Length > MaxLength)
        {
            return false;
        }

        foreach (var c in value)
        {
            if (!char.IsAsciiLetterOrDigit(c) && c != '-' && c != '_')
            {
                return false;
            }
        }

        return true;
    }
}

This middleware reads an incoming correlation ID or generates one, and pushes it into LogContext for the duration of the request. Every log statement inside _next(context), meaning the rest of the pipeline including controllers, picks up the CorrelationId property automatically.

LogContext follows the async execution context, so work started within the request with await or Task.Run keeps the correlation ID. It would only be lost if execution-context flow is deliberately suppressed, or if the work runs in an unrelated context, such as a queue consumer in another process.

The ID is echoed on the response so callers can log it too. It is written inside Response.OnStarting rather than set directly, because other middleware, such as exception handling, can reset the response and discard headers that were set earlier. The callback applies the header at the moment the response begins, so error responses carry it as well.

The validation step matters. The header comes from the caller, so accepting it blindly would let a client inject arbitrarily long or malformed values into your logs. Anything empty, longer than 64 characters, or containing characters other than letters, digits, hyphens and underscores is replaced with a fresh GUID. Adjust the rule if your upstream systems use a different ID format.

This middleware only handles X-Correlation-ID. It does not read or validate the W3C traceparent header, which ASP.NET Core processes on its own as part of distributed tracing.

The middleware also stores the value in HttpContext.Items so other code can read it. Register it early in the pipeline, before request logging and exception handling, so that those components also log with the correlation ID.

To continue the chain when this service calls another one, attach the same ID to outgoing requests. A DelegatingHandler does this once for every HttpClient it is attached to:

csharp
public class CorrelationIdHandler : DelegatingHandler
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public CorrelationIdHandler(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        if (_httpContextAccessor.HttpContext?.Items.TryGetValue(CorrelationIdMiddleware.ItemKey, out var value) == true
            && value is string correlationId)
        {
            request.Headers.TryAddWithoutValidation(CorrelationIdMiddleware.HeaderName, correlationId);
        }

        return base.SendAsync(request, cancellationToken);
    }
}

The handler is registered in Program.cs with AddHttpMessageHandler<CorrelationIdHandler>(). The example attaches it to a named client called "payments", which application code obtains with IHttpClientFactory.CreateClient("payments"). Attach the handler to every client that calls one of your own services.

It depends on an active HTTP request. If an outgoing call is made from a background thread, a fire-and-forget task that outlives the request, or a scheduled worker, HttpContext is null and no header is added. In that case, pass the correlation ID to the code explicitly, or generate a new one when the work starts.

For fire-and-forget work started from a request, read the correlation ID into a plain string before starting the task, and push it into a new LogContext scope inside the task so its logs stay searchable. Don't capture HttpContext itself in the task, since it must not be used after the request has finished.

A custom enricher for something domain-specific, like a tenant ID pulled from a claim, looks like this:

csharp
using Serilog.Core;
using Serilog.Events;

public class TenantEnricher : ILogEventEnricher
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public TenantEnricher(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
    {
        var tenantId = _httpContextAccessor.HttpContext?.User
            .FindFirst("tenant_id")?.Value;

        if (tenantId is null)
        {
            return;
        }

        logEvent.AddPropertyIfAbsent(
            propertyFactory.CreateProperty("TenantId", tenantId));
    }
}

This implements Serilog's ILogEventEnricher interface directly. It reads the authenticated user's tenant claim from IHttpContextAccessor and attaches it as a TenantId property. When no tenant claim is available, it adds nothing rather than a placeholder value, so events from background jobs aren't tagged with a misleading tenant.

AddPropertyIfAbsent does not overwrite a property that is already on the event. This matters if you push a TenantId through LogContext in non-HTTP code, since enrichers run in the order they are registered and FromLogContext() is registered first in the example above.

The enricher is registered in dependency injection as an ILogEventEnricher and picked up by ReadFrom.Services(services), along with AddHttpContextAccessor(). That accessor is not registered by default, and without it the app fails at startup.

You might wonder whether a singleton that reads request data is a captive-dependency problem. It is not. IHttpContextAccessor is itself a singleton, and it looks up the current request through AsyncLocal<T> each time it is called, so the enricher always sees the request the log event belongs to.

The same rule applies if your enricher needs data from a scoped service, such as a per-request tenant provider. Don't inject that service into the enricher's constructor: a singleton holding a scoped service is a captive dependency, and depending on scope validation it fails at startup or keeps a stale instance. Inject IHttpContextAccessor instead and resolve the scoped service inside Enrich from HttpContext.RequestServices, returning early when HttpContext is null.

The same lookup explains the main limitation. Logs written outside an HTTP request, such as in a background worker, have no HttpContext, so no TenantId is added. Also, the claim is only available after authentication has run, so events written earlier in the pipeline won't carry it. The request-completion line written by UseSerilogRequestLogging() is written after the rest of the pipeline has run, so it normally will.

For settings such as sink URLs and level overrides, appsettings.json keeps them out of code:

json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft.AspNetCore": "Warning",
        "System": "Warning"
      }
    }
  },
  "Seq": {
    "ServerUrl": "http://seq.internal:5341"
  }
}

Here Seq sits at the root of the configuration because this setup takes a hybrid approach: the sink is wired in C# code and only its URL is read with Configuration["Seq:ServerUrl"]. That is a design choice, not a Serilog limitation.

If you prefer a fully declarative setup with no sink code in C#, you can define the sink in the Serilog section instead, as an entry in its WriteTo array with "Name": "Seq" and the server URL under Args. In that case, remove the WriteTo lines from Program.cs, including the console sink if you also declare it in configuration, so events aren't sent twice.

Keeping these values in configuration means you can point staging and production at different Seq or Elasticsearch instances, for example through appsettings.Production.json or environment variables, without recompiling. You can also change log verbosity without a code change. Whether a level change takes effect without a restart depends on how configuration reloading is set up in your app, so test that before relying on it during an incident.

To confirm everything is wired up, send a request to any endpoint with your own ID, for example curl -i -H "X-Correlation-ID: test-123" http://localhost:5000/your-endpoint. The response should include the same X-Correlation-ID header. In Seq, filtering with CorrelationId = 'test-123' should return every log event written while that request was processed, including the request-completion line.

When to Use It

This level of architecture is justified once you have more than one service that needs correlated logs, or once a single service generates enough log volume that plain-text files stop being searchable in a reasonable time. A solo developer running one small internal tool probably doesn't need Seq, custom enrichers and correlation middleware. A console sink and a rolling file sink cover that case fine.

It becomes clearly worth the investment in a few realistic scenarios:

  • An e-commerce platform where an order touches an API gateway, an inventory service and a payment service, and a failed payment needs to be traced through all three.
  • A multi-tenant SaaS product where support needs to filter logs by customer. Note that filtering by a TenantId property is a convenience, not access control, so restrict who can query the log store separately.
  • Any system where background jobs (queue consumers, scheduled tasks) process work asynchronously from the original HTTP request, making the correlation ID the only thread connecting cause and effect.

If you already use OpenTelemetry or W3C trace context for distributed tracing, avoid running a second, parallel identifier. ASP.NET Core and HttpClient propagate W3C trace context by default, and recent Serilog versions attach the current trace and span IDs to log events when an Activity is active. Whether those IDs are indexed depends on the sink. In that case, you may not need the custom middleware above at all; you can use the trace ID as your correlation ID.

The integration details depend on the packages you use, so verify them against the current Serilog and OpenTelemetry .NET documentation before committing to an approach.

Trade-offs

Centralized structured logging isn't free. Sinks like Seq or Elasticsearch add infrastructure you now have to run, monitor and pay for, either in hosting cost or in operational time.

Network sinks also introduce a failure mode that local file logging doesn't have. If the log server is unreachable, the sink needs buffering or batching to avoid either blocking the application or silently dropping events. Most production-grade sinks handle this with an internal batching and retry mechanism, but the exact guarantees vary by sink implementation. Check the specific sink's documentation rather than assuming lossless delivery, and consider keeping a local file sink as a fallback.

Enrichers add a small amount of per-event overhead, since each one runs on every event that passes the minimum-level check. In practice this is usually negligible, unless an enricher does something expensive such as a database lookup on every log call. Avoid that entirely: enrichers should read from already-available context, not perform I/O.

Correlation ID propagation across HTTP is straightforward, but across message queues it requires discipline. Every producer must remember to attach the correlation ID to message metadata, and every consumer must read it back out and push it into LogContext before processing.

Nothing enforces this the way middleware enforces it for HTTP, so it tends to degrade over time. Wrapping it in a shared library or base class that all message handlers use keeps it consistent.

Common Mistakes

The most common mistake is logging with string interpolation instead of message templates. Writing _logger.LogInformation($"Order {orderId} shipped") instead of _logger.LogInformation("Order {OrderId} shipped", orderId) collapses everything into a flat string before Serilog ever sees it. That throws away the structured property and defeats the point of structured logging. The .NET analyzer rule CA2254 can flag this pattern.

Another frequent problem is forgetting Enrich.FromLogContext() in the logger configuration. Without it, any property pushed via LogContext.PushProperty, not just the correlation ID, is silently dropped, and nobody notices until they go looking for a value that isn't there.

Logging sensitive data as structured properties is a real risk that's easy to overlook, since structured fields feel less "visible" than a formatted string. Passwords, tokens and personally identifiable information (PII, data that can identify a specific individual) can end up as searchable, indexed properties in a centralized log store.

Be especially careful with the @ destructuring operator, as in "{@Order}", which logs an object's public properties and can capture more than you intended. Build a redaction or filtering strategy in early rather than retrofitting it later. If your logs cross a network, also use HTTPS and an API key on the log server rather than sending events unauthenticated.

Finally, teams sometimes configure a single minimum level across the entire application. They then either drown in noise from framework internals or miss important application-level events because the level was set too high to save on volume. Namespace-level overrides, as shown in the configuration examples above, solve this without an all-or-nothing trade-off.

Conclusion

A well-designed structured logging architecture for ASP.NET Core with Serilog treats logs as queryable data, not scrolling text. That shift is what makes sinks, enrichers and correlation IDs worth the setup effort.

The core pattern is consistent. Enrich every event with contextual properties automatically. Propagate a validated correlation ID through HTTP headers and message metadata so a request's full path across services can be reconstructed. Route enriched events to the sinks that fit each environment. Use message templates, confirm that LogContext enrichment is actually wired up, and be deliberate about what sensitive data ends up in your logs.

As a next step, if your system already touches distributed tracing, look into aligning your correlation scheme with OpenTelemetry's trace and span ID conventions rather than maintaining two separate identifiers side by side.

Codingvila provides articles and blogs on web and software development for beginners as well as free Academic projects for final year students in Asp.Net, MVC, C#, Vb.Net, SQL Server, Angular Js, Android, PHP, Java, Python, Desktop Software Application and etc.

If you have any questions, contact us on info.codingvila@gmail.com