Structured Logging in .NET 10: From Console.WriteLine to Professional Log Systems with Serilog
Posted on: 4/25/2026 11:15:56 AM
Table of contents
- What is Structured Logging and Why Does It Matter?
- Setting Up Serilog in .NET 10
- Production appsettings.json Configuration
- Message Templates — The Art of Writing Proper Logs
- Correlation ID — Across Microservices
- Advanced Request Logging
- Sensitive Data Masking
- Sinks — Where Logs Are Sent
- OpenTelemetry Integration
- Production Best Practices
- Logging Architecture for Distributed Systems
- Structured Logging Deployment Checklist
- Conclusion
- References
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.
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"
]
}
}
| Override | Level | Reason |
|---|---|---|
Microsoft | Warning | Reduces noise from framework logs |
Microsoft.AspNetCore.Hosting.Diagnostics | Error | Request logging handled by Serilog |
Microsoft.EntityFrameworkCore.Database.Command | Warning | Only log SQL when issues occur |
System.Net.Http.HttpClient | Warning | HttpClient 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
| Rule | Good Example | Bad 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
| Sink | Use Case | Cost | Query |
|---|---|---|---|
| Console | Development, container stdout | Free | None |
| File (JSON) | Local backup, compliance | Free | jq, grep |
| Seq | Structured log server, team dev | Free tier (1 user) | SQL-like queries |
| OpenTelemetry | Unified observability (traces + logs) | Depends on backend | Grafana, Jaeger |
| Elasticsearch | ELK Stack, full-text search | Self-host or Elastic Cloud | Kibana, Lucene |
| Grafana Loki | Cost-effective log aggregation | Free self-host | LogQL |
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
| Item | Development | Production |
|---|---|---|
| Console Sink | ✅ Verbose | ⚠️ Information (container stdout) |
| File Sink | Optional | ✅ JSON rolling, 14 days |
| Centralized Sink | Seq localhost | ✅ Seq/Loki/Elasticsearch |
| Correlation ID | Optional | ✅ Required |
| Sensitive Masking | Optional | ✅ Required (GDPR/PCI) |
| MinimumLevel Override | Debug | ✅ Information + Overrides |
| SelfLog | ✅ Console.Error | ✅ Separate file |
| Async Sink | Not 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
- Serilog — Simple .NET logging with fully-structured events
- Structured Logging with Serilog in ASP.NET Core .NET 10 — codewithmukesh
- 5 Serilog Best Practices For Better Structured Logging — Milan Jovanović
- Microsoft.Extensions.Logging — Datalust/Seq
- Mastering Distributed Tracing with Serilog and Seq in .NET — DEV Community
ClickHouse — Real-Time Analytics Database for Large-Scale Systems
AWS Step Functions: Orchestrating Serverless Workflows for Distributed Systems
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.