Structured Logging in .NET with Serilog and Seq

A text log line says Order 48213 submitted by user 9918. A structured log event says the same thing, but it also carries OrderId = 48213 and UserId = 9918 as properties you can filter on, group by, and alert against. That difference is the whole argument for structured logging. Once your events are data, “show me everything user 9918 did in the five minutes before the error” is a query, not a grep across four servers.
Serilog is the de facto structured logger for .NET. Seq is a log server built around Serilog’s event model: a single binary or container, a web UI with a SQL-like query language, and ingestion over HTTP. Together they’re the fastest route from “we have logs” to “we can answer questions with logs”, which is why this pairing shows up in so many .NET shops. This post sets them up the way you’d want them in production, not just the way that gets a line on screen.
Running Seq
Locally, Docker:
# docker-compose.yml
services:
seq:
image: datalust/seq:latest
environment:
ACCEPT_EULA: "Y"
ports:
- "5341:80" # UI and ingestion on the host at http://localhost:5341
volumes:
- seq-data:/data
volumes:
seq-data:
Inside the container Seq listens on port 80 for both UI and ingestion, and on 5341 for ingestion only. Mapping host 5341 to container 80 gives you one address for everything on your machine. When another container needs to send logs, it addresses the service by name on the container network: http://seq:5341 or http://seq:80, not localhost. That mismatch is the most common reason a first setup shows nothing.
The free tier allows a single user and is fine for development and small teams. Seq is a single-node product by design; if you need horizontal scale or multi-tenancy, that’s the point to look at the alternatives in the last section.
Serilog in ASP.NET Core
Packages:
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Seq
Serilog.AspNetCore pulls in the console sink, the configuration reader and the hosting integration. The Seq sink is separate.
Program.cs
using Serilog;
using Serilog.Events;
// Bootstrap logger: catches failures that happen before configuration is read
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.Enrich.FromLogContext()
.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()
.Enrich.WithProperty("Application", "Orders.Api")
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName));
var app = builder.Build();
app.UseSerilogRequestLogging(); // one event per request, with timing and status
app.MapGet("/", () => "ok");
await app.RunAsync();
}
catch (Exception ex) when (ex is not HostAbortedException)
{
Log.Fatal(ex, "Host terminated unexpectedly");
}
finally
{
await Log.CloseAndFlushAsync();
}
Three things in there are easy to skip and each one costs you later.
The bootstrap logger exists because configuration binding, DI registration and builder.Build() can all throw, and without a logger already in place those exceptions go to stderr and nowhere else. CreateBootstrapLogger gets replaced by the full logger once UseSerilog runs.
UseSerilogRequestLogging replaces the five or six events ASP.NET Core emits per request with one: method, path, status code, elapsed milliseconds, as properties. It’s the single most useful event in the whole system, and it’s off by default. Quieten the framework’s own chatter with the Microsoft.AspNetCore override in configuration below.
CloseAndFlushAsync matters because the Seq sink batches. Events are buffered and shipped every couple of seconds; if the process exits without flushing, the last batch (which usually contains the reason it exited) is lost.
appsettings.json
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "Seq",
"Args": {
"serverUrl": "http://localhost:5341",
"apiKey": ""
}
}
]
}
}
Put the real server URL and API key in environment variables (Serilog__WriteTo__1__Args__serverUrl, and so on) or a secrets store; see centralised configuration with Vault for the latter. Never commit a Seq API key: anyone holding it can write events into your log stream, which is a nice way to hide an attack.
Writing events that are worth querying
Message templates, not interpolation
// Yes: OrderId and UserId become properties on the event
logger.LogInformation("Order {OrderId} submitted by {UserId}", order.Id, user.Id);
// No: a unique string per order, no properties, nothing to query
logger.LogInformation($"Order {order.Id} submitted by {user.Id}");
The interpolated version compiles, runs, and quietly destroys the value of the whole setup. Seq will group the first form into one message template with thousands of occurrences. The second form produces thousands of distinct templates. Turn on the Serilog analyzer (Serilog.Analyzer or the built-in CA2254 rule) so the build catches it.
Destructuring with @
{Order} calls ToString() on the object. {@Order} serialises its public properties into the event:
logger.LogInformation("Payment captured {@Payment}", new { payment.Id, payment.Amount, payment.Currency });
Project to an anonymous type rather than logging the whole entity. Logging {@Order} on an EF entity with navigation properties will serialise the customer, the customer’s orders, and so on until Serilog’s depth limit stops it, and it may well include a card number or an email you didn’t mean to ship to a log server. Decide what goes in the event at the call site.
For types you log everywhere, set the policy once:
.Destructure.ByTransforming<Customer>(c => new { c.Id, c.Tier })
Scopes and LogContext
Properties that apply to a block of work rather than one line go in a scope:
using (logger.BeginScope(new Dictionary<string, object> { ["TenantId"] = tenant.Id }))
{
// every event inside carries TenantId
}
In middleware or anywhere without an ILogger, LogContext.PushProperty does the same job. Enrich.FromLogContext() in the configuration is what makes either of these work; without it, scope properties are dropped silently.
Exceptions
catch (Exception ex)
{
logger.LogError(ex, "Failed to process order {OrderId}", order.Id);
throw;
}
Pass the exception as the first argument. Don’t put ex.Message in the template, and don’t catch, log and swallow in a service layer that the controller is also going to log; you end up with every failure recorded two or three times at different levels. Pick one boundary (usually the ASP.NET Core exception handler middleware) that logs unhandled exceptions, and let everything below it throw.
Correlation across services
A request that touches the API, a worker and a payments service produces events in three processes. You need one identifier that ties them together.
Since .NET 5, you already have it. ASP.NET Core starts an Activity for every incoming request following the W3C Trace Context standard, and HttpClient automatically adds the traceparent header to outgoing calls, so the downstream service continues the same trace. Serilog.AspNetCore (version 8 onwards) attaches TraceId and SpanId to every event when an activity is active. No middleware, no custom header, no DelegatingHandler. In Seq:
TraceId = '4bf92f3577b34da6a3ce929d0e0e4736'
returns every event from every service that took part in that request, in order.
You still need a hand-rolled correlation ID when the chain crosses something that doesn’t speak Trace Context: a message queue without propagation set up, a third-party webhook, a legacy service. Then the pattern is the one you’d expect. Read X-Correlation-ID from the incoming request or generate one, push it into LogContext, echo it on the response, and forward it on outgoing calls:
app.Use(async (ctx, next) =>
{
var correlationId = ctx.Request.Headers.TryGetValue("X-Correlation-ID", out var v) && !string.IsNullOrWhiteSpace(v)
? v.ToString()
: Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N");
ctx.Response.Headers["X-Correlation-ID"] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
await next();
});
Seeding it from the TraceId when there’s no incoming header means the two systems agree whenever both are present. For outgoing HttpClient calls registered through IHttpClientFactory, a DelegatingHandler that copies the header from IHttpContextAccessor keeps it moving; for queue messages, put it in the message headers and restore it into LogContext in the consumer. If you’re fronting services with a gateway, Ocelot can inject the header before the request reaches any of them.
Querying in Seq
Seq’s query language is close enough to SQL that you won’t need the docs for the first hour. The built-in properties start with @: @Timestamp, @Level, @Message, @Exception, @Properties. Everything else is a property your code attached.
Errors from one service:
Application = 'Orders.Api' and @Level = 'Error'
Slow requests, using the properties UseSerilogRequestLogging adds:
RequestPath is not null and Elapsed > 500
Error rate per hour as a chart:
select count(*) from stream where @Level = 'Error' group by time(1h)
Which endpoints are slowest, on average, this morning:
select mean(Elapsed) as AvgMs, count(*) as Hits
from stream
where RequestPath is not null and @Timestamp > Now() - 6h
group by RequestPath
order by AvgMs desc
Everything a user did around an error:
UserId = 9918 and @Timestamp > DateTime('2026-10-30T14:00Z') and @Timestamp < DateTime('2026-10-30T14:10Z')
Save the filters you keep typing as signals, which also act as the building blocks for dashboards and alerts.
Operating it
Levels without redeploys. Hand the Seq sink a LoggingLevelSwitch and it will accept level changes from the Seq server, per API key:
var levelSwitch = new LoggingLevelSwitch(LogEventLevel.Information);
// ...
.WriteTo.Seq("http://seq:5341", apiKey: key, controlLevelSwitch: levelSwitch)
Then in Seq, under the API key’s settings, drop the level to Debug for one service while you chase a bug and put it back after. If you’d rather that happened automatically when errors spike, that’s the idea behind the auto-logging service I built on Grafana and OpenTelemetry.
API keys per application. One key per service, each with its own minimum level and an Application property applied server-side. That way a misconfigured service can’t impersonate another one, and you can revoke a single key.
Retention. Seq keeps everything until you tell it otherwise. Set a retention policy on day one (say, 7 days for Debug and below, 30 days for everything else) or the disk fills up at the worst possible moment. Audit-grade retention belongs in an archive, not a log server.
Alerts. Alerts are built on signals. A good first three: any Fatal event; Error count above a threshold in a five-minute window; and the request logging event with StatusCode >= 500 above some rate. Route them to Slack or email and tune the thresholds after the first week of noise.
Seq, or something else?
Serilog is almost always the right logging library for .NET. Seq is a narrower recommendation, and it’s worth deciding this before you build dashboards on it:
- You already run Grafana with Loki or Tempo, or an OpenTelemetry collector. Send Serilog events through the OpenTelemetry sink and keep one observability stack instead of two.
- You need many teams, SSO, role-based access and horizontal scale. Seq is single-node. Look at Elastic, Datadog, or a hosted OpenTelemetry back end.
- Your logs are also your audit trail. A log server is not an audit store; events can be deleted, retention expires, and nobody signs them. Write audit records to an append-only table in the application database.
- You’re on a platform with first-class logging already (Azure Monitor, CloudWatch) and nobody on the team will log into another tool. The best log server is the one people open.
Troubleshooting
Nothing appears in Seq.
In order: is the URL reachable from where the app runs (container networking, see above)? Is the process exiting before the batch flushes (CloseAndFlush)? Is the API key’s minimum level filtering everything out? Turn on Serilog’s own diagnostics to see what the sink is complaining about:
Serilog.Debugging.SelfLog.Enable(Console.Error);
Events arrive but have no TraceId or scope properties.
Enrich.FromLogContext() is missing from the configuration, or you’re on an old Serilog.AspNetCore that doesn’t attach activity IDs.
Hundreds of distinct message templates for what should be one.
String interpolation somewhere. Search the codebase for LogInformation($" and friends, and turn on the analyzer.
The request log shows every static file and health check.
Filter them in UseSerilogRequestLogging options with GetLevel, returning Verbose for paths you don’t care about, or raise the level override for Microsoft.AspNetCore.Hosting.Diagnostics.
Seq’s disk is full. No retention policy. Add one, then check whether a Debug-level service is responsible for most of the volume.
Properties are there but every value is a string.
You’re logging {Amount} with a decimal.ToString() call somewhere upstream, or the value was pulled out of a header. Pass the typed value and Seq will treat it as a number for comparisons and aggregates.