Backend Systemsbeginner
Structured Logging with Serilog in .NET
Set up structured logging in ASP.NET Core with Serilog. Learn log levels, sinks, enrichers, and how to write logs that are searchable and actionable in production.
Asma HafeezApril 17, 20263 min read
dotnetloggingserilogobservabilityaspnet-core
Structured Logging with Serilog
Structured logging captures data as key-value pairs, not just text strings. This makes logs searchable, filterable, and queryable in tools like Seq, Elastic, or Datadog.
Why Structured Logging?
# Unstructured (hard to query)
"User 42 placed order 100 for $250.00"
# Structured (queryable)
{ "userId": 42, "orderId": 100, "total": 250.00, "event": "OrderPlaced" }Install Serilog
Bash
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.ThreadConfigure in Program.cs
C#
using Serilog;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.Enrich.FromLogContext()
.Enrich.WithEnvironmentName()
.Enrich.WithThreadId()
.WriteTo.Console(outputTemplate:
"[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
.WriteTo.File("logs/app-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7)
.CreateLogger();
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog(); // replace default logger with SerilogLog Levels
C#
// From least to most severe:
logger.LogTrace("Entering method {MethodName}", nameof(GetUser)); // very detailed
logger.LogDebug("Cache miss for key {Key}", cacheKey); // diagnostic
logger.LogInformation("User {UserId} logged in", userId); // normal events
logger.LogWarning("Retry {Attempt}/3 for payment {PaymentId}", n, id); // unusual
logger.LogError(ex, "Failed to process order {OrderId}", orderId); // errors
logger.LogCritical("Database unreachable — {Service} starting down", "orders"); // fatalConfigure per-category minimum levels
JSON
// appsettings.json
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
"System": "Warning"
}
}
}
}Structured Properties
C#
// Use structured parameters — {PropertyName}, not string interpolation
logger.LogInformation(
"Order {OrderId} placed by {CustomerId} for {Total:C}",
order.Id, order.CustomerId, order.Total);
// Log entire objects (destructure with @)
logger.LogDebug("Processing request {@Request}", request);
// Context properties — available in all logs within the scope
using (logger.BeginScope(new { UserId = userId, CorrelationId = Guid.NewGuid() }))
{
logger.LogInformation("Starting checkout");
// All logs inside this scope include UserId and CorrelationId
await ProcessCheckoutAsync();
logger.LogInformation("Checkout complete");
}Request Logging Middleware
C#
// Log all HTTP requests automatically
app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate =
"{RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000}ms";
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"]);
if (httpContext.User.Identity?.IsAuthenticated == true)
diagnosticContext.Set("UserId", httpContext.User.FindFirstValue("sub"));
};
});Logging in Services
C#
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger)
{
_logger = logger;
}
public async Task<Order> PlaceOrderAsync(PlaceOrderRequest request)
{
_logger.LogInformation("Placing order for customer {CustomerId}, {ItemCount} items",
request.CustomerId, request.Items.Count);
try
{
var order = await ProcessAsync(request);
_logger.LogInformation("Order {OrderId} placed successfully, total {Total:C}",
order.Id, order.Total);
return order;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to place order for customer {CustomerId}",
request.CustomerId);
throw;
}
}
}Production Setup — Seq
Seq is a local log server with a web UI for querying structured logs.
Bash
# Run Seq locally
docker run --name seq -d -e ACCEPT_EULA=Y -p 5341:80 datalust/seqBash
dotnet add package Serilog.Sinks.SeqC#
.WriteTo.Seq("http://localhost:5341")Open http://localhost:5341 → searchable, filterable log viewer.
Key Takeaways
- Use structured parameters (
{UserId}not"user " + id) — this makes logs queryable - Log context enriches all logs within a scope with common properties (correlation IDs, user IDs)
- Set minimum levels per namespace — suppress verbose Microsoft framework logs in production
LogError(exception, "...")captures the full stack trace — always pass the exception as first arg- Structured logs are only valuable if you can query them — set up Seq locally, Elasticsearch or Datadog in production
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.