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.
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().
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:
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.
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:
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:
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:
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:
{
"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
TenantIdproperty 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.
