Imagine you run a handful of microservices in an event-driven architecture where every request flows asynchronously through queues and background workers. Structured logging is in place and life is good.

Until a customer reports a failure. You open your log viewer (Seq, Datadog, Application Insights, take your pick) and you do see an error, but the breadcrumb trail stops there. Which request triggered it? Did it originate from another service, or is it part of a larger saga? Without more context you have little hope of stitching the story together.

What is missing is correlation. When you link the actions and log entries that belong to the same request with a shared correlation identifier, you can follow a request across services, topics, and threads.

The idea is simple. Create or forward a correlation ID at the edge of your system, hold onto it for the duration of the request, and pass it along to any downstream call. Every log line written within that flow gets stamped with the same identifier, so the full journey becomes searchable.

Rather than wiring this plumbing by hand, you can hand it off to Correlate. It is a small library that manages the lifecycle of the correlation ID, exposes it to your code, and propagates it for you.

All the code in this post is in a runnable companion repo: mhdbouk/correlate-everything-in-dotnet. Two web services, one Azure Service Bus consumer, and ten passing tests that prove every claim.

TL;DR

  • Install Correlate.AspNetCore and call AddCorrelate() + UseCorrelate() in Program.cs.
  • Inject ICorrelationContextAccessor to read the current correlation ID from any service.
  • Chain .CorrelateRequests("X-Correlation-ID") on every HttpClient to propagate the ID outbound.
  • For Azure Service Bus, write the ID into ServiceBusMessage.CorrelationId on publish, then wrap consumers in IAsyncCorrelationManager.CorrelateAsync(message.CorrelationId, ...) to restore it.
  • Add Enrich.FromLogContext() to Serilog so every log line carries the correlation ID as a structured property.

Install the package

dotnet add package Correlate.AspNetCore

That single package pulls in the core library and the dependency injection extensions.

Register and add the middleware

Register the services in Program.cs:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCorrelate(options =>
{
    options.RequestHeaders = ["X-Correlation-ID"];
});

var app = builder.Build();

The RequestHeaders list controls which inbound headers Correlate inspects. If the request already carries one of those headers, that value is used. If not, a new ID is generated. You can list more than one entry to support older clients that send a different header name.

Add the middleware before any handler you want correlated:

app.UseCorrelate();

await app.RunAsync();

From this point on, every request lives inside a request-scoped correlation context.

Read the ID inside your code

Anywhere you need the current ID, inject ICorrelationContextAccessor:

internal sealed class OrderService
{
    private readonly ICorrelationContextAccessor _accessor;
    private readonly ILogger<OrderService> _logger;

    public OrderService(ICorrelationContextAccessor accessor, ILogger<OrderService> logger)
    {
        _accessor = accessor;
        _logger = logger;
    }

    public Task PlaceOrderAsync(OrderRequest request, CancellationToken ct)
    {
        var id = _accessor.CorrelationContext?.CorrelationId ?? "none";
        _logger.LogInformation("Placing order under correlation {CorrelationId}", id);
        return Task.CompletedTask;
    }
}

The context is AsyncLocal under the hood, so it survives across await boundaries within the same logical request.

Propagate it to outbound HTTP calls

This is where most setups quietly break. You correlate the inbound side, then forget the outbound side, and the chain ends at the first downstream call.

Correlate ships a DelegatingHandler that attaches the current ID to outbound requests. Wire it onto any HttpClient you register:

builder.Services
    .AddHttpClient<IPaymentClient, PaymentClient>()
    .CorrelateRequests("X-Correlation-ID");

Every outbound request sent through that client now carries the correlation ID in the configured header. The downstream service, also running Correlate, picks it up at its own edge, and the chain continues.

Propagate the correlation ID across Azure Service Bus

HTTP is the easy hop. Message buses are where correlation usually gets dropped. The fix is the same idea: read the ID before you publish, restore it after you consume.

Azure Service Bus makes this clean because every ServiceBusMessage has a built-in CorrelationId property on the envelope. You do not need a custom header. Read from Correlate on publish, write into the envelope, restore on consume.

On the publish side, stamp the current correlation ID onto the message:

public static ServiceBusMessage Create<T>
(
    T payload,
    ICorrelationContextAccessor accessor,
    JsonSerializerOptions? serializerOptions = null
)
{
    var body = JsonSerializer.Serialize(payload, serializerOptions);

    return new ServiceBusMessage(body)
    {
        ContentType = "application/json",
        Subject = typeof(T).Name,
        MessageId = Guid.NewGuid().ToString(),
        CorrelationId = accessor.CorrelationContext?.CorrelationId ?? Guid.NewGuid().ToString()
    };
}

On the consume side, wrap the handler in IAsyncCorrelationManager.CorrelateAsync, using the value off the envelope:

public sealed class OrderPlacedProcessor : IHostedService
{
    private readonly IAsyncCorrelationManager _correlationManager;
    private readonly ServiceBusProcessor _processor;

    public OrderPlacedProcessor
    (
        ServiceBusClient client,
        IAsyncCorrelationManager correlationManager
    )
    {
        _correlationManager = correlationManager;
        _processor = client.CreateProcessor("orders");
        _processor.ProcessMessageAsync += OnMessageReceivedAsync;
    }

    private Task OnMessageReceivedAsync(ProcessMessageEventArgs args)
    {
        return _correlationManager.CorrelateAsync
        (
            args.Message.CorrelationId,
            async () =>
            {
                // your handler runs here under the same correlation context the publisher was in
                await args.CompleteMessageAsync(args.Message, args.CancellationToken);
            }
        );
    }

    public Task StartAsync(CancellationToken ct) => _processor.StartProcessingAsync(ct);
    public Task StopAsync(CancellationToken ct) => _processor.StopProcessingAsync(ct);
}

That is the whole pattern. Publisher writes the ID into a property the SDK already has, consumer pulls it back out and restores the ambient context. Every log line on the worker now carries the same correlation ID as the request that produced the message.

Wire it into your logger

Correlate adds the correlation ID to the active log scope. With Serilog, one line activates it:

Log.Logger = new LoggerConfiguration()
    .Enrich.FromLogContext()
    .WriteTo.Console()
    .CreateLogger();

Every entry inside a request now includes CorrelationId as a structured property. With Microsoft.Extensions.Logging and a sink that respects scopes (Datadog, Seq, Application Insights), you get the same behaviour without extra code.

How is this different from distributed tracing?

The two often get conflated. They solve overlapping problems but they are not the same thing.

W3C TraceContext (traceparent, tracestate) is the protocol used by OpenTelemetry, Datadog APM, and Application Insights to stitch spans into a trace. It is built for tooling, not for humans.

A correlation ID is a single value you control. It survives in places traces do not. A customer-facing error reference. A manual log query. A SQL audit column. A support ticket. Tracing pipelines are opaque to most readers. A correlation ID is the string you paste into chat when you say “look at this one”.

Use both. Let your APM handle traces. Let Correlate handle the ID that humans search for.

What you get back

Here is the same correlation ID riding through every hop in a real run from the companion repo:

Hop Log line
Caller sets header X-Correlation-ID: ride-the-id-1778521287
ServiceA receives [ride-the-id-1778521287] Service A handling order 11111111-...
ServiceA outbound HTTP [ride-the-id-1778521287] Sending HTTP request GET http://localhost:5002/downstream
ServiceB receives [ride-the-id-1778521287] Service B handled downstream call
ServiceA publishes to bus [ride-the-id-1778521287] Service A published OrderPlaced 22222222-... as 2cafa8c9-...
Worker consumes [ride-the-id-1778521287] Worker handled OrderPlaced 22222222-... under correlation ride-the-id-1778521287

Six log lines, three processes, one ID. The bracketed prefix is Serilog enrichment kicking in via Enrich.FromLogContext(). The trailing value on the Worker line is ICorrelationContextAccessor.CorrelationContext.CorrelationId read inside the handler, after the context was restored from ServiceBusMessage.CorrelationId. Both resolve to the same string.

With this wired up, a customer-reported failure goes from a hunt to a lookup. Take the correlation ID off the response, paste it into your log viewer, and read the trail across every service that touched the request. Without it, you are stitching timestamps. With it, you are reading a story.

FAQ

What is a correlation ID in .NET?

A correlation ID is a single value attached to every log line, metric, and outbound call that belongs to the same logical request. When you search a log viewer for that value, you see the full journey of the request across services, threads, and queues. In .NET the Correlate library is the most common way to manage one.

Do I still need a correlation ID if I use OpenTelemetry?

Yes. OpenTelemetry trace IDs are designed for tooling pipelines like Datadog APM, Application Insights, and Honeycomb. A correlation ID is the value humans use: customer-facing error references, manual log queries, SQL audit columns, support tickets. They coexist. Let the APM handle traces, let Correlate handle the ID humans search for.

Which HTTP header should I use for a correlation ID?

X-Correlation-ID is the most common convention in .NET. Correlate inspects it by default and echoes it back on the response. You can configure additional headers via options.RequestHeaders to support older clients that send a different name.

How do I propagate a correlation ID across an HttpClient?

Chain .CorrelateRequests("X-Correlation-ID") on the IHttpClientBuilder returned by AddHttpClient. Correlate registers a DelegatingHandler that stamps the current correlation ID onto every outbound request.

How do I propagate a correlation ID across Azure Service Bus?

Azure Service Bus messages have a built-in CorrelationId property on the envelope. Write ICorrelationContextAccessor.CorrelationContext?.CorrelationId into it on publish. On consume, wrap the handler in IAsyncCorrelationManager.CorrelateAsync(message.CorrelationId, ...) to restore the context for every log line and downstream call inside that handler.

Is Correlate still maintained?

Yes. As of May 2026 the package targets net8.0, net9.0, and net10.0. The active version is Correlate.AspNetCore 6.x on NuGet.