Most .NET applications have logging. Very few have good logging. The difference is structure. Structured logs are not human-readable strings — they are queryable data with named properties, consistent schemas, and meaningful context. Here is how to do it right.
Message Templates, Not String Interpolation
The most common logging mistake in .NET is using string interpolation. It looks clean but destroys the ability to query and aggregate logs by template.
// WRONG — string interpolation
logger.LogInformation($"Order {orderId} created by {userId} for {total:C}");
// Produces: "Order 3fa85f64-5717-4562-b3fc-2c963f66afa6 created by abc123 for $150.00"
// Every log entry has a unique message — impossible to group or query
// CORRECT — message template with named properties
logger.LogInformation(
"Order {OrderId} created by {UserId} for {Total}",
orderId, userId, total);
// Produces structured data:
// {
// "MessageTemplate": "Order {OrderId} created by {UserId} for {Total}",
// "Properties": {
// "OrderId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
// "UserId": "abc123",
// "Total": 150.00
// }
// }
With structured logging, you can query "show me all logs where OrderId = X" or "count log entries grouped by MessageTemplate." String interpolation makes this impossible.
Scoped Properties for Context
Log scopes attach properties to every log entry within a scope. Use them for request-level context like user IDs, correlation IDs, and tenant IDs.
public class CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
var correlationId = context.Request.Headers["X-Correlation-Id"]
.FirstOrDefault() ?? Guid.NewGuid().ToString();
using (logger.BeginScope(new Dictionary<string, object>
{
["CorrelationId"] = correlationId,
["UserId"] = context.User.FindFirst("sub")?.Value ?? "anonymous",
["TenantId"] = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default"
}))
{
context.Response.Headers["X-Correlation-Id"] = correlationId;
await next(context);
}
}
}
Every log entry within the request now carries CorrelationId, UserId, and TenantId without any explicit logging call needing to include them.
Log Levels Have Meaning
Use log levels consistently across your entire application. Document what each level means for your team and enforce it in code reviews.
public class OrderService(ILogger<OrderService> logger)
{
public async Task<Order> ProcessOrderAsync(CreateOrderCommand command, CancellationToken ct)
{
// Trace — very detailed diagnostic info, usually disabled in production
logger.LogTrace("Entering ProcessOrderAsync with {ItemCount} items", command.Items.Count);
// Debug — diagnostic info useful during development
logger.LogDebug("Validating inventory for order from customer {CustomerId}", command.CustomerId);
// Information — normal operations worth recording
logger.LogInformation("Order {OrderId} created for customer {CustomerId}", order.Id, command.CustomerId);
// Warning — unexpected but handled situations
logger.LogWarning("Order {OrderId} total {Total} exceeds threshold {Threshold}",
order.Id, total, threshold);
// Error — failures that need attention
logger.LogError(ex, "Failed to process payment for order {OrderId}", order.Id);
// Critical — application-wide failures
logger.LogCritical(ex, "Database connection lost during order processing");
}
}
High-Performance Logging with LoggerMessage
For hot paths, the LoggerMessage source generator eliminates boxing and string allocation overhead. The performance difference is meaningful when logging millions of entries per minute.
public static partial class LogMessages
{
[LoggerMessage(Level = LogLevel.Information,
Message = "Order {OrderId} created for customer {CustomerId} with total {Total}")]
public static partial void OrderCreated(
this ILogger logger, Guid orderId, Guid customerId, decimal total);
[LoggerMessage(Level = LogLevel.Warning,
Message = "Order {OrderId} processing took {ElapsedMs}ms, exceeding {ThresholdMs}ms threshold")]
public static partial void SlowOrderProcessing(
this ILogger logger, Guid orderId, long elapsedMs, long thresholdMs);
}
// Usage — zero allocation, strongly typed
logger.OrderCreated(order.PublicId, command.CustomerId, total);
Structured logging is foundational. Get it right early, enforce it consistently, and your production debugging experience will be transformed. When an incident occurs at 3 AM, the difference between queryable structured logs and a wall of text is the difference between a 10-minute resolution and a 3-hour investigation.
Comments (2)
Could you elaborate on this topic in a follow-up post?
Good question, I'd like to know too.
Interesting thought, thanks for adding that.
This is exactly what I was looking for, thank you!
Leave a Comment