Skip to content

Background Thread Correlation

aryehcitron@gmail.com edited this page May 24, 2026 · 10 revisions

Background processing — change feed processors, message handlers, hosted services, timer triggers — runs on threads where the test framework's execution context is unavailable. This page explains how to correlate those operations back to the originating test.

Event-driven architectures? If your SUT is triggered entirely by consuming messages (no direct HTTP calls from the test), see Event-Driven Architecture Testing for the complete end-to-end guide. This page covers the lower-level mechanisms used under the hood.

Parallel test execution? If you need parallel-safe background correlation (v2.36.0+), see Parallel-Safe Background Correlation for TestCorrelationStore — a thread-safe alternative to GlobalFallback that requires no production code changes.


The Problem

CurrentTestInfo.Fetcher (xUnit3, and equivalents in other frameworks) uses TestContext.Current.Test to determine the active test. This only works on the test runner thread — any background thread, task continuation, or callback will see TestContext.Current.Test as null, and the fetcher will return ("Unknown", "unknown").

Common scenarios where this occurs:

Scenario Why TestContext is null
Cosmos DB Change Feed Processor Polls on a dedicated background thread
Azure Functions timer triggers Executes on a thread pool thread
Hosted services (IHostedService) Runs on a separate thread
Event handlers (e.g. AfterPublish) May fire on thread pool threads
Read-through cache refresh HTTP calls from a caching layer's background refresh
Task.Run() / thread pool work Any work dispatched to the thread pool

Symptoms

  • Diagnostic report shows a large number of "Unknown" entries
  • Sequence diagrams for tests that trigger background processing are missing operations
  • Service Bus publishes triggered by change feed events don't appear in diagrams

Solution 1: TestIdentityScope (v2.28.5+)

TestIdentityScope is an AsyncLocal-based ambient scope that propagates test identity across async boundaries. It is the recommended approach when you control the code that dispatches background work.

// Before dispatching background work, push the test identity into scope:
using (TestIdentityScope.Begin(testName, testId))
{
    await TriggerBackgroundProcessing();
    // All tracking within this scope (and any async continuations) will use testName/testId
}

Automatic Context Propagation Middleware (v3.0.19+)

For SUT code that dispatches work via Task.Run or similar fire-and-forget patterns within an HTTP request, you can automatically establish TestIdentityScope from the incoming request's tracking headers using AddTestTrackingContextPropagation():

builder.ConfigureTestServices(services =>
{
    services.AddTestTrackingContextPropagation();
});

This registers TestTrackingContextMiddleware (via IStartupFilter) which reads the test-tracking-current-test-name and test-tracking-current-test-id headers from each incoming request and calls TestIdentityScope.Begin(name, id) for the duration of the request. Because AsyncLocal propagates into Task.Run, any background work dispatched during that request will inherit the test identity automatically — no manual TestIdentityScope.Begin() calls needed.

Idempotent (v3.0.20+): AddTestTrackingContextPropagation() is safe to call more than once — for example, from both a base WebApplicationFactory and a derived one. Only one TestTrackingContextStartupFilter will be registered regardless of how many times the method is called.

When to use this: Your SUT receives an HTTP request from the test, then dispatches fire-and-forget work (e.g. Task.Run(() => httpClient.PostAsync(...))) within that request. Without this middleware, the background thread would have no HttpContext and no TestIdentityScope, causing the tracking handler to silently skip logging. With it, the AsyncLocal scope flows into the background thread.

Resolution order for all trackers (v2.28.16+):

Priority Source When Available
1 HTTP request headers (IHttpContextAccessor) Code running inside the SUT's request pipeline (e.g. WebApplicationFactory scenarios). Both test-tracking-current-test-name and test-tracking-current-test-id headers must be present; if only one is present the handler falls through to Priority 2.
2 CurrentTestInfoFetcher delegate Code running on the test thread where the framework's TestContext is accessible
3 TestIdentityScope.Current Code wrapped in TestIdentityScope.Begin(...), set via SetFromMessage(), or established by TestTrackingContextMiddleware (v3.0.19+) — propagates via AsyncLocal
4 Message headers (kronikol-test-name / kronikol-test-id) Consumer/subscriber receives a message produced during a tracked test (v2.34.0+)
5 TestIdentityScope.GlobalFallback Pre-existing background threads (Change Feed Processor, Hangfire, hosted services)
6 Returns null Operation is silently not logged

v2.28.22+: Priority 2 (CurrentTestInfoFetcher) now throws InvalidOperationException when invoked outside a test context. The resolver catches this and falls through to priorities 3–6. See Tracking Custom Dependencies#Test Context Availability for details.

Scenario Resolution Reference

Scenario Resolves At Why
HTTP request inside WebApplicationFactory Priority 1 TestTrackingMessageHandler propagates test identity as HTTP headers
Test method body (direct call) Priority 2 Framework TestContext is available on the test thread
Task.Run / background thread wrapped in TestIdentityScope.Begin Priority 3 AsyncLocal propagates to async continuations
Kafka/ServiceBus/EventHubs/PubSub consumer processing a tracked message Priority 4 Message headers carry test identity from producer to consumer
Change Feed Processor / hosted service with SetGlobalFallback Priority 5 Pre-existing thread reads the static fallback
Background thread with nothing configured Priority 6 Returns null — operation not tracked

See Tracking Custom Dependencies#Tracking Background Processing with TestIdentityScope (v2.28.5+) for full details and examples.


Solution 1b: Automatic Message Header Propagation (v2.34.0+)

For event-driven architectures where an ASP.NET application listens to messages (Kafka, Service Bus, Event Hubs, Google Pub/Sub, MassTransit), test identity is automatically propagated through message headers — no manual TestIdentityScope.Begin() or GlobalFallback needed.

When a tracked producer sends a message during a test, the kronikol-test-name and kronikol-test-id headers are injected into the message metadata. When a tracked consumer receives that message, the headers are extracted and established as the ambient TestIdentityScope via SetFromMessage().

How It Works

Test Thread                    Producer                         Consumer (Background Thread)
    │                              │                                │
    │── Produce("order-created") ──│                                │
    │   [headers injected:         │                                │
    │    kronikol-test-name + id]       │                                │
    │                              │── Message on topic ───────────>│
    │                              │                                │── Headers extracted
    │                              │                                │── TestIdentityScope.SetFromMessage()
    │                              │                                │── All tracking resolves to test

Supported Extensions

Extension Producer Injects Consumer Extracts Header Location
Kafka TrackingKafkaProducer TrackingKafkaConsumer message.Headers (byte[])
Service Bus TrackingServiceBusSender TrackingServiceBusReceiver message.ApplicationProperties
Event Hubs TrackingEventHubProducerClient TrackingEventHubConsumerClient eventData.Properties
Google Pub/Sub TrackingPublisherClient TrackingSubscriberClient message.Attributes
MassTransit TrackingSendObserver / TrackingPublishObserver TrackingConsumeObserver Transport headers

Configuration

Propagation is enabled by default. To disable it (e.g. for performance-sensitive benchmarks):

var options = new KafkaTrackingOptions
{
    PropagateTestIdentity = false,  // Disable header injection/extraction
    // ...
};

All messaging extension option classes expose the same PropagateTestIdentity property.

When to Use This vs Other Solutions

Scenario Recommended Solution
Event-driven app consuming Kafka/ServiceBus/EventHubs/PubSub Automatic propagation (v2.34.0+) — zero config needed
Background processing triggered by HTTP request HTTP headers (Priority 1) — already works via WebApplicationFactory
Task.Run / manual thread dispatch TestIdentityScope.Begin() — wraps the dispatch point
Pre-existing hosted service threads GlobalFallback — for threads started before the test

Solution 2: Instance-Scoped Test Tracker

When you cannot use TestIdentityScope (e.g. you don't control the dispatch point), use an instance-scoped tracker that explicitly sets the active test identity on the fixture:

public class ActiveTestTracker
{
    private readonly object _syncLock = new();
    private (string Name, string Id)? _activeTest;

    public void Set(string name, string id)
    {
        lock (_syncLock) { _activeTest = (name, id); }
    }

    public void Clear()
    {
        lock (_syncLock) { _activeTest = null; }
    }

    /// <summary>
    /// Tries TestContext.Current first (works on the test thread),
    /// falls back to the explicitly set active test (works on background threads).
    /// </summary>
    public (string Name, string Id) Fetcher()
    {
        var (name, id) = CurrentTestInfo.Fetcher();
        if (name != "Unknown")
            return (name, id);

        lock (_syncLock)
        {
            return _activeTest ?? ("Unknown", "unknown");
        }
    }
}

Wiring It Up

1. Create the tracker on your fixture

public class MyCollectionFixture : IAsyncLifetime
{
    public ActiveTestTracker TestTracker { get; } = new();

    public async ValueTask InitializeAsync()
    {
        // Pass TestTracker.Fetcher to ALL tracking configuration:

        // Cosmos tracking
        var cosmosOptions = new CosmosTrackingMessageHandlerOptions
        {
            ServiceName = "CosmosDB",
            CallerName = "My Service",
            CurrentTestInfoFetcher = TestTracker.Fetcher,
        };

        // HTTP tracking
        services.TrackDependenciesForDiagrams(new TestTrackingMessageHandlerOptions
        {
            CallerName = "My Service",
            CurrentTestInfoFetcher = TestTracker.Fetcher,
        });

        // Message tracking
        services.TrackMessagesForDiagrams(new MessageTrackerOptions
        {
            CallerName = "My Service",
            ServiceName = "Service Bus",
            CurrentTestInfoFetcher = TestTracker.Fetcher,
        });
    }

    public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

2. Set/clear in the test lifecycle

public class MyTestBase : IAsyncLifetime
{
    protected MyCollectionFixture Fixture { get; }

    public ValueTask InitializeAsync()
    {
        // Set BEFORE test runs — background threads from here on will resolve correctly
        // ⚠️ Safe here because InitializeAsync runs on the test thread.
        // v2.28.22+: Fetcher() throws InvalidOperationException if called
        // outside a test context (e.g. background thread or fixture constructor).
        var (name, id) = CurrentTestInfo.Fetcher();
        Fixture.TestTracker.Set(name, id);
        return ValueTask.CompletedTask;
    }

    public ValueTask DisposeAsync()
    {
        // Clear BEFORE logging — prevents stale attribution
        Fixture.TestTracker.Clear();

        DiagrammedTestRun.TestContexts.Enqueue(TestContext.Current);
        return ValueTask.CompletedTask;
    }
}

Why Instance-Scoped (Not Static)

A static tracker breaks under parallel execution. Each fixture instance must own its tracker:

  • Per-test fixtures: New fixture per test → new tracker → no contention
  • Per-class fixtures: Tests within a class run sequentially (xUnit guarantee) → safe
  • Per-collection fixtures: Tests within a collection run sequentially → safe
  • Parallel collections: Each collection has its own fixture → own tracker → no cross-contamination

Solution 3: GlobalFallback (v2.28.16+)

When pre-existing background threads (Change Feed Processor polling threads, Hangfire workers, hosted service loops) were started before TestIdentityScope.Begin(), the AsyncLocal value never propagates to them. GlobalFallback is a process-wide static fallback that these threads can read.

public class MyTestBase : IAsyncLifetime
{
    protected MyCollectionFixture Fixture { get; }

    public ValueTask InitializeAsync()
    {
        var (name, id) = CurrentTestInfo.Fetcher();
        // AsyncLocal scope for new async continuations:
        _scope = TestIdentityScope.Begin(name, id);
        // Static fallback for pre-existing threads:
        TestIdentityScope.SetGlobalFallback(name, id);
        return ValueTask.CompletedTask;
    }

    public ValueTask DisposeAsync()
    {
        _scope?.Dispose();
        TestIdentityScope.ClearGlobalFallback();
        DiagrammedTestRun.TestContexts.Enqueue(TestContext.Current);
        return ValueTask.CompletedTask;
    }

    private IDisposable? _scope;
}

Resolution order (v2.28.16+): Same as the #scenario-resolution-reference — HTTP headers → delegate → scope → GlobalFallback → null.

⚠ Parallel execution warning: GlobalFallback is a single process-wide value. It only works correctly when tests sharing the same background infrastructure run serially (e.g. within an xUnit collection fixture). This matches the existing ActiveTestTracker pattern's semantics — if your tests already run in parallel with shared infrastructure, use Solution 1 (TestIdentityScope.Begin) instead.

When to Use GlobalFallback vs ActiveTestTracker

Scenario Recommended
Pre-existing Change Feed Processor threads GlobalFallback — simplest, no custom tracker class needed
Hosted services started before test GlobalFallback — eliminates CurrentTestInfoFetcher boilerplate
Multiple independent message trackers ActiveTestTracker — each tracker can have its own fetcher
Need to distinguish between "no test" and "test active" ActiveTestTrackerFetcher() can conditionally return Unknown

Understanding "Unknown" Entries

When a tracking handler captures an operation but cannot determine which test it belongs to, the operation is counted as Unknown in the diagnostic report.

Expected Unknown Entries

Some Unknown entries are normal and expected:

  • Background processing: Change feed processors, message handlers, hosted services — they have no HttpContext or test scope
  • Application startup/teardown: Operations during WebApplicationFactory initialisation or disposal happen outside test scope
  • Health checks and middleware: Periodic background operations from infrastructure

Unexpected Unknown Entries

If all entries for a component are Unknown, investigate:

Symptom Likely Cause Fix
All Cosmos operations are Unknown IHttpContextAccessor not wired to the tracking handler See Integration CosmosDB Extension#Using with CosmosDB.InMemoryEmulator
All outgoing HTTP calls are Unknown IHttpContextAccessor not passed to TestTrackingMessageHandler See HTTP Tracking Setup#Dual-Resolution Test Identity (v2.23.0+)
Unknown count matches total IHttpContextAccessor was null at handler construction time Use the LazyHttpContextAccessor pattern (see below)

Healthy Benchmarks

A typical diagnostic report shows:

  • 0% Unknown for inbound HTTP handlers (API controller calls)
  • 0% Unknown for synchronous outbound HTTP calls (typed HttpClients)
  • 30-80% Unknown for Cosmos/Service Bus when async processing is involved
  • 100% Unknown for components used only in background processing

LazyHttpContextAccessor Pattern

When CosmosTrackingMessageHandler (or any extension handler) is constructed before IHttpContextAccessor is available (common with WebApplicationFactory where the Cosmos client is a singleton built during DI), use a lazy wrapper:

internal sealed class LazyHttpContextAccessor : IHttpContextAccessor
{
    private IHttpContextAccessor? _inner;

    public HttpContext? HttpContext
    {
        get => _inner?.HttpContext;
        set { if (_inner is not null) _inner.HttpContext = value; }
    }

    public void SetInner(IHttpContextAccessor inner) => _inner = inner;
}

Usage:

// 1. Create wrapper and assign to options BEFORE building the Cosmos client
var lazyAccessor = new LazyHttpContextAccessor();
cosmosTrackingOptions.HttpContextAccessor = lazyAccessor;

// 2. Build client — handler captures the lazyAccessor reference
var cosmos = InMemoryCosmos.Builder()
    .WrapHandler(h => new CosmosTrackingMessageHandler(cosmosTrackingOptions, h))
    .Build();

// 3. Wire the real accessor after WebApplicationFactory is available
lazyAccessor.SetInner(factory.Services.GetRequiredService<IHttpContextAccessor>());

Important: The wrapper must be assigned to options before calling WrapHandler. Setting options.HttpContextAccessor afterwards has no effect — the handler has already captured the value at construction time.


Verification

After implementing either solution, check the Diagnostic Report with DiagnosticMode = true. The "Unknown" count should either disappear or be reduced to expected background operations (e.g. initial container setup before any test starts).

Home


Demo


Getting Started

Common Tasks

Integration Guides

Extensions

Configuration

Features

Reference

Clone this wiki locally