Skip to main content
SEMastery
DevOpsbeginner

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.

15 min readUpdated February 1, 2026

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

Install
Configure
Sinks
Log
View

Steps

1

Install

Add the Serilog NuGet packages

2

Configure

Wire Serilog into Program.cs

3

Sinks

Pick where logs go: console, file, Seq

4

Log

Write structured messages in your code

5

View

Search and filter by named fields

Five small steps from an empty project to clean, searchable logs.

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.

Figure 1: Plain text logs flatten everything into a string. Structured logs keep each value as a named field you can query.

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.Async

That 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.Configuration reads Serilog settings from appsettings.json. This is the recommended source, because you can change logging without rebuilding the app.
  • ReadFrom.Services lets enrichers and sinks registered in dependency injection take part.
  • Enrich.FromLogContext allows per-request properties (like a correlation ID) to attach to every log. We will use this soon.
  • WriteTo.Console sends logs to your terminal so you can see them right away.
  • UseSerilogRequestLogging adds a tidy summary line for each HTTP request.

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.

Figure 2: The bootstrap logger guards early startup, then hands over to the full logger once the host is ready.

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.

SinkGood forNotes
ConsoleLocal developmentEasy to read, included by default
File (rolling)Simple servers, audit trailsRolls daily so files stay small
SeqSearching and dashboardsGreat UI for structured fields
Elasticsearch / OpenSearchLarge scale searchOften paired with Kibana
Standard output (JSON)Containers and KubernetesPlatform 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.

Figure 3: A single structured log can travel to many sinks at the same time.

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:

FieldValue
MessageOrder 42 shipped to Pune
OrderId42
CityPune
LevelInformation
Timestamp2026-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

Request
Middleware
Handler
Summary

Steps

1

Request

Client calls your API

2

Middleware

Serilog starts a timer

3

Handler

Your endpoint runs

4

Summary

One line: path, status, time

One request in, one clean structured summary line out.

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:

LevelWhen to use it
VerboseDeep tracing, rarely on in production
DebugDeveloper details while diagnosing
InformationNormal events worth recording
WarningSomething odd but not broken
ErrorA failure that needs attention
FatalThe 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 UseSerilogRequestLogging too late. It then misses part of the request. Put it early.
  • Forgetting Log.CloseAndFlush(). Buffered logs can be lost on shutdown.
  • Leaving Debug on 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

Request
Correlation
Template
RequestLog
Sinks
Searchable

Steps

1

Request

Client hits your API

2

Correlation

One ID pushed for the request

3

Template

Safe named fields captured

4

RequestLog

Clean summary line written

5

Sinks

Console, file, Seq, cloud

6

Searchable

Filter by any named field

From a single HTTP request to a stored, searchable, structured event.

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.AspNetCore package; it brings the core library, the console and file sinks, and request logging.
  • Configure Serilog in Program.cs with AddSerilog, and prefer the two-stage setup with CreateBootstrapLogger() 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 LogContext so 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 call Log.CloseAndFlush() on shutdown.
  • Always inject the standard ILogger<T>; let Serilog do the work underneath.

References and further reading

Related Posts