Add Serilog in 10 Minutes — Logs Your Team Can Search
Replace the default ASP.NET Core logger with Serilog. Set up structured logging with sinks, enrichers, and request logging middleware so your production logs are actually searchable.
Why ILogger Alone Isn't Enough for Production
ASP.NET Core's built-in ILogger is fine for local output. In production it falls short:
- Console output is plain text — you can't query
where OrderId = 42 - No structured properties on log events
- No correlation IDs or user context automatically enriched
- No centralized log storage without extra plumbing
Serilog emits structured log events. Instead of a flat string, each event is a document with typed properties you can filter, aggregate, and alert on.
Install Packages
# Core + ASP.NET Core integration
dotnet add package Serilog.AspNetCore
# Sinks — where logs go
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Sinks.Seq # local dev structured log UI
# Enrichers — properties added to every event
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Thread
dotnet add package Serilog.Enrichers.ProcessMinimal Setup in Program.cs
using Serilog;
// Bootstrap logger — catches startup errors before full config loads
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
// Replace ASP.NET Core's logger with Serilog
builder.Host.UseSerilog((context, services, config) =>
{
config
.ReadFrom.Configuration(context.Configuration) // appsettings.json
.ReadFrom.Services(services) // inject registered services
.Enrich.FromLogContext() // ILogger.BeginScope() values
.WriteTo.Console();
});
// ... rest of Program.cs
var app = builder.Build();
// Log every HTTP request automatically
app.UseSerilogRequestLogging();
app.MapControllers();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application failed to start");
}
finally
{
await Log.CloseAndFlushAsync();
}Full appsettings.json Configuration
Move Serilog config out of code and into appsettings.json so you can change log levels without redeploying:
{
"Serilog": {
"Using": [
"Serilog.Sinks.Console",
"Serilog.Sinks.File",
"Serilog.Sinks.Seq"
],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} — {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/orderflow-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 30,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} — {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "Seq",
"Args": {
"serverUrl": "http://localhost:5341"
}
}
],
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithThreadId",
"WithProcessId"
],
"Properties": {
"Application": "OrderFlow.Api",
"Environment": "Development"
}
}
}// appsettings.Production.json — no Seq, no debug output
{
"Serilog": {
"MinimumLevel": {
"Default": "Warning",
"Override": {
"OrderFlow": "Information"
}
},
"WriteTo": [
{
"Name": "Console"
}
]
}
}Structured Logging — The Core Benefit
Never concatenate strings into log messages. Use message templates with named properties.
// BAD — flat string, can't query by OrderId
_logger.LogInformation($"Order {orderId} created for customer {customerId}");
// Stored as: "Order 42 created for customer 18"
// GOOD — structured event, properties are queryable
_logger.LogInformation(
"Order {OrderId} created for customer {CustomerId} with total {Total:C}",
orderId, customerId, total);
// Stored as: { OrderId: 42, CustomerId: 18, Total: 199.99 }
// Seq query: SELECT * WHERE OrderId = 42public class OrderService : IOrderService
{
private readonly ILogger<OrderService> _logger;
private readonly IOrderRepository _repo;
public OrderService(ILogger<OrderService> logger, IOrderRepository repo)
{
_logger = logger;
_repo = repo;
}
public async Task<OrderDto> CreateAsync(CreateOrderRequest req, CancellationToken ct)
{
_logger.LogInformation(
"Creating order for Customer {CustomerId} with {ItemCount} items",
req.CustomerId, req.Items.Count);
var order = Order.Create(req);
try
{
await _repo.AddAsync(order, ct);
_logger.LogInformation(
"Order {OrderId} persisted. Total: {Total:C}",
order.Id, order.Total);
return order.ToDto();
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to create order for Customer {CustomerId}",
req.CustomerId);
throw;
}
}
}Log Levels
_logger.LogTrace("Entering method. Parameter value: {Param}", param); // dev only
_logger.LogDebug("Cache miss for key {CacheKey}", key); // dev + staging
_logger.LogInformation("Order {OrderId} created", order.Id); // normal flow
_logger.LogWarning("Payment retry #{Attempt} for Order {OrderId}", n, id); // needs attention
_logger.LogError(ex, "Payment failed for Order {OrderId}", order.Id); // something broke
_logger.LogCritical(ex, "Database unreachable — service is degraded"); // wake someone upProduction rule of thumb:
- Default minimum:
Warning - Your own namespaces:
Information - EF Core queries:
Warning(unless debugging slow queries)
Enrichers — Properties on Every Log Event
// Program.cs — code-based enrichers
builder.Host.UseSerilog((ctx, svc, config) =>
{
config
.ReadFrom.Configuration(ctx.Configuration)
.ReadFrom.Services(svc)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithThreadId()
.Enrich.WithProperty("Application", "OrderFlow.Api")
.Enrich.WithProperty("Environment", ctx.HostingEnvironment.EnvironmentName)
.WriteTo.Console();
});Every log event now contains MachineName, ThreadId, Application, and Environment automatically. No per-call code needed.
Log Context — Per-Request Enrichment
Push values that apply to a block of code without passing them to every method:
public class OrdersController : ControllerBase
{
private readonly IOrderService _orders;
private readonly ILogger<OrdersController> _logger;
public OrdersController(IOrderService orders, ILogger<OrdersController> logger)
{
_orders = orders;
_logger = logger;
}
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrderRequest req,
CancellationToken ct)
{
// All logs within this scope include CustomerId and CorrelationId
using var scope = _logger.BeginScope(new Dictionary<string, object>
{
["CustomerId"] = req.CustomerId,
["CorrelationId"] = HttpContext.TraceIdentifier
});
_logger.LogInformation("Received order request");
var order = await _orders.CreateAsync(req, ct);
_logger.LogInformation("Order created successfully");
// Both of these include CustomerId and CorrelationId
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
}
}Serilog Request Logging Middleware
Replace the noisy default ASP.NET Core request logs with one structured event per request:
// Program.cs
app.UseSerilogRequestLogging(options =>
{
// Customize the log level by status code
options.GetLevel = (ctx, elapsed, ex) =>
ex is not null || ctx.Response.StatusCode >= 500 ? LogLevel.Error
: ctx.Response.StatusCode >= 400 ? LogLevel.Warning
: LogLevel.Information;
// Add extra properties to every request log
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].ToString());
diagnosticContext.Set("UserId", httpContext.User.Identity?.Name ?? "anonymous");
var correlationId = httpContext.Request.Headers["X-Correlation-Id"].FirstOrDefault();
if (correlationId is not null)
diagnosticContext.Set("CorrelationId", correlationId);
};
});This replaces six default framework log lines per request with one clean line:
[10:23:15 INF] HTTP GET /api/orders/42 responded 200 in 14.3ms | UserId: john@example.comAvoiding Logging Sensitive Data
// BAD — logs the actual card number
_logger.LogInformation("Processing payment with card {CardNumber}", req.CardNumber);
// BAD — logs the full request body including password
_logger.LogDebug("Request body: {Body}", JsonSerializer.Serialize(req));
// GOOD — log only non-sensitive identifiers
_logger.LogInformation("Processing payment for Order {OrderId} via {Provider}",
req.OrderId, req.PaymentProvider);
// GOOD — mask sensitive fields in a DTO before logging
_logger.LogDebug("Payment request: {PaymentRequest}",
new { req.OrderId, req.Amount, CardLastFour = req.CardNumber[^4..] });
// GOOD — destructuring with selective properties
_logger.LogInformation("User logged in: {@UserSummary}",
new { user.Id, user.Email, user.Role });
// DO NOT log: user.PasswordHash, user.SecurityStamp, user.TwoFactorKeyWhat to Learn Next
- Middleware Pipeline: Build the correlation ID and request logging middleware that feeds your Serilog output
- Observability: Distributed tracing with OpenTelemetry to go beyond logs
- Health Checks: Add structured logging for health probe failures
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.