.NET & C# Development · Lesson 13 of 92
Add Serilog in 10 Minutes — Logs Your Team Can Search
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