20. Advanced Logging with Serilog
- Advanced Logging with Serilog
About this chapter
Replace default logging with Serilog for advanced structured logging capabilities including multiple sinks, enrichers, and centralized log aggregation.
- Serilog advantages: Structured logging, flexible sinks, and semantic message templates
- Installation: Core packages and enricher libraries
- Configuration: Setting up Serilog in Program.cs and appsettings.json
- Sinks: Writing logs to console, files, and centralized systems like Seq
- Enrichers: Adding contextual information (machine name, thread ID, user ID)
- Request logging: Capturing HTTP request/response details automatically
Learning outcomes:
- Install and configure Serilog in ASP.NET Core
- Write logs to multiple sinks simultaneously
- Use enrichers to add contextual information
- Configure file rolling and retention policies
- Set up centralized logging with Seq
- Log HTTP requests with custom enrichment
20.1 NEW: Installing Serilog
# Core Serilog packages
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Sinks.Seq
# Additional enrichers
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Process
dotnet add package Serilog.Enrichers.Thread
dotnet add package Serilog.Expressions
Why Serilog:
- Structured Logging: First-class support for structured data
- Flexible Sinks: Write to multiple destinations simultaneously
- Rich Formatting: JSON, compact JSON, plain text
- Message Templates: Efficient, semantic logging
- Enrichers: Add contextual information automatically
- Performance: High-throughput, low-allocation
20.2 Configuring Serilog
// Program.cs - Early initialization
using Serilog;
using Serilog.Events;
using Serilog.Formatting.Compact;
// Configure Serilog BEFORE building the host
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithThreadId()
.Enrich.WithProcessId()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} " +
"{Properties:j}{NewLine}{Exception}")
.WriteTo.File(
path: "logs/commandapi-.log",
rollingInterval: RollingInterval.Day,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] " +
"{Message:lj}{NewLine}{Exception}",
retainedFileCountLimit: 31)
.WriteTo.File(
new CompactJsonFormatter(),
path: "logs/commandapi-.json",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 31)
.WriteTo.Seq(
serverUrl: "http://localhost:5341",
restrictedToMinimumLevel: LogEventLevel.Debug)
.CreateLogger();
try
{
Log.Information("Starting CommandAPI application");
var builder = WebApplication.CreateBuilder(args);
// Replace built-in logging with Serilog
builder.Host.UseSerilog();
// ... rest of service configuration
var app = builder.Build();
// Add Serilog request logging (logs HTTP requests)
app.UseSerilogRequestLogging(options =>
{
// Customize request logging
options.MessageTemplate =
"HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
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"]);
// Add user information if authenticated
if (httpContext.User.Identity?.IsAuthenticated == true)
{
diagnosticContext.Set("UserId",
httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
}
};
// Custom log level based on response
options.GetLevel = (httpContext, elapsed, ex) =>
{
if (ex != null) return LogEventLevel.Error;
if (httpContext.Response.StatusCode > 499) return LogEventLevel.Error;
if (httpContext.Response.StatusCode > 399) return LogEventLevel.Warning;
return LogEventLevel.Information;
};
});
// ... rest of middleware
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
Configuration from appsettings.json:
{
"Serilog": {
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File", "Serilog.Sinks.Seq"],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
"CommandAPI": "Debug",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/commandapi-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 31,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "Seq",
"Args": {
"serverUrl": "http://localhost:5341"
}
}
],
"Enrich": ["FromLogContext", "WithMachineName", "WithThreadId", "WithEnvironmentName"],
"Properties": {
"Application": "CommandAPI"
}
}
}
// Program.cs - Read from configuration
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext());
20.3 Configuring Sinks
Console Sink (Development):
.WriteTo.Console(
restrictedToMinimumLevel: LogEventLevel.Debug,
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}{NewLine}" +
"{Message:lj}{NewLine}{Exception}",
theme: Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme.Code)
File Sink (All Environments):
// Text file with rotation
.WriteTo.File(
path: "logs/commandapi-.log",
rollingInterval: RollingInterval.Day, // New file daily
rollOnFileSizeLimit: true, // Roll if file too large
fileSizeLimitBytes: 10_485_760, // 10 MB
retainedFileCountLimit: 31, // Keep 31 days
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} " +
"[{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}")
// JSON file for machine parsing
.WriteTo.File(
new CompactJsonFormatter(),
path: "logs/commandapi-.json",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 31)
Seq Sink (Structured Log Server):
.WriteTo.Seq(
serverUrl: builder.Configuration["Seq:ServerUrl"] ?? "http://localhost:5341",
apiKey: builder.Configuration["Seq:ApiKey"],
restrictedToMinimumLevel: LogEventLevel.Information)
Running Seq with Docker:
# Add to docker-compose.yaml
services:
seq:
image: datalust/seq:latest
container_name: seq_logs
ports:
- "5341:80"
environment:
ACCEPT_EULA: Y
volumes:
- seq_data:/data
volumes:
seq_data:
driver: local
docker-compose up -d seq
# Access Seq UI at http://localhost:5341
Application Insights Sink (Azure):
dotnet add package Serilog.Sinks.ApplicationInsights
.WriteTo.ApplicationInsights(
telemetryConfiguration: builder.Configuration["ApplicationInsights:InstrumentationKey"],
telemetryConverter: TelemetryConverter.Traces)
Async Wrapper (Performance):
dotnet add package Serilog.Sinks.Async
.WriteTo.Async(a => a.File(
path: "logs/commandapi-.log",
rollingInterval: RollingInterval.Day))
20.4 Structured Logging Benefits
Message Templates:
// ❌ String interpolation (not structured)
_logger.LogInformation($"User {userId} created platform {platformName}");
// ✅ Message template (structured)
_logger.LogInformation(
"User {UserId} created platform {PlatformName}",
userId,
platformName);
Query Benefits in Seq:
-- Find all platform creations by specific user
UserId = "auth0|12345" AND PlatformName IS NOT NULL
-- Find slow operations
Elapsed > 1000
-- Find errors in specific controller
SourceContext LIKE "%PlatformsController%" AND @Level = "Error"
-- Find all operations for a specific platform
PlatformId = 42
-- Complex query
UserId = "auth0|12345" AND @Level IN ["Warning", "Error"] AND Elapsed > 500
Structured Data Example:
_logger.LogInformation(
"Command created: {CommandId} on platform {PlatformId} " +
"by user {UserId} with {@CommandDetails}",
command.Id,
command.PlatformId,
userId,
new
{
command.HowTo,
command.CommandLine,
CreatedAt = command.CreatedAt,
PlatformName = command.Platform.PlatformName
});
// Log output (JSON):
{
"Timestamp": "2025-11-20T10:30:45.1234567Z",
"Level": "Information",
"MessageTemplate": "Command created: {CommandId} on platform {PlatformId} by user {UserId} with {@CommandDetails}",
"Properties": {
"CommandId": 123,
"PlatformId": 5,
"UserId": "auth0|12345",
"CommandDetails": {
"HowTo": "Run container",
"CommandLine": "docker run",
"CreatedAt": "2025-11-20T10:30:45",
"PlatformName": "Docker"
},
"SourceContext": "CommandAPI.Controllers.CommandsController",
"MachineName": "WEB01",
"ThreadId": 15
}
}
Destructuring Operators:
// @ - Destructure object (serialize properties)
_logger.LogInformation("Created {@Platform}", platform);
// $ - Serialize to string (ToString())
_logger.LogInformation("Created {$Platform}", platform);
// No operator - Use ToString()
_logger.LogInformation("Created {Platform}", platform);
20.5 Log Enrichment
Built-in Enrichers:
.Enrich.FromLogContext() // Add properties via LogContext.PushProperty
.Enrich.WithMachineName() // Add machine name
.Enrich.WithThreadId() // Add thread ID
.Enrich.WithProcessId() // Add process ID
.Enrich.WithEnvironmentName() // Add environment (Dev, Prod)
.Enrich.WithEnvironmentUserName() // Add OS user
Custom Enricher:
using Serilog.Core;
using Serilog.Events;
public class ApplicationVersionEnricher : ILogEventEnricher
{
private LogEventProperty? _cachedProperty;
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
_cachedProperty ??= propertyFactory.CreateProperty(
"Version",
typeof(Program).Assembly.GetName().Version?.ToString() ?? "Unknown");
logEvent.AddPropertyIfAbsent(_cachedProperty);
}
}
// Usage
.Enrich.With<ApplicationVersionEnricher>()
Request Context Enrichment:
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
public RequestLoggingMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Add request ID to all logs in this request
using (LogContext.PushProperty("RequestId", context.TraceIdentifier))
using (LogContext.PushProperty("UserAgent", context.Request.Headers["User-Agent"].ToString()))
using (LogContext.PushProperty("ClientIp", context.Connection.RemoteIpAddress?.ToString()))
{
// Add user ID if authenticated
if (context.User.Identity?.IsAuthenticated == true)
{
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
using (LogContext.PushProperty("UserId", userId))
{
await _next(context);
}
}
else
{
await _next(context);
}
}
}
}
// Register middleware
app.UseMiddleware<RequestLoggingMiddleware>();
Correlation IDs:
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
private const string CorrelationIdHeaderName = "X-Correlation-ID";
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Get or create correlation ID
var correlationId = context.Request.Headers[CorrelationIdHeaderName].FirstOrDefault()
?? Guid.NewGuid().ToString();
// Add to response headers
context.Response.Headers.Add(CorrelationIdHeaderName, correlationId);
// Add to all logs in this request
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await _next(context);
}
}
}
20.6 REVISIT: Updating Background Jobs with Proper Logging
ProcessCommandJob with Serilog:
public class ProcessCommandJob
{
private readonly ICommandRepository _commandsRepo;
private readonly IBulkJobRepository _bulkJobRepo;
private readonly IMapper _mapper;
private readonly ILogger<ProcessCommandJob> _logger;
public ProcessCommandJob(
ICommandRepository commandsRepo,
IBulkJobRepository bulkJobRepo,
IMapper mapper,
ILogger<ProcessCommandJob> logger)
{
_commandsRepo = commandsRepo;
_bulkJobRepo = bulkJobRepo;
_mapper = mapper;
_logger = logger;
}
[AutomaticRetry(Attempts = 0)]
public async Task Execute(
string userId,
CommandMutateDto[] commandMutateDtos,
PerformContext context)
{
string jobId = context.BackgroundJob.Id;
int totalCommands = commandMutateDtos.Length;
// Add job context to all logs
using (LogContext.PushProperty("JobId", jobId))
using (LogContext.PushProperty("UserId", userId))
using (LogContext.PushProperty("TotalCommands", totalCommands))
{
_logger.LogInformation(
"Starting bulk command processing job {JobId} for user {UserId} " +
"with {TotalCommands} commands",
jobId,
userId,
totalCommands);
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
int position = 0;
int successCount = 0;
int failureCount = 0;
var failures = new List<(int Position, string Error)>();
foreach (var commandDto in commandMutateDtos)
{
position++;
string? exceptionMessage = null;
var commandModel = _mapper.Map<Command>(commandDto);
commandModel.UserId = userId;
try
{
_logger.LogDebug(
"Processing command at position {Position}/{TotalCommands}: {@CommandDto}",
position,
totalCommands,
commandDto);
await _commandsRepo.CreateCommandAsync(commandModel);
await _commandsRepo.SaveChangesAsync();
successCount++;
_logger.LogInformation(
"Successfully created command {CommandId} at position {Position}. " +
"HowTo: {HowTo}, Platform: {PlatformId}",
commandModel.Id,
position,
commandModel.HowTo,
commandModel.PlatformId);
}
catch (DbUpdateException dbuEx)
{
failureCount++;
exceptionMessage = dbuEx.Message;
failures.Add((position, exceptionMessage));
_logger.LogError(dbuEx,
"Database error at position {Position}. " +
"CommandLine: {CommandLine}, Platform: {PlatformId}. " +
"Error: {ErrorMessage}",
position,
commandDto.CommandLine,
commandDto.PlatformId,
exceptionMessage);
_commandsRepo.DetachModelState(commandModel);
}
catch (Exception ex)
{
failureCount++;
exceptionMessage = ex.Message;
failures.Add((position, exceptionMessage));
_logger.LogError(ex,
"Unexpected error at position {Position}. " +
"CommandLine: {CommandLine}",
position,
commandDto.CommandLine);
_commandsRepo.DetachModelState(commandModel);
}
finally
{
// Store job result
var bulkJobResult = new BulkJobResult
{
Position = position,
LoadData = JsonConvert.SerializeObject(commandModel),
ObjectId = commandModel.Id,
ObjectType = commandModel.GetType().Name,
LoadException = exceptionMessage ?? "n/a",
JobId = jobId
};
await _bulkJobRepo.CreateBulkJobResultAsync(bulkJobResult);
await _bulkJobRepo.SaveChangesAsync();
}
// Log progress every 10 commands
if (position % 10 == 0)
{
_logger.LogInformation(
"Progress: {ProcessedCount}/{TotalCommands} processed. " +
"Success: {SuccessCount}, Failed: {FailureCount}",
position,
totalCommands,
successCount,
failureCount);
}
}
stopwatch.Stop();
// Final summary with all details
_logger.LogInformation(
"Bulk job {JobId} completed in {ElapsedMs}ms. " +
"Total: {TotalCommands}, Success: {SuccessCount}, Failed: {FailureCount}, " +
"Success Rate: {SuccessRate:P2}",
jobId,
stopwatch.ElapsedMilliseconds,
totalCommands,
successCount,
failureCount,
totalCommands > 0 ? (double)successCount / totalCommands : 0);
if (failureCount > 0)
{
_logger.LogWarning(
"Job {JobId} had {FailureCount} failures: {@Failures}",
jobId,
failureCount,
failures);
}
}
}
}
Hangfire Dashboard with Serilog:
// Log Hangfire job lifecycle events
GlobalJobFilters.Filters.Add(new HangfireLoggingFilter());
public class HangfireLoggingFilter : JobFilterAttribute,
IClientFilter, IServerFilter, IElectStateFilter, IApplyStateFilter
{
private static readonly ILogger Logger =
Log.ForContext<HangfireLoggingFilter>();
public void OnCreating(CreatingContext context)
{
Logger.Information(
"Creating job {JobId} of type {JobType}",
context.BackgroundJob?.Id ?? "unknown",
context.Job.Type.Name);
}
public void OnCreated(CreatedContext context)
{
Logger.Information(
"Job {JobId} created successfully",
context.BackgroundJob?.Id);
}
public void OnPerforming(PerformingContext context)
{
Logger.Information(
"Starting job {JobId} of type {JobType}",
context.BackgroundJob.Id,
context.BackgroundJob.Job.Type.Name);
}
public void OnPerformed(PerformedContext context)
{
if (context.Exception != null)
{
Logger.Error(context.Exception,
"Job {JobId} failed with exception",
context.BackgroundJob.Id);
}
else
{
Logger.Information(
"Job {JobId} completed successfully",
context.BackgroundJob.Id);
}
}
public void OnStateElection(ElectStateContext context)
{
Logger.Debug(
"Job {JobId} state election: {NewState}",
context.BackgroundJob.Id,
context.CandidateState.Name);
}
public void OnStateApplied(ApplyStateContext context, IWriteOnlyTransaction transaction)
{
Logger.Information(
"Job {JobId} state changed to {NewState}",
context.BackgroundJob.Id,
context.NewState.Name);
}
public void OnStateUnapplied(ApplyStateContext context, IWriteOnlyTransaction transaction)
{
}
}