Structured Logging Best Practices in .NET

Structured Logging Best Practices in .NET

Julian Krause · March 05, 2026
DevOps.NETPerformance
ArchitectureEngineering Culture

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)

David Feb 22, 2026 · 17:08

Could you elaborate on this topic in a follow-up post?

Felix Feb 22, 2026 · 20:08

Good question, I'd like to know too.

Greta Feb 23, 2026 · 20:08

Interesting thought, thanks for adding that.

Elena Feb 23, 2026 · 17:08

This is exactly what I was looking for, thank you!


Leave a comment

An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.