Learn Serilog structured logging in .NET with runnable C# examples, sinks, enrichers, and production best practices. Start logging smarter today.
If you have ever opened a 400 MB text log file at 2 a.m. trying to figure out why one customer's checkout failed, you already understand why Serilog .NET exists. Traditional logging writes sentences. Serilog writes data. That single difference — structured logging instead of string concatenation — is what turns your logs from a wall of text into a queryable database of everything your application did in production.
In this tutorial you will learn how to set up Serilog in a .NET 8/9 application, configure sinks and enrichers, use scopes and correlation IDs, and apply the production best practices that separate a hobby project from a system you can actually operate. Every example is runnable.
What Is Structured Logging (and Why String Logs Fail)
Consider the classic approach:
// The old way — information is destroyed at write time
logger.LogInformation($"Order {orderId} for customer {customerId} failed after {elapsed}ms");
The output is a string: Order 8812 for customer 4471 failed after 2310ms. To answer "show me every order that took longer than 2 seconds", you now need a regular expression. And if someone changes the wording of that message, your regex silently breaks.
Structured logging keeps the message template and the values separate:
// The Serilog way — values stay first-class
logger.LogInformation("Order {OrderId} for customer {CustomerId} failed after {ElapsedMs}ms",
orderId, customerId, elapsed);
Serilog renders a human-readable line for your console and emits a JSON event where OrderId, CustomerId and ElapsedMs are typed properties. In Seq, Elasticsearch, or Azure Application Insights you can now write ElapsedMs > 2000 and get an exact answer in milliseconds.
This is the "why" that most tutorials skip: structured logging in C# is not about prettier output. It is about making your logs machine-queryable so that debugging becomes a search problem rather than a reading problem.
Message Templates Are Not String Interpolation
The single most common Serilog mistake is using $"..." interpolation. It compiles, it runs, and it quietly destroys every benefit you just paid for. Always pass values as arguments. Install the Serilog.Analyzers or enable .NET's built-in CA2254 analyzer rule to catch this at build time.
Getting Started: Serilog .NET Setup in ASP.NET Core
Install the core packages:
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Seq
Serilog.AspNetCore is a meta-package that pulls in the console and file sinks plus the hosting integration, so it is the only dependency most web apps need to start.
Now wire it up in Program.cs. The pattern below is the recommended two-stage bootstrap, and it matters more than it looks:
using Serilog;
// Stage 1: a bootstrap logger so startup crashes are never silent
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
// Stage 2: the real logger, built from configuration + DI
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.WriteTo.Console()
.WriteTo.Seq("http://localhost:5341"));
builder.Services.AddControllers();
var app = builder.Build();
app.UseSerilogRequestLogging();
app.MapControllers();
Log.Information("Application starting up");
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
Three details deserve attention:
CreateBootstrapLogger()gives you a logger before the DI container exists. Without it, an exception thrown while reading configuration produces a silent process exit — the worst possible failure mode in a container.Log.CloseAndFlush()in afinallyblock is mandatory. Serilog's file, Seq, and Elasticsearch sinks batch writes in the background. Skip the flush and you lose the last few seconds of logs, which is exactly the window containing the crash you are investigating.UseSerilogRequestLogging()replaces roughly six noisy ASP.NET Core log lines per request with one rich summary event containing path, status code, and elapsed time.
Configuring Serilog from appsettings.json
Hardcoded configuration means a redeploy every time you want to raise the log level in production. Use appsettings.json instead (requires Serilog.Settings.Configuration, already included in Serilog.AspNetCore):
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "File",
"Args": {
"path": "logs/app-.json",
"rollingInterval": "Day",
"retainedFileCountLimit": 14,
"formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact"
}
}
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ],
"Properties": {
"Application": "CSharpCoder.Api"
}
}
}
The MinimumLevel.Override section is the highest-value block in this file. Framework namespaces are extraordinarily chatty at Information level — EF Core logs every SQL statement, and HttpClient logs every request twice. Overriding them to Warning typically cuts log volume by 80–90% while keeping your own application logs at full fidelity. Since most hosted log platforms bill by ingested volume, this one change has a direct line to your monthly invoice.
Sinks: Where Your Logs Actually Go
A sink is a destination. Serilog ships over 200 of them, but production systems converge on a small set:
- Console — required in containers. Docker, Kubernetes, and cloud log collectors read stdout. In production, write JSON to the console rather than human-formatted text so the collector can parse it.
- File — with
rollingInterval: DayandretainedFileCountLimit. Without a retention limit, you will eventually fill the disk and take the application down with it. - Seq — the best developer experience for querying structured logs locally; free for single-user development.
- OpenTelemetry — the vendor-neutral choice via
Serilog.Sinks.OpenTelemetry, routing to Grafana Loki, Honeycomb, Datadog, or Azure Monitor without rewriting your code. - ApplicationInsights / Elasticsearch / Datadog — direct vendor sinks when you are already committed to a platform.
Environment-Specific Sink Configuration
Developers want colored console output; production wants JSON. Handle both with appsettings.Production.json, or in code:
builder.Host.UseSerilog((context, services, configuration) =>
{
configuration
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext();
if (context.HostingEnvironment.IsDevelopment())
{
configuration.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}");
}
else
{
// Machine-readable for log collectors
configuration.WriteTo.Console(new Serilog.Formatting.Compact.CompactJsonFormatter());
}
});
Enrichment and Correlation: The Real Payoff
Logging a single event is easy. Answering "what happened during this user's request across four services" requires enrichment.
LogContext attaches properties to every log event written inside a scope — including events from code you did not write, such as EF Core or a third-party library:
using Serilog.Context;
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger) => _logger = logger;
public async Task<Result> PlaceOrderAsync(Guid orderId, Guid customerId)
{
// Every event inside this block carries OrderId and CustomerId
using (LogContext.PushProperty("OrderId", orderId))
using (LogContext.PushProperty("CustomerId", customerId))
{
_logger.LogInformation("Validating order");
try
{
await _repository.SaveAsync(orderId);
_logger.LogInformation("Order persisted");
return Result.Success();
}
catch (DbUpdateException ex)
{
// Pass the exception as the FIRST argument, never in the template
_logger.LogError(ex, "Failed to persist order");
return Result.Failure("Could not save order");
}
}
}
}
Note the exception handling: LogError(ex, "message") preserves the full exception object — type, message, stack trace, and inner exceptions — as structured data. Writing LogError("Failed: {Error}", ex.Message) throws away the stack trace, which is the only part you actually needed.
For distributed systems, add a middleware that pulls the trace identifier into every event:
public class CorrelationMiddleware
{
private readonly RequestDelegate _next;
public CorrelationMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? context.TraceIdentifier;
context.Response.Headers["X-Correlation-ID"] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await _next(context);
}
}
}
Now a single Seq query — CorrelationId = 'abc-123' — returns the complete story of one request across every service that forwarded the header.
Destructuring Complex Objects
The @ operator serializes an object's structure instead of calling ToString():
var order = new { Id = 8812, Total = 149.99m, Items = 3 };
_logger.LogInformation("Order received {@Order}", order); // full JSON structure
_logger.LogInformation("Order received {Order}", order); // ToString() only
// Use $ to force stringification of a type that would serialize badly
_logger.LogInformation("Key {$CacheKey}", cacheKey);
Be deliberate here. Destructuring an EF Core entity can serialize half your object graph through lazy-loaded navigation properties, producing megabyte log events and unexpected database queries. Project to a small anonymous type or DTO first, and configure limits globally:
new LoggerConfiguration()
.Destructure.ToMaximumDepth(3)
.Destructure.ToMaximumStringLength(1000)
.Destructure.ToMaximumCollectionCount(10)
.CreateLogger();
Serilog Best Practices for Production Applications
These are the practices that matter once real traffic arrives.
1. Inject ILogger<T>, Not the Static Log Class
Use the Microsoft ILogger<T> abstraction in your application code. Serilog sits behind it as the provider. Your business logic stays testable and framework-agnostic; the static Log class is reserved for Program.cs startup and shutdown, where DI is not yet available.
2. Never Log Secrets or Personal Data
Logs are frequently the least-protected copy of your data and are often shipped to third-party vendors. Passwords, tokens, full card numbers, and government IDs must never reach a sink. GDPR, UK GDPR, CCPA, Australia's Privacy Act, and India's DPDP Act all treat log files as in-scope personal data storage. Enforce it in code rather than in code review:
public class RedactPasswordEnricher : ILogEventEnricher
{
private static readonly string[] Sensitive =
{ "Password", "Token", "ApiKey", "Secret", "Ssn", "CardNumber" };
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory factory)
{
foreach (var key in Sensitive)
{
if (logEvent.Properties.ContainsKey(key))
{
logEvent.AddOrUpdateProperty(
new LogEventProperty(key, new ScalarValue("***REDACTED***")));
}
}
}
}
// Registration
configuration.Enrich.With<RedactPasswordEnricher>();
3. Use Log Levels Consistently
Level discipline is what makes alerting possible. A pragmatic convention:
- Verbose / Trace — developer-only detail; never enabled in production.
- Debug — diagnostic detail, enabled temporarily to investigate an incident.
- Information — business milestones: order placed, payment captured, user registered.
- Warning — recoverable anomalies: a retry succeeded, a cache missed, input was rejected.
- Error — an operation failed and a user is affected. Someone should see this.
- Fatal — the application cannot continue. Page someone.
The rule of thumb: if Error events do not wake somebody up, you have misclassified something as Error that is really a Warning.
4. Guard Expensive Logging Calls
Serilog is fast, but it cannot un-evaluate arguments you already computed:
// Bad: ExpensiveSerialize() runs even when Debug is disabled
_logger.LogDebug("State: {State}", ExpensiveSerialize(state));
// Good: check first
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("State: {State}", ExpensiveSerialize(state));
}
Reserve this for genuinely costly arguments. Wrapping every call in a guard adds noise for no measurable benefit.
5. Make Sinks Fail Safely
A network sink that hangs must never hang your application. Use async and buffering, and always configure a durable fallback:
// Requires Serilog.Sinks.Async
configuration
.WriteTo.Async(a => a.File("logs/app-.json", rollingInterval: RollingInterval.Day))
.WriteTo.Seq("https://seq.internal:5341",
apiKey: seqApiKey,
// Buffer to disk if Seq is unreachable, then replay
bufferBaseFilename: "logs/seq-buffer");
// And surface Serilog's own failures somewhere visible
Serilog.Debugging.SelfLog.Enable(msg => Console.Error.WriteLine(msg));
SelfLog is how you discover that your log shipping has been silently broken for three weeks.
6. Sample High-Volume Events
At scale, logging every health check probe or cache hit is pure cost. Filter noise at the source:
// Requires Serilog.Expressions
configuration
.Filter.ByExcluding("RequestPath like '/health%'")
.Filter.ByExcluding("RequestPath like '/metrics%'");
Serilog vs NLog vs Microsoft.Extensions.Logging
A question worth settling once. Microsoft.Extensions.Logging is an abstraction, not an implementation — you still need a provider behind it, and its built-in console provider is not production-grade. Serilog and NLog are both mature implementations; NLog has excellent file-target performance and a long history, while Serilog was designed around structured events from day one, which shows in its message templates, enrichers, and the breadth of its sink ecosystem.
For a new .NET application in 2026, Serilog is the default recommendation: the strongest structured-logging model, first-class OpenTelemetry support, and by far the largest sink catalogue. Keep ILogger<T> in your application code either way, and swapping providers later becomes a Program.cs change rather than a rewrite.
Common Pitfalls to Avoid
- String interpolation in templates — destroys structure. Enable
CA2254. - Forgetting
Log.CloseAndFlush()— loses the final, most important events. - Inconsistent property names —
{UserId}in one file and{User_Id}in another makes cross-service queries impossible. Agree on PascalCase names and stick to them. - Logging inside tight loops — log once with an aggregate count instead of once per iteration.
- No retention policy — disks fill, and full disks cause outages.
- Logging and rethrowing — produces the same exception three times at three layers. Log where you handle, not where you catch and rethrow.
- Treating logs as metrics — counting log lines to measure throughput is expensive and imprecise. Use
System.Diagnostics.Metricsfor that.
Conclusion and Key Takeaways
Adopting Serilog .NET takes about fifteen minutes; using it well is a set of habits that pays off during your first production incident. Structured logging changes the fundamental question from "can I find the relevant line?" to "what do I want to ask my data?"
The essentials to carry forward:
- Pass values as message template arguments — never interpolate.
- Bootstrap with
CreateBootstrapLogger()and alwaysCloseAndFlush()in afinally. - Drive configuration from
appsettings.json, and useMinimumLevel.Overrideto silence chatty framework namespaces. - Push correlation IDs with
LogContextso one query reconstructs an entire request. - Inject
ILogger<T>in application code; reserve staticLogfor startup. - Redact secrets with an enricher, and give network sinks an async wrapper plus a durable buffer.
- Enable
SelfLogin production so broken log shipping cannot hide.
Start with the console and file sinks today, add Seq locally to feel the difference querying structured data makes, and reach for the OpenTelemetry sink when you are ready to centralize. Your future self — the one debugging at 2 a.m. — will thank you.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment