Skip to content

HTTP Tracking Setup

aryehcitron@gmail.com edited this page Nov 7, 2026 · 47 revisions

HTTP tracking is the core mechanism that captures all HTTP traffic for diagram generation. There are two sides to track:

  1. Outgoing calls from the SUT — Calls your API makes to its downstream dependencies
  2. Incoming calls to the SUT — Calls your test makes to the API under test

Deprecation (v2.28.1): CallingServiceName has been renamed to CallerName across all options classes. The old property still works but produces a compile warning. Update your code to use CallerName — the old name will be removed in a future major version.

This page covers the basic setup using TrackDependenciesForDiagrams() and the options reference. If your project uses named clients, typed clients, existing DelegatingHandler chains, or other advanced HttpClient patterns, see Tracking Dependencies for a step-by-step guide covering every common pattern.

Registering Tracking in DI

Inside your WebApplicationFactory configuration, register tracking for outgoing calls from the SUT:

Simplified imports (v2.0.131+): TrackDependenciesForDiagrams() and CreateTestTrackingClient() are now available directly from the core Kronikol namespace. You can use using Kronikol; instead of a framework-specific namespace — the core extensions accept the base TestTrackingMessageHandlerOptions type, so any framework-specific options class works.

builder.ConfigureServices(services =>
{
    services.TrackDependenciesForDiagrams(
        new XUnitTestTrackingMessageHandlerOptions
        {
            CallerName = "My API",
            PortsToServiceNames =
            {
                { 80, "My API" },
                { 5001, "Auth Service" },
                { 5002, "Payment Service" }
            }
        });
});

This registers:

  • TestTrackingMessageHandlerOptions as a singleton
  • IHttpContextAccessor for extracting tracking headers from incoming requests
  • IHttpMessageHandlerBuilderFilter as TrackingHttpMessageHandlerBuilderFilter (automatically injects the tracking DelegatingHandler into every HttpClient built by IHttpClientFactory)

TestTrackingMessageHandlerOptions

Property Type Default Description
PortsToServiceNames Dictionary<int, string> {} Maps port numbers to human-readable service names for the diagram. When the SUT makes an HTTP call to localhost:5001, the diagram will show the target as the mapped name. The map is looked up by the port alone, whatever the host. A call no map names appears as its host and port (payments.example.com:443), or as localhost:5001 when it goes to this machine (localhost, 127.0.0.1, [::1]); before 5.13.0 every such call appeared as localhost:{port}, whatever its host.
ClientNamesToServiceNames Dictionary<string, string> {} Maps IHttpClientFactory client names to human-readable service names. Useful when HTTP mocking makes port-based mapping unreliable. Pass the client name via the clientName constructor parameter: new TestTrackingMessageHandler(options, clientName: builder.Name). Resolution order: FixedNameForReceivingService > ClientNamesToServiceNames > PortsToServiceNames > the call's host and port (localhost:{port} for this machine).
FixedNameForReceivingService string? null If set, all requests handled by this handler are labelled with this fixed name (useful for the test-to-SUT client).
TestDouble string? null What answers this handler's calls in their service's place, written on each call as testDouble (5.32.0). null reads it from the chain: a handler at its end that answers the call itself, followed past any handler that hands the call on. A name marks every call the handler tracks as that double's answer, whatever the chain ends in, for a double the chain cannot show, such as "WireMock" on a socket. Empty or white space marks no call. See Tracking Dependencies.
CallerName string "Caller" The name shown in the diagram for the service making the call.
HeadersToForward IEnumerable<string> [] Additional HTTP headers to forward from incoming requests to outgoing requests (propagated through the call chain). A header the outgoing request already carries is left as it is (4.16.1): a value the caller set wins, and a retry that sends the same message again no longer carries it twice. The four test identity headers need not be listed: the handler puts them on every request it tracks, whatever the phase (4.14.3+). It does not track a request sent synchronously (Send), one to a host in ExcludedHosts or one for which no identity resolves, and such a request carries none of them. On a hop the request carries the Kronikol trace id of the request being served only when the handler holds an IHttpContextAccessor.
CurrentTestInfoFetcher Func<(string Name, string Id)>? null Delegate that returns the current test's name and unique ID. Each framework-specific options class sets this automatically.
CurrentStepTypeFetcher Func<string?>? null Delegate that returns the current BDD step type (e.g. "GIVEN", "WHEN"), for a framework that starts no steps: it marks the Setup/Action boundary. It is not asked once a framework's own steps have set the test's phase, and one that throws counts as no step type. Set automatically by the BDDfy, LightBDD and ReqNRoll options classes.
InternalFlowActivitySources string[]? null Obsolete from 5.34.0, never read (#138). The listener the handler starts hears every source but the runtime's connection-level ones whatever this holds, your own ActivitySources among them. To draw only some sources, set ReportConfigurationOptions.InternalFlowActivitySources with InternalFlowSpanGranularity.Manual; see [[Internal Flow Tracking › Manual Source Filtering
TrackDuringSetup bool true When false, HTTP requests made during the Setup phase are not tracked. See Phase-Aware Tracking.
TrackDuringAction bool true When false, HTTP requests made during the Action phase are not tracked. See Phase-Aware Tracking.
ExcludedHosts IReadOnlyCollection<string> ["override.com"] Host names to exclude from tracking. Requests to these hosts are forwarded without logging. Default includes override.com (ASP.NET Core TestServer's internal base address in WebApplicationFactory scenarios). Set to an empty collection to disable host-based filtering.
HttpContextAccessor IHttpContextAccessor? null Optional — enables dual-resolution of test identity from HTTP headers. Auto-resolved by CreateTestTrackingClient() (v2.26.3+). See [[Dual-Resolution Test Identity (v2.23.0+)

ClientNamesToServiceNames — Matching Semantics

ClientNamesToServiceNames uses a tiered resolution strategy (v3.0.23+):

  1. Exact match — The full client name is looked up as a dictionary key
  2. EndsWith with boundary — If the client name (stripped of assembly qualification) ends with a key, and the preceding character is non-alphanumeric (e.g. +, ., -), the match succeeds
  3. Contains (assembly-qualified names only) — For Refit v9 and other source generators that produce assembly-qualified client names (containing , ), a case-sensitive Contains check is used as a final fallback

The Contains fallback is restricted to assembly-qualified names to prevent false positives on simple names (e.g. key "Client" should not match "MyBetterClient").

For typed HttpClients registered via services.AddHttpClient<TClient>(), IHttpClientFactory uses the full type name as the client name. For example:

// Registration:
services.AddHttpClient<TenantHierarchyHttpClient>(o => o.BaseAddress = ...);

// In IHttpMessageHandlerBuilderFilter.Configure(), builder.Name = "TenantHierarchyHttpClient"
// So the dictionary key must be exactly "TenantHierarchyHttpClient":
ClientNamesToServiceNames =
{
    { "TenantHierarchyHttpClient", "Tenant Hierarchy Service" }  // ✅ exact match
    // { "TenantHierarchyService", "Tenant Hierarchy Service" }  // ❌ won't match
}

For named HttpClients registered via services.AddHttpClient("my-client"), the key is the string you passed.

Refit / source-generated clients (v3.0.21+): Refit and other source generators produce complex client names. For Refit v8-style names like Refit.Implementation.Generated+SomeModule+IIntelligenceAiApiClient, ends-with matching works (the + is a valid boundary). For Refit v9 assembly-qualified names like ...IntelligenceAiIIntelligenceAiApiClient, Data.Insights.Api, Version=1.0.0.0, ..., the contains fallback matches:

ClientNamesToServiceNames =
{
    { "IIntelligenceAiApiClient", "Intelligence AI" }  // ✅ matches both Refit v8 (EndsWith) and v9 (Contains)
}

Diagnostic tip (v2.28.12+): When DiagnosticMode=true, the diagnostic report includes an Unmatched HTTP Client Names section that lists all clientName values that didn't match any ClientNamesToServiceNames key (neither exact nor ends-with), along with request counts. This makes misconfigured mappings immediately visible.

Framework-specific options classes (XUnitTestTrackingMessageHandlerOptions, NUnitTestTrackingMessageHandlerOptions, BDDfyTestTrackingMessageHandlerOptions, LightBddTestTrackingMessageHandlerOptions, ReqNRollTestTrackingMessageHandlerOptions) extend TestTrackingMessageHandlerOptions and auto-populate CurrentTestInfoFetcher from the framework's test context. Each framework package also provides a CurrentTestInfo static class with a Fetcher property — use CurrentTestInfo.Fetcher when you need to set CurrentTestInfoFetcher on extension options.

⚠️ Startup HTTP calls: XUnitTestTrackingMessageHandlerOptions (and XUnit2TestTrackingMessageHandlerOptions) set CurrentTestInfoFetcher to read from the test context. If your SUT makes HTTP calls during startup — e.g. hosted services fetching configuration, health checks, warm-up requests — the fetcher will execute when no test is active. CurrentTestInfo.Fetcher throws InvalidOperationException when no test is running (since v2.28.22; xUnit v2's and NUnit's since 4.13.1). The tracking handler catches it and sends the startup call untracked, without test headers, unless a TestIdentityScope or TestIdentityScope.GlobalFallback names a test (it goes to that test) or RequestResponseLogger.CaptureBackground is on (it is kept as a background call, under the unknown identity). Tests still pass and diagrams for actual tests are unaffected.

If you want explicit control, use the base TestTrackingMessageHandlerOptions with CurrentTestInfo.Fetcher:

services.TrackDependenciesForDiagrams(new TestTrackingMessageHandlerOptions
{
    CallerName = "My API",
    PortsToServiceNames = { { 80, "My API" }, { 5001, "Auth Service" } },
    CurrentTestInfoFetcher = CurrentTestInfo.Fetcher,
});

See also: Tracking Dependencies — Gotcha: HTTP Calls During HttpClient Construction for a related pitfall when HTTP calls are made during HttpClient construction (not just app startup).

A call that threw

A send that throws (a refused connection, a broken response body, HttpClient's timeout surfacing as a TaskCanceledException) is logged as a response whose status is the exception's type behind a bang, !HttpRequestException, with the message chain in error (3.18.0); the exception is rethrown untouched. The fingerprint the history reads names it (GET /asyncapi/v1.json !HttpRequestException), the diagram draws the return with the message in the note on it (5.35.0), kronikol query http <report> s3/i1 prints the message under the status (the address of the response half, which holds it: s3/i0 is the request), and from 4.3.0 Failures.md prints it under the calls table and kronikol export sends the call as an ERROR span with it as the message. Before 3.18.0 the request stood alone with no reason, which hid every first attempt a retry loop swallowed. FailedSend.Status and FailedSend.Describe are public for any other capturer.

From 5.34.3 every Kronikol tracker answers a call that throws this way (#232): the AWS handlers, the Atlas Data API, Redis, the Google and Azure storage handlers when a response body breaks off as it is read, the brokers, the SQL family and EF Core (a cancelled command among them), Spanner, gRPC, MongoDB, Elasticsearch and TrackingProxy. A tracker whose service gives a status of its own keeps it, with the reason in error: the SQL family and Spanner answer Error, MassTransit draws a Fault call, and Kafka answers its error code (!Local_MsgTimedOut). Before, many of them left the request alone.

Non-HttpClient Dependencies

Not all dependencies use IHttpClientFactory. For dependencies with their own HTTP internals:

  • Azure Cosmos DB SDK — Uses its own HttpClient internally. Use Kronikol.Extensions.CosmosDB which provides CosmosTrackingMessageHandler. See Integration CosmosDB Extension.
  • EF Core / Relational databases — EF Core uses ADO.NET DbCommand internally, not HttpClient. Use Kronikol.Extensions.EfCore.Relational which provides SqlTrackingInterceptor — a DbCommandInterceptor that classifies SQL operations and shows them as Select: /ordersdb/Users, Insert: /ordersdb/Orders, etc. Works with SQL Server, PostgreSQL, MySQL, SQLite, Oracle, and Spanner. See Integration EF Core Relational Extension.
  • gRPC services — gRPC clients use GrpcChannel, not IHttpClientFactory. Use Kronikol.Extensions.Grpc which provides GrpcTrackingInterceptor for outgoing calls, GrpcTrackingChannel.Create() for incoming (test → SUT) calls, and AddTrackedGrpcClient<TClient>() for DI-based registration (v2.26.0+). Produces protobuf-aware labels like SayHello: grpc:///greet.Greeter/SayHello instead of raw HTTP/2 binary traffic. AddTrackedGrpcClient<T>() and CreateTestTrackingGrpcClient auto-resolve IHttpContextAccessor from DI so test identity flows through for SUT → downstream calls. See Integration Grpc Extension for the full setup guide.
  • Non-HTTP interactions (Kafka, Service Bus, etc.) — Use MessageTracker. See Event & Message Tracking.

Creating a Tracking Client

To track incoming calls from the test to the SUT, create the test's HttpClient via the CreateTestTrackingClient() extension method:

HttpClient client = factory.CreateTestTrackingClient(
    new XUnitTestTrackingMessageHandlerOptions
    {
        FixedNameForReceivingService = "My API"
    });

This is a convenience method equivalent to:

var client = factory.CreateDefaultClient(new TestTrackingMessageHandler(options));

It wraps the WebApplicationFactory's HttpClient in a TestTrackingMessageHandler so that all calls from the test to the SUT appear in the diagrams.

Parallel tests sharing one factory (5.6.1+). CreateTestTrackingClient() makes its client under a lock of the factory's own, as every framework package's CreateTestTrackingClient() and the gRPC extension's CreateTestTrackingGrpcClient() do, so tests running in parallel can share one factory and make their clients from it at the same moment, as a Reqnroll suite's features do in their [BeforeScenario] hooks. WebApplicationFactory has no lock of its own there: before 5.6.1, the first clients made at once each started a copy of the application, and a client made at the same moment as another could be lost from the factory's list of clients or leave a null in it, so the factory's Dispose threw NullReferenceException. A client you make with the factory's own CreateClient() while tests run is outside that lock: start the factory before the tests run (factory.StartServer() in a run-start hook) and make such clients one at a time.

Chaining with Existing DelegatingHandlers

If your test suite already has custom DelegatingHandlers on the test-to-SUT client (e.g. a handler that waits for background processing to complete after each request), use the overload that accepts additional handlers:

HttpClient client = factory.CreateTestTrackingClient(
    new XUnitTestTrackingMessageHandlerOptions
    {
        FixedNameForReceivingService = "My API"
    },
    new MyBackgroundWorkHandler(),
    new MyRetryHandler());

This is equivalent to manually calling CreateDefaultClient with the tracking handler first:

var trackingHandler = new TestTrackingMessageHandler(
    new XUnit2TestTrackingMessageHandlerOptions
    {
        FixedNameForReceivingService = "My API"
    });

HttpClient client = factory.CreateDefaultClient(trackingHandler, new MyBackgroundWorkHandler());

Ordering: The tracking handler is always placed first (outermost) so it captures the full request/response including any modifications made by subsequent handlers.

For more complex handler chaining scenarios (named clients, typed clients, existing DelegatingHandler chains, IHttpMessageHandlerBuilderFilter, etc.), see Tracking Dependencies which covers 8 common patterns in detail.


Dual-Resolution Test Identity (v2.23.0+)

The Problem

When your SUT processes a request inside WebApplicationFactory, the code runs on the server's thread pool — not on the test thread. This means AsyncLocal<T>-based test context (used by xUnit, NUnit, LightBDD, ReqNRoll, etc.) is not available inside the SUT's request pipeline.

This affects all tracking extensions that run inside the pipeline. For example, if your API produces a Kafka message, writes to Cosmos DB, or logs to Redis as part of handling a request, the extension's CurrentTestInfoFetcher delegate will throw an exception (caught internally by TestInfoResolver, so tests still pass — but unless a TestIdentityScope or the global fallback names the test, the operation is not logged at all, or with RequestResponseLogger.CaptureBackground on is kept as a background call under the unknown identity, and won't appear in diagrams).

The standard HTTP tracking (TestTrackingMessageHandler) never had this problem because it propagates test identity via HTTP headers (test-tracking-current-test-name, test-tracking-current-test-id) on the incoming request. But non-HTTP extensions (Kafka, Cosmos DB, Redis, EF Core, Service Bus, etc.) had no way to read those headers — until now.

The Solution

Starting in v2.23.0, all 22 tracking extensions accept an optional IHttpContextAccessor? httpContextAccessor parameter in their constructors. When provided, the extension uses dual-resolution to determine the current test identity:

  1. Try HTTP headers first — If an HttpContext is available (meaning the code is running inside a request pipeline), read test-tracking-current-test-name and test-tracking-current-test-id from the request headers
  2. Fall back to the delegate — If no HttpContext is available, or its request carries no tracking headers (e.g. the code is running directly on the test thread), use CurrentTestInfoFetcher as before

This is fully backward-compatible. The httpContextAccessor parameter defaults to null, and all existing code continues to work unchanged.

How to Use It

Every extension options class with a CurrentTestInfoFetcher has an HttpContextAccessor property (the TcpTap, ProxyTap and OTLP taps take neither): most since v2.26.3, MongoDB's since 4.10.0, and Kafka's, Bigtable's, Spanner's, MediatR's and EF Core's (SqlTrackingInterceptorOptions) since 4.11.0. Convenience methods, DI extensions, and CreateTestTrackingClient() auto-resolve IHttpContextAccessor from DI when available — no manual wiring needed for the standard setup paths:

builder.ConfigureTestServices(services =>
{
    // TrackDependenciesForDiagrams() registers IHttpContextAccessor automatically
    services.TrackDependenciesForDiagrams(new XUnitTestTrackingMessageHandlerOptions { ... });

    // ServiceBus — AddServiceBusTestTracking auto-resolves IHttpContextAccessor from DI
    services.AddServiceBusTestTracking(new ServiceBusTrackingOptions
    {
        ServiceName = "ServiceBus",
        CallerName = "My API",
        CurrentTestInfoFetcher = CurrentTestInfo.Fetcher
    });

    // gRPC — AddTrackedGrpcClient auto-resolves IHttpContextAccessor from DI
    services.AddTrackedGrpcClient<MyGrpcClient>(handler, uri, opts => { ... });
});

If you construct trackers manually (not via DI extensions), you can set HttpContextAccessor on the options:

builder.ConfigureTestServices(services =>
{
    services.AddHttpContextAccessor();

    services.AddSingleton(sp =>
    {
        var options = new KafkaTrackingOptions
        {
            ServiceName = "Kafka",
            CallerName = "My API",
            CurrentTestInfoFetcher = CurrentTestInfo.Fetcher,
            HttpContextAccessor = sp.GetRequiredService<IHttpContextAccessor>()
        };
        return new KafkaTracker(options);
    });
});

The constructor httpContextAccessor parameter still works and takes precedence over the options property. KafkaTrackingOptions.HttpContextAccessor is 4.11.0+; before it, pass the accessor to the constructor: new KafkaTracker(options, accessor).

Auto-Resolution (v2.26.3+)

All DI extensions and convenience methods auto-resolve IHttpContextAccessor from the service provider when it's available in DI. This covers:

  • Core: CreateTestTrackingClient() (both overloads)
  • ServiceBus: AddServiceBusTestTracking()
  • gRPC: AddTrackedGrpcClient<TClient>(), CreateTestTrackingGrpcClient()
  • MediatR: TrackMediatorForDiagrams() (auto-resolved since v2.23.0)
  • All other extensions: Set HttpContextAccessor on the options class — the tracker reads it via ?? options.HttpContextAccessor (BigQuery's and Pub/Sub's trackers since 4.18.2)

When Do You Need This?

You need dual-resolution (via auto-resolve or manual HttpContextAccessor) if all of these are true:

  1. Your extension tracker runs inside the SUT's request pipeline (not on the test thread)
  2. The SUT is hosted via WebApplicationFactory (in-process)
  3. You're using CreateTestTrackingClient() or manually adding TestTrackingMessageHandler on the test-to-SUT client, or (4.13.0+) a gRPC test client from CreateTestTrackingGrpcClient or GrpcTrackingChannel (each propagates the headers)

If your extension only runs on the test thread (e.g. in [BeforeScenario] / [AfterScenario] hooks, or in step definitions that call the SDK directly without going through the SUT), the delegate alone is sufficient.

A host that registers AddTestTrackingContextPropagation() resolves the scenario without an accessor: its middleware begins a TestIdentityScope from the headers of each request it serves, so a tracker in the pipeline whose fetcher cannot see the test resolves through that scope (attribution Scope, where the accessor gives RequestHeader), and a TestTrackingMessageHandler without an accessor passes that identity on. The accessor adds what the scope cannot: the request's Kronikol trace id, which the HTTP handler, the gRPC interceptor and MessageTracker reuse so kronikol query flow can nest their calls under the request by trace id, and the headers HeadersToForward copies.

Note: If you register trackers manually via services.AddSingleton(sp => new XxxTracker(options, sp.GetService<IHttpContextAccessor>())), the explicit constructor parameter takes precedence over the options property.

How It Works Internally

All extensions delegate to TestInfoResolver.Resolve(httpContextAccessor, currentTestInfoFetcher) (or ResolveWithSource, which also says which step answered), which tries, in order:

  1. The request the host is serving: when httpContextAccessor?.HttpContext?.Request carries both test-tracking-current-test-name and test-tracking-current-test-id, their values, decoded (HTTP Tracking Setup › Header values).
  2. In a flow detached from the test (TestIdentityScope.Detach(); AddTestTrackingContextPropagation() detaches a host's hosted services), only an explicit TestIdentityScope or the scenario whose document the flow is working on; otherwise the call is background.
  3. The CurrentTestInfoFetcher delegate, with its exceptions caught, unless it answers the unknown id.
  4. TestIdentityScope.Current: the scope the middleware AddTestTrackingContextPropagation() installs begins for each request that carries the headers, or one the test began.
  5. TestIdentityScope.GlobalFallback.
  6. Nothing: null, so the operation is not logged, unless RequestResponseLogger.CaptureBackground is on, when it is logged as background (Unknown).

Extensions Supporting Dual-Resolution

All 22 tracking extensions support the optional IHttpContextAccessor parameter as of v2.23.0. v2.26.3+: options classes expose an HttpContextAccessor property (MongoDB's from 4.10.0; Kafka's, Bigtable's, Spanner's, MediatR's and EF Core's (SqlTrackingInterceptorOptions) from 4.11.0), and convenience methods / DI extensions auto-resolve it from DI:

Category Extensions Auto-resolved since
Core CreateTestTrackingClient() v2.26.2
Messaging Kafka, ServiceBus, EventHubs, EventBridge, SQS, SNS, MassTransit, PubSub, StorageQueues v2.26.2 (ServiceBus DI extension); others via options property (Kafka's from 4.11.0)
Databases CosmosDB, MongoDB, DynamoDB, Redis, EF Core (Dapper), BigQuery, Elasticsearch v2.26.2 via options property (MongoDB's from 4.10.0; Bigtable's, Spanner's and EF Core's from 4.11.0). The BigQuery, Bigtable, Spanner, EF Core, PubSub, Kafka and MongoDB DI registrations use the options' accessor first and the container's when the options carry none (BigQuery's and Pub/Sub's tracker registrations since 4.18.2); the subscriber AddMongoDbTestTracking registers records nothing until a client subscribes it ([[MongoDB
Storage BlobStorage, S3, CloudStorage v2.26.2 via options property
RPC gRPC v2.26.1 (AddTrackedGrpcClient / CreateTestTrackingGrpcClient)
Proxy-based DispatchProxy, MediatR MediatR auto-resolved since v2.23.0; DispatchProxy v2.26.2 via ReplaceWithTrackingProxy overload

Using with JustEat.HttpClientInterception

When using JustEat.HttpClientInterception for HTTP mocking, you need a custom IHttpMessageHandlerBuilderFilter to insert TestTrackingMessageHandler before the interception handler:

public class HttpClientInterceptionFilter(
    HttpClientInterceptorOptions options,
    IServiceProvider serviceProvider,
    TestTrackingMessageHandlerOptions? trackingOptions = null) : IHttpMessageHandlerBuilderFilter
{
    public Action<HttpMessageHandlerBuilder> Configure(Action<HttpMessageHandlerBuilder> next) =>
        builder =>
        {
            next(builder);

            string? serviceName = null;
            if (trackingOptions is not null && !string.IsNullOrEmpty(builder.Name))
            {
                var match = trackingOptions.ClientNamesToServiceNames
                    .FirstOrDefault(kvp => builder.Name.Contains(kvp.Key, StringComparison.OrdinalIgnoreCase));
                serviceName = match.Value;
            }

            if (serviceName is not null)
            {
                var httpContextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
                builder.PrimaryHandler = new TestTrackingMessageHandler(
                    new TestTrackingMessageHandlerOptions
                    {
                        CallerName = trackingOptions!.CallerName,
                        FixedNameForReceivingService = serviceName,
                        CurrentTestInfoFetcher = trackingOptions.CurrentTestInfoFetcher,
                    },
                    httpContextAccessor)
                {
                    InnerHandler = new MockHttpMessageHandler(options)
                    {
                        InnerHandler = builder.PrimaryHandler
                    }
                };
            }
            else
            {
                builder.AdditionalHandlers.Add(options.CreateHttpMessageHandler());
            }
        };
}

The MockHttpMessageHandler wrapper handles GetResponseAsync properly as a DelegatingHandler:

public class MockHttpMessageHandler(HttpClientInterceptorOptions options) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var response = await options.GetResponseAsync(request, cancellationToken);
        if (response is not null) return response;
        if (options.ThrowOnMissingRegistration)
            throw new HttpRequestNotInterceptedException(
                $"No HTTP response configured for {request.Method} {request.RequestUri}.", request);
        return await base.SendAsync(request, cancellationToken);
    }
}

Register in ConfigureTestServices:

services.AddSingleton<IHttpMessageHandlerBuilderFilter>(sp =>
    new HttpClientInterceptionFilter(interceptorOptions, sp, trackingOptions));

Common mistake — _ => vs sp =>: Using _ => discards the service provider, leaving IHttpContextAccessor null. Always use sp => and resolve IHttpContextAccessor from it. See Dual-Resolution Test Identity (v2.23.0+).


Handler Pipeline Ordering

When combining TestTrackingMessageHandler with test faking (JustEat.HttpClientInterception, WireMock, or custom handlers), the handler chain ordering is critical:

┌─────────────────────────────────────┐
│ HttpClient                          │
├─────────────────────────────────────┤
│ TestTrackingMessageHandler          │  ← Records request + response
│   InnerHandler ─┐                   │
│                 ▼                    │
│ InterceptionHandler                 │  ← Returns fake response
│   InnerHandler ─┐                   │
│                 ▼                    │
│ PrimaryHandler (HttpClientHandler)  │  ← Never reached (intercepted)
└─────────────────────────────────────┘

The tracking handler must be outermost — it wraps the interception handler, not the reverse. This way:

  1. Request flows through TestTrackingMessageHandler (recorded)
  2. Then through the interception handler (fake response returned)
  3. Response flows back through TestTrackingMessageHandler (recorded)

Why builder.PrimaryHandler, Not builder.AdditionalHandlers

When implementing IHttpMessageHandlerBuilderFilter, replace PrimaryHandler with the tracking handler chained to the interceptor:

// ❌ Wrong: AdditionalHandlers runs after PrimaryHandler interception
builder.AdditionalHandlers.Add(trackingHandler);

// ✅ Correct: tracking wraps interception as the primary handler
var interceptionHandler = _interceptor.CreateHttpMessageHandler();
interceptionHandler.InnerHandler = builder.PrimaryHandler;

builder.PrimaryHandler = new TestTrackingMessageHandler(options, httpContextAccessor)
{
    InnerHandler = interceptionHandler
};

IHttpContextAccessor Timing

TestTrackingMessageHandler needs IHttpContextAccessor for dual-resolution of test identity. This must be resolved from DI at handler creation time, not at registration time:

// ❌ Wrong: HttpContextAccessor resolved at registration — HttpContext will be null
services.AddSingleton<IHttpMessageHandlerBuilderFilter>(_ =>
    new MyFilter(new HttpContextAccessor()));

// ✅ Correct: resolved from service provider at filter creation time
services.AddSingleton<IHttpMessageHandlerBuilderFilter>(sp =>
    new MyFilter(sp));

CreateTestTrackingClient Behaviour

CreateTestTrackingClient() is the primary entry point for tracking inbound HTTP requests (test → SUT). It:

  1. Creates an HttpClient targeting the in-memory test server
  2. Inserts a TestTrackingMessageHandler as the outermost handler
  3. Auto-injects test-tracking-current-test-name, test-tracking-current-test-id, test-tracking-trace-id and test-tracking-caller-name headers into every request it tracks, each only when the request lacks it. The call is logged with the test-tracking-trace-id the request carries, such as one you set or a first attempt's when a retry sends the message again (4.16.1; before, it was logged with another)
  4. (v2.26.3+) Auto-resolves IHttpContextAccessor from DI
  5. (5.6.1+) Makes the client under a lock of the factory's own, so tests running in parallel can share the factory (Parallel tests sharing one factory)

These headers are how the SUT's internal tracking (CosmosDB, Redis, Service Bus, etc.) correlates operations back to the originating test.

Header values

A test name, test id or caller name that is plain printable ASCII, neither begins nor ends with a space and does not begin with UTF-8'' is sent as it is. Any other value is sent in the RFC 8187 form that Content-Disposition: filename*= uses: UTF-8'' followed by the value's UTF-8 bytes, percent-encoded, so Café order travels as UTF-8''Caf%C3%A9%20order. That covers a name with an accent, an emoji or a line feed, an xUnit v3 theory whose long string argument xUnit shortened with ···, and an MSTest id naming a method with a non-ASCII letter.

Every Kronikol reader decodes the form: the middleware AddTestTrackingContextPropagation() installs, the dual resolution described above, MessageTracker, ProxyTap, and TestTrackingServerBridge.GetCurrentTestInfo(accessor), which is the way to read the identity in your own code. The handler, the gRPC interceptor (4.13.0+), ProxyTap and Playwright's ToHeaders() write it. Until 4.12.1 such a value failed the request over a real socket ("Request headers must contain only ASCII characters"), a line feed failed it in memory too, and ProxyTap and Playwright sent ? for each such character.

When you write the headers yourself, encode the same way:

static string HeaderValue(string value) =>
    value.All(c => c is >= ' ' and <= '~') && !value.StartsWith("UTF-8''") && value.Trim(' ') == value
        ? value
        : "UTF-8''" + Uri.EscapeDataString(value);

FixedNameForReceivingService sets the target service name in diagrams. It should match the CallerName used in outbound tracking options:

// Inbound: "Caller" → "My Service"
var client = factory.CreateTestTrackingClient(new XUnitTestTrackingMessageHandlerOptions
{
    FixedNameForReceivingService = "My Service"
});

// Outbound: "My Service" → "Downstream A"
services.TrackDependenciesForDiagrams(new XUnitTestTrackingMessageHandlerOptions
{
    CallerName = "My Service",  // Must match FixedNameForReceivingService above
    ClientNamesToServiceNames = { { "DownstreamAClient", "Downstream A" } }
});

One client per collection: Create one tracked client per test collection (not per test). The client is thread-safe and Kronikol headers are injected per-request based on the current test context.

Home · 🔍 Search this wiki Full-text search across every page. The “Pages” list above matches page and heading names, not page content.


Demo


Getting Started

Common Tasks

Integration Guides

Uninstrumentable / polyglot backends

Extensions

Configuration

Features

Reference

Clone this wiki locally