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);
}
.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.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.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 }); // ✓