Scopes, Trace Ids and Correlation
Attach request context to every log line with BeginScope and W3C trace ids, and keep one trace id across service calls.
using (logger.BeginScope(new Dictionary<string, object?> { ["OrderId"] = orderId }))
{
logger.LogInformation("Reserving stock");
}
var traceId = Activity.Current?.TraceId.ToString();Three different values get called "the correlation id", and mixing them up is the usual reason logs cannot be joined.
| Value | What it is | Scope |
|---|---|---|
Activity.Current.TraceId | 32 hex characters shared by every span of one distributed operation | All services the request touches |
Activity.Current.SpanId | 16 hex characters for one unit of work in one service | One span |
HttpContext.TraceIdentifier | Kestrel connection id plus request number, such as 0HN7QK2V1J3L4:00000002 | One process; never propagated |
Scopes
using (logger.BeginScope(state)) pushes state onto a stack held in an AsyncLocal, so it flows through await and into Task.Run, and every event logged inside it, by any logger in any class, carries it. Pass a dictionary or list of key/value pairs (or a template with arguments) so the values become properties; a plain string adds no properties. Providers decide whether to emit scopes: the console logger only with IncludeScopes = true, the OpenTelemetry provider only with IncludeScopes = true, and a custom provider must implement ISupportExternalScope to see the shared stack.
Trace ids in every log for free
For each request, ASP.NET Core starts an Activity whenever something is listening: a logger enabled for the hosting category, an OpenTelemetry tracer or a diagnostic listener. The host sets ActivityTrackingOptions to TraceId | SpanId | ParentId by default, so the logger factory adds those three values as a scope to every event written while the activity is current. You do not write correlation middleware; you make sure the sink keeps scopes.
Propagation across services
On an incoming request the default propagator reads the W3C traceparent header (00-{trace-id}-{parent-span-id}-{flags}) and tracestate, and the new activity continues that trace. On an outgoing HttpClient call made while an activity is current, the handler writes a traceparent for the downstream service. Service A and service B log the same TraceId with no code. The chain breaks in three places: work handed to a queue or Channel and processed later (carry the trace context in the message and start a consumer activity from it), a proxy that strips unknown headers, and fire-and-forget work that outlives the request.
Decisions and failure modes
- Give clients the trace id. The default ProblemDetails writer adds a
traceIdextension holding the W3CActivity.Id(00-<trace-id>-<span-id>-01), orTraceIdentifierwhen no activity exists; the 32-hex segment after00-is the trace id to search for. - Do not invent an
X-Correlation-Idscheme next to W3C trace context. If a partner requires one, send it from a handler and log it as a property, but join on TraceId. - Do not trust
traceparentfrom the public internet: an edge gateway should start a new trace, or clients can pick your trace ids and force sampling. - Scopes hold references until disposed; never put a large object or a
DbContextin one, and keep scope keys from colliding with template hole names.
Example
When to use it
- Follow one checkout from the gateway through the orders and inventory services by filtering every log store on a single TraceId.
- Tag every log line inside a request with TenantId and UserId from the token without passing them to each service method.
- Let a customer quote the trace-id response header in a support ticket so the on-call engineer opens the failing trace in one click.
More examples
Scope from claims and the trace id on the response
Putting <code>TenantId</code> and <code>UserId</code> in a scope once means no handler has to repeat them, and every log line from EF Core, HttpClient handlers or your services inside the request carries them. The middleware must run after <code>UseAuthentication</code> in a real app so <code>context.User</code> is populated. Returning the trace id in a header is cheap and lets a client report <code>trace-id: 4bf92f35...</code> instead of a timestamp and a guess.
using System.Diagnostics;
using System.Security.Claims;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();
app.Use(async (context, next) =>
{
// Clients and support staff quote this value; it opens the trace directly.
if (Activity.Current is { } activity)
context.Response.Headers["trace-id"] = activity.TraceId.ToString();
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Shop.Requests");
var scope = new Dictionary<string, object?>
{
["TenantId"] = context.User.FindFirstValue("tenant_id"),
["UserId"] = context.User.FindFirstValue(ClaimTypes.NameIdentifier),
};
using (logger.BeginScope(scope))
{
await next(context);
}
});
app.MapGet("/invoices/{number}", (string number, ILogger<Program> logger) =>
{
logger.LogInformation("Invoice {InvoiceNumber} requested", number);
return Results.Ok(new { number, amount = 1250.00m, currency = "EUR" });
});
app.Run();
JSON console output with scopes
The console provider ignores scopes unless <code>IncludeScopes</code> is true, which is the most common reason container logs have no trace id even though the activity exists. With the JSON formatter each scope is written into a <code>Scopes</code> array, and dictionary scopes appear as objects whose keys a log shipper such as Fluent Bit or the OpenTelemetry Collector's filelog receiver can lift into indexed fields.
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Console": {
"FormatterName": "json",
"FormatterOptions": {
// Without this the TraceId/SpanId scope and BeginScope values are dropped from stdout.
"IncludeScopes": true,
"UseUtcTimestamp": true,
"JsonWriterOptions": { "Indented": false }
}
}
}
}
A partner that insists on X-Correlation-Id
When a third party needs its own header, derive it from the trace id instead of generating an unrelated GUID; then a ticket from the partner quoting <code>X-Correlation-Id</code> finds the trace with no lookup table. <code>traceparent</code> is still sent by <code>HttpClient</code> alongside it. The GUID fallback only happens for calls made outside any activity, such as a startup job with no listener.
using System.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTransient<CorrelationIdHandler>();
builder.Services.AddHttpClient<CarrierClient>(c => c.BaseAddress = new Uri("https://api.carrier.example"))
.AddHttpMessageHandler<CorrelationIdHandler>();
var app = builder.Build();
app.Run();
public sealed class CorrelationIdHandler(ILogger<CorrelationIdHandler> logger) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
// Reuse the trace id so the partner's id and ours are the same string.
var correlationId = Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N");
request.Headers.TryAddWithoutValidation("X-Correlation-Id", correlationId);
logger.LogDebug("Calling carrier with correlation id {CorrelationId}", correlationId);
return base.SendAsync(request, ct);
}
}
public sealed class CarrierClient(HttpClient http)
{
public Task<HttpResponseMessage> CreateLabelAsync(string orderId, CancellationToken ct) =>
http.PostAsJsonAsync("/labels", new { reference = orderId }, ct);
}
Discussion