Structured Logging in .NET 10: From Console.WriteLine to Professional Log Systems with Serilog

Posted on: 4/25/2026 11:15:56 AM

In the world of microservices and distributed systems, logging is far more than Console.WriteLine(). Structured Logging is the foundation that enables faster debugging, precise tracing, and professional production operations. This article dives deep into implementing Structured Logging with Serilog in .NET 10 — from basic configuration to advanced patterns for distributed systems.

390M+Serilog NuGet downloads
< 3%Performance overhead when properly configured
50+Available sinks
10xQuery speed vs plain text logs

What is Structured Logging and Why Does It Matter?

Traditional unstructured logging stores everything as plain text strings. This works for small systems, but when you have 20 microservices each producing thousands of log lines per minute, grepping through text logs becomes a nightmare.

Structured Logging solves this by recording logs as structured events — each log entry is an object with named properties that can be queried, filtered, and aggregated just like database queries.

graph LR
    A["📝 Unstructured Log"] -->|"Plain text"| B["grep/regex"]
    B -->|"Slow, imprecise"| C["❌ Hard to debug"]

    D["📊 Structured Log"] -->|"JSON/Object"| E["Query Engine"]
    E -->|"Fast, precise"| F["✅ Efficient debugging"]

    style A fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style D fill:#e94560,stroke:#fff,color:#fff
    style C fill:#f8f9fa,stroke:#ff9800,color:#ff9800
    style F fill:#f8f9fa,stroke:#4CAF50,color:#4CAF50

Unstructured vs Structured Logging comparison

A Visual Example

Unstructured log (❌ Bad):

2026-04-25 10:30:15 INFO Processing order 12345 for customer john@example.com, total $99.50

Structured log (✅ Good):

{
  "Timestamp": "2026-04-25T10:30:15.123Z",
  "Level": "Information",
  "MessageTemplate": "Processing order {OrderId} for customer {Email}, total {Total}",
  "Properties": {
    "OrderId": 12345,
    "Email": "john@example.com",
    "Total": 99.50,
    "CorrelationId": "abc-123-def",
    "MachineName": "web-server-03"
  }
}

With structured logs, you can easily run queries like: SELECT * FROM logs WHERE OrderId = 12345 or WHERE Total > 100 AND Level = 'Error' — something impossible with plain text logs.

Setting Up Serilog in .NET 10

Serilog is the most popular structured logging library in the .NET ecosystem, with over 390 million NuGet downloads. It integrates seamlessly with Microsoft.Extensions.Logging and supports over 50 different sinks.

Install NuGet Packages

dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Thread
dotnet add package Serilog.Enrichers.Process
dotnet add package Serilog.Exceptions
dotnet add package Serilog.Sinks.Seq
dotnet add package Serilog.Sinks.OpenTelemetry
dotnet add package Serilog.Formatting.Compact

Two-Stage Bootstrap Pattern

The most important pattern when using Serilog in .NET 10 is Two-Stage Bootstrap — creating a bootstrap logger before the full configuration loads, ensuring every exception during startup is captured.

using Serilog;

// Stage 1: Bootstrap logger — catches errors before config loads
Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateBootstrapLogger();

try
{
    Log.Information("Starting server.");

    var builder = WebApplication.CreateBuilder(args);

    // Stage 2: Full configuration from appsettings.json
    builder.Services.AddSerilog((services, lc) => lc
        .ReadFrom.Configuration(builder.Configuration)
        .ReadFrom.Services(services));

    var app = builder.Build();

    app.UseSerilogRequestLogging();
    app.UseHttpsRedirection();

    app.MapGet("/", () => "Hello from Serilog!");

    app.Run();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Server terminated unexpectedly.");
}
finally
{
    Log.CloseAndFlush();
}

💡 Why Two-Stage?

If appsettings.json has a syntax error or a bad connection string, the application crashes during startup. Without the bootstrap logger, you won't see any log explaining why — just an exit code with no context.

Production appsettings.json Configuration

{
  "Serilog": {
    "Using": [
      "Serilog.Sinks.Console",
      "Serilog.Sinks.File",
      "Serilog.Sinks.Seq",
      "Serilog.Enrichers.Environment",
      "Serilog.Enrichers.Thread",
      "Serilog.Enrichers.Process",
      "Serilog.Exceptions"
    ],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "Microsoft.AspNetCore.Hosting.Diagnostics": "Error",
        "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
        "System.Net.Http.HttpClient": "Warning"
      }
    },
    "WriteTo": [
      { "Name": "Console" },
      {
        "Name": "File",
        "Args": {
          "path": "logs/log-.json",
          "rollingInterval": "Day",
          "rollOnFileSizeLimit": true,
          "fileSizeLimitBytes": 104857600,
          "retainedFileCountLimit": 14,
          "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact"
        }
      },
      {
        "Name": "Seq",
        "Args": { "serverUrl": "http://seq:5341" }
      }
    ],
    "Enrich": [
      "FromLogContext",
      "WithMachineName",
      "WithProcessId",
      "WithThreadId",
      "WithExceptionDetails"
    ]
  }
}
OverrideLevelReason
MicrosoftWarningReduces noise from framework logs
Microsoft.AspNetCore.Hosting.DiagnosticsErrorRequest logging handled by Serilog
Microsoft.EntityFrameworkCore.Database.CommandWarningOnly log SQL when issues occur
System.Net.Http.HttpClientWarningHttpClient logs every request — very noisy

Message Templates — The Art of Writing Proper Logs

Message Templates are the heart of Structured Logging. How you write log messages determines your ability to query and analyze them later.

// ✅ CORRECT — Message Template with named properties
logger.LogInformation(
    "Processing order {OrderId} for customer {Email}, total {Total:C}",
    orderId, email, total);

// ✅ CORRECT — Destructure object with @
logger.LogInformation("Order details: {@Order}", order);

// ❌ WRONG — String interpolation destroys structure
logger.LogInformation($"Processing order {orderId} for {email}");

// ❌ WRONG — Concatenation doesn't create properties
logger.LogInformation("Processing order " + orderId + " for " + email);

⚠️ Most Common Anti-pattern

Using $"..." (string interpolation) instead of message templates. With interpolation, Serilog receives a pre-formatted string and cannot extract OrderId or Email as separate properties — completely losing the benefit of structured logging.

Property Naming Conventions

RuleGood ExampleBad Example
PascalCase{OrderId}{orderId}, {order_id}
Specific, meaningful{CustomerEmail}{Email}, {E}
Avoid high cardinality{StatusCode}{RequestBody}
Use @ for objects{@Order}{Order} (only calls ToString)

Correlation ID — Across Microservices

In a microservices architecture, a single client request can traverse 5-10 services. A Correlation ID is the thread that ties together the entire journey of a request across all services.

sequenceDiagram
    participant Client
    participant Gateway as API Gateway
    participant OrderSvc as Order Service
    participant PaymentSvc as Payment Service
    participant NotifySvc as Notification Service

    Client->>Gateway: POST /orders
    Note over Gateway: Generate CorrelationId: abc-123
    Gateway->>OrderSvc: X-Correlation-Id: abc-123
    OrderSvc->>PaymentSvc: X-Correlation-Id: abc-123
    PaymentSvc->>NotifySvc: X-Correlation-Id: abc-123

    Note over Gateway,NotifySvc: All logs contain CorrelationId = abc-123
→ One query reveals the entire flow

Correlation ID flow across microservices

Correlation ID Middleware

using Serilog.Context;

public class CorrelationIdMiddleware(RequestDelegate next)
{
    private const string Header = "X-Correlation-Id";

    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId = context.Request.Headers[Header].FirstOrDefault()
            ?? Guid.NewGuid().ToString();

        context.Response.Headers[Header] = correlationId;

        using (LogContext.PushProperty("CorrelationId", correlationId))
        {
            await next(context);
        }
    }
}

Register the middleware in Program.cs:

app.UseMiddleware<CorrelationIdMiddleware>();
app.UseSerilogRequestLogging();

When calling downstream services via HttpClient, forward the Correlation ID:

public class CorrelationIdHandler(IHttpContextAccessor accessor) : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        if (accessor.HttpContext?.Request.Headers
            .TryGetValue("X-Correlation-Id", out var id) == true)
        {
            request.Headers.Add("X-Correlation-Id", id.ToString());
        }
        return base.SendAsync(request, ct);
    }
}

// Registration
builder.Services.AddTransient<CorrelationIdHandler>();
builder.Services.AddHttpClient("PaymentService")
    .AddHttpMessageHandler<CorrelationIdHandler>();

Advanced Request Logging

Serilog replaces ASP.NET Core's default request logging with a single log line per request instead of 3-4 lines from Microsoft's logger — significantly reducing noise.

app.UseSerilogRequestLogging(options =>
{
    options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
    {
        diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
        diagnosticContext.Set("UserAgent",
            httpContext.Request.Headers.UserAgent.ToString());
        diagnosticContext.Set("ClientIp",
            httpContext.Connection.RemoteIpAddress?.ToString());
    };

    // Health check endpoints → Verbose (hidden at Info level)
    // Slow requests (>500ms) → Warning
    options.GetLevel = (httpContext, elapsed, ex) =>
    {
        if (httpContext.Request.Path.StartsWithSegments("/health"))
            return Serilog.Events.LogEventLevel.Verbose;

        return elapsed > 500
            ? Serilog.Events.LogEventLevel.Warning
            : Serilog.Events.LogEventLevel.Information;
    };
});

Sensitive Data Masking

In production, logs must comply with GDPR, PCI-DSS, and other security regulations. Never log plaintext passwords, credit card numbers, or sensitive PII data.

builder.Services.AddSerilog((services, lc) => lc
    .ReadFrom.Configuration(builder.Configuration)
    .Destructure.ByTransforming<UserLoginRequest>(r =>
        new { r.Username, Password = "***REDACTED***" })
    .Destructure.ByTransforming<PaymentRequest>(r =>
        new {
            r.OrderId,
            CardNumber = MaskCard(r.CardNumber),
            r.Amount
        }));

static string MaskCard(string card)
    => card.Length > 4 ? $"****-****-****-{card[^4..]}" : "****";

Sinks — Where Logs Are Sent

graph TD
    A["🖥️ .NET Application"] --> B["Serilog Pipeline"]
    B --> C["Console Sink
Development"] B --> D["File Sink
JSON rolling"] B --> E["Seq Sink
Query & Dashboard"] B --> F["OpenTelemetry Sink
Grafana/Jaeger"] B --> G["Elasticsearch Sink
ELK Stack"] style A fill:#e94560,stroke:#fff,color:#fff style B fill:#2c3e50,stroke:#fff,color:#fff style C fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style D fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style E fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style F fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style G fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50

Serilog Sink Pipeline — Log events routed to multiple destinations

SinkUse CaseCostQuery
ConsoleDevelopment, container stdoutFreeNone
File (JSON)Local backup, complianceFreejq, grep
SeqStructured log server, team devFree tier (1 user)SQL-like queries
OpenTelemetryUnified observability (traces + logs)Depends on backendGrafana, Jaeger
ElasticsearchELK Stack, full-text searchSelf-host or Elastic CloudKibana, Lucene
Grafana LokiCost-effective log aggregationFree self-hostLogQL

Seq — Free Log Server for Small Teams

Seq is a structured log server designed specifically for Serilog. The free tier allows 1 user, sufficient for personal projects or small teams.

# docker-compose.yml
services:
  seq:
    image: datalust/seq:latest
    container_name: seq
    environment:
      - ACCEPT_EULA=Y
    ports:
      - "5341:5341"   # Ingestion API
      - "8081:80"     # Web UI
    volumes:
      - seq-data:/data

volumes:
  seq-data:

After running docker compose up -d, access http://localhost:8081 to view the dashboard. All logs sent to Seq can be queried using SQL-like syntax: CorrelationId = 'abc-123' AND Level = 'Error'.

OpenTelemetry Integration

Serilog can export logs to an OpenTelemetry Collector, enabling correlation of logs with traces and metrics in a unified observability system.

{
  "WriteTo": [
    {
      "Name": "OpenTelemetry",
      "Args": {
        "endpoint": "http://otel-collector:4317",
        "protocol": "Grpc",
        "resourceAttributes": {
          "service.name": "order-service",
          "service.version": "1.0.0",
          "deployment.environment": "production"
        }
      }
    }
  ]
}

💡 Log + Trace Correlation

When using both the Serilog OpenTelemetry Sink and AddOpenTelemetry() for tracing, TraceId and SpanId are automatically attached to every log event. In Grafana, you can click from a log entry to jump directly to the corresponding trace — making debugging dramatically faster.

Production Best Practices

1. Use Async Sinks

// Console sink can block threads when stdout is slow
// In production containers, use Async wrapper
.WriteTo.Async(a => a.Console())

2. Sampling for High-Throughput

// Only log 10% of requests at Information level
// Keep 100% of Warning and Error
.Filter.ByExcluding(logEvent =>
    logEvent.Level == LogEventLevel.Information
    && Random.Shared.NextDouble() > 0.1)

3. Control Log Volume

⚠️ Log Storm Protection

A bug causing an exception loop can generate millions of log entries in minutes, crashing your log server and incurring massive storage costs. Always set fileSizeLimitBytes and retainedFileCountLimit for the File sink, and use rate limiting at the Seq/Elasticsearch level.

4. Don't Log in Hot Paths

// ❌ Logging in tight loop — hurts performance
foreach (var item in millionItems)
{
    logger.LogDebug("Processing item {ItemId}", item.Id);
}

// ✅ Log aggregated summary
logger.LogInformation(
    "Processed {Count} items in {Elapsed}ms",
    millionItems.Count, stopwatch.ElapsedMilliseconds);

5. Debug Serilog Configuration

// Enable self-log when Serilog encounters internal errors
Serilog.Debugging.SelfLog.Enable(Console.Error);

Logging Architecture for Distributed Systems

graph TB
    subgraph Services["Microservices"]
        S1["Order Service
Serilog"] S2["Payment Service
Serilog"] S3["Notification Service
Serilog"] end subgraph Pipeline["Log Pipeline"] OC["OpenTelemetry
Collector"] end subgraph Storage["Storage & Query"] Loki["Grafana Loki
Log Storage"] Tempo["Grafana Tempo
Trace Storage"] Grafana["Grafana
Dashboard"] end S1 -->|OTLP gRPC| OC S2 -->|OTLP gRPC| OC S3 -->|OTLP gRPC| OC OC --> Loki OC --> Tempo Loki --> Grafana Tempo --> Grafana style S1 fill:#e94560,stroke:#fff,color:#fff style S2 fill:#e94560,stroke:#fff,color:#fff style S3 fill:#e94560,stroke:#fff,color:#fff style OC fill:#2c3e50,stroke:#fff,color:#fff style Loki fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style Tempo fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style Grafana fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50

Centralized Logging architecture with OpenTelemetry Collector and Grafana Stack

Structured Logging Deployment Checklist

ItemDevelopmentProduction
Console Sink✅ Verbose⚠️ Information (container stdout)
File SinkOptional✅ JSON rolling, 14 days
Centralized SinkSeq localhost✅ Seq/Loki/Elasticsearch
Correlation IDOptional✅ Required
Sensitive MaskingOptional✅ Required (GDPR/PCI)
MinimumLevel OverrideDebug✅ Information + Overrides
SelfLog✅ Console.Error✅ Separate file
Async SinkNot needed✅ Prevents blocking

Conclusion

Structured Logging is not just "better logging" — it's the foundation of observability in modern distributed systems. With Serilog and .NET 10, implementing structured logging is easier than ever: configure via appsettings.json, integrate natively with OpenTelemetry, and choose from dozens of sinks for any system scale.

Start with three steps: (1) replace all string interpolation with message templates, (2) add Correlation ID middleware, and (3) connect to a centralized log server. These three changes alone will dramatically improve your debugging and production operations capabilities.

References