Skip to main content
SEMastery
ASP.NETintermediate

Better Request Tracing with User Context in ASP.NET Core

Learn how to trace requests in ASP.NET Core by adding user context and correlation IDs to your logs using middleware, logging scopes, and Activity.

12 min readUpdated October 15, 2025

Imagine you order food from a busy restaurant kitchen. You hand over a slip of paper with your order. That slip gets a small number written on it. As your order moves from the cook to the person who plates it to the waiter, everyone writes notes on that same slip. If your dish comes out wrong, the manager can pull the slip, read every note, and see exactly where things went sideways.

A web request is just like that food order. It passes through many hands inside your app. Without a number on the slip, every note is scattered and nobody can tell which note belongs to which order. In this post you will learn how to put a number on the slip, and how to also write down who placed the order, so your logs tell a complete and honest story.

The problem: logs without a story

When something breaks in production, you open your logs. But plain logs look like a pile of loose notes:

[12:01:03] Loading product list
[12:01:03] User not allowed
[12:01:04] Saving order
[12:01:04] Payment failed

Whose order failed? Which "User not allowed" message belongs to which request? You cannot tell. Two hundred people may be using the app at the same moment, and all their notes are mixed together in one big pile.

We want two things added to every line:

  • A correlation ID so we can group all the notes from one request.
  • The user ID so we know whose request it was.

Once both are there, the same logs become a clear story you can follow.

A request moves through many parts of your app. Without an ID, the logs from each part are hard to connect.

What is a correlation ID?

A correlation ID is one unique value for one request. Think of it as the number written on the order slip. The browser may send it in a header called X-Correlation-ID. If the browser does not send one, our app makes a fresh one. Then every log line for that request carries that same value.

ASP.NET Core already gives each request a value called HttpContext.TraceIdentifier, and the .NET runtime also tracks an Activity with its own ID. We can use these, or generate our own. The important idea is simple: one request, one ID, written everywhere.

Here is a small table comparing the IDs you might use.

ID sourceWhere it comes fromGood for
HttpContext.TraceIdentifierBuilt into ASP.NET CoreQuick local tracing
Activity.Current.IdThe .NET diagnostics systemDistributed tracing across services
X-Correlation-ID headerThe client or a gatewayFollowing a request across many apps
Your own GuidYou generate it in middlewareFull control over format

Logging scopes: the magic trick

Here is the part that keeps your code clean. Instead of passing the correlation ID and user ID into every single method, you open a logging scope once. A scope is like a sticky note attached to the whole request. Every log line written while the scope is open gets that note attached for free.

You open a scope with ILogger.BeginScope. You wrap it in a using block. When the request ends, the scope closes by itself.

A logging scope adds properties to every log line written inside it, without touching your business code.

Step 1: a correlation ID middleware

Middleware is code that runs for every request, before it reaches your controllers. It is the perfect place to read or create the correlation ID, because it runs first and sees everything.

Let me show the middleware. It checks the incoming header. If there is no ID, it makes one. It writes the ID back into the response so the caller can see it. Then it opens a logging scope.

public sealed class CorrelationIdMiddleware
{
    private const string HeaderName = "X-Correlation-ID";
    private readonly RequestDelegate _next;
    private readonly ILogger<CorrelationIdMiddleware> _logger;
 
    public CorrelationIdMiddleware(
        RequestDelegate next,
        ILogger<CorrelationIdMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }
 
    public async Task InvokeAsync(HttpContext context)
    {
        // Read the header, or create a new ID if it is missing.
        string correlationId = context.Request.Headers[HeaderName].FirstOrDefault()
            ?? Guid.NewGuid().ToString();
 
        // Send the same ID back so the caller can match it later.
        context.Response.Headers[HeaderName] = correlationId;
 
        // Also tag the current Activity for distributed tracing tools.
        Activity.Current?.SetTag("correlation.id", correlationId);
 
        // Open a scope. Every log line below now carries the ID.
        using (_logger.BeginScope(new Dictionary<string, object>
        {
            ["CorrelationId"] = correlationId
        }))
        {
            await _next(context);
        }
    }
}

The using block matters a lot. When the request finishes and the block exits, the scope is removed. The next request starts clean with its own ID. No leaking between requests.

Correlation ID middleware flow

Read header
Create if missing
Write to response
Open scope

Steps

1

Read header

Check X-Correlation-ID

2

Create if missing

Generate a Guid

3

Write to response

Add header back

4

Open scope

BeginScope wraps the request

How the middleware decides on an ID and shares it.

Step 2: adding the user context

The correlation ID groups the notes. Now we want to know who placed the order. After authentication runs, ASP.NET Core fills HttpContext.User with claims. One of those claims is the user ID. We read it and push it into another scope.

Order matters here. Authentication must run before we read the user, so this middleware sits after UseAuthentication.

public sealed class UserContextMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<UserContextMiddleware> _logger;
 
    public UserContextMiddleware(
        RequestDelegate next,
        ILogger<UserContextMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }
 
    public async Task InvokeAsync(HttpContext context)
    {
        // If the user is signed in, read their id from the claims.
        string? userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
 
        if (string.IsNullOrEmpty(userId))
        {
            // Anonymous request. Nothing to add, just continue.
            await _next(context);
            return;
        }
 
        // Tag the Activity so tracing tools (like Jaeger) see the user too.
        Activity.Current?.SetTag("user.id", userId);
 
        using (_logger.BeginScope(new Dictionary<string, object>
        {
            ["UserId"] = userId
        }))
        {
            await _next(context);
        }
    }
}

Notice we keep the two scopes in two small middlewares. You could merge them, but keeping them separate makes each one easy to read and easy to test. The correlation ID works even for anonymous requests; the user ID is added only when someone is signed in.

Step 3: wiring it up in the pipeline

Now we register both middlewares in Program.cs. The order of these lines is the order they run. Read it top to bottom like a checklist.

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddControllers();
builder.Services.AddAuthentication(/* your scheme */);
builder.Services.AddAuthorization();
 
var app = builder.Build();
 
// 1. Correlation ID runs first so even early errors get an ID.
app.UseMiddleware<CorrelationIdMiddleware>();
 
// 2. Authentication fills HttpContext.User.
app.UseAuthentication();
 
// 3. Now we can read the user and add it to the scope.
app.UseMiddleware<UserContextMiddleware>();
 
app.UseAuthorization();
 
app.MapControllers();
 
app.Run();

Middleware order in Program.cs

CorrelationId
Authentication
UserContext
Authorization
Endpoint

Steps

1

CorrelationId

Tag every request

2

Authentication

Fill User claims

3

UserContext

Add UserId scope

4

Authorization

Check access

5

Endpoint

Run your code

The pipeline order decides what data is available when.

What your logs look like now

Before, your logs were loose notes. With scopes in place and structured logging turned on, each line carries the extra fields. Here is the same kitchen story, but now every note has the slip number and the customer name.

[12:01:03 INF] Loading product list {CorrelationId=8a3f, UserId=42}
[12:01:03 WRN] User not allowed       {CorrelationId=8a3f, UserId=42}
[12:01:04 INF] Saving order           {CorrelationId=9c7d, UserId=87}
[12:01:04 ERR] Payment failed         {CorrelationId=9c7d, UserId=87}

Now the story is clear. Search for CorrelationId=9c7d and you see only user 87's failed order, start to finish. The mixed pile became two clean threads.

Two requests run at the same time, but their logs stay separate because each has its own correlation ID.

Turning on structured logging

For scopes to actually appear in your output, the logger must read them. With the built-in console logger you turn on IncludeScopes. Add this to appsettings.json:

{
  "Logging": {
    "Console": {
      "IncludeScopes": true
    }
  }
}

If you use Serilog, the popular logging library, you call Enrich.FromLogContext() when you set it up. Serilog then reads the scope properties and writes them as structured fields you can search in tools like Seq, Elasticsearch, or Grafana Loki.

builder.Host.UseSerilog((context, configuration) =>
{
    configuration
        .ReadFrom.Configuration(context.Configuration)
        .Enrich.FromLogContext()  // reads BeginScope properties
        .WriteTo.Console();
});

A quick note on packages: the older message-pipeline library MediatR and the messaging library MassTransit are now commercially licensed for many uses. Serilog and the built-in Microsoft.Extensions.Logging are still free and open source, so the approach in this post does not require any paid library.

Scopes versus passing parameters

Why use a scope at all? Why not just pass the IDs around? This table makes the trade-off clear.

ApproachCode cleanlinessRisk of forgettingWorks in deep code
Pass IDs into every methodMessy, noisy signaturesHigh, easy to miss oneOnly where you pass them
Use ILogger.BeginScopeClean business codeLow, set once in middlewareEverywhere in the request

The scope approach uses the ambient context of the request. That means any service pulled from dependency injection during the request automatically gets the IDs in its logs, with no extra arguments. Your service methods stay focused on business logic, which is how it should be.

A small service example

Here is a service that does not know anything about correlation IDs or users. It just logs normally. Because a scope is open, the IDs still show up.

public sealed class OrderService
{
    private readonly ILogger<OrderService> _logger;
 
    public OrderService(ILogger<OrderService> logger)
    {
        _logger = logger;
    }
 
    public async Task PlaceOrderAsync(int productId)
    {
        // No correlation ID here. The scope adds it for us.
        _logger.LogInformation("Placing order for product {ProductId}", productId);
 
        await Task.Delay(50); // pretend to call a payment provider
 
        _logger.LogInformation("Order placed");
    }
}

When this runs inside a request, every line gets the CorrelationId and UserId from the scopes opened by the middleware. The service code never mentions them. That is the whole point.

Passing the ID to other services

In a bigger system, one request often calls another service over HTTP. To keep the story connected across services, send the correlation ID along in the outgoing call. You read it from the current context and add it as a header on your HttpClient request. The other service reads that same header in its own middleware, and now both apps share one ID.

This is how teams trace a single click across five or six services. Each service writes its own logs, but they all carry the same correlation ID, so you can stitch them together in one search. Tools like OpenTelemetry do a lot of this for you through the Activity system, which is why we also called Activity.Current?.SetTag earlier. The tag travels with the trace and shows up next to your spans.

A simple rule helps here: read the ID at the edge, carry it everywhere inside, and pass it on at the next edge. The edge is the middleware. Everything in the middle is your clean business code that never thinks about IDs at all.

Be careful with sensitive data

A word of caution. Log the user ID, not the user's name, email, or anything private. IDs are safe and meaningless to an outsider. Personal details in logs can break privacy rules and laws like GDPR. When in doubt, log the boring number, not the person.

Also keep correlation IDs to a safe format like a Guid or a short random string. If you blindly trust the incoming header, a bad actor could inject odd characters. Validate or replace anything that does not look right before you log it.

Quick recap

  • A correlation ID is one unique value per request, like a number on a kitchen order slip.
  • A logging scope opened with ILogger.BeginScope attaches data to every log line inside it, automatically.
  • Put a correlation ID middleware first so even early errors get an ID, and write the ID back as a response header.
  • Put a user context middleware after UseAuthentication, so you can read the user's ID from claims.
  • Tag Activity.Current too, so tracing tools like Jaeger and OpenTelemetry see the same IDs.
  • Turn on IncludeScopes for the console logger, or Enrich.FromLogContext() for Serilog, so the scope data actually appears.
  • Prefer scopes over passing IDs to every method; your business code stays clean.
  • Log the user ID, never private details. Keep logs safe and lawful.

References and further reading

Related Posts