Serilog is a structured logging library for .NET that records log events as typed properties rather than plain text, making logs searchable and analyzable in tools like Seq, Elasticsearch, or Grafana. Instead of interpolating values into a string, you write message templates such as "Creating order {OrderId} for user {UserId}", and Serilog captures each named value as a queryable field. The library plugs into ASP.NET Core through Serilog.AspNetCore, ships dozens of sinks for consoles, rolling files, and log servers, and supports enrichers that attach context like machine name, thread ID, or a per-request correlation ID to every event. That structure is what turns logging from console noise into an observability tool: you can filter production logs to a single order, user, or request in seconds. This guide covers setup, structured logging patterns, correlation IDs, custom enrichers, configuration, scopes, and performance logging.
Setup
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Sinks.Seq
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Thread// Program.cs
using Serilog;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithEnvironmentName()
.Enrich.WithMachineName()
.Enrich.WithThreadId()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day)
.WriteTo.Seq("http://localhost:5341")
.CreateLogger();
try
{
Log.Information("Starting application");
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog();
var app = builder.Build();
// Add request logging
app.UseSerilogRequestLogging(options =>
{
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].FirstOrDefault());
};
});
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}How Does Structured Logging Work in Serilog?
public class OrderService
{
private readonly ILogger<OrderService> _logger;
// GOOD: Structured logging with properties
public async Task CreateOrderAsync(Order order)
{
_logger.LogInformation(
"Creating order {OrderId} for user {UserId} with {ItemCount} items, total: {TotalAmount}",
order.Id, order.UserId, order.Items.Count, order.TotalAmount);
// These become searchable properties in Seq/Elasticsearch
}
// BAD: String interpolation loses structure
public async Task BadExample(Order order)
{
_logger.LogInformation($"Creating order {order.Id}"); // Don't do this!
}
// Log objects
public async Task LogComplexData(Order order)
{
_logger.LogInformation("Processing order {@Order}", order);
// @ serializes the object, $ just calls ToString()
}
// Conditional logging
public async Task ProcessAsync()
{
if (_logger.IsEnabled(LogLevel.Debug))
{
var expensiveData = ComputeExpensiveDebugInfo();
_logger.LogDebug("Debug info: {Data}", expensiveData);
}
}
}How Do You Add Correlation IDs?
// Middleware to add correlation ID
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
private const string CorrelationIdHeader = "X-Correlation-ID";
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var correlationId = context.Request.Headers[CorrelationIdHeader].FirstOrDefault()
?? Guid.NewGuid().ToString();
context.Response.Headers[CorrelationIdHeader] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await _next(context);
}
}
}
// Usage
app.UseMiddleware<CorrelationIdMiddleware>();
// Now all logs within a request have CorrelationId
_logger.LogInformation("Processing request"); // Includes CorrelationId automaticallyCustom Enrichers
// Custom enricher for user info
public class UserEnricher : ILogEventEnricher
{
private readonly IHttpContextAccessor _httpContextAccessor;
public UserEnricher(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext?.User?.Identity?.IsAuthenticated == true)
{
var userId = httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var userName = httpContext.User.Identity.Name;
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("UserId", userId));
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("UserName", userName));
}
}
}
// Register
builder.Services.AddHttpContextAccessor();
builder.Host.UseSerilog((context, services, config) =>
{
config
.Enrich.With(new UserEnricher(services.GetRequiredService<IHttpContextAccessor>()))
.WriteTo.Console();
});Configuration from appsettings.json
{
"Serilog": {
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File", "Serilog.Sinks.Seq"],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/app-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 30,
"fileSizeLimitBytes": 10485760
}
},
{
"Name": "Seq",
"Args": {
"serverUrl": "http://localhost:5341"
}
}
],
"Enrich": ["FromLogContext", "WithMachineName", "WithEnvironmentName"]
}
}// Program.cs
builder.Host.UseSerilog((context, config) =>
{
config.ReadFrom.Configuration(context.Configuration);
});Logging Scopes
public class OrderProcessor
{
private readonly ILogger<OrderProcessor> _logger;
public async Task ProcessOrderAsync(Order order)
{
using (_logger.BeginScope(new Dictionary<string, object>
{
["OrderId"] = order.Id,
["CustomerId"] = order.CustomerId
}))
{
_logger.LogInformation("Starting order processing");
await ValidateOrder(order);
await ProcessPayment(order);
await UpdateInventory(order);
_logger.LogInformation("Order processing completed");
}
// All logs within scope include OrderId and CustomerId
}
private async Task ValidateOrder(Order order)
{
_logger.LogDebug("Validating order"); // Includes OrderId, CustomerId
}
}How Should You Log Exceptions?
public async Task ProcessAsync()
{
try
{
await DoWorkAsync();
}
catch (ValidationException ex)
{
// Log expected exceptions as warnings
_logger.LogWarning(ex, "Validation failed for request");
throw;
}
catch (Exception ex)
{
// Log unexpected exceptions as errors
_logger.LogError(ex, "Unexpected error during processing");
throw;
}
}
// Global exception handler middleware
public class ExceptionLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionLoggingMiddleware> _logger;
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Unhandled exception for {Method} {Path}",
context.Request.Method,
context.Request.Path);
throw;
}
}
}Performance Logging
public class PerformanceLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<PerformanceLoggingMiddleware> _logger;
public async Task InvokeAsync(HttpContext context)
{
var sw = Stopwatch.StartNew();
try
{
await _next(context);
}
finally
{
sw.Stop();
if (sw.ElapsedMilliseconds > 500)
{
_logger.LogWarning(
"Slow request: {Method} {Path} took {ElapsedMs}ms",
context.Request.Method,
context.Request.Path,
sw.ElapsedMilliseconds);
}
}
}
}
// Operation timing helper
public static class LoggerExtensions
{
public static IDisposable TimeOperation(this ILogger logger, string operationName)
{
return new OperationTimer(logger, operationName);
}
private class OperationTimer : IDisposable
{
private readonly ILogger _logger;
private readonly string _operationName;
private readonly Stopwatch _sw;
public OperationTimer(ILogger logger, string operationName)
{
_logger = logger;
_operationName = operationName;
_sw = Stopwatch.StartNew();
_logger.LogDebug("Starting {Operation}", operationName);
}
public void Dispose()
{
_sw.Stop();
_logger.LogInformation(
"{Operation} completed in {ElapsedMs}ms",
_operationName,
_sw.ElapsedMilliseconds);
}
}
}
// Usage
using (_logger.TimeOperation("DatabaseQuery"))
{
await _repository.GetDataAsync();
}Best Practices
Logging best practices:
- Use structured logging with named properties, not string interpolation
- Log at appropriate levels: Debug for dev, Info for operations, Warn for issues, Error for failures
- Include correlation IDs for request tracing
- Don't log sensitive data (passwords, tokens, PII)
- Use scopes for operation context
- Log timing for performance monitoring
- Configure different outputs for different environments
// Sensitive data filtering
Log.Logger = new LoggerConfiguration()
.Destructure.ByTransforming<User>(u => new
{
u.Id,
u.Email,
Password = "***REDACTED***"
})
.CreateLogger();
// Or use a custom policy
public class SensitiveDataPolicy : IDestructuringPolicy
{
public bool TryDestructure(object value, ILogEventPropertyValueFactory factory, out LogEventPropertyValue? result)
{
if (value is CreditCard card)
{
result = factory.CreatePropertyValue(new
{
LastFour = card.Number[^4..],
card.ExpiryMonth,
card.ExpiryYear
});
return true;
}
result = null;
return false;
}
}Logs are your application's story. Write them for your future self who's debugging at 3 AM. Structure them for machines to search, but make them readable for humans to understand.
Good logging practices pay dividends when troubleshooting production issues. Invest in structured logging with Serilog, ship logs to a centralized platform like Seq or Elasticsearch, and establish consistent logging patterns across your team.
