Logging is often treated as an afterthought—sprinkle some Console.WriteLine() calls during development, maybe add ILogger if you remember, and hope for the best in production. Then something breaks at 3 AM, and you're grepping through text files trying to reconstruct what happened. Structured logging changes this game entirely.
The answer is refreshingly simple: Serilog. It's a .NET logging library that captures events as structured data instead of text blobs. Instead of parsing "User [email protected] logged in at 2023-12-15", you get a structured event with properties: {Username: "[email protected]", Timestamp: "2023-12-15T10:30:00Z", EventType: "UserLogin"}. Query it, analyze it, alert on it—all without regex.
By capturing structure upfront, Serilog makes production debugging actually feasible. When your application throws an exception in production, you don't just see "Error occurred"—you see the user ID, the request path, the correlation ID linking related events, and the full context that led to the failure. When performance degrades, you can query timing data across millions of events to find the bottleneck.
This isn't to say Serilog is perfect for everyone. If you're building a simple console app that logs to stdout, the built-in .NET logging might be sufficient. But if you're running distributed systems, need queryable logs, want correlation across services, or need production observability, Serilog's structured approach is transformative.
For instance: A microservices architecture with 20+ services? Serilog's enrichers add correlation IDs automatically, letting you trace requests across service boundaries. A monolith with complex workflows? Structured properties let you filter logs by user, tenant, or operation type without parsing text.
Serilog is a diagnostic logging library for .NET applications that captures log events as structured data rather than text. It provides powerful sinks for writing logs to various destinations, enrichers for adding contextual information, and a flexible configuration system.
Key characteristics:
Instead of string concatenation, capture rich structured data that's queryable and analyzable.
Traditional Logging:
_logger.LogInformation($"User {userId} ordered {itemCount} items totaling ${totalAmount}");
// Output: "User 12345 ordered 3 items totaling $149.99"
// How do you find all orders over $100? Parse strings with regex!
With Serilog:
_logger.Information("User {UserId} ordered {ItemCount} items totaling {TotalAmount:C}",
userId, itemCount, totalAmount);
// Output: Structured event with properties:
// { UserId: 12345, ItemCount: 3, TotalAmount: 149.99, Message: "User ordered items..." }
// Query: SELECT * FROM logs WHERE TotalAmount > 100
Rich contextual information makes production debugging possible:
Write logs to multiple destinations simultaneously:
Async logging, batching, and buffering ensure logging doesn't slow down your application. Background processing means your API doesn't wait for logs to be written.
Configure in code, appsettings.json, or environment variables. Different sinks and levels per environment without code changes.
Works seamlessly with Microsoft.Extensions.Logging, ASP.NET Core, and the entire .NET ecosystem.
Traditional Text Logging:
[INFO] 2023-12-15 10:30:00 - User login successful: [email protected]
[ERROR] 2023-12-15 10:31:00 - Failed to process order: Order not found
Problems:
With Structured Logging:
{
"Timestamp": "2023-12-15T10:30:00.000Z",
"Level": "Information",
"MessageTemplate": "User login successful: {Email}",
"Properties": {
"Email": "[email protected]",
"UserId": 12345,
"SourceContext": "MyApp.AuthService",
"CorrelationId": "abc-123-def"
}
}
Benefits:
Serilog uses message templates, not string interpolation:
// ❌ WRONG - String interpolation
_logger.Information($"User {userId} logged in");
// ✅ RIGHT - Message template
_logger.Information("User {UserId} logged in", userId);
Why templates matter:
# Core package
dotnet add package Serilog
# ASP.NET Core integration
dotnet add package Serilog.AspNetCore
# Common sinks
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Sinks.Seq
# Configuration
dotnet add package Serilog.Settings.Configuration
# Enrichers
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Thread
dotnet add package Serilog.Enrichers.Process
using Serilog;
using System;
namespace MyConsoleApp
{
class Program
{
static void Main(string[] args)
{
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.CreateLogger();Console()
.WriteTo.File("logs/myapp-.txt", rollingInterval: RollingInterval.Day)
.CreateLogger();
try
{
Log.Information("Application starting up");
// Your application code
ProcessOrder(12345, 3, 149.99m);
Log.Information("Application completed successfully");
}
catch (Exception ex)
{
Log.Fatal(ex, "Application failed to start");
}
finally
{
Log.CloseAndFlush();
}
}
static void ProcessOrder(int userId, int itemCount, decimal total)
{
Log.Information(
"Processing order for user {UserId}: {ItemCount} items, total {Total:C}",
userId, itemCount, total);
}
}
}
Program.cs (Minimal API / .NET 6+):
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog
builder.Host.UseSerilog((context, configuration) =>
{
configuration
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.WriteTo.Console()
.WriteTo.File("logs/myapp-.txt", rollingInterval: RollingInterval.Day);
});
var app = builder.Build();
// Add Serilog request logging
app.UseSerilogRequestLogging();
app.MapGet("/", () => "Hello World!");
app.Run();
appsettings.json Configuration:
{
"Serilog": {
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "File",
"Args": {
"path": "logs/myapp-.txt",
"rollingInterval": "Day",
"retainedFileCountLimit": 7,
"fileSizeLimitBytes": 10485760,
"rollOnFileSizeLimit": true
}
}
],
"Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"],
"Properties": {
"Application": "MyWebApp",
"Environment": "Development"
}
}
}
Log.Verbose("Detailed diagnostic information"); // Most verbose
Log.Debug("Internal system events"); // Development debugging
Log.Information("General informational messages"); // Standard operation
Log.Warning("Unexpected but handled situations"); // Potential issues
Log.Error(ex, "Errors and exceptions"); // Failures
Log.Fatal(ex, "Critical failures"); // Application crashes
When to use each level:
// Properties in templates
Log.Information("User {UserId} from {Country} ordered {ProductName}",
userId, country, productName);
// Destructuring with @
Log.Information("Processing {@Order}", order);
// Serializes entire order object as structured data
// Stringification with $
Log.Information("Order summary: {$Order}", order);
// Uses ToString() for simple representation
// Formatting
Log.Information("Order total: {Amount:C}", amount); // Currency
Log.Information("Processing time: {Duration:0.00} seconds", seconds); // Numbers
Log.Information("Occurred at {Timestamp:yyyy-MM-dd}", timestamp); // Dates
try
{
ProcessPayment(orderId);
}
catch (PaymentException ex)
{
Log.Error(ex,
"Payment failed for order {OrderId} by user {UserId}",
orderId, userId);
// Exception details are automatically captured and structured
}
// Result includes:
// - Exception type, message, stack trace
// - OrderId and UserId as properties
// - Full context from enrichers
using Serilog.Context;
// Add properties to all logs in scope
using (LogContext.PushProperty("OrderId", orderId))
using (LogContext.PushProperty("UserId", userId))
{
Log.Information("Starting order processing");
ProcessOrder();
Log.Information("Order processing complete");
// Both logs automatically include OrderId and UserId
}
// Dynamic context
public async Task<IActionResult> ProcessOrder(int orderId)
{
using (LogContext.PushProperty("OrderId", orderId))
using (LogContext.PushProperty("UserId", User.GetUserId()))
{
// All logs in this scope have OrderId and UserId
_logger.Information("Processing order");
await _orderService.Process(orderId);
_logger.Information("Order completed");
}
}
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateLogger();
// With formatting
Log.Logger = new LoggerConfiguration()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
// JSON output
Log.Logger = new LoggerConfiguration()
.WriteTo.Console(new JsonFormatter())
.CreateLogger();
// Basic file logging
Log.Logger = new LoggerConfiguration()
.WriteTo.File("logs/myapp.txt")
.CreateLogger();
// Rolling files by date
Log.Logger = new LoggerConfiguration()
.WriteTo.File(
"logs/myapp-.txt",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7) // Keep last 7 days
.CreateLogger();
// Rolling by size
Log.Logger = new LoggerConfiguration()
.WriteTo.File(
"logs/myapp.txt",
fileSizeLimitBytes: 10_485_760, // 10MB
rollOnFileSizeLimit: true,
retainedFileCountLimit: 10)
.CreateLogger();
// JSON files for structured parsing
Log.Logger = new LoggerConfiguration()
.WriteTo.File(
new JsonFormatter(),
"logs/myapp-.json",
rollingInterval: RollingInterval.Day)
.CreateLogger();
// Seq - structured log server
dotnet add package Serilog.Sinks.Seq
Log.Logger = new LoggerConfiguration()
.WriteTo.Seq("http://localhost:5341")
.CreateLogger();
// With API key
Log.Logger = new LoggerConfiguration()
.WriteTo.Seq(
serverUrl: "http://localhost:5341",
apiKey: "your-api-key")
.CreateLogger();
// Batch size for performance
Log.Logger = new LoggerConfiguration()
.WriteTo.Seq(
"http://localhost:5341",
batchPostingLimit: 100,
period: TimeSpan.FromSeconds(2))
.CreateLogger();
SQL Server:
dotnet add package Serilog.Sinks.MSSqlServer
Log.Logger = new LoggerConfiguration()
.WriteTo.MSSqlServer(
connectionString: "Server=localhost;Database=Logs;Trusted_Connection=True;",
sinkOptions: new MSSqlServerSinkOptions
{
TableName = "Logs",
AutoCreateSqlTable = true
})
.CreateLogger();
// Custom columns
var columnOptions = new ColumnOptions();
columnOptions.Store.Remove(StandardColumn.Properties);
columnOptions.Store.Add(StandardColumn.LogEvent);
columnOptions.AdditionalColumns = new Collection<SqlColumn>
{
new SqlColumn { ColumnName = "UserId", DataType = SqlDbType.Int },
new SqlColumn { ColumnName = "RequestPath", DataType = SqlDbType.NVarChar, DataLength = 500 }
};
Log.Logger = new LoggerConfiguration()
.WriteTo.MSSqlServer(
connectionString,
sinkOptions: new MSSqlServerSinkOptions { TableName = "Logs" },
columnOptions: columnOptions)
.CreateLogger();
PostgreSQL:
dotnet add package Serilog.Sinks.PostgreSQL
Log.Logger = new LoggerConfiguration()
.WriteTo.PostgreSQL(
connectionString: "Host=localhost;Database=logs;Username=postgres;Password=pass",
tableName: "logs",
needAutoCreateTable: true)
.CreateLogger();
Application Insights:
dotnet add package Serilog.Sinks.ApplicationInsights
Log.Logger = new LoggerConfiguration()
.WriteTo.ApplicationInsights(
telemetryConfiguration: new TelemetryConfiguration
{
ConnectionString = "your-connection-string"
},
TelemetryConverter.Traces)
.CreateLogger();
AWS CloudWatch:
dotnet add package Serilog.Sinks.AwsCloudWatch
Log.Logger = new LoggerConfiguration()
.WriteTo.AmazonCloudWatch(
logGroup: "/myapp/logs",
logStreamPrefix: "production",
cloudWatchClient: new AmazonCloudWatchLogsClient())
.CreateLogger();
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.WriteTo.File("logs/myapp-.txt", rollingInterval: RollingInterval.Day)
.WriteTo.Seq("http://localhost:5341")
.WriteTo.MSSqlServer(connectionString, sinkOptions: new MSSqlServerSinkOptions())
.CreateLogger();
// Different levels per sink
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Information)
.WriteTo.File("logs/debug-.txt",
rollingInterval: RollingInterval.Day,
restrictedToMinimumLevel: LogEventLevel.Debug)
.WriteTo.File("logs/errors-.txt",
rollingInterval: RollingInterval.Day,
restrictedToMinimumLevel: LogEventLevel.Error)
.CreateLogger();
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Thread
dotnet add package Serilog.Enrichers.Process
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext() // From LogContext.PushProperty
.Enrich.WithMachineName() // Computer name
.Enrich.WithEnvironmentName() // Development/Production
.Enrich.WithEnvironmentUserName() // Current user
.Enrich.WithThreadId() // Thread ID
.Enrich.WithThreadName() // Thread name
.Enrich.WithProcessId() // Process ID
.Enrich.WithProcessName() // Process name
.WriteTo.Console()
.CreateLogger();
using Serilog.Core;
using Serilog.Events;
public class ApplicationVersionEnricher : ILogEventEnricher
{
private LogEventProperty _cachedProperty;
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
_cachedProperty ??= propertyFactory.CreateProperty(
"ApplicationVersion",
typeof(Program).Assembly.GetName().Version?.ToString() ?? "Unknown");
logEvent.AddPropertyIfAbsent(_cachedProperty);
}
}
// Usage
Log.Logger = new LoggerConfiguration()
.Enrich.With<ApplicationVersionEnricher>()
.WriteTo.Console()
.CreateLogger();
Custom Enricher Examples:
// Tenant ID Enricher
public class TenantIdEnricher : ILogEventEnricher
{
private readonly IHttpContextAccessor _contextAccessor;
public TenantIdEnricher(IHttpContextAccessor contextAccessor)
{
_contextAccessor = contextAccessor;
}
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var tenantId = _contextAccessor.HttpContext?.User?.FindFirst("TenantId")?.Value;
if (tenantId != null)
{
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TenantId", tenantId));
}
}
}
// Git Commit Enricher
public class GitCommitEnricher : ILogEventEnricher
{
private LogEventProperty _cachedProperty;
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
_cachedProperty ??= propertyFactory.CreateProperty(
"GitCommit",
Environment.GetEnvironmentVariable("GIT_COMMIT") ?? "Unknown");
logEvent.AddPropertyIfAbsent(_cachedProperty);
}
}
app.UseSerilogRequestLogging(options =>
{
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"].ToString());
// Add user information
if (httpContext.User.Identity?.IsAuthenticated == true)
{
diagnosticContext.Set("UserId", httpContext.User.FindFirst("sub")?.Value);
diagnosticContext.Set("UserName", httpContext.User.Identity.Name);
}
// Add custom headers
if (httpContext.Request.Headers.TryGetValue("X-Correlation-Id", out var correlationId))
{
diagnosticContext.Set("CorrelationId", correlationId.ToString());
}
};
options.GetLevel = (httpContext, elapsed, ex) =>
{
if (ex != null) return LogEventLevel.Error;
if (httpContext.Response.StatusCode > 499) return LogEventLevel.Error;
if (elapsed > 10000) return LogEventLevel.Warning; // Slow requests
return LogEventLevel.Information;
};
});
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.Add(CorrelationIdHeader, correlationId);
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await _next(context);
}
}
}
// Register in Program.cs
app.UseMiddleware<CorrelationIdMiddleware>();
// Service A - API Gateway
public async Task<IActionResult> ProcessOrder(OrderRequest request)
{
var correlationId = HttpContext.Request.Headers["X-Correlation-Id"].FirstOrDefault()
?? Guid.NewGuid().ToString();
using (LogContext.PushProperty("CorrelationId", correlationId))
{
Log.Information("Received order request: {@Request}", request);
// Call downstream service
using var httpClient = _httpClientFactory.CreateClient();
httpClient.DefaultRequestHeaders.Add("X-Correlation-Id", correlationId);
var response = await httpClient.PostAsJsonAsync(
"http://inventory-service/api/check",
request);
Log.Information("Inventory check completed");
return Ok();
}
}
// Service B - Inventory Service
public async Task<IActionResult> CheckInventory(InventoryRequest request)
{
var correlationId = HttpContext.Request.Headers["X-Correlation-Id"].FirstOrDefault();
using (LogContext.PushProperty("CorrelationId", correlationId))
{
Log.Information("Checking inventory: {@Request}", request);
// All logs in this service now have the same correlation ID
var result = await _inventoryRepository.Check(request.ProductId);
Log.Information("Inventory check result: {Available}", result.Available);
return Ok(result);
}
}
// Now you can query logs across both services using the correlation ID:
// SELECT * FROM Logs WHERE CorrelationId = 'abc-123-def'
using System.Diagnostics;
public class ActivityEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var activity = Activity.Current;
if (activity != null)
{
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TraceId", activity.TraceId));
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("SpanId", activity.SpanId));
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("ParentId", activity.ParentId));
}
}
}
Log.Logger = new LoggerConfiguration()
.Enrich.With<ActivityEnricher>()
.WriteTo.Console()
.CreateLogger();
public class UserContextEnricher : ILogEventEnricher
{
private readonly IHttpContextAccessor _httpContextAccessor;
public UserContextEnricher(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;
var email = httpContext.User.FindFirst(ClaimTypes.Email)?.Value;
var roles = httpContext.User.FindAll(ClaimTypes.Role)
.Select(c => c.Value)
.ToArray();
if (userId != null)
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("UserId", userId));
if (userName != null)
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("UserName", userName));
if (email != null)
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("UserEmail", email));
if (roles.Any())
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("UserRoles", roles));
}
// IP Address
var ipAddress = httpContext?.Connection?.RemoteIpAddress?.ToString();
if (ipAddress != null)
{
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("ClientIpAddress", ipAddress));
}
}
}
// Register enricher
services.AddHttpContextAccessor();
services.AddSingleton<ILogEventEnricher, UserContextEnricher>();
Log.Logger = new LoggerConfiguration()
.Enrich.With<UserContextEnricher>()
.WriteTo.Console()
.CreateLogger();
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.MinimumLevel.Override("System", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
.WriteTo.Console()
.CreateLogger();
// In appsettings.json
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
}
}
using Serilog.Filters;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Filter.ByExcluding(Matching.FromSource("Microsoft"))
.Filter.ByExcluding(Matching.WithProperty("RequestPath", "/health"))
.Filter.ByExcluding(e => e.Properties.ContainsKey("SensitiveData"))
.WriteTo.Console()
.CreateLogger();
// Filter specific sinks
Log.Logger = new LoggerConfiguration()
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e => e.Level >= LogEventLevel.Error)
.WriteTo.File("logs/errors-.txt", rollingInterval: RollingInterval.Day))
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e => e.Level < LogEventLevel.Error)
.WriteTo.File("logs/info-.txt", rollingInterval: RollingInterval.Day))
.CreateLogger();
dotnet add package Serilog.Sinks.Async
Log.Logger = new LoggerConfiguration()
.WriteTo.Async(a => a.File("logs/myapp-.txt", rollingInterval: RollingInterval.Day))
.WriteTo.Async(a => a.MSSqlServer(connectionString, sinkOptions))
.CreateLogger();
// With buffering configuration
Log.Logger = new LoggerConfiguration()
.WriteTo.Async(
configure => configure.File("logs/myapp-.txt"),
bufferSize: 10000,
blockWhenFull: false)
.CreateLogger();
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Error)
.WriteTo.File("logs/errors-.txt"))
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e =>
e.Properties.ContainsKey("Audit") &&
(bool)((ScalarValue)e.Properties["Audit"]).Value)
.WriteTo.File("logs/audit-.txt"))
.WriteTo.