Skip to content

Tracking Dependencies

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

This page explains how Kronikol captures HTTP traffic between your service under test (SUT) and its downstream dependencies, and how to configure tracking for every common HttpClient pattern.

Event-driven architecture? If your tests don't make direct HTTP calls to the SUT (instead publishing events that the SUT consumes via a background service), see Event-Driven Architecture Testing. The HTTP tracking described on this page still applies to the SUT's outbound calls to dependencies.

How Tracking Works

Kronikol works by intercepting HTTP traffic at the HttpClient level using a DelegatingHandler called TestTrackingMessageHandler. When the SUT makes an outgoing HTTP call, the handler:

  1. Adds correlation headers to the request (TraceId, CurrentTestName, CurrentTestId, CallerName)
  2. Logs the full request (method, URL, headers, body)
  3. Passes the request through to the actual destination
  4. Logs the full response (status code, headers, body)
  5. Forwards tracking headers downstream so multi-hop calls are correlated

This captured data is then used to generate PlantUML sequence diagrams showing exactly what happened during each test.

InnerHandler Default Behaviour

v3.0.19+: TestTrackingMessageHandler no longer pre-sets InnerHandler in the constructor. It is lazily initialised to HttpClientHandler on first SendAsync only if nothing else has set it. This means you can now use it directly with AddHttpMessageHandler<T>(), IHttpMessageHandlerBuilderFilter, or any other pipeline builder pattern — no workarounds needed.

Pre-v3.0.19 behaviour (click to expand)

Prior to v3.0.19, TestTrackingMessageHandler set InnerHandler ??= new HttpClientHandler() in its constructor. This caused InvalidOperationException when used with IHttpClientFactory's CreateHandlerPipeline(), which expects InnerHandler to be null so it can wire the chain automatically.

The old workaround was:

httpClientBuilder.AddHttpMessageHandler(sp =>
{
    var handler = new TestTrackingMessageHandler(options, sp.GetRequiredService<IHttpContextAccessor>());
    handler.InnerHandler = null!; // Let the pipeline builder set InnerHandler
    return handler;
});

This is no longer necessary — the handler now works correctly in all pipeline scenarios without modification.

You can still use ConfigurePrimaryHttpMessageHandler if you prefer, or use the IHttpMessageHandlerBuilderFilter approach shown in #Pattern 8 below.

TrackDependenciesForDiagrams vs TrackMessagesForDiagrams

These two registration methods serve different purposes and are typically used together:

Method What it registers Purpose
TrackDependenciesForDiagrams() Registers TrackingHttpMessageHandlerBuilderFilter (v3.0.21+), TestTrackingMessageHandlerOptions, IHttpContextAccessor Track HTTP traffic between SUT and dependencies
TrackMessagesForDiagrams() Registers MessageTracker and IHttpContextAccessor Track non-HTTP interactions (Kafka, Service Bus, EventGrid, etc.)

They are not alternatives to each other. If your SUT has both HTTP dependencies and messaging, register both. If you need HTTP tracking without the built-in filter (e.g. because you want more control over which clients are tracked), don't use either — instead, register IHttpContextAccessor and TestTrackingMessageHandlerOptions yourself, then use one of the patterns on this page (Pattern 2 Option B, Pattern 5, Pattern 8) to inject the tracking handler.

✅ (v3.0.21+) TrackDependenciesForDiagrams now uses IHttpMessageHandlerBuilderFilter instead of replacing IHttpClientFactory. This means custom filter registrations (e.g. JustEat HttpClient Interception's HttpClientInterceptionFilter, Polly, logging filters) coexist correctly with Kronikol's tracking. The builder.Name is automatically passed as clientName for ClientNamesToServiceNames resolution (with ends-with fallback for Refit-generated names). In versions prior to v3.0.21, TrackDependenciesForDiagrams replaced IHttpClientFactory entirely — if you were using Pattern 5 or Pattern 8 as a workaround, you can now safely switch back to TrackDependenciesForDiagrams.

Note (v2.28.22+): Requests to override.com (ASP.NET Core TestServer's internal base address) are automatically excluded from tracking. If you see unpaired entries to override.com in your diagnostic report, upgrade to v2.28.22+. To customise the list of excluded hosts, set ExcludedHosts on your options:

var options = new XUnitTestTrackingMessageHandlerOptions
{
    ExcludedHosts = ["override.com", "health-check.internal"]
};

The Key Requirement: Same-Process Execution

Kronikol requires that the SUT runs in the same process as the tests. This is the standard setup when using WebApplicationFactory<T> from Microsoft.AspNetCore.Mvc.Testing — it hosts your API in-memory within the test process.

This means tracking will not work if your tests are calling a separately running SUT (e.g. an API deployed to a staging environment, a Docker container on a different port, or a service started in a separate terminal). In those scenarios, the test framework has no way to replace or wrap the SUT's internal HttpClient instances.

The one exception is the test-to-SUT client itself: you can always wrap that in a TestTrackingMessageHandler regardless of where the SUT runs. But you won't see any of the SUT's outgoing calls to its dependencies in the diagrams.

What About External/Fake Dependencies?

The downstream services that your SUT calls do not need to be in-memory or faked. They can be:

  • In-memory fake APIs spun up using WebApplicationFactory (as in the Example.Api project)
  • WireMock or similar HTTP stub servers
  • Real external services running locally, in Docker, or remotely
  • JustEat.HttpClientInterception or other handler-level fakes

It doesn't matter where the dependent service lives — what matters is that the SUT's outgoing HttpClient goes through a TestTrackingMessageHandler. The handler intercepts the call, logs it, and then passes it through to whatever is actually listening at the target URL.

Tracking Two Sides of the Traffic

Every integration test has two categories of HTTP traffic to track:

┌─────────┐          ┌─────────────┐          ┌────────────────────┐
│  Test    │ ──(1)──▶ │  SUT (API)  │ ──(2)──▶ │  Dependencies      │
│  Runner  │ ◀─────── │  in-memory  │ ◀─────── │  (real or faked)   │
└─────────┘          └─────────────┘          └────────────────────┘
  1. Incoming: Test → SUT — Use CreateTestTrackingClient() on the WebApplicationFactory (see HTTP Tracking Setup#Creating a Tracking Client). This wraps the test's HttpClient in a TestTrackingMessageHandler so the call from the test to the SUT appears in diagrams.

  2. Outgoing: SUT → Dependencies — Override the SUT's IHttpClientFactory (or individual HttpClient registrations) in ConfigureTestServices so that all outgoing calls go through a TestTrackingMessageHandler. This is the part that varies depending on how your project uses HttpClient.

The rest of this page focuses on (2) — how to intercept the SUT's outgoing calls for each HttpClient pattern.

Azure Cosmos DB: The Cosmos DB SDK uses its own internal HttpClient, not IHttpClientFactory. It cannot be tracked with TestTrackingMessageHandler or TrackDependenciesForDiagrams. Instead, use the Kronikol.Extensions.CosmosDB package, which provides CosmosTrackingMessageHandler — a specialised DelegatingHandler that classifies Cosmos operations and shows them as Create Document [orders], Query [orders]: SELECT ..., etc. See Integration CosmosDB Extension for the full setup guide.

EF Core / Relational databases: EF Core uses ADO.NET DbCommand objects internally, not HttpClient. It cannot be tracked with TestTrackingMessageHandler or TrackDependenciesForDiagrams. Instead, use the Kronikol.Extensions.EfCore.Relational package, which provides SqlTrackingInterceptor — a DbCommandInterceptor that classifies SQL operations and shows them as Select: /ordersdb/Users, Insert: /ordersdb/Orders, StoredProc: /ordersdb/GetReport, etc. Works with any EF Core relational provider (SQL Server, PostgreSQL, MySQL, SQLite, Oracle, Spanner). See Integration EF Core Relational Extension for the full setup guide.

Redis (StackExchange.Redis): StackExchange.Redis uses its own multiplexed TCP connection, not HttpClient. It cannot be tracked with TestTrackingMessageHandler or TrackDependenciesForDiagrams. Instead, use the Kronikol.Extensions.Redis package, which provides RedisTrackingDatabase — a DispatchProxy-based IDatabase decorator that classifies Redis operations with cache hit/miss detection and shows them as Get (Hit): redis://db0/user:123, Set: redis://db0/session:abc, HashGet (Miss): redis://db0/cart:456, etc. See Integration Redis Extension for the full setup guide.

gRPC services: gRPC clients use GrpcChannel with HTTP/2 transport, not IHttpClientFactory. Raw HTTP tracking would show binary protobuf bodies and POST labels. Instead, use the Kronikol.Extensions.Grpc package, which provides GrpcTrackingInterceptor — a Grpc.Core.Interceptors.Interceptor that classifies gRPC operations, deserializes protobuf messages to JSON, and produces rich labels like SayHello: grpc:///greet.Greeter/SayHello. For incoming gRPC calls (test → SUT), use GrpcTrackingChannel.Create(factory.Server.CreateHandler(), ...). See Integration Grpc Extension for the full setup guide.

⚠️ SUT → downstream gRPC: If your SUT makes gRPC calls to downstream services during request processing, use AddTrackedGrpcClient<TClient>() (v2.26.0+) which auto-resolves IHttpContextAccessor from DI. Without it, the interceptor cannot resolve test identity on the SUT's worker thread and calls will silently disappear from per-test reports. See Integration Grpc Extension#DI Extension: AddTrackedGrpcClient.

Other non-HTTP dependencies (Blob Storage, Key Vault, SDK fakes, etc.): If your dependency is faked at the SDK level (e.g. overriding BlobClient virtual methods) and there is no HTTP pipeline to intercept, use RequestResponseLogger.Log() directly. This is the same API that CosmosTrackingMessageHandler uses internally. See Tracking Custom Dependencies for a complete guide with examples.


Pattern 1: Basic IHttpClientFactory (The Simplest Case)

When your SUT does this:

// In Program.cs / Startup.cs
builder.Services.AddHttpClient();
// In a controller or service
public class MyController(IHttpClientFactory factory)
{
    public async Task<string> GetData()
    {
        var client = factory.CreateClient();
        client.BaseAddress = new Uri("http://some-service/");
        return await client.GetStringAsync("/data");
    }
}

This is the simplest case. Your SUT calls IHttpClientFactory.CreateClient() to get anonymous HttpClient instances.

How to Track

Replace the entire IHttpClientFactory with TestTrackingHttpClientFactory:

var factory = new WebApplicationFactory<Program>()
    .WithWebHostBuilder(builder =>
    {
        builder.ConfigureTestServices(services =>
        {
            services.TrackDependenciesForDiagrams(
                new XUnitTestTrackingMessageHandlerOptions
                {
                    CallerName = "My API",
                    PortsToServiceNames =
                    {
                        { 80, "My API" },
                        { 5001, "Auth Service" },
                        { 5002, "Payment Service" }
                    }
                });
        });
    });

Framework-specific options classes: The examples on this page use XUnitTestTrackingMessageHandlerOptions (for xUnit v3), but each supported test framework has its own equivalent that auto-populates the test context. Use the one that matches your framework:

  • xUnit v3XUnitTestTrackingMessageHandlerOptions
  • NUnit 4NUnitTestTrackingMessageHandlerOptions
  • BDDfy + xUnit 3BDDfyTestTrackingMessageHandlerOptions
  • LightBDD + xUnit 2LightBddTestTrackingMessageHandlerOptions
  • ReqNRoll + xUnit 2ReqNRollTestTrackingMessageHandlerOptions (from Kronikol.ReqNRoll.xUnit2)
  • ReqNRoll + xUnit 3ReqNRollTestTrackingMessageHandlerOptions (from Kronikol.ReqNRoll.xUnit3)

When constructing a TestTrackingMessageHandler directly (rather than through TrackDependenciesForDiagrams), you can also use the base TestTrackingMessageHandlerOptions class — but you'll need to set CurrentTestInfoFetcher yourself.

See Framework Integration Guides for the full list.

TrackDependenciesForDiagrams() registers a TrackingHttpMessageHandlerBuilderFilter (v3.0.21+) that adds TestTrackingMessageHandler to every HttpClient pipeline via the standard IHttpMessageHandlerBuilderFilter mechanism — so every outgoing call is automatically tracked while other filters (Polly, logging, custom) continue to work.

For a full description of all the options (PortsToServiceNames, FixedNameForReceivingService, CallerName, HeadersToForward, etc.), see the HTTP Tracking Setup reference.

This is the approach used in the Example.Api project. It's the recommended starting point.

What PortsToServiceNames Does

When the handler intercepts a call to http://localhost:5001/api/data, it uses the port number (5001) to look up a human-readable name for the diagram. If port 5001 is mapped to "Auth Service", the diagram will show My API -> Auth Service: GET: /api/data. Unmapped ports appear as localhost:5001.


Pattern 2: Named HttpClient Instances

When your SUT does this:

// In Program.cs / Startup.cs
services.AddHttpClient("PaymentService", client =>
{
    client.BaseAddress = new Uri("https://payments.example.com/");
    client.Timeout = TimeSpan.FromSeconds(30);
});

services.AddHttpClient("AuthService", client =>
{
    client.BaseAddress = new Uri("https://auth.example.com/");
});
// In a controller or service
public class PaymentController(IHttpClientFactory factory)
{
    public async Task<PaymentResult> ProcessPayment(PaymentRequest request)
    {
        var client = factory.CreateClient("PaymentService");
        return await client.PostAsJsonAsync("/process", request);
    }
}

Named clients are configured once at startup— AddHttpClient("name", ...) — and retrieved by name via IHttpClientFactory.CreateClient("name"). They commonly have pre-configured base addresses, timeouts, and default headers.

How to Track

Option A: Replace the entire factory (simple, but loses named client configuration)

services.TrackDependenciesForDiagrams(
    new XUnitTestTrackingMessageHandlerOptions
    {
        CallerName = "My API",
        PortsToServiceNames =
        {
            { 80, "My API" },
            { 443, "Payment Service" },
            { 8080, "Auth Service" }
        }
    });

This works, but the TestTrackingHttpClientFactory returns plain HttpClient instances — it does not apply the named client configurations (base address, default headers, timeouts). If your SUT relies on those being pre-configured (e.g. it calls client.GetAsync("/process") without setting a base address because it expects the named registration to provide one), the calls will fail.

Use this approach if your SUT always specifies full absolute URLs when making HTTP calls, or if you're overriding all the configuration via test settings anyway.

Option B: Use ConfigureHttpClientDefaults to inject the tracking handler (recommended)

builder.ConfigureTestServices(services =>
{
    var trackingOptions = new XUnitTestTrackingMessageHandlerOptions
    {
        CallerName = "My API",
        PortsToServiceNames =
        {
            { 80, "My API" },
            { 443, "Payment Service" },
            { 8080, "Auth Service" }
        }
    };

    services.AddHttpContextAccessor();
    services.AddSingleton<TestTrackingMessageHandlerOptions>(trackingOptions);

    // Inject tracking handler as the primary handler for ALL named clients
    services.ConfigureHttpClientDefaults(httpClientBuilder =>
        httpClientBuilder.ConfigurePrimaryHttpMessageHandler(sp =>
            new TestTrackingMessageHandler(
                sp.GetRequiredService<TestTrackingMessageHandlerOptions>(),
                sp.GetRequiredService<IHttpContextAccessor>())));
});

ConfigureHttpClientDefaults applies to every HttpClient created by the factory, regardless of name. By setting TestTrackingMessageHandler as the primary handler, all outgoing calls are tracked while the named client configuration (base address, headers, timeouts) is preserved.

Option C: Inject the tracking handler per named client

If you need to track only specific named clients, or need different tracking options per client:

services.AddHttpContextAccessor();

services.ConfigureHttpClientDefaults(httpClientBuilder =>
    httpClientBuilder.AddHttpMessageHandler(sp =>
        new TestTrackingMessageHandler(
            new XUnitTestTrackingMessageHandlerOptions
            {
                CallerName = "My API",
                PortsToServiceNames =
                {
                    { 80, "My API" },
                    { 443, "Payment Service" }
                }
            },
            sp.GetRequiredService<IHttpContextAccessor>())));

Or target a specific named client:

services.AddHttpClient("PaymentService")
    .AddHttpMessageHandler(sp =>
        new TestTrackingMessageHandler(
            new XUnitTestTrackingMessageHandlerOptions
            {
                CallerName = "My API",
                FixedNameForReceivingService = "Payment Service"
            },
            sp.GetRequiredService<IHttpContextAccessor>()));

FixedNameForReceivingService gives the target a fixed name regardless of what port or URL it's on. This is useful when you know exactly which service a particular named client talks to.


Pattern 3: Typed HttpClient Instances

When your SUT does this:

// In Program.cs / Startup.cs
services.AddHttpClient<IPaymentClient, PaymentClient>();
services.AddHttpClient<IAuthClient, AuthClient>();
// The typed client
public class PaymentClient(HttpClient client)
{
    public Task<PaymentResult> ProcessAsync(PaymentRequest request)
        => client.PostAsJsonAsync<PaymentResult>("/process", request);
}

Typed clients receive an HttpClient via constructor injection. The DI container creates the HttpClient from IHttpClientFactory and injects it directly.

How to Track

The approaches are the same as for named clients because, under the hood, a typed client registration is just a named client (named after the type) with a transient service registration.

Option A: Replace the entire factory — same as Pattern 2, Option A. Same caveats about losing configuration.

Option B: ConfigureHttpClientDefaults (recommended) — same as Pattern 2, Option B. This is the cleanest approach:

builder.ConfigureTestServices(services =>
{
    var trackingOptions = new XUnitTestTrackingMessageHandlerOptions
    {
        CallerName = "My API",
        PortsToServiceNames =
        {
            { 80, "My API" },
            { 5001, "Payment Service" },
            { 5002, "Auth Service" }
        }
    };

    services.AddHttpContextAccessor();
    services.AddSingleton<TestTrackingMessageHandlerOptions>(trackingOptions);

    services.ConfigureHttpClientDefaults(httpClientBuilder =>
        httpClientBuilder.ConfigurePrimaryHttpMessageHandler(sp =>
            new TestTrackingMessageHandler(
                sp.GetRequiredService<TestTrackingMessageHandlerOptions>(),
                sp.GetRequiredService<IHttpContextAccessor>())));
});

Pattern 4: Manually Constructed HttpClient (Direct new HttpClient())

When your SUT does this:

public class LegacyService
{
    public async Task<string> GetDataAsync()
    {
        using var client = new HttpClient();
        return await client.GetStringAsync("https://external-service.com/data");
    }
}

Direct HttpClient construction bypasses IHttpClientFactory entirely, so there is no DI hook to inject a tracking handler.

How to Track

You need to refactor the SUT code so instead of creating its own HttpClient, it receives one from IHttpClientFactory (or receives it via DI). Then use one of the patterns above.

If refactoring is not possible, the only remaining option is to replace the entire service in tests:

builder.ConfigureTestServices(services =>
{
    services.RemoveAll<LegacyService>();
    services.AddSingleton(sp =>
    {
        var handler = new TestTrackingMessageHandler(
            new XUnitTestTrackingMessageHandlerOptions
            {
                CallerName = "My API",
                FixedNameForReceivingService = "External Service"
            },
            sp.GetRequiredService<IHttpContextAccessor>());

        return new LegacyService(new HttpClient(handler));
    });
});

This only works if the service can accept an HttpClient through a constructor or property.

Bottom line: If your code uses new HttpClient() directly, you'll need to modify it to accept an injected HttpClient or use IHttpClientFactory. This is also recommended practice by Microsoft for unrelated reasons (socket exhaustion, DNS changes).


Pattern 5: Named Clients with Existing DelegatingHandler Chains

When your SUT does this:

services.AddTransient<CorrelationIdDelegatingHandler>();

services.AddHttpClient("CowService", (sp, client) =>
{
    var config = sp.GetRequiredService<IOptions<CowServiceConfig>>().Value;
    client.BaseAddress = new Uri(config.BaseAddress);
}).AddHttpMessageHandler<CorrelationIdDelegatingHandler>();

The SUT has its own DelegatingHandler pipeline (e.g. for correlation IDs, auth tokens, retry policies). You need the tracking handler to participate in this chain.

How to Track

Option A: Custom IHttpClientFactory that preserves the handler chain

Build a custom factory that keeps the existing handlers but inserts TestTrackingMessageHandler at the bottom of the chain:

public class TestHttpClientFactory(
    IHttpContextAccessor httpContextAccessor,
    ComponentTestSettings settings) : IHttpClientFactory
{
    public HttpClient CreateClient(string name)
    {
        var (baseUrl, label) = name switch
        {
            "CowService" => (settings.CowServiceBaseUrl!, "Cow Service"),
            "GoatService" => (settings.GoatServiceBaseUrl!, "Goat Service"),
            _ => ("http://localhost/", name)
        };

        var handler = new CorrelationIdDelegatingHandler(httpContextAccessor)
        {
            InnerHandler = new TestTrackingMessageHandler(
                new TestTrackingMessageHandlerOptions
                {
                    FixedNameForReceivingService = label,
                    CallerName = "My API"
                },
                httpContextAccessor)
            {
                InnerHandler = new HttpClientHandler()
            }
        };

        return new HttpClient(handler) { BaseAddress = new Uri(baseUrl) };
    }
}

Then register it:

builder.ConfigureTestServices(services =>
{
    services.AddHttpContextAccessor();
    services.AddSingleton<IHttpClientFactory>(sp =>
        new TestHttpClientFactory(
            sp.GetRequiredService<IHttpContextAccessor>(),
            settings));
});

This is the most flexible approach: you control exactly which handlers are in the chain, what order they run in, and what names appear on the diagrams. It's particularly useful when:

  • Different named clients talk to different services
  • You want to add additional test handlers (request capturing, fake header propagation) alongside tracking
  • you need to replicate production handler behaviour (e.g. correlation ID propagation) in tests

Option B: ConfigureHttpClientDefaults with AddHttpMessageHandler

If you just want tracking injected into all existing chains without rewriting the factory:

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

    services.ConfigureHttpClientDefaults(httpClientBuilder =>
        httpClientBuilder.AddHttpMessageHandler(sp =>
            new TestTrackingMessageHandler(
                new XUnitTestTrackingMessageHandlerOptions
                {
                    CallerName = "My API",
                    PortsToServiceNames =
                    {
                        { 80, "My API" },
                        { 5031, "Cow Service" },
                        { 5032, "Goat Service" }
                    }
                },
                sp.GetRequiredService<IHttpContextAccessor>())));
});

With AddHttpMessageHandler, the tracking handler is added on top of the existing chain. The SUT's existing handlers (correlation ID, auth, etc.) still run.


Pattern 6: ConfigurePrimaryHttpMessageHandler Replacement

When your SUT does this:

services.AddHttpClient("MerchantService")
    .ConfigurePrimaryHttpMessageHandler(() => new MtlsHandler(certificate));

The SUT replaces the default HttpClientHandler with a custom primary handler (e.g. for mutual TLS).

How to Track

You can't use ConfigurePrimaryHttpMessageHandler for tracking in this case because the SUT already claims that slot. Instead, add the tracking handler as a delegating handler:

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

    services.ConfigureHttpClientDefaults(httpClientBuilder =>
        httpClientBuilder.AddHttpMessageHandler(sp =>
            new TestTrackingMessageHandler(
                new XUnitTestTrackingMessageHandlerOptions
                {
                    CallerName = "My API",
                    PortsToServiceNames =
                    {
                        { 80, "My API" },
                        { 443, "Merchant Service" }
                    }
                },
                sp.GetRequiredService<IHttpContextAccessor>())));
});

Or, if the MTLS handler isn't needed in tests (because you're hitting a fake/local service), replace both:

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

    services.AddHttpClient("MerchantService")
        .ConfigurePrimaryHttpMessageHandler(sp =>
            new TestTrackingMessageHandler(
                new XUnitTestTrackingMessageHandlerOptions
                {
                    CallerName = "My API",
                    FixedNameForReceivingService = "Merchant Service"
                },
                sp.GetRequiredService<IHttpContextAccessor>()));
});

Pattern 7: Non-IHttpClientFactory Clients (e.g. OpenIdConnectOptions.Backchannel)

Some .NET libraries expose an HttpClient property that bypasses the DI container entirely. A common example is OpenID Connect authentication, where OpenIdConnectOptions.Backchannel is a plain HttpClient.

When your SUT does this:

services.AddAuthentication()
    .AddOpenIdConnect("ExternalProvider", options =>
    {
        options.Authority = "https://external-idp.com/";
        // Backchannel is a plain HttpClient, not from IHttpClientFactory
    });

How to Track

Use PostConfigure to replace the Backchannel with a tracked client:

builder.ConfigureServices(services =>
{
    services.PostConfigure<OpenIdConnectOptions>("ExternalProvider", options =>
    {
        options.Backchannel = new HttpClient(
            new TestTrackingMessageHandler(
                new TestTrackingMessageHandlerOptions
                {
                    FixedNameForReceivingService = "External Auth Provider",
                    CallerName = "My API"
                },
                services.BuildServiceProvider()
                    .GetRequiredService<IHttpContextAccessor>()));
    });
});

Similarly, JwtBearerOptions.BackchannelHttpHandler can be replaced:

services.PostConfigure<JwtBearerOptions>(
    JwtBearerDefaults.AuthenticationScheme, 
    options =>
    {
        options.BackchannelHttpHandler = new TestTrackingMessageHandler(
            new TestTrackingMessageHandlerOptions
            {
                FixedNameForReceivingService = "Token Validation Service",
                CallerName = "My API"
            });
    });

This pattern applies to any library that exposes an HttpClient or HttpMessageHandler property. Look for Backchannel, BackchannelHttpHandler, HttpClient, or HttpMessageHandler properties in the options classes of the libraries your SUT uses.


Combining Tracking with Other Test Handlers

In a real project, you often want more than just tracking. You might need:

  • Request capturing — record outgoing requests for test assertions
  • Header propagation — forward test-specific headers (e.g. X-Fake-* headers to control fakes)
  • Handler recording — log all requests for debugging

You can stack multiple DelegatingHandler instances. The order matters — handlers run from outer to inner on requests, and inner to outer on responses:

var handler = new FakeHeaderPropagationHandler(httpContextAccessor)
{
    InnerHandler = new CorrelationIdDelegatingHandler(httpContextAccessor)
    {
        InnerHandler = new RequestCapturingHandler(store, httpContextAccessor, clientName)
        {
            InnerHandler = new TestTrackingMessageHandler(trackingOptions, httpContextAccessor)
            {
                InnerHandler = new HttpClientHandler()
            }
        }
    }
};

return new HttpClient(handler) { BaseAddress = new Uri(baseUrl) };

In this chain:

  1. FakeHeaderPropagationHandler forwards test control headers
  2. CorrelationIdDelegatingHandler adds correlation IDs (production behaviour preserved in tests)
  3. RequestCapturingHandler captures the request for assertions
  4. TestTrackingMessageHandler logs the request/response for diagram generation
  5. HttpClientHandler makes the actual HTTP call

Tip: Place TestTrackingMessageHandler as close to the bottom of the chain as possible. This way the tracking handler sees the request in its final form (with all headers added by upstream handlers) and the response before other handlers modify it.


Pattern 8: IHttpMessageHandlerBuilderFilter (Dynamic Per-Client Configuration)

When your project has many named clients and you need to dynamically map each client to a different service name — or when you need to coexist with other handler filters (e.g. JustEat's HttpClientInterceptionFilter) — implementing IHttpMessageHandlerBuilderFilter gives you the most control.

When to use this:

  • You need to inspect HttpMessageHandlerBuilder.Name to decide the service name dynamically
  • You have an existing IHttpMessageHandlerBuilderFilter (like JustEat's HttpClientInterceptionFilter) and need tracking alongside it
  • You want to set the primary handler per named client without a custom IHttpClientFactory

Example: Per-Client Service Name Mapping

Simplified with ClientNamesToServiceNames: Instead of manually cloning options with FixedNameForReceivingService for each client, you can define all mappings in the options and pass the client name to the handler constructor:

var options = new XUnitTestTrackingMessageHandlerOptions
{
    CallerName = "My API",
    ClientNamesToServiceNames =
    {
        { "PaymentServiceClient", "Payment Service" },
        { "AuthServiceClient", "Auth Service" },
        { "NotificationClient", "Notification Service" },
    }
};

// In the builder filter:
builder.PrimaryHandler = new TestTrackingMessageHandler(options, httpContextAccessor, clientName: builder.Name);

The handler resolves the service name automatically: FixedNameForReceivingService > ClientNamesToServiceNames > PortsToServiceNames > localhost:port.

Manual approach (also still valid):

public class TestTrackingBuilderFilter(
    IHttpContextAccessor httpContextAccessor,
    TestTrackingMessageHandlerOptions baseOptions) : IHttpMessageHandlerBuilderFilter
{
    private static readonly Dictionary<string, string> ClientNameToServiceName = new()
    {
        { "PaymentServiceClient", "Payment Service" },
        { "AuthServiceClient", "Auth Service" },
        { "NotificationClient", "Notification Service" },
    };

    public Action<HttpMessageHandlerBuilder> Configure(Action<HttpMessageHandlerBuilder> next) =>
        builder =>
        {
            next(builder); // preserve existing configuration

            var serviceName = ClientNameToServiceName
                .FirstOrDefault(kvp => builder.Name.Contains(kvp.Key))
                .Value;

            if (serviceName is null)
                return; // don't track unknown clients

            builder.PrimaryHandler = new TestTrackingMessageHandler(
                new TestTrackingMessageHandlerOptions
                {
                    CallerName = baseOptions.CallerName,
                    FixedNameForReceivingService = serviceName,
                    CurrentTestInfoFetcher = baseOptions.CurrentTestInfoFetcher,
                },
                httpContextAccessor);
        };
}

Register it:

builder.ConfigureTestServices(services =>
{
    services.AddHttpContextAccessor();
    services.AddSingleton<IHttpMessageHandlerBuilderFilter>(sp =>
        new TestTrackingBuilderFilter(
            sp.GetRequiredService<IHttpContextAccessor>(),
            new XUnitTestTrackingMessageHandlerOptions
            {
                CallerName = "My API"
            }));
});

Combining with JustEat Interception Filter

If your project already uses JustEat's HttpClientInterceptionFilter (or a similar IHttpMessageHandlerBuilderFilter), you can chain tracking with interception by setting the interception handler as InnerHandler:

builder.PrimaryHandler = new TestTrackingMessageHandler(
    trackingOptions, httpContextAccessor)
{
    InnerHandler = new MockHttpMessageHandler(interceptionOptions)
};

Note: Setting builder.PrimaryHandler replaces whatever primary handler was previously configured. This is expected — in tests, you typically want the tracking + interception chain to replace the production handler (which may use mTLS or other infrastructure you don't need in tests).

DI Pitfall: Resolving the Wrong Filter

When multiple IHttpMessageHandlerBuilderFilter implementations are registered, GetRequiredService<IHttpMessageHandlerBuilderFilter>() returns the last registered implementation. If your test fixture resolves the JustEat filter from DI (e.g. to access the HttpClientInterceptorOptions), registering a second filter for tracking will change which filter is returned.

Fix: Store the JustEat filter in a dedicated field rather than resolving it from DI:

private HttpClientInterceptionFilter? _interceptionFilter;

// In ConfigureWebHost:
_interceptionFilter = new HttpClientInterceptionFilter(interceptorOptions);
services.AddSingleton<IHttpMessageHandlerBuilderFilter>(_ => _interceptionFilter);

// Kronikol filter registered separately
services.AddSingleton<IHttpMessageHandlerBuilderFilter, TestTrackingBuilderFilter>(...);

// Access via field, not DI
public HttpClientInterceptionFilter InterceptionFilter => _interceptionFilter!;

Gotcha: HTTP Calls Triggered During HttpClient Construction

Some projects use IOptionsMonitor<HttpClientFactoryOptions> (or IConfigureOptions<HttpClientFactoryOptions>) to dynamically configure named HttpClient instances at construction time — for example, fetching a service's base URL from a configuration API before the client is used.

When your IHttpMessageHandlerBuilderFilter sets builder.PrimaryHandler = new TestTrackingMessageHandler(...) with an interception handler (e.g. MockHttpMessageHandler) as InnerHandler, this creates the handler chain:

TestTrackingMessageHandler → MockHttpMessageHandler → (intercepts)

This works during normal test execution. But if the Configure method itself makes HTTP calls (e.g. to resolve a base URL from a reference data service), those calls also flow through this same handler chain. The problem:

  1. No HttpContext existsIHttpClientFactory constructs the HttpClient when it's first requested from DI, which may happen during test setup, fixture initialisation, or hosted service startup — not during an HTTP request.
  2. TestTrackingMessageHandler reads IHttpContextAccessor to identify the current test. With no HttpContext, the tracking handler may fail or silently swallow the error.
  3. Interception fallback breaks — If the construction-time HTTP call doesn't match a registered interceptor (e.g. because the URL has a dynamic suffix), the OnMissingRegistration handler returns a 404. Normally the app code would retry with a fallback URL. But because the 404 response also passes through TestTrackingMessageHandler (which can't identify the test), the fallback flow may not complete correctly.

General principle: When TestTrackingMessageHandler is the PrimaryHandler and interception is the InnerHandler, ensure that all HTTP calls made during HttpClient construction are fully handled by the interception layer. Any unmatched request that falls through to OnMissingRegistration will produce a response that TestTrackingMessageHandler tries to track — and if there's no HttpContext, that tracking attempt can interfere with the app's error-handling or retry logic.

Solution: Handle unmatched URLs at the interception level, before the response reaches TestTrackingMessageHandler. Use OnMissingRegistration on the HttpClientInterceptorOptions to catch requests that don't have an exact interceptor match and resolve them yourself:

interceptor.ThrowOnMissingRegistration = false;
interceptor.OnMissingRegistration = async (req) =>
{
    var path = req.RequestUri?.AbsolutePath ?? string.Empty;

    // ──────────────────────────────────────────────────────────────────
    // YOUR LOGIC HERE: Match whatever URL pattern your app constructs
    // at HttpClient build time. The example below is illustrative —
    // adapt it to whatever your Configure method actually requests.
    // The goal is to return a valid response so it never falls through
    // to TestTrackingMessageHandler with no HttpContext.
    // ──────────────────────────────────────────────────────────────────
    if (path.StartsWith("/some-config-endpoint/"))
    {
        // e.g. normalise a dynamic URL variant back to a registered base URL
        var basePath = "/some-config-endpoint/default";
        var baseUri = new UriBuilder(req.RequestUri!) { Path = basePath }.Uri;
        var baseRequest = new HttpRequestMessage(req.Method, baseUri);
        var response = await interceptor.GetResponseAsync(baseRequest, default);
        if (response is not null) return response;
    }

    return new HttpResponseMessage(System.Net.HttpStatusCode.NotFound);
};

This ensures the interception layer returns a 200 for the suffixed URL directly, so the response never triggers the tracking handler's test-identification logic with a missing HttpContext.


Pattern 9: Refit Clients with IHttpMessageHandlerBuilderFilter (v3.0.19+)

Refit's AddRefitClient<T>() registers a named HTTP client using an internally-generated name (e.g. Refit.Implementation.Generated+...). This makes it hard to target the correct pipeline using AddHttpMessageHandler by name. The IHttpMessageHandlerBuilderFilter approach (Pattern 8) combined with substring matching is the recommended way to inject tracking.

v3.0.19 simplification: Prior versions required a SafeTrackingDelegatingHandler wrapper because TestTrackingMessageHandler pre-set InnerHandler. This is no longer needed — the handler's lazy InnerHandler initialization works correctly in all pipeline scenarios.

Step 1: Register Tracking via IHttpMessageHandlerBuilderFilter

internal sealed class RefitTrackingBuilderFilter(
    TestTrackingMessageHandlerOptions options) : IHttpMessageHandlerBuilderFilter
{
    public Action<HttpMessageHandlerBuilder> Configure(Action<HttpMessageHandlerBuilder> next) =>
        builder =>
        {
            next(builder); // preserve existing Refit configuration

            // Refit-generated names contain the interface name as a substring
            if (builder.Name?.Contains("IMyApiClient", StringComparison.OrdinalIgnoreCase) != true)
                return;

            builder.AdditionalHandlers.Add(new TestTrackingMessageHandler(options));
        };
}

Register it in ConfigureTestServices:

builder.ConfigureTestServices(services =>
{
    var options = new XUnitTestTrackingMessageHandlerOptions
    {
        CallerName = "My Service",
        FixedNameForReceivingService = "External AI API"
    };

    services.AddSingleton<IHttpMessageHandlerBuilderFilter>(
        _ => new RefitTrackingBuilderFilter(options));
});

Step 2: Context Propagation for Fire-and-Forget (Task.Run) Scenarios

When the SUT dispatches HTTP calls inside Task.Run or other fire-and-forget patterns, IHttpContextAccessor.HttpContext is null on the background thread. The TestTrackingMessageHandler falls back to TestIdentityScope.Current (which propagates via AsyncLocal).

To automatically set TestIdentityScope from incoming test request headers, register the context propagation middleware:

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

This registers TestTrackingContextMiddleware via IStartupFilter, which:

  1. Reads test-tracking-current-test-name and test-tracking-current-test-id headers from the incoming request
  2. Calls TestIdentityScope.Begin(name, id) for the duration of the request
  3. The AsyncLocal-based scope propagates into any Task.Run, ThreadPool.QueueUserWorkItem, or other async dispatch within that request

Resolution order in Task.Run:

  1. HTTP headers (null — no HttpContext on background thread)
  2. CurrentTestInfoFetcher (may throw — no framework TestContext available)
  3. TestIdentityScope.Current — set by the middleware, propagated via AsyncLocal

Step 3: Waiting for Fire-and-Forget Completion

Tests that trigger fire-and-forget work need to wait for it to complete before asserting on the diagrams. Common approaches:

Option A: Semaphore drain — If your SUT uses a throttle semaphore:

[AfterScenario]
public static async Task FlushPendingBackgroundCalls()
{
    await Task.Delay(500); // Allow Task.Run to start

    var field = typeof(MyService)
        .GetField("Throttle", BindingFlags.NonPublic | BindingFlags.Static)!;
    var semaphore = (SemaphoreSlim)field.GetValue(null)!;

    for (var i = 0; i < MaxConcurrent; i++)
        await semaphore.WaitAsync();
    semaphore.Release(MaxConcurrent);
}

Option B: Poll for expected log entries:

await WaitForCondition(() =>
    RequestResponseLogger.RequestAndResponseLogs
        .Count(l => l.TestId == testId && l.ServiceName == "External AI API") >= 2);

Complete Example: Refit + Fire-and-Forget

// ─── SUT Registration (Program.cs) ──────────────────────────────
services.AddRefitClient<IExternalAiClient>()
    .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://ai.example.com/"));

// ─── Service that uses fire-and-forget ──────────────────────────
public class MyService(IExternalAiClient aiClient)
{
    public void ProcessAsync(Request request)
    {
        // Fire-and-forget — no await
        Task.Run(async () =>
        {
            await aiClient.AnalyseAsync(request.Data);
        });
    }
}

// ─── Test Configuration ─────────────────────────────────────────
builder.ConfigureTestServices(services =>
{
    // 1. Propagate test identity into Task.Run via AsyncLocal
    services.AddTestTrackingContextPropagation();

    // 2. Inject tracking into Refit's named client pipeline
    var options = new XUnitTestTrackingMessageHandlerOptions
    {
        CallerName = "My Service",
        FixedNameForReceivingService = "External AI"
    };
    services.AddSingleton<IHttpMessageHandlerBuilderFilter>(
        _ => new RefitTrackingBuilderFilter(options));
});

Quick Reference: Choosing the Right Approach

Your SUT's HttpClient Pattern Recommended Approach
services.AddHttpClient() (basic) services.TrackDependenciesForDiagrams(...) — replaces entire IHttpClientFactory
Named clients (AddHttpClient("name", ...)) ConfigureHttpClientDefaults with ConfigurePrimaryHttpMessageHandler
Typed clients (AddHttpClient<I, T>()) Same as named clients
Named clients + existing DelegatingHandlers Custom IHttpClientFactory or ConfigureHttpClientDefaults with AddHttpMessageHandler
ConfigurePrimaryHttpMessageHandler in SUT AddHttpMessageHandler (not primary) or replace both
new HttpClient() (manual construction) Refactor to use IHttpClientFactory, or replace the service in DI
Library-owned clients (Backchannel, etc.) PostConfigure<TOptions> to replace the HttpClient/HttpMessageHandler
Mixed patterns Custom IHttpClientFactory for full control
Many named clients with dynamic service names IHttpMessageHandlerBuilderFilter (Pattern 8)
Refit clients (AddRefitClient<T>()) IHttpMessageHandlerBuilderFilter with substring matching (Pattern 9)
TestWebHostDriverBase<T> (non-WebApplicationFactory) ConfigureAll<HttpClientFactoryOptions> with explicit IStartupFilter (Pattern 10)

Pattern 10: TestWebHostDriverBase<T> / Raw WebHostBuilder (Non-WebApplicationFactory Hosts)

When your test infrastructure uses a raw WebHostBuilder (e.g. TestWebHostDriverBase<T> from shared libraries) rather than WebApplicationFactory<T>, IHttpMessageHandlerBuilderFilter registrations may not be invoked (timing differences in service configuration). Use ConfigureAll<HttpClientFactoryOptions> instead.

When to use this

  • Your test host wraps new WebHostBuilder() or IWebHost directly
  • TrackDependenciesForDiagrams or custom IHttpMessageHandlerBuilderFilter registrations don't fire
  • You need to inject tracking into Refit/typed/named client pipelines

Working Pattern

public static IServiceCollection TrackSutHttpDependencyCallsInKronikol(
    this IServiceCollection services)
{
    // 1. Register context propagation (AsyncLocal flows into Task.Run)
    services.AddTestTrackingContextPropagation();

    // 2. Inject tracking into ALL HttpClient pipelines via ConfigureAll
    services.ConfigureAll<HttpClientFactoryOptions>(options =>
    {
        options.HttpMessageHandlerBuilderActions.Add(builder =>
        {
            // Filter by client name — Refit generates names containing the interface
            if (builder.Name?.Contains(nameof(IMyRefitClient), StringComparison.OrdinalIgnoreCase) != true)
                return;

            builder.AdditionalHandlers.Add(new TestTrackingMessageHandler(
                new ReqNRollTestTrackingMessageHandlerOptions
                {
                    CallerName = "My Service",
                    FixedNameForReceivingService = "Target Service",
                    CurrentTestInfoFetcher = CurrentTestInfo.Fetcher
                }));
        });
    });

    return services;
}

Why ConfigureAll<HttpClientFactoryOptions> works

ConfigureAll attaches handler builder actions to the HttpClientFactoryOptions for all named clients. These actions run when DefaultHttpClientFactory builds a handler pipeline — regardless of when the service was registered relative to the host startup. This bypasses the timing issue that can prevent IHttpMessageHandlerBuilderFilter from firing in non-WebApplicationFactory hosts.

Why TrackDependenciesForDiagrams may not work here

TrackDependenciesForDiagrams registers a TrackingHttpMessageHandlerBuilderFilter — an IHttpMessageHandlerBuilderFilter. In WebApplicationFactory<T> scenarios this works because the factory rebuilds the DI container with test services. In raw WebHostBuilder test hosts, the filter may be registered too late or the DefaultHttpClientFactory may already be resolved before the filter is registered.

Alternative: Use ClientNamesToServiceNames for multiple clients

If you have many Refit clients and want a single registration:

services.ConfigureAll<HttpClientFactoryOptions>(options =>
{
    options.HttpMessageHandlerBuilderActions.Add(builder =>
    {
        var trackingOptions = new ReqNRollTestTrackingMessageHandlerOptions
        {
            CallerName = "My Service",
            ClientNamesToServiceNames =
            {
                { "IIntelligenceAiApiClient", "Intelligence AI" },
                { "IPaymentApiClient", "Payment Service" },
                { "INotificationClient", "Notification Service" },
            },
            CurrentTestInfoFetcher = CurrentTestInfo.Fetcher
        };

        builder.AdditionalHandlers.Add(
            new TestTrackingMessageHandler(trackingOptions, clientName: builder.Name));
    });
});

The ClientNamesToServiceNames resolver will match Refit v9 assembly-qualified names via contains matching (v3.0.23+).


Faking Dependencies: Getting Proper HTTP Tracking

One of the most common mistakes when integrating Kronikol is faking downstream HTTP dependencies using mocks or stubs that bypass the HTTP pipeline, and then using MessageTracker to manually log those interactions. This produces event-style arrows (blue notes) instead of proper HTTP-style arrows — even though the underlying interactions are HTTP calls.

Rule of thumb: If the real production interaction is HTTP, keep it as HTTP in your tests. Route the traffic through TestTrackingMessageHandler so diagrams show proper HTTP method labels (GET, POST, etc.), status codes, headers, and response bodies. Reserve MessageTracker for genuinely non-HTTP interactions like Kafka events, RabbitMQ messages, or EventGrid notifications.

The Anti-Pattern: Using MessageTracker for HTTP Calls

When you mock or stub a service client interface (e.g. with NSubstitute or Moq) and then manually call MessageTracker.TrackMessageRequest() to make the interaction appear in diagrams, the result is misleading:

// ❌ ANTI-PATTERN — Do NOT do this for HTTP-based dependencies
public class TrackingScvProvider : IScvProvider
{
    private readonly IScvProvider _inner;
    private readonly MessageTracker _tracker;

    public async Task<ScvResponse> GetIdentifiersAsync(string ssoId)
    {
        // This manually logs as an event, not an HTTP call
        var correlationId = _tracker.TrackMessageRequest(
            protocol: "HTTP",                                    // misleading!
            destinationName: "Single Customer View API",
            destinationUri: new Uri("http://scv/v2/identifiers?ssoId=" + ssoId),
            payload: ssoId);

        var result = await _inner.GetIdentifiersAsync(ssoId);   // calls a mock, no real HTTP

        _tracker.TrackMessageResponse(
            protocol: "HTTP",
            destinationName: "Single Customer View API",
            destinationUri: new Uri("http://scv/v2/identifiers"),
            requestResponseId: correlationId);

        return result;
    }
}

This produces diagram output like:

customerDomainAPI -> singleCustomerViewAPI: HTTP: /v2/identifiers?ssoId=sub
note<<eventNote>> left    ← blue event-style note, NOT an HTTP arrow
"sub"
end note

TrackingTraceContext

TrackingTraceContext provides explicit trace context control for correlating spans with tracked HTTP interactions. This is useful when you need to manually start a trace scope (e.g. for background work or non-HTTP entry points).

using var scope = TrackingTraceContext.BeginTrace();
// All OTel spans within this scope will share the same trace ID
await client.GetAsync("/api/resource");

You can also capture the trace ID for correlation:

using var scope = TrackingTraceContext.BeginTrace(out var traceId);
// traceId is a Guid you can use for manual correlation

CreateParentContext() returns an ActivityContext suitable for passing to Activity.StartActivity() as a parent, ensuring your custom spans are parented under the current trace.

TestTrackingServerBridge

TestTrackingServerBridge provides a way to read test-tracking context from the server side of a tracked HTTP call. When TestTrackingMessageHandler sends a request, it forwards the current test name and ID as headers. Server-side code can read these headers to determine which test is currently executing.

// In server-side middleware or controller
var testInfo = TestTrackingServerBridge.GetCurrentTestInfo(httpContextAccessor);
if (testInfo is not null)
{
    var (testName, testId) = testInfo.Value;
    // Use for conditional faking, server-side tracking, etc.
}

This is useful when:

  • You need server-side code to behave differently during testing (e.g. disabling caching)
  • You want to track server-side events and correlate them with the test that triggered them
  • You need to pass test identity to downstream services for two-sided tracking

Deferred Logging (PendingRequestResponseLogs)

When using TrackingProxy<T> or DeferredLogFlushHandler, tracked interactions are buffered until test context is available. The PendingRequestResponseLogs static class manages this buffer:

// Enqueue a log entry before test context is known
PendingRequestResponseLogs.Enqueue(new PendingLogEntry(
    ServiceName: "Redis",
    CallerName: "Caller",
    Method: "GET",
    RequestContent: null,
    ResponseContent: "{ \"key\": \"value\" }",
    Uri: new Uri("mock://redis/mykey")
));

// Flush all pending entries once test context is available
PendingRequestResponseLogs.FlushAll(testName: "My test", testId: "test-123");

// Check pending count
var pending = PendingRequestResponseLogs.Count;

// Clear without flushing (e.g. on test teardown)
PendingRequestResponseLogs.Clear();

This is used internally by TrackingProxy<T> when TrackingLogMode.Deferred is set, and by DeferredLogFlushHandler for MediatR command/query tracking. See Integration DispatchProxy Extension and Integration MediatR Extension for usage examples. singleCustomerViewAPI --> customerDomainAPI: Responded ← no status code, no headers


**What's wrong with this:**
- The arrow label says `HTTP:` as a text prefix rather than showing the actual HTTP method (`GET:`, `POST:`, etc.)
- The note has a **blue background with rounded corners** (event styling), not the standard white HTTP note
- The response shows `"Responded"` instead of a real HTTP status code (`200 OK`, `404 Not Found`, etc.)
- **No response headers or response body** are captured
- **No request headers** are captured
- The diagram is visually inconsistent — some calls look like HTTP, others look like events, even though they're all HTTP in production

The correct approach is to ensure the SUT's outgoing HTTP calls pass through a `TestTrackingMessageHandler` and hit a fake that responds over HTTP. There are several ways to do this.

### Approach 1: In-Memory Fake APIs (WebApplicationFactory)

Spin up lightweight in-memory APIs that serve canned responses. This is the approach used in the [Example.Api](Example-Project) project.

```csharp
// Create an in-memory fake for the downstream service
var fakeSingleCustomerView = new WebApplicationFactory<FakeScvProgram>()
    .WithWebHostBuilder(builder =>
    {
        builder.ConfigureTestServices(services =>
        {
            // Configure fake responses
        });
    });

// Start on a specific port so PortsToServiceNames can map it
var fakeScvClient = fakeSingleCustomerView.CreateClient();

Then configure the SUT to route its HttpClient through the tracking handler to the fake:

builder.ConfigureTestServices(services =>
{
    services.TrackDependenciesForDiagrams(new XUnitTestTrackingMessageHandlerOptions
    {
        CallerName = "Customer Domain API",
        PortsToServiceNames =
        {
            { 80, "Customer Domain API" },
            { 5001, "Single Customer View API" },
            { 5002, "FiServ Gateway" }
        }
    });
});

Result: All outgoing calls from the SUT pass through TestTrackingMessageHandler, producing proper HTTP arrows with methods, status codes, headers, and bodies.

Approach 2: JustEat HttpClient Interception

JustEat.HttpClientInterception intercepts HTTP requests at the HttpMessageHandler level — no network server needed. It's lightweight and explicitly designed for testing.

using JustEat.HttpClientInterception;

// Set up intercepted responses
var options = new HttpClientInterceptionOptions();

new HttpRequestInterceptionBuilder()
    .Requests()
    .ForGet()
    .ForHttps()
    .ForHost("scv.example.com")
    .ForPath("v2/identifiers")
    .ForQuery("ssoId=sub")
    .Responds()
    .WithJsonContent(new { customerId = "custNbr123" })
    .WithStatus(HttpStatusCode.OK)
    .RegisterWith(options);

// Create a handler that intercepts matching requests
var interceptingHandler = options.CreateHttpMessageHandler();

The key is to chain TestTrackingMessageHandler with the interception handler so tracking still captures everything:

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

    // Replace the SUT's HttpClient with one that chains tracking + interception
    services.ConfigureHttpClientDefaults(httpClientBuilder =>
        httpClientBuilder.ConfigurePrimaryHttpMessageHandler(sp =>
        {
            var trackingHandler = new TestTrackingMessageHandler(
                new XUnitTestTrackingMessageHandlerOptions
                {
                    CallerName = "Customer Domain API",
                    PortsToServiceNames =
                    {
                        { 443, "Single Customer View API" }
                    }
                },
                sp.GetRequiredService<IHttpContextAccessor>())
            {
                // The interception handler sits below the tracking handler
                InnerHandler = interceptingHandler
            };
            return trackingHandler;
        }));
});

How it works: The SUT makes an HTTP call → TestTrackingMessageHandler logs the request → passes it down to HttpClientInterceptionHandler → the interceptor short-circuits the request and returns a canned response → TestTrackingMessageHandler logs the response. The diagram shows a proper HTTP interaction with method, URL, status code, headers, and body — even though no network call was made.

You can also use IHttpClientFactory filtering for more fine-grained control:

// Register a filter that injects tracking + interception into all HttpClients
services.AddSingleton<IHttpMessageHandlerBuilderFilter>(sp =>
    new TrackingInterceptionFilter(
        sp.GetRequiredService<IHttpContextAccessor>(),
        options,
        trackingOptions));

Where the filter implementation would be:

public class TrackingInterceptionFilter(
    IHttpContextAccessor httpContextAccessor,
    HttpClientInterceptionOptions interceptionOptions,
    TestTrackingMessageHandlerOptions trackingOptions) : IHttpMessageHandlerBuilderFilter
{
    public Action<HttpMessageHandlerBuilder> Configure(Action<HttpMessageHandlerBuilder> next)
    {
        return builder =>
        {
            next(builder);

            // Wrap the existing pipeline with tracking, then interception at the bottom
            builder.PrimaryHandler = new TestTrackingMessageHandler(
                trackingOptions, httpContextAccessor)
            {
                InnerHandler = interceptionOptions.CreateHttpMessageHandler()
            };
        };
    }
}

Approach 3: WireMock.Net

WireMock.Net runs an actual HTTP server that listens on a port and responds to requests based on matching rules. It's heavier than HttpClient Interception but gives you a real HTTP endpoint.

using WireMock.Server;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;

// Start a WireMock server on a random port
var scvServer = WireMockServer.Start();

scvServer
    .Given(Request.Create()
        .WithPath("/v2/identifiers")
        .WithParam("ssoId", "sub")
        .UsingGet())
    .RespondWith(Response.Create()
        .WithStatusCode(200)
        .WithHeader("Content-Type", "application/json")
        .WithBody("""{ "customerId": "custNbr123" }"""));

Configure the SUT to point at the WireMock server, with tracking enabled:

builder.ConfigureTestServices(services =>
{
    services.TrackDependenciesForDiagrams(new XUnitTestTrackingMessageHandlerOptions
    {
        CallerName = "Customer Domain API",
        PortsToServiceNames =
        {
            { 80, "Customer Domain API" },
            { scvServer.Port, "Single Customer View API" },
        }
    });

    // Point the SUT's SCV client at the WireMock server
    services.Configure<ScvOptions>(o => o.BaseAddress = scvServer.Url);
});

Advantage: Because WireMock is a real HTTP server, the SUT makes actual HTTP calls through its normal HttpClient pipeline. TestTrackingMessageHandler intercepts these automatically — no special chaining required. Just map the WireMock server's port in PortsToServiceNames.

Remember to stop the server in teardown:

scvServer.Stop();
scvServer.Dispose();

Approach 4: Replacing the DI-Registered Service Client

If your SUT uses an interface like IScvClient backed by an HttpClient from the DI container, you can replace the HttpClient configuration in tests while keeping the service client itself:

builder.ConfigureTestServices(services =>
{
    // Override the SCV client's base address to point at a fake
    services.AddHttpClient<IScvClient, ScvClient>(client =>
    {
        client.BaseAddress = new Uri("http://localhost:5001/");
    })
    .ConfigurePrimaryHttpMessageHandler(sp =>
        new TestTrackingMessageHandler(
            new XUnitTestTrackingMessageHandlerOptions
            {
                CallerName = "Customer Domain API",
                FixedNameForReceivingService = "Single Customer View API"
            },
            sp.GetRequiredService<IHttpContextAccessor>()));
});

This keeps the SUT's own service client code in the loop (serialisation, error handling, etc.) while ensuring the HTTP call is tracked.

Comparison: Dependency Faking Approaches

Approach Real HTTP? Tracking Setup Complexity Best For
In-memory fake API (WebApplicationFactory) Yes (in-process) PortsToServiceNames Medium Full contract testing with a real server
JustEat HttpClient Interception No (handler-level) Chain TestTrackingMessageHandler as outer handler Low Fast, lightweight, no server process
WireMock.Net Yes (real HTTP) PortsToServiceNames (auto) Medium Contract testing, recording/playback, complex matching
Replace typed client config Yes (in-process) ConfigurePrimaryHttpMessageHandler Low When SUT uses typed HttpClient registrations
Mock + MessageTracker No Manual N/A Do not use for HTTP — produces event-style arrows

Key takeaway: All four recommended approaches ensure the SUT's outgoing HTTP calls flow through TestTrackingMessageHandler. This produces diagrams with proper HTTP method labels, status codes, request/response headers, and bodies. Using MessageTracker for HTTP-based dependencies produces visually inconsistent, informationally incomplete diagrams — reserve it for genuinely non-HTTP interactions.


Further Reading


DI Decorator Helpers (v2.23.11+)

The core library provides two generic DI helper methods for decorating existing service registrations. These are used internally by the Kafka extension's AddKafkaProducerTestTracking / AddKafkaConsumerTestTracking, but are also available for custom decoration patterns.

DecorateAll<TService>

Wraps all existing registrations of TService with a decorator. Removes the original registration and adds the decorated version, preserving the original service lifetime.

// Wrap all IProducerFactory registrations with a tracking decorator:
services.DecorateAll<IProducerFactory>((sp, inner) =>
{
    var tracker = new KafkaTracker(options, sp.GetService<IHttpContextAccessor>());
    return new TrackingProducerFactory(inner, tracker);
});
  • Handles all descriptor types: ImplementationFactory, ImplementationInstance, ImplementationType
  • No-op when no matching registrations exist
  • Does not duplicate registrations (removes original before adding decorated)

DecorateAllOpen

Scans the IServiceCollection for all closed-generic registrations matching an open generic type, and replaces each with a decorator type.

// Find all PubSubEventPublisher<T> registrations and wrap with TrackedPubSubEventPublisher<T>:
services.DecorateAllOpen(
    typeof(PubSubEventPublisher<>),           // open generic service type to find
    typeof(TrackedPubSubEventPublisher<>)      // open generic decorator type to create
);

The decorator's constructor must accept the inner service as its first parameter. Additional constructor parameters are resolved from DI via ActivatorUtilities.

TestInfoResolver.CreateHttpFallbackFetcher

Creates a Func<(string Name, string Id)> that encapsulates the dual-resolution pattern: tries HTTP request headers first (from IHttpContextAccessor), then falls back to the provided delegate.

var httpContextAccessor = sp.GetService<IHttpContextAccessor>();
var fetcher = TestInfoResolver.CreateHttpFallbackFetcher(
    httpContextAccessor, 
    CurrentTestInfo.Fetcher);

var options = new KafkaTrackingOptions
{
    CurrentTestInfoFetcher = fetcher
};

Note: Most tracking extension types (KafkaTracker, PubSubTracker, MessageTracker, etc.) already perform this dual-resolution internally when given an IHttpContextAccessor. Use CreateHttpFallbackFetcher only when you need to set CurrentTestInfoFetcher on options for a component that does not accept IHttpContextAccessor in its constructor, or when building custom tracking wrappers.

Home


Demo


Getting Started

Common Tasks

Integration Guides

Extensions

Configuration

Features

Reference

Clone this wiki locally