Structured logging with Serilog
Most teams still grep through plain-text logs on production VMs, losing hours to outages that could have been diagnosed in minutes. Structured logging with Serilog transforms your logs into queryable data: instead of searching for "Order 12345" across gigabytes of text files, you run a one-line query that returns every event where OrderId equals 12345, with all context already parsed and indexed. This is day 48 of our 100-day journey, and it's where logging stops being a debugging afterthought and becomes true observability.
Why structured logging matters
Traditional logging treats everything as strings. You write a log message, it gets written to a file, and later you grep or Ctrl+F through it. That works when you have one server and one user, but the moment you scale to multiple services, concurrent requests, and distributed tracing, plain text becomes your enemy.
Structured logging changes the game. Instead of storing "Order 12345 paid by customer 67890" as one big string, Serilog stores it as structured data with named properties: OrderId: 12345, CustomerId: 67890, Timestamp, Level, and so on. These properties are indexed and searchable. You can ask your logging system, "Show me all payment events for CustomerId 67890" and get results instantly, across all your services, all your deployments.
Without this, you're operating blind at scale. With it, you're running a proper SRE operation.
Message templates — Named placeholders become searchable properties
The foundation of Serilog is the message template. It looks like string interpolation, but it's not. The placeholders become properties.
var orderId = 12345;
var customerId = 67890;
var amount = 99.99m;
// Without Serilog—just a string
Console.WriteLine($"Order {orderId} paid by {customerId}: {amount}");
// With Serilog—structured data
_logger.LogInformation(
"Order {OrderId} paid by {CustomerId} for {Amount}",
orderId, customerId, amount);
When that log is written to a structured sink (Seq, Elasticsearch, or Loki), Serilog extracts OrderId, CustomerId, and Amount as separate, indexed properties. Now you can query:
// In Seq or Elasticsearch
OrderId == 12345
CustomerId == 67890 AND Amount > 100
Level == "Error" AND OrderId == *
Message template naming conventions matter. Use PascalCase and be specific: {OrderId} not {id}, {UserId} not {user}. This makes queries consistent across your codebase and leaves no ambiguity about what each value represents.
For collections or objects, Serilog can destructure them automatically:
var order = new { Id = 12345, Total = 99.99m, Status = "paid" };
_logger.LogInformation(
"Order completed: {@Order}",
order);
The @ symbol tells Serilog to destructure the object into nested properties. You can then query Order.Total > 50 directly. Be careful with sensitive data here—more on that in the secrets section.
Enrich every log — Add correlation ID, user ID, tenant and environment to every entry automatically
One of Serilog's superpowers is enrichment. You add context once, and every log gets it automatically. This is critical for distributed systems.
Imagine a user reports a problem. You find their UserID. You want to see every log entry they triggered across all your services over the last hour. Without enrichment, you'd have to manually add {UserId} to every single log statement. With enrichment, it's already there.
// In your Program.cs
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.Enrich.FromLogContext()
.Enrich.WithProperty("Environment", "Production")
.Enrich.WithProperty("Service", "OrderService")
.Enrich.WithMachineName()
.WriteTo.Seq("http://localhost:5341")
.CreateLogger();
Now every log has Environment, Service, and MachineName automatically. But correlation IDs and user IDs are request-scoped. Use LogContext for those:
// In middleware, capture the request context
app.Use(async (context, next) =>
{
var correlationId = context.Request.Headers
.TryGetValue("X-Correlation-ID", out var value)
? value.ToString()
: Guid.NewGuid().ToString();
using (LogContext.PushProperty("CorrelationId", correlationId))
using (LogContext.PushProperty("UserId", context.User?.FindFirst("sub")?.Value ?? "anonymous"))
using (LogContext.PushProperty("TenantId", context.Items["TenantId"]))
{
context.Response.Headers.Add("X-Correlation-ID", correlationId);
await next();
}
});
Everything inside that middleware's using scope gets those properties automatically. When this middleware calls your business logic, which calls your data layer, every log entry down the stack has CorrelationId, UserId, and TenantId. No repetition. No missed context.
Request logging — UseSerilogRequestLogging collapses noisy per-request logs into one summary line
By default, ASP.NET Core logs every request, every action filter, every middleware. In a busy API, this creates noise. You get hundreds of logs per second, most of them garbage.
Serilog has a middleware that replaces all that noise with one clean summary line per request:
// In Program.cs, before routing
app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate =
"HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.00}ms";
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestId", httpContext.TraceIdentifier);
diagnosticContext.Set("UserId", httpContext.User?.FindFirst("sub")?.Value);
diagnosticContext.Set("StatusCode", httpContext.Response.StatusCode);
if (httpContext.Request.Query.Count > 0)
diagnosticContext.Set("QueryString", httpContext.Request.QueryString);
};
});
Now instead of 10 logs per request (routing, auth, action filters, result formatters), you get one clean line with method, path, status code, elapsed time, and whatever custom context you added. Your log volume drops by 80%. Signal-to-noise improves dramatically.
You still get detailed logs for errors—if a request throws an exception or returns 5xx, that gets logged in full. It's only the successful requests that get collapsed into one line.
Ship to a real sink — Seq, Elasticsearch, Grafana Loki or CloudWatch. Console logs on a VM are not observability
Logs in files on a single VM are not observability—they're a liability. When the VM goes down, your logs go with it. When you scale to 10 VMs, you have to check 10 log files. When you need logs from a week ago, they're already rotated and deleted.
Real observability means shipping logs to a centralized, indexed, queryable store. Serilog has built-in sinks for all the major platforms.
Seq (simplest for development and small teams):
Log.Logger = new LoggerConfiguration()
.WriteTo.Seq("http://localhost:5341")
.CreateLogger();
Seq runs as a Docker container, gives you a web UI, and lets you query and filter logs instantly. It's not production-grade for massive scale, but it's excellent for learning and for teams under 20 people.
Elasticsearch (distributed, searchable, built for scale):
Log.Logger = new LoggerConfiguration()
.WriteTo.Elasticsearch(new ElasticsearchSinkOptions(new Uri("http://localhost:9200"))
{
AutoRegisterTemplate = true,
IndexFormat = "logs-orderservice-{0:yyyy.MM.dd}"
})
.CreateLogger();
Elasticsearch indexes your logs and makes them queryable in milliseconds, even across billions of entries. Use Kibana for UI or query directly. This is the choice for larger teams and higher log volumes.
AWS CloudWatch (if you're on AWS):
Log.Logger = new LoggerConfiguration()
.WriteTo.AWSCloudWatch(
logGroup: "/aws/lambda/my-service",
textFormatter: new CompactJsonFormatter())
.CreateLogger();
Native to AWS, integrated with Lambda, CloudTrail, and other services. Simpler than running Elasticsearch yourself.
Grafana Loki (if you're using Grafana for metrics):
Loki is designed to be cheaper than Elasticsearch (doesn't index log content, only labels) and pairs beautifully with Prometheus metrics and Grafana dashboards.
The pattern is always the same: configure a sink, point it at your centralized store, and forget about files. Your logs now live in a queryable system of record.
Never log secrets — Tokens, passwords and card data must be filtered. Destructure carefully
The worst time to discover you've been logging credit card numbers or API keys is when your security audit fails. Serilog makes it easy to be careful, but you have to be intentional.
Never include secrets in message templates:
// NEVER do this
_logger.LogInformation("User logged in with password {Password}", password);
// ALSO NEVER
_logger.LogInformation("API call with token {Token}", apiToken);
// ALSO NEVER
_logger.LogInformation("Card {CardNumber} charged {Amount}", card, amount);
Be careful when destructuring objects:
public class User
{
public int Id { get; set; }
public string Email { get; set; }
public string PasswordHash { get; set; } // Never log this
public string ApiToken { get; set; } // Never log this
}
// This logs EVERYTHING including secrets
_logger.LogInformation("User loaded: {@User}", user);
Solution: Use the [LoggerIgnore] attribute if you're on .NET 8+, or create a dedicated logging DTO:
public class UserLogInfo
{
public int Id { get; set; }
public string Email { get; set; }
// PasswordHash and ApiToken deliberately omitted
}
_logger.LogInformation(
"User loaded: {@UserInfo}",
new UserLogInfo { Id = user.Id, Email = user.Email });
You can also use a custom policy to filter properties by name:
Log.Logger = new LoggerConfiguration()
.Destructure.ByTransforming(user => new
{
user.Id,
user.Email
// PasswordHash and ApiToken omitted
})
.WriteTo.Seq("http://localhost:5341")
.CreateLogger();
This ensures that even if someone accidentally destructures a User object, the sensitive fields are never written to your logs.
Common mistakes
Using string interpolation instead of message templates: $"Order {orderId} paid" becomes a pre-formatted string. Serilog can't extract the properties. Always use the template pattern with named placeholders and separate arguments.
Not setting up correlation IDs: You end up with logs from different requests tangled together. Every production service needs correlation IDs. No exceptions.
Logging everything to Console in production: Console output is not queryable, not indexed, and disappears when the process restarts. Set up a real sink immediately.
Ignoring log levels: If you log warnings and errors at Information level, your alerts and filters won't work. Use LogWarning for recoverable issues, LogError for real problems, LogInformation for important business events.
Not testing your log queries: You set up Serilog, ship logs to Seq, and forget about it. Then an incident happens and you realize your queries don't work. Practice querying your logs weekly. Make it a habit.
Key takeaways
- Structured logging with Serilog turns logs into queryable data. Use message templates with named placeholders, never string interpolation.
- Enrich every log with correlation ID, user ID, tenant, and environment. Use LogContext for request-scoped values, configuration for global values.
- Replace noisy per-request logging with UseSerilogRequestLogging. One clean line per request instead of ten fragmented lines.
- Ship logs to a centralized sink: Seq for small teams, Elasticsearch for scale, CloudWatch for AWS, Loki for Grafana. File-based logging is not observability.
- Protect secrets. Never log passwords, tokens, or card data. Use destructuring DTOs or transformer policies to prevent accidental exposure.






Comments
Loading comments…