Structured Logging in ASP.NET Core with Serilog: A Beginner's Guide
A friendly, step-by-step guide to structured logging in ASP.NET Core with Serilog: setup, sinks, request logging, and viewing logs on .NET 10.
A shop register that you can actually search
Imagine you run a small shop. Every evening you write down what happened in a notebook: "Today Priya bought 2 kg rice and paid 120 rupees." That reads nicely. But when your uncle asks "how much rice did we sell this whole month?", you have to read every page and count by hand. Slow and painful.
Now imagine a smarter register. It has neat columns: Customer, Item, Weight, Amount, Time. The same facts, but now each value sits under a clear label. You can add up the Amount column in seconds, or find every row where Customer is "Priya." A machine can answer your questions instantly because the data has names.
That second register is structured. And structured logging is exactly this idea for your software. Instead of writing logs as one long sentence, you write them as data with named fields. Later, a log tool can search and filter them in a blink.
In ASP.NET Core, the most loved library for this is Serilog. It has been the go-to choice through .NET 8, .NET 9, and now .NET 10 (LTS). This guide walks you through it slowly, from zero to a working, searchable logging setup. No prior Serilog knowledge needed.
What we are building
Here is the whole journey at a glance. We will install Serilog, wire it into the app, send logs to one or more places, and learn to write good structured messages.
Your Serilog Journey
Steps
Install
Add the Serilog NuGet packages
Configure
Wire Serilog into Program.cs
Sinks
Pick where logs go: console, file, Seq
Log
Write structured messages in your code
View
Search and filter by named fields
Plain text logs versus structured logs
Before any code, let us see why this matters with one picture. A plain text log throws away the structure. A structured log keeps it.
With plain text, if you want every order shipped to Pune, you must scan text and hope the wording is always the same. With structured logs, you simply filter where City equals Pune. That is the whole reason structured logging exists.
Step 1: Install the packages
For an ASP.NET Core app, you mainly need one package: Serilog.AspNetCore. It already pulls in the core Serilog library, the console sink, the file sink, and the request logging middleware. On .NET 10 you would install version 10.
// Run these in your project folder (terminal):
// dotnet add package Serilog.AspNetCore
// Optional extra sinks you might add later:
// dotnet add package Serilog.Sinks.Seq
// dotnet add package Serilog.Sinks.AsyncThat single Serilog.AspNetCore package is enough to start. The others are for when you want to ship logs to a log server (Seq) or write to slow sinks on a background thread (Async). We will touch both later.
Step 2: Wire Serilog into your app
There are two ways to set up Serilog. We will start simple, then show the modern, recommended two-stage way.
The simple way
The simplest setup creates the logger and tells the host to use it. Add this near the top of Program.cs:
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Tell ASP.NET Core to use Serilog for all logging
builder.Services.AddSerilog((services, configuration) => configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.WriteTo.Console());
var app = builder.Build();
app.UseSerilogRequestLogging(); // one clean log line per request
app.MapGet("/", () => "Hello from Serilog!");
app.Run();A few words on each line, because every one earns its place:
ReadFrom.Configurationreads Serilog settings fromappsettings.json. This is the recommended source, because you can change logging without rebuilding the app.ReadFrom.Serviceslets enrichers and sinks registered in dependency injection take part.Enrich.FromLogContextallows per-request properties (like a correlation ID) to attach to every log. We will use this soon.WriteTo.Consolesends logs to your terminal so you can see them right away.UseSerilogRequestLoggingadds a tidy summary line for each HTTP request.
The recommended two-stage way
There is one problem with the simple setup. If something breaks very early during startup, for example a bad connection string or a missing config file, that crash can be swallowed silently with no log at all. That is scary in production.
The fix is two-stage initialization. You create a tiny "bootstrap" logger the moment the program starts. It catches early crashes. Then, once the host is built, the full logger replaces it.
using Serilog;
// Stage 1: a small bootstrap logger, alive from the very first line
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
Log.Information("Starting up the application");
var builder = WebApplication.CreateBuilder(args);
// Stage 2: the full logger, built from configuration and services
builder.Services.AddSerilog((services, lc) => lc
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.WriteTo.Console());
var app = builder.Build();
app.UseSerilogRequestLogging();
app.MapGet("/", () => "Hello!");
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application failed to start");
}
finally
{
Log.CloseAndFlush(); // make sure buffered logs are written before exit
}Notice two important details. First, CreateBootstrapLogger() is used instead of CreateLogger(). Second, the try/catch/finally wraps everything, so a startup crash is logged as Fatal and buffered logs are flushed on the way out. One small gotcha: the full logger fully replaces the bootstrap one, so if you want console output in both stages, you must write to the console in both.
Here is how the two stages flow.
Step 3: Choose your sinks
A sink is simply where logs go. The beauty of Serilog is that you can send the same log to several places at once. During development you want the console. In production you might also want a file and a log server.
Here is a quick map of common sinks and when to reach for each.
| Sink | Good for | Notes |
|---|---|---|
| Console | Local development | Easy to read, included by default |
| File (rolling) | Simple servers, audit trails | Rolls daily so files stay small |
| Seq | Searching and dashboards | Great UI for structured fields |
| Elasticsearch / OpenSearch | Large scale search | Often paired with Kibana |
| Standard output (JSON) | Containers and Kubernetes | Platform collects the logs |
The cleanest way to configure sinks is in appsettings.json, so you can change them without touching code. Here is a sample Serilog section that writes to both the console and a rolling daily file.
// appsettings.json (shown as text)
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "File",
"Args": {
"path": "logs/app-.log",
"rollingInterval": "Day"
}
}
],
"Enrich": [ "FromLogContext", "WithMachineName" ]
}
}Two things to point out. The Override block keeps the chatty framework logs at Warning while your own code logs at Information, which cuts noise and cost. The rollingInterval: Day makes a fresh file each day, so no single log file grows forever.
This picture shows one log fanning out to several sinks.
Step 4: Write structured logs in your code
Now the most important habit. Your normal classes should not call Serilog directly. Instead, inject the standard ILogger<T> from Microsoft.Extensions.Logging. Serilog does the real work underneath. This keeps your code clean and free from a hard dependency on one library.
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger)
{
_logger = logger;
}
public void Ship(int orderId, string city)
{
// Named holes: {OrderId} and {City} become real, searchable fields
_logger.LogInformation("Order {OrderId} shipped to {City}", orderId, city);
}
}The magic is in the message template. The holes {OrderId} and {City} are not just placeholders for text. Serilog stores each one as a separate named property. A stored event looks like this:
| Field | Value |
|---|---|
| Message | Order 42 shipped to Pune |
| OrderId | 42 |
| City | Pune |
| Level | Information |
| Timestamp | 2026-06-10T09:15:00Z |
Because OrderId and City are real fields, you can later ask your log tool: "show every event where OrderId is 42," or "count orders shipped to Pune." That is the payoff of all this setup.
Templates, not string interpolation
This is the one rule beginners get wrong, so it deserves a clear warning. Never use C# string interpolation for log messages.
// Bad: interpolation glues everything into one flat string
_logger.LogInformation($"Order {orderId} shipped to {city}");
// Good: template keeps OrderId and City as separate fields
_logger.LogInformation("Order {OrderId} shipped to {City}", orderId, city);With the bad version, C# builds the full string before Serilog ever sees it. The structure is gone forever, so you can never filter by OrderId. The good version keeps the fields. It is also faster, because if that log level is turned off, Serilog skips building the message entirely.
One handy extra: put a @ in front of a hole to capture an object's shape as fields instead of calling ToString() on it.
var summary = new { Items = 3, Total = 120m };
// @ keeps the object's properties as structured fields
_logger.LogInformation("Cart checked out {@Summary}", summary);Be careful, though. Do not capture whole objects that might hold secrets like passwords or tokens. Pick the safe fields on purpose.
Step 5: Add automatic request logging
ASP.NET Core, by default, writes several noisy lines for each request. Serilog can replace all that with one clean, structured summary line. You already added it in Step 2:
app.UseSerilogRequestLogging();This single line records the request path, the response status code, and how long the request took, all as structured fields. There is an important rule about where to place it: put it early in the pipeline, before MVC or endpoint handlers. The middleware can only time and log the components that come after it. If you place it too late, you miss part of the request.
Here is the lifecycle of one request with this middleware in place.
Request Logging Lifecycle
Steps
Request
Client calls your API
Middleware
Serilog starts a timer
Handler
Your endpoint runs
Summary
One line: path, status, time
You can also enrich the request log with extra fields, such as the user name or a tenant id, by customizing the EnrichDiagnosticContext option. That is a small step beyond this guide, but it is good to know it exists.
Tying logs of one request together
Here is a real-world scene. In a busy hospital, every patient gets a wristband with a unique number. Every test, note, and prescription is tagged with that number, so a doctor can later pull up the full story of one patient.
A web request is the patient. A correlation ID is the wristband. When a request arrives, you give it one ID, and every log written during that request carries the same ID. Later, you can trace one request from start to finish, even across many classes.
Serilog makes this easy with LogContext, which works because we called .Enrich.FromLogContext() during setup. A tiny middleware does the job:
using Serilog.Context;
app.Use(async (context, next) =>
{
// Reuse an incoming ID, or create a fresh one
var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? Guid.NewGuid().ToString();
// Every log inside this request now carries CorrelationId
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await next();
}
});Now when a customer reports a failed order, you find their request's CorrelationId and pull every single log for that exact journey. No more guessing which lines belong together.
Setting the right log level
The minimum level is the lowest severity Serilog keeps. Anything below it is dropped early, before it reaches any sink. This is your main control for log volume and cost. From least to most severe, the levels are:
| Level | When to use it |
|---|---|
| Verbose | Deep tracing, rarely on in production |
| Debug | Developer details while diagnosing |
| Information | Normal events worth recording |
| Warning | Something odd but not broken |
| Error | A failure that needs attention |
| Fatal | The app cannot continue |
A common production setup uses Information as the floor for your own code, with framework logs overridden to Warning. In development, you might drop to Debug to see more. Because we read this from appsettings.json, you can even have a different floor per environment without changing any code.
A note on slow sinks
Some sinks are slow. Writing to a file, a database, or over the network takes time. If that happens on the same thread serving your user, the user waits. The fix is Serilog.Sinks.Async, which buffers logs and writes them on a background thread.
using Serilog;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.Async(a => a.File("logs/app-.log", rollingInterval: RollingInterval.Day))
.CreateLogger();Your request drops the log into a buffer and moves on instantly. A background worker handles the slow write. Always remember to call Log.CloseAndFlush() on shutdown so buffered logs are not lost when the app exits. We already did this in the two-stage setup's finally block.
Common beginner mistakes
Even careful people hit the same traps. Keep this short list nearby:
- Using
$"..."interpolation. It destroys structure. Always use named holes like{OrderId}. - Logging whole objects with secrets. A
{@User}can leak a password. Log only safe fields. - Placing
UseSerilogRequestLoggingtoo late. It then misses part of the request. Put it early. - Forgetting
Log.CloseAndFlush(). Buffered logs can be lost on shutdown. - Leaving
Debugon in production. It floods storage and raises your bill. - Calling Serilog directly everywhere. Inject
ILogger<T>instead and stay loosely coupled.
How it all fits together
Let us connect the dots. A request comes in, gets a correlation ID, your code logs with safe templates, the framework adds context, request logging writes a clean summary, and your sinks store everything as searchable events.
The Full Picture
Steps
Request
Client hits your API
Correlation
One ID pushed for the request
Template
Safe named fields captured
RequestLog
Clean summary line written
Sinks
Console, file, Seq, cloud
Searchable
Filter by any named field
When all of these work together, your logs stop being a wall of text. They become a clean, searchable record that helps you find and fix problems fast. A new developer can join your team and answer "what happened to order 42?" in seconds, without reading a single page by hand. That is the quiet power of structured logging in ASP.NET Core with Serilog.
Quick recap
- Structured logging stores logs as data with named fields, like a shop register with neat columns, so you can search and filter instantly.
- Install the
Serilog.AspNetCorepackage; it brings the core library, the console and file sinks, and request logging. - Configure Serilog in
Program.cswithAddSerilog, and prefer the two-stage setup withCreateBootstrapLogger()so early startup crashes are not lost. - Sinks decide where logs go: console, rolling file, Seq, Elasticsearch, or cloud output. You can use several at once, ideally configured in
appsettings.json. - Log with templates, never string interpolation. Holes like
{OrderId}become real, searchable fields. Use@to capture an object's shape, but never capture secrets. - Add
UseSerilogRequestLogging()early for one clean summary line per request. - Use a correlation ID with
LogContextso all logs of one request stay tied together, like a hospital wristband. - Set a sensible minimum level, wrap slow sinks with
Serilog.Sinks.Async, and callLog.CloseAndFlush()on shutdown. - Always inject the standard
ILogger<T>; let Serilog do the work underneath.
References and further reading
- Serilog.AspNetCore — GitHub
- Serilog — official site
- Bootstrap logging with Serilog and ASP.NET Core — Nicholas Blumhardt
- Serilog.AspNetCore 10.0.0 — NuGet
- Structured Logging in ASP.NET Core with Serilog — Milan Jovanović
- Structured Logging with Serilog in ASP.NET Core — codewithmukesh
- ASP.NET Core logging with Serilog — .NET Blog (Microsoft)
Related Posts
5 Serilog Best Practices for Better Structured Logging in .NET
Learn 5 simple Serilog best practices for structured logging in .NET: message templates, enrichers, correlation IDs, hiding secrets, and async sinks.
Better Request Tracing with User Context in ASP.NET Core
Learn how to trace requests in ASP.NET Core by adding user context and correlation IDs to your logs using middleware, logging scopes, and Activity.
Building Resilient Cloud Applications With .NET
Learn to build resilient cloud apps in .NET with retries, timeouts, and circuit breakers using Polly and Microsoft.Extensions.Resilience.
Logging Best Practices in ASP.NET Core: A Beginner's Guide
Learn logging best practices in ASP.NET Core: log levels, structured logging, source-generated LoggerMessage, scopes, correlation IDs, and keeping secrets out.
Monitoring .NET Applications With OpenTelemetry and Grafana
A beginner-friendly guide to monitoring .NET apps with OpenTelemetry and Grafana. Send metrics, traces, and logs to Prometheus, Tempo, and Loki step by step.
Logging Requests and Responses for APIs and HttpClient in ASP.NET Core
Learn to log incoming API requests and outgoing HttpClient calls in ASP.NET Core using built-in HTTP logging and a custom DelegatingHandler, step by step.