Logging in .NET · Lesson 1 of 1
Structured Logging with Serilog
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