-
Notifications
You must be signed in to change notification settings - Fork 1
Tracking Dependencies
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.
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:
- Adds correlation headers to the request (
TraceId,CurrentTestName,CurrentTestId,CallerName) - Logs the full request (method, URL, headers, body)
- Passes the request through to the actual destination
- Logs the full response (status code, headers, body)
- 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.
v3.0.19+:
TestTrackingMessageHandlerno longer pre-setsInnerHandlerin the constructor. It is lazily initialised toHttpClientHandleron firstSendAsynconly if nothing else has set it. This means you can now use it directly withAddHttpMessageHandler<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.
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+)
TrackDependenciesForDiagramsnow usesIHttpMessageHandlerBuilderFilterinstead of replacingIHttpClientFactory. This means custom filter registrations (e.g. JustEat HttpClient Interception'sHttpClientInterceptionFilter, Polly, logging filters) coexist correctly with Kronikol's tracking. Thebuilder.Nameis automatically passed asclientNameforClientNamesToServiceNamesresolution (with ends-with fallback for Refit-generated names). In versions prior to v3.0.21,TrackDependenciesForDiagramsreplacedIHttpClientFactoryentirely — if you were using Pattern 5 or Pattern 8 as a workaround, you can now safely switch back toTrackDependenciesForDiagrams.
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 tooverride.comin your diagnostic report, upgrade to v2.28.22+. To customise the list of excluded hosts, setExcludedHostson your options:var options = new XUnitTestTrackingMessageHandlerOptions { ExcludedHosts = ["override.com", "health-check.internal"] };
Kronikol requires that the SUT runs in the same process as the tests. This is the standard setup when using
WebApplicationFactory<T>fromMicrosoft.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
HttpClientinstances.The one exception is the test-to-SUT client itself: you can always wrap that in a
TestTrackingMessageHandlerregardless of where the SUT runs. But you won't see any of the SUT's outgoing calls to its dependencies in the diagrams.
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.
Every integration test has two categories of HTTP traffic to track:
┌─────────┐ ┌─────────────┐ ┌────────────────────┐
│ Test │ ──(1)──▶ │ SUT (API) │ ──(2)──▶ │ Dependencies │
│ Runner │ ◀─────── │ in-memory │ ◀─────── │ (real or faked) │
└─────────┘ └─────────────┘ └────────────────────┘
-
Incoming: Test → SUT — Use
CreateTestTrackingClient()on theWebApplicationFactory(see HTTP Tracking Setup#Creating a Tracking Client). This wraps the test'sHttpClientin aTestTrackingMessageHandlerso the call from the test to the SUT appears in diagrams. -
Outgoing: SUT → Dependencies — Override the SUT's
IHttpClientFactory(or individualHttpClientregistrations) inConfigureTestServicesso that all outgoing calls go through aTestTrackingMessageHandler. This is the part that varies depending on how your project usesHttpClient.
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, notIHttpClientFactory. It cannot be tracked withTestTrackingMessageHandlerorTrackDependenciesForDiagrams. Instead, use theKronikol.Extensions.CosmosDBpackage, which providesCosmosTrackingMessageHandler— a specialisedDelegatingHandlerthat classifies Cosmos operations and shows them asCreate Document [orders],Query [orders]: SELECT ..., etc. See Integration CosmosDB Extension for the full setup guide.
EF Core / Relational databases: EF Core uses ADO.NET
DbCommandobjects internally, notHttpClient. It cannot be tracked withTestTrackingMessageHandlerorTrackDependenciesForDiagrams. Instead, use theKronikol.Extensions.EfCore.Relationalpackage, which providesSqlTrackingInterceptor— aDbCommandInterceptorthat classifies SQL operations and shows them asSelect: /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 withTestTrackingMessageHandlerorTrackDependenciesForDiagrams. Instead, use theKronikol.Extensions.Redispackage, which providesRedisTrackingDatabase— aDispatchProxy-basedIDatabasedecorator that classifies Redis operations with cache hit/miss detection and shows them asGet (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
GrpcChannelwith HTTP/2 transport, notIHttpClientFactory. Raw HTTP tracking would show binary protobuf bodies andPOSTlabels. Instead, use theKronikol.Extensions.Grpcpackage, which providesGrpcTrackingInterceptor— aGrpc.Core.Interceptors.Interceptorthat classifies gRPC operations, deserializes protobuf messages to JSON, and produces rich labels likeSayHello: grpc:///greet.Greeter/SayHello. For incoming gRPC calls (test → SUT), useGrpcTrackingChannel.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, useAddTrackedGrpcClient<TClient>()(v2.26.0+) which auto-resolvesIHttpContextAccessorfrom 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
BlobClientvirtual methods) and there is no HTTP pipeline to intercept, useRequestResponseLogger.Log()directly. This is the same API thatCosmosTrackingMessageHandleruses internally. See Tracking Custom Dependencies for a complete guide with examples.
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.
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 v3 →
XUnitTestTrackingMessageHandlerOptions- NUnit 4 →
NUnitTestTrackingMessageHandlerOptions- BDDfy + xUnit 3 →
BDDfyTestTrackingMessageHandlerOptions- LightBDD + xUnit 2 →
LightBddTestTrackingMessageHandlerOptions- ReqNRoll + xUnit 2 →
ReqNRollTestTrackingMessageHandlerOptions(fromKronikol.ReqNRoll.xUnit2)- ReqNRoll + xUnit 3 →
ReqNRollTestTrackingMessageHandlerOptions(fromKronikol.ReqNRoll.xUnit3)When constructing a
TestTrackingMessageHandlerdirectly (rather than throughTrackDependenciesForDiagrams), you can also use the baseTestTrackingMessageHandlerOptionsclass — but you'll need to setCurrentTestInfoFetcheryourself.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.
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.
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.
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>()));
FixedNameForReceivingServicegives 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.
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.
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>())));
});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.
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 injectedHttpClientor useIHttpClientFactory. This is also recommended practice by Microsoft for unrelated reasons (socket exhaustion, DNS changes).
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.
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.
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).
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>()));
});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
});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
HttpClientorHttpMessageHandlerproperty. Look forBackchannel,BackchannelHttpHandler,HttpClient, orHttpMessageHandlerproperties in the options classes of the libraries your SUT uses.
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:
-
FakeHeaderPropagationHandlerforwards test control headers -
CorrelationIdDelegatingHandleradds correlation IDs (production behaviour preserved in tests) -
RequestCapturingHandlercaptures the request for assertions -
TestTrackingMessageHandlerlogs the request/response for diagram generation -
HttpClientHandlermakes the actual HTTP call
Tip: Place
TestTrackingMessageHandleras 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.
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.Nameto decide the service name dynamically - You have an existing
IHttpMessageHandlerBuilderFilter(like JustEat'sHttpClientInterceptionFilter) and need tracking alongside it - You want to set the primary handler per named client without a custom
IHttpClientFactory
Simplified with
ClientNamesToServiceNames: Instead of manually cloning options withFixedNameForReceivingServicefor 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"
}));
});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.PrimaryHandlerreplaces 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).
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!;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:
-
No
HttpContextexists —IHttpClientFactoryconstructs theHttpClientwhen it's first requested from DI, which may happen during test setup, fixture initialisation, or hosted service startup — not during an HTTP request. -
TestTrackingMessageHandlerreadsIHttpContextAccessorto identify the current test. With noHttpContext, the tracking handler may fail or silently swallow the error. -
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
OnMissingRegistrationhandler returns a 404. Normally the app code would retry with a fallback URL. But because the 404 response also passes throughTestTrackingMessageHandler(which can't identify the test), the fallback flow may not complete correctly.
General principle: When
TestTrackingMessageHandleris thePrimaryHandlerand interception is theInnerHandler, ensure that all HTTP calls made duringHttpClientconstruction are fully handled by the interception layer. Any unmatched request that falls through toOnMissingRegistrationwill produce a response thatTestTrackingMessageHandlertries to track — and if there's noHttpContext, 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.
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
SafeTrackingDelegatingHandlerwrapper becauseTestTrackingMessageHandlerpre-setInnerHandler. This is no longer needed — the handler's lazyInnerHandlerinitialization works correctly in all pipeline scenarios.
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));
});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:
- Reads
test-tracking-current-test-nameandtest-tracking-current-test-idheaders from the incoming request - Calls
TestIdentityScope.Begin(name, id)for the duration of the request - The
AsyncLocal-based scope propagates into anyTask.Run,ThreadPool.QueueUserWorkItem, or other async dispatch within that request
Resolution order in Task.Run:
HTTP headers (null — noHttpContexton background thread)CurrentTestInfoFetcher(may throw — no frameworkTestContextavailable)-
TestIdentityScope.Current— set by the middleware, propagated viaAsyncLocal✓
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);// ─── 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));
});| 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) |
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.
- Your test host wraps
new WebHostBuilder()orIWebHostdirectly -
TrackDependenciesForDiagramsor customIHttpMessageHandlerBuilderFilterregistrations don't fire - You need to inject tracking into Refit/typed/named client pipelines
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;
}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.
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.
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+).
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
TestTrackingMessageHandlerso diagrams show proper HTTP method labels (GET, POST, etc.), status codes, headers, and response bodies. ReserveMessageTrackerfor genuinely non-HTTP interactions like Kafka events, RabbitMQ messages, or EventGrid notifications.
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 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 correlationCreateParentContext() returns an ActivityContext suitable for passing to Activity.StartActivity() as a parent, ensuring your custom spans are parented under the current trace.
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
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.
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 →
TestTrackingMessageHandlerlogs the request → passes it down toHttpClientInterceptionHandler→ the interceptor short-circuits the request and returns a canned response →TestTrackingMessageHandlerlogs 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()
};
};
}
}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
HttpClientpipeline.TestTrackingMessageHandlerintercepts these automatically — no special chaining required. Just map the WireMock server's port inPortsToServiceNames.
Remember to stop the server in teardown:
scvServer.Stop();
scvServer.Dispose();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.
| 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 |
| 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. UsingMessageTrackerfor HTTP-based dependencies produces visually inconsistent, informationally incomplete diagrams — reserve it for genuinely non-HTTP interactions.
-
Microsoft: IHttpClientFactory patterns — all the
HttpClientDI patterns - HTTP Tracking Setup — core tracking configuration reference
- Event & Message Tracking — tracking non-HTTP interactions (Kafka, EventGrid, etc.)
- Example Project — working example using Pattern 1
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.
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)
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.
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 anIHttpContextAccessor. UseCreateHttpFallbackFetcheronly when you need to setCurrentTestInfoFetcheron options for a component that does not acceptIHttpContextAccessorin its constructor, or when building custom tracking wrappers.
Getting Started
Common Tasks
Integration Guides
- Integration xUnit3
- Integration xUnit2
- Integration NUnit
- Integration MSTest
- Integration TUnit
- Integration BDDfy xUnit3
- Integration LightBDD xUnit2
- Integration LightBDD xUnit3
- Integration LightBDD TUnit
- Integration ReqNRoll xUnit2
- Integration ReqNRoll xUnit3
- Integration ReqNRoll TUnit
Extensions
- Integration AtlasDataApi Extension
- Integration BigQuery Extension
- Integration Bigtable Extension
- Integration BlobStorage Extension
- Integration CloudStorage Extension
- Integration CosmosDB Extension
- Integration Dapper Extension
- Integration DynamoDB Extension
- Integration EF Core Relational Extension
- Integration Elasticsearch Extension
- Integration EventBridge Extension
- Integration EventHubs Extension
- Integration Grpc Extension
- Integration Kafka Extension
- Integration MassTransit Extension
- Integration MongoDB Extension
- Integration MySqlConnector Extension
- Integration Npgsql Extension
- Integration Oracle Extension
- Integration PubSub Extension
- Integration Redis Extension
- Integration S3 Extension
- Integration ServiceBus Extension
- Integration SNS Extension
- Integration Spanner Extension
- Integration SqlClient Extension
- Integration Sqlite Extension
- Integration SQS Extension
- Integration StorageQueues Extension
- Integration OpenTelemetry Extension
- Integration DispatchProxy Extension
- Integration MediatR Extension
- Integration PlantUML IKVM
Configuration
- Tracking Dependencies
- Tracking Custom Dependencies
- HTTP Tracking Setup
- Report Configuration
- Diagram Customisation
- Phase-Aware Tracking
- Content Formatting
- PlantUML Server Configuration
Features
- Generated Reports
- Search Syntax
- Component Diagrams
- PlantUML Browser Rendering
- Inline SVG Rendering
- Internal Flow Tracking
- Tags and Attributes
- Excluding Requests
- Excluded Headers
- Multi-Host Test Architectures
- Event-Driven Architecture Testing
- Service Bus Tracking Patterns
- Background Thread Correlation
- Parallel-Safe Background Correlation
- Event & Message Tracking
- Assertion Tracking
- Step Tracking
- Tabular Attributes
- Large Response and Diagram Handling
- Diagnostics and Debugging
- CI Summary Integration
- CI Artifact Upload
Reference