Skip to main content

Serilog .NET Tutorial: Structured Logging Best Practices

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 a finally block 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: Day and retainedFileCountLimit. 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.Metrics for 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 always CloseAndFlush() in a finally.
  • Drive configuration from appsettings.json, and use MinimumLevel.Override to silence chatty framework namespaces.
  • Push correlation IDs with LogContext so one query reconstructs an entire request.
  • Inject ILogger<T> in application code; reserve static Log for startup.
  • Redact secrets with an enricher, and give network sinks an async wrapper plus a durable buffer.
  • Enable SelfLog in 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.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...