| name | serilog |
| description | Structured logging with Serilog for .NET 10 applications. Covers two-stage bootstrap, appsettings configuration, enrichers, sinks, request logging, destructuring, and Serilog.Expressions. Load this skill when setting up Serilog, configuring log sinks, enrichers, or structured logging, or when the user mentions "Serilog", "structured logging", "log enrichment", "Seq", "LogContext", "UseSerilog", "WriteTo", "message template", "Serilog.Expressions", "request logging", "log sink", "rolling file", or "audit log".
|
Serilog
Core Principles
- Two-stage initialization — Create a bootstrap logger for startup, then replace it with the full logger after DI is ready. This captures startup errors that would otherwise be lost.
AddSerilog() over UseSerilog() — Use builder.Services.AddSerilog() (the modern API) instead of builder.Host.UseSerilog(). It integrates with DI services via ReadFrom.Services(services).
- Message templates, not interpolation —
{PropertyName} syntax creates structured data that can be queried. String interpolation ($"...") breaks structure and allocates even when the log level is disabled.
- Configure via appsettings.json — Keep log levels, sinks, and overrides in configuration so they can change per environment without redeployment.
Patterns
Two-Stage Bootstrap Setup
using Serilog;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
Log.Information("Starting application");
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog((services, lc) => lc
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("Application", "MyApp.Api"));
var app = builder.Build();
app.UseSerilogRequestLogging(options =>
{
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("UserAgent",
httpContext.Request.Headers.UserAgent.ToString());
};
});
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
await Log.CloseAndFlushAsync();
}
appsettings.json Configuration
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/app-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 30,
"fileSizeLimitBytes": 104857600
}
},
{
"Name": "Seq",
"Args": { "serverUrl": "http://localhost:5341" }
}
],
"Enrich": ["FromLogContext", "WithMachineName", "WithEnvironmentName"],
"Destructure": [
{ "Name": "ToMaximumDepth", "Args": { "maximumDestructuringDepth": 4 } },
{ "Name": "ToMaximumStringLength", "Args": { "maximumStringLength": 1024 } },
{ "Name": "ToMaximumCollectionCount", "Args": { "maximumCollectionCount": 10 } }
]
}
}
Override section uses namespace prefixes matched against SourceContext. More specific prefixes take precedence.
Request Logging Middleware
Replaces the multiple per-request log events from ASP.NET Core with a single summary event.
app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate =
"HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
options.GetLevel = (httpContext, elapsed, ex) => ex is not null
? LogEventLevel.Error
: httpContext.Response.StatusCode >= 500
? LogEventLevel.Error
: LogEventLevel.Information;
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("UserId",
httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous");
};
});
Structured Logging and Destructuring
logger.LogInformation("Order {OrderId} placed by {CustomerId} for {Total:C}",
orderId, customerId, total);
logger.LogInformation("Processing {@SensorInput}", sensorInput);
logger.LogInformation("Received {$Data}", new[] { 1, 2, 3 });
Scoped Properties with LogContext
using (LogContext.PushProperty("CorrelationId", correlationId))
using (LogContext.PushProperty("TenantId", tenantId))
{
logger.LogInformation("Processing order {OrderId}", orderId);
}
Requires .Enrich.FromLogContext() on the logger configuration.
OpenTelemetry Sink (OTLP Export)
Export Serilog events directly to any OTLP backend without the OpenTelemetry SDK:
.WriteTo.OpenTelemetry(options =>
{
options.Endpoint = "http://localhost:4317";
options.Protocol = OtlpProtocol.Grpc;
options.ResourceAttributes = new Dictionary<string, object>
{
["service.name"] = "MyApp.Api",
["deployment.environment"] = "production"
};
})
Serilog.Expressions for Filtering
Requires the Serilog.Expressions package.
.Filter.ByExcluding("RequestPath like '/health%'")
.WriteTo.Conditional("@l = 'Error'",
wt => wt.File("logs/errors-.log", rollingInterval: RollingInterval.Day))
[LoggerMessage] Source Generator for Hot Paths
Built into Microsoft.Extensions.Logging.Abstractions — compile-time generated, zero allocations when the level is disabled.
public static partial class OrderLogs
{
[LoggerMessage(Level = LogLevel.Information,
Message = "Order {OrderId} created for {CustomerId}")]
public static partial void OrderCreated(this ILogger logger, Guid orderId, Guid customerId);
}
logger.OrderCreated(order.Id, order.CustomerId);
Anti-patterns
Don't Use String Interpolation
logger.LogInformation($"Order {orderId} created for {customerId}");
logger.LogInformation("Order {OrderId} created for {CustomerId}", orderId, customerId);
Don't Skip CloseAndFlush
app.Run();
try { app.Run(); }
catch (Exception ex) { Log.Fatal(ex, "Unhandled exception"); }
finally { await Log.CloseAndFlushAsync(); }
Don't Log Sensitive Data
logger.LogInformation("Login: {Email} with password {Password}", email, password);
logger.LogInformation("Login: {Email}", email);
Don't Destructure Without Limits
logger.LogInformation("Request: {@Request}", httpContext.Request);
.Destructure.ToMaximumDepth(4)
.Destructure.ToMaximumStringLength(1024)
.Destructure.ToMaximumCollectionCount(10)
.Destructure.ByTransforming<HttpRequest>(r => new { r.Method, r.Path })
Don't Use the Deprecated Elasticsearch Sink
.WriteTo.Elasticsearch("http://localhost:9200")
.WriteTo.Elasticsearch([new Uri("https://elastic.example.com:9200")], opts =>
opts.DataStream = new DataStreamName("logs", "myapp"))
Decision Guide
| Scenario | Recommendation |
|---|
| Application logging | Serilog with AddSerilog() and appsettings.json |
| Log storage (development) | Seq (free single-user) or Aspire Dashboard |
| Log storage (production) | Seq, Elasticsearch (Elastic sink), or OTLP backend |
| Request logging | UseSerilogRequestLogging() (replaces per-request noise) |
| Scoped properties | LogContext.PushProperty() in middleware |
| Log filtering | Serilog.Expressions for expression-based filtering |
| High-performance paths | [LoggerMessage] source generator |
| Audit trails | AuditTo (synchronous, exceptions propagate) |
| Log levels by environment | MinimumLevel.Override per namespace in appsettings |
| OpenTelemetry integration | Serilog.Sinks.OpenTelemetry (no SDK dependency) |