Skip to content

HTTP Tracking Setup

aryehcitron@gmail.com edited this page May 24, 2026 · 31 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. Unmapped ports will appear as localhost_80, localhost_5001, etc.
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 > localhost:port.
FixedNameForReceivingService string? null If set, all requests handled by this handler are labelled with this fixed name (useful for the test-to-SUT client).
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).
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"). Used for automatic setup separation. Set automatically by BDDfy and ReqNRoll options classes.
InternalFlowActivitySources string[]? null Additional ActivitySource names to capture for Internal Flow Tracking. The handler auto-starts a listener for well-known sources (ASP.NET Core, HttpClient, EF Core, etc.). Add your custom source names here to include them in internal flow diagrams. See Internal Flow Tracking#Span Granularity for the full list of well-known sources and granularity modes.
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. As of v2.27.10, CurrentTestInfo.Fetcher is null-safe and returns ("Unknown", "unknown") when the test context is unavailable. The tracking handler catches this and the startup call is either logged with "Unknown" identity or silently skipped — 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).

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.

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 the operation is logged with no test 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 (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

v2.26.3+: All extension options classes now have an HttpContextAccessor property. 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.

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

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 (which 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.

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) which:

  1. Checks if httpContextAccessor?.HttpContext?.Request has test-tracking-current-test-name and test-tracking-current-test-id headers
  2. If both headers are present and non-empty, returns them
  3. Otherwise, invokes the CurrentTestInfoFetcher delegate (with exception handling)
  4. If the delegate also fails or is null, returns null (operation is not logged)

Extensions Supporting Dual-Resolution

All 22 tracking extensions support the optional IHttpContextAccessor parameter as of v2.23.0. v2.26.3+: All options classes now expose an HttpContextAccessor property, 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
Databases CosmosDB, MongoDB, DynamoDB, Redis, EF Core (Dapper), BigQuery, Elasticsearch v2.26.2 via options property; MongoDB/BigQuery/Bigtable/Spanner/EF Core/PubSub/Kafka already auto-resolved
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, and test-tracking-trace-id headers into every request
  4. (v2.26.3+) Auto-resolves IHttpContextAccessor from DI

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

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


Demo


Getting Started

Common Tasks

Integration Guides

Extensions

Configuration

Features

Reference

Clone this wiki locally