Structured Logging — Properties, Scopes and Correlation

Structured logging separates the message template from the data values, allowing log aggregation tools to index each value as a first-class property. A log entry like "User {UserId} logged in from {IpAddress}" with values 42 and "192.168.1.1" produces a log entry where UserId and IpAddress are indexable fields — you can query UserId = 42 directly in your log tool. Log scopes attach additional contextual properties to every log entry within a block — useful for attaching the request ID, user ID, or tenant to all logs for a given operation without passing them explicitly to every log call.

Log Scopes — Ambient Context

// ── BeginScope — attaches properties to all logs within the using block ───
public async Task ProcessOrderAsync(int orderId, string userId, CancellationToken ct)
{
    // All log entries inside this using block include OrderId and UserId
    using var scope = logger.BeginScope(new Dictionary<string, object>
    {
        ["OrderId"] = orderId,
        ["UserId"]  = userId,
        ["Operation"] = "ProcessOrder"
    });

    logger.LogInformation("Starting order processing.");          // includes OrderId, UserId
    await ValidateInventoryAsync(ct);                             // any logs here also include them
    await ChargePaymentAsync(ct);                                 // same
    logger.LogInformation("Order processed successfully.");       // includes OrderId, UserId
}
// All log entries between BeginScope and the end of the using block
// automatically carry OrderId and UserId — no need to pass them to every call

// ── Nested scopes ─────────────────────────────────────────────────────────
using var outerScope = logger.BeginScope("BatchJob-{BatchId}", batchId);
foreach (var item in items)
{
    using var innerScope = logger.BeginScope("Item-{ItemId}", item.Id);
    // Logs here include both BatchId and ItemId
    await ProcessItemAsync(item, ct);
}
Note: Log scopes are supported by Serilog, NLog, and other structured logging providers — they map scope values to enriched log properties. The built-in console provider shows scopes in a different format. When using Serilog (the next lesson), scopes are automatically captured by .Enrich.FromLogContext() and appear as searchable properties in all sink outputs. Scopes are especially valuable in ASP.NET Core middleware where you want request-level properties on every log entry without threading them through every method call.
Tip: ASP.NET Core automatically adds request-level properties to log scopes — the request ID (Microsoft.AspNetCore.Hosting.RequestId) is added by the framework for every request. To add your own per-request properties (user ID, tenant ID, correlation ID), do it in middleware early in the pipeline: using var scope = _logger.BeginScope(new { UserId = user.Id, TenantId = tenant.Id }). All subsequent logs during that request inherit these properties without any code changes in individual services.
Warning: Log scopes are async-context-aware when using async/await — the scope follows the logical async flow. However, they are NOT thread-safe for concurrent operations. If you fire multiple tasks in parallel (Task.WhenAll), each parallel task should create its own scope rather than sharing one. Shared mutable scope state in concurrent contexts causes interleaved properties that corrupt log correlation.

Correlation IDs for Distributed Tracing

// ── Middleware: attach correlation ID to every request ────────────────────
public class CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger)
{
    private const string CorrelationIdHeader = "X-Correlation-Id";

    public async Task InvokeAsync(HttpContext context)
    {
        // Read from incoming header or generate a new ID
        string correlationId = context.Request.Headers[CorrelationIdHeader].FirstOrDefault()
            ?? Guid.NewGuid().ToString("N")[..8];

        // Set on response so the client can correlate requests/responses
        context.Response.Headers[CorrelationIdHeader] = correlationId;

        // Add to log scope — all logs during this request include CorrelationId
        using var scope = logger.BeginScope(
            new Dictionary<string, object> { ["CorrelationId"] = correlationId });

        await next(context);
    }
}

// ── Register before routing (early in pipeline) ──────────────────────────
// app.UseMiddleware<CorrelationIdMiddleware>();

Common Mistakes

Mistake 1 — Using BeginScope with string format instead of dictionary (loses structure)

❌ Wrong — scope value is a flat string, not searchable properties:

logger.BeginScope($"Processing order {orderId}");  // flat string, no structure

✅ Correct — use Dictionary or anonymous object for structured scope properties:

logger.BeginScope(new { OrderId = orderId });  // searchable OrderId property

Mistake 2 — Forgetting to assign BeginScope to a variable (scope disposed immediately)

❌ Wrong — scope is created and immediately disposed; no enrichment:

logger.BeginScope(new { OrderId = orderId });  // IDisposable not used — no effect!

✅ Correct — assign to a using variable:

using var scope = logger.BeginScope(new { OrderId = orderId });  // ✓

🧠 Test Yourself

A service method calls three private methods and each logs entries. You want all of those entries to include the current request’s OrderId without passing it to every method. What is the cleanest solution?