ogging requires aligning the built-in ILogger abstraction with a structured provider, enforcing context enrichment, and routing events through non-blocking sinks. The following steps outline a battle-tested implementation path.
1. Adopt ILogger<T> with Dependency Injection
Never instantiate loggers manually. Use constructor injection with the generic ILogger<T> interface. This automatically scopes the logger to the class name, enabling precise filtering and category-based routing.
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger)
{
_logger = logger;
}
public async Task ProcessAsync(OrderRequest request)
{
_logger.LogInformation("Processing order {OrderId} for customer {CustomerId}",
request.OrderId, request.CustomerId);
// business logic
}
}
2. Implement Structured Logging with Serilog
While Microsoft.Extensions.Logging provides the abstraction, Serilog delivers the structured engine. It parses message templates at compile time, extracts named properties, and routes them to sinks without string concatenation.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, config) =>
{
config
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}")
.WriteTo.Seq("http://localhost:5341")
.WriteTo.File(
path: "logs/app-.log",
rollingInterval: RollingInterval.Day,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} | {Message:lj}{NewLine}{Exception}");
});
var app = builder.Build();
Hardcoded log levels defeat observability. Centralize filtering in appsettings.json to enable runtime adjustments without redeployment.
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.Seq", "Serilog.Sinks.File" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System.Net.Http": "Warning",
"MyApp.Domain": "Debug"
}
}
}
}
4. Enrich with Contextual Properties
Logs are useless without correlation. Attach request IDs, user contexts, and business identifiers using LogContext.PushProperty or middleware.
// Middleware for correlation ID
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
public CorrelationIdMiddleware(RequestDelegate next) => _next = next;
public async Task Invoke(HttpContext context, ILogger<CorrelationIdMiddleware> logger)
{
var correlationId = context.Request.Headers["X-Correlation-ID"].ToString()
?? Guid.NewGuid().ToString("N");
context.Response.Headers["X-Correlation-ID"] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await _next(context);
}
}
}
5. Route to Async/Buffered Sinks
Synchronous logging blocks request threads. Use buffered sinks with flush intervals to decouple application throughput from I/O latency.
.WriteTo.Async(a => a.File(
path: "logs/app-.log",
rollingInterval: RollingInterval.Day,
buffered: true,
flushToDiskInterval: TimeSpan.FromSeconds(15)
));
Architecture Decisions & Rationale
- Abstraction vs Implementation:
ILogger<T> remains the application boundary. Serilog is configured at the composition root. This preserves testability and allows sink swaps without modifying business logic.
- Message Templates Over Interpolation:
{OrderId} creates a discrete property. $"Order {OrderId}" creates a single string. Templates enable indexing, grouping, and alerting on specific fields.
- Async Buffering: Decouples logging I/O from request processing. Flush intervals balance durability against latency. For compliance-heavy workloads, synchronous fallback sinks with retry policies should be added.
- Context Enrichment: Correlation IDs, tenant IDs, and user claims must flow through
AsyncLocal or LogContext to maintain traceability across service boundaries.
Pitfall Guide
1. String Interpolation in Log Messages
Mistake: _logger.LogInformation($"Order {orderId} processed");
Impact: Destroys structured logging. The provider receives "Order 12345 processed" as a single string. Indexers cannot extract orderId for filtering or aggregation. Alerting on specific order patterns becomes impossible.
Best Practice: Always use message templates: _logger.LogInformation("Order {OrderId} processed", orderId);
2. Logging PII or Secrets
Mistake: Logging raw tokens, passwords, email addresses, or credit card fragments.
Impact: Compliance violations (GDPR, PCI-DSS), security exposure in log aggregators, and potential data breach liabilities.
Best Practice: Implement a custom ITextFormatter or use Serilog's Destructure.With<RedactionPolicy>() to mask sensitive fields. Never log raw authentication tokens or cryptographic material.
3. Synchronous Blocking Sinks Under Load
Mistake: Writing directly to disk or network sinks without buffering.
Impact: Thread pool starvation, increased p99 latency, and potential request timeouts during log volume spikes.
Best Practice: Wrap all file/network sinks in .WriteTo.Async(). Configure flushToDiskInterval and bufferSizeLimit. Monitor sink latency separately from application metrics.
4. Missing Correlation Context
Mistake: Logs lack request IDs, tenant context, or user identifiers.
Impact: Impossible to reconstruct execution flow across microservices or async operations. Debugging requires manual log grep across multiple files.
Best Practice: Inject correlation ID middleware. Use LogContext.PushProperty for business context. Ensure HTTP clients propagate headers (traceparent, correlation-id).
5. Over-Logging in Production
Mistake: Running LogLevel.Debug or Trace in production environments.
Impact: Exponential log volume growth, storage cost inflation, and signal-to-noise ratio degradation. Critical events get buried.
Best Practice: Default production to Information or Warning. Use dynamic configuration providers (Azure App Configuration, Consul) to enable debug logging per-request or per-tenant during incidents.
6. Ignoring Log Level Hierarchy & Override Rules
Mistake: Setting a single global log level without framework overrides.
Impact: Noisy framework logs (Entity Framework, HttpClient, ASP.NET Core routing) drown out application signals.
Best Practice: Use MinimumLevel.Override to silence noisy third-party categories. Keep application namespaces at higher verbosity for debugging.
7. Treating Logs as the Sole Observability Pillar
Mistake: Relying exclusively on logs for performance monitoring and alerting.
Impact: Logs are high-cardinality, expensive to query in real-time, and lack histogram/percentile capabilities.
Best Practice: Pair structured logging with OpenTelemetry metrics and distributed traces. Use logs for root-cause analysis, metrics for SLO tracking, and traces for latency breakdowns.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-throughput microservices | Async buffered Serilog + Seq/Datadog sink | Prevents thread pool starvation, enables fast property-based querying | Reduces storage by 40-60%, maintains p99 latency |
| Monolith with compliance requirements | Structured logging + synchronous fallback sink + PII redaction | Ensures audit trail durability while masking sensitive data | Increases storage cost 15-20% due to retention policies |
| Multi-tenant SaaS application | Tenant ID enrichment + per-tenant log routing + dynamic level override | Enables tenant isolation, debugging, and cost allocation | Moderate increase in ingestion complexity, lowers support MTTR by 50% |
| Legacy .NET Framework migration | Microsoft.Extensions.Logging abstraction + Serilog.Enrichers.Process | Maintains DI compatibility, enables gradual modernization without rewrite | Low migration cost, immediate observability improvement |
Configuration Template
appsettings.json
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.Seq", "Serilog.Sinks.File" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System.Net.Http": "Warning",
"MyApp": "Debug"
}
},
"Enrich": [ "FromLogContext", "WithMachineName", "WithEnvironmentName" ],
"WriteTo": [
{ "Name": "Console", "Args": { "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" } },
{
"Name": "Seq",
"Args": { "serverUrl": "http://seq:5341", "apiKey": "" }
},
{
"Name": "Async",
"Args": {
"configure": [
{
"Name": "File",
"Args": {
"path": "logs/app-.log",
"rollingInterval": "Day",
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} | {Message:lj}{NewLine}{Exception}",
"buffered": true,
"flushToDiskInterval": "00:00:15"
}
}
]
}
}
]
}
}
Program.cs (Minimal Setup)
using Serilog;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, config) =>
config.ReadFrom.Configuration(context.Configuration));
var app = builder.Build();
app.UseSerilogRequestLogging();
app.UseMiddleware<CorrelationIdMiddleware>();
app.MapGet("/test", (ILogger<Program> logger) =>
{
logger.LogInformation("Test endpoint invoked at {Timestamp}", DateTimeOffset.UtcNow);
return Results.Ok();
});
app.Run();
Quick Start Guide
- Install packages:
dotnet add package Serilog.AspNetCore Serilog.Sinks.Seq Serilog.Sinks.Console Serilog.Sinks.File
- Add configuration: Place the
appsettings.json template in your project root. Adjust sink URLs and API keys for your environment.
- Wire up in Program.cs: Replace
builder.Host.UseSerilog(...) with the template code. Ensure UseSerilogRequestLogging() and correlation middleware are registered before app.Run().
- Validate: Run the application, trigger a few requests, and verify structured properties appear in Seq or console output. Confirm no string-interpolated logs remain in your codebase using a regex search or Roslyn analyzer.