-
Notifications
You must be signed in to change notification settings - Fork 1
Tracking Custom Dependencies
This page explains how to track interactions with dependencies that bypass HTTP entirely — such as Azure Blob Storage, Queue Storage, Key Vault, or any SDK that uses virtual methods or interfaces rather than HttpClient.
Prefer automated tracking? If your dependency is accessed via an interface, consider using
TrackingProxy<T>or the Integration DispatchProxy Extension instead of manual logging. These wrap your interface implementation and automatically log every method call. The manual approach described below gives you full control over what's logged and how.
TestTrackingDiagrams provides several tracking APIs, each suited to a different type of dependency:
| Your dependency is... | Use | Why |
|---|---|---|
| An HTTP service (REST API, downstream microservice) |
TestTrackingMessageHandler / TrackDependenciesForDiagrams
|
Automatically tracks HTTP request/response through the pipeline |
| Cosmos DB (via CosmosDB.InMemoryEmulator) | CosmosTrackingMessageHandler |
Pre-built extension; uses RequestResponseLogger.Log() internally |
| EF Core / relational database | SqlTrackingInterceptor |
Pre-built extension; intercepts DbCommand
|
| Redis (StackExchange.Redis) | RedisTrackingDatabase |
Pre-built extension; decorates IDatabase
|
| gRPC service (outgoing or incoming) |
GrpcTrackingInterceptor / AddTrackedGrpcClient<T>()
|
Pre-built extension; intercepts CallInvoker, deserializes protobuf, classifies operations. v2.26.0+: AddTrackedGrpcClient<T>() auto-resolves IHttpContextAccessor from DI. See Integration Grpc Extension
|
| Any interface-based dependency (SDK fakes, gateways) |
TrackingProxy<T> / ReplaceWithTracked<T>()
|
Zero-code tracking via DispatchProxy; see Integration DispatchProxy Extension |
| MediatR / CQRS handlers | TrackMediatorForDiagrams() |
Optional. In-process dispatch — not needed for most projects. Only use when you want CQRS commands/queries as separate diagram arrows. See Integration MediatR Extension |
| An async message broker (Service Bus, Kafka, RabbitMQ) | MessageTracker |
Designed for fire-and-forget or pub/sub messaging; produces event-styled arrows |
| An event published through an outbox pattern (e.g. EventGrid via Cosmos outbox) |
MessageTracker with UseHttpContextCorrelation = true
|
Tracks the write inside the HTTP request context where test headers are available; see Tracking Outbox Event Publishing |
| A synchronous dependency with no HTTP (Blob Storage, SDK fakes) |
RequestResponseLogger.LogPair() or Log()
|
Creates synchronous call-and-return arrows in diagrams |
Use RequestResponseLogger.Log() when your dependency is faked at the SDK level (e.g. overriding BlobClient virtual methods) and there is no HTTP pipeline to intercept. This is the same API that CosmosTrackingMessageHandler uses internally.
Simpler alternative: If you have both request and response data available at once, use
RequestResponseLogger.LogPair()instead of two separateLog()calls. See Diagnostics and Debugging#RequestResponseLogger.LogPair() for details.
Why not
MessageTracker?MessageTrackermarks entries withRequestResponseMetaType.Event, which produces event-styled diagram arrows (light blue background, rounded corners, "Responded" instead of status codes). This is semantically incorrect for synchronous request/response dependencies like blob storage.RequestResponseLogger.Log()produces standard-styled arrows (white background, HTTP method/status labels) — matching whatTestTrackingMessageHandlerproduces for HTTP dependencies. See Event & Message Tracking for a visual comparison.
using System.Net;
using TestTrackingDiagrams.Tracking;
// Use the appropriate import for your test framework:
// using TestTrackingDiagrams.xUnit2; // XUnit2TestTrackingContext.GetCurrentTestInfo()
// using TUnit.Core; // TestContext.Current
// For xUnit 3: TestContext.Current.Test (built-in)
// For ReqNRoll.TUnit: ReqNRollTestContext.CurrentTestInfo
internal static class BlobTracker
{
internal static class BlobOperation
{
internal const string Exists = "Blob Exists";
internal const string Delete = "Blob Delete";
internal const string Download = "Blob Download";
internal const string Upload = "Blob Upload";
}
private const int MaxPayloadLength = 1000;
private const string TruncationSuffix = "...(Truncated)";
private const string ServiceName = "Blob Storage";
private const string CallerName = "My Service";
internal static void TrackBlobOperation(
string container,
string fileName,
string operation,
string? content = null,
HttpStatusCode statusCode = HttpStatusCode.OK)
{
try
{
var testInfo = XUnit2TestTrackingContext.GetCurrentTestInfo();
// For TUnit, use instead:
// var testInfo = (TestContext.Current!.Metadata.DisplayName, TestContext.Current.Id);
// For ReqNRoll + TUnit:
// var testInfo = ReqNRollTestContext.CurrentTestInfo
// ?? throw new InvalidOperationException("No scenario executing.");
var requestResponseId = Guid.NewGuid();
var traceId = Guid.NewGuid();
var requestUri = new Uri($"https://blob.core.windows.net/{container}/{fileName}");
string? truncatedContent = content is not null && content.Length > MaxPayloadLength
? string.Concat(content.AsSpan(0, MaxPayloadLength), TruncationSuffix)
: content;
TestTrackingDiagrams.Tracking.OneOf<HttpMethod, string> method = operation;
RequestResponseLogger.Log(new RequestResponseLog(
testInfo.Name,
testInfo.Id,
method,
truncatedContent,
requestUri,
[],
ServiceName,
CallerName,
RequestResponseType.Request,
traceId,
requestResponseId,
false
));
RequestResponseLogger.Log(new RequestResponseLog(
testInfo.Name,
testInfo.Id,
method,
null,
requestUri,
[],
ServiceName,
CallerName,
RequestResponseType.Response,
traceId,
requestResponseId,
false,
statusCode
));
}
catch
{
// Silently skip tracking if something goes wrong (e.g. during fixture setup)
}
}
}public class TestBlobClient(InMemoryBlobStorage blobStorage, string container, string fileName)
: BlobClient(new Uri($"https://test.blob.core.windows.net/{container}/{fileName}"))
{
public override async Task<Response<BlobContentInfo>> UploadAsync(
BinaryData content, bool overwrite = false, CancellationToken ct = default)
{
var contentString = content.ToString();
blobStorage.CreateBlob(container, fileName, contentString);
BlobTracker.TrackBlobOperation(container, fileName, BlobTracker.BlobOperation.Upload, contentString);
return await Task.FromResult(new FakeAzureResponse<BlobContentInfo>(null!));
}
public override async Task<Response<bool>> ExistsAsync(CancellationToken ct = default)
{
var exists = blobStorage.BlobExists(container, fileName);
BlobTracker.TrackBlobOperation(container, fileName, BlobTracker.BlobOperation.Exists);
return await Task.FromResult(new FakeAzureResponse<bool>(exists));
}
}If your blob dependency uses a custom interface (not the Azure SDK's BlobClient), use LogPair() directly:
internal static class BlobTracker
{
internal static void TrackUpload(string container, string blobName)
{
RequestResponseLogger.LogPair(
method: "Blob Upload",
uri: new Uri($"blob://{container}/{blobName}"),
serviceName: "Blob Storage",
callerName: "Caller",
testInfoFetcher: CurrentTestInfo.Fetcher,
statusCode: HttpStatusCode.OK);
}
internal static void TrackDownload(string container, string blobName)
{
RequestResponseLogger.LogPair(
method: "Blob Download",
uri: new Uri($"blob://{container}/{blobName}"),
serviceName: "Blob Storage",
callerName: "Caller",
testInfoFetcher: CurrentTestInfo.Fetcher,
statusCode: HttpStatusCode.OK);
}
}The auto-resolving LogPair overload is used here — it resolves test identity from the testInfoFetcher delegate and falls back to TestIdentityScope.Current. If neither resolves, the call is silently skipped (safe during fixture setup/teardown).
Always log a Request followed by a Response with the same traceId and requestResponseId. This creates the call-and-return arrow pair in sequence diagrams.
Service A ──────────▶ Blob Storage ← Request log
Service A ◀────────── Blob Storage ← Response log (same traceId + requestResponseId)
If you only log a Request without a matching Response (or vice versa), the diagram will show an unpaired arrow.
The Method parameter is OneOf<HttpMethod, string>. For non-HTTP dependencies, pass a string label that describes the operation — e.g. "Blob Upload", "Blob Download", "Cache Get". This label appears on the arrow in the sequence diagram.
// HTTP dependency — use HttpMethod
OneOf<HttpMethod, string> method = HttpMethod.Get;
// Non-HTTP dependency — use a descriptive string
OneOf<HttpMethod, string> method = "Blob Upload";The ServiceName parameter determines the target participant name in the diagram. The CallerName parameter determines the calling participant. These should match the names used by your other tracking mechanisms to keep the diagram consistent.
The StatusCode parameter is OneOf<HttpStatusCode, string>?. For non-HTTP dependencies, you can pass either an HttpStatusCode enum value (e.g. HttpStatusCode.OK) if it semantically maps, or a custom string label (e.g. "Success", "Not Found"). Pass null to omit the status from the response arrow.
TestTrackingDiagrams defines its own OneOf<T1, T2> type in the TestTrackingDiagrams.Tracking namespace. If your project also references the popular OneOf NuGet package, you will get a CS0104 ambiguity error:
error CS0104: 'OneOf<,>' is an ambiguous reference between
'OneOf.OneOf<T0, T1>' and 'TestTrackingDiagrams.Tracking.OneOf<TOption1, TOption2>'
Fix this by using the fully qualified type:
TestTrackingDiagrams.Tracking.OneOf<HttpMethod, string> method = "Blob Upload";The framework-specific test context (e.g. XUnit2TestTrackingContext.GetCurrentTestInfo()) provides the test name and ID needed by RequestResponseLog. Behaviour varies by framework:
-
xUnit 2:
XUnit2TestTrackingContext.GetCurrentTestInfo()returns a fallback("Unknown Test", <new GUID>)when no test is active (e.g. during fixture setup/teardown), rather than throwing. -
xUnit 3:
TestContext.Current.Testmay benulloutside a test context. -
TUnit:
TestContext.Currentis available during test execution. UseTestContext.Current!.Metadata.DisplayNamefor the test name andTestContext.Current.Idfor the test ID. -
ReqNRoll + TUnit: Use
ReqNRollTestContext.CurrentTestInfowhich returns(string Name, string Id)?—nulloutside a scenario context. -
LightBDD + TUnit: Use
ScenarioExecutionContext.CurrentScenario.Infofor the test name andRuntimeId.
Even though GetCurrentTestInfo() won't throw, wrapping tracking calls in try/catch is still recommended — your own code around the tracking call (e.g. accessing test-specific state, serialising payloads) could throw during fixture setup or teardown. Tracking failures should never break tests.
This is the same pattern used by CosmosTrackingMessageHandler, which checks CurrentTestInfoFetcher?.Invoke() and skips tracking entirely if the result is null.
When tracking dependencies that handle large payloads (e.g. blob storage uploads containing entire JSON files, large Cosmos DB documents), the tracked content can significantly increase report size and reduce diagram readability.
Set RequestResponseLogger.MaxContentLength to automatically truncate all logged content at capture time. This applies to every extension — HTTP, BigQuery, Spanner, Bigtable, Cosmos, Redis, gRPC, etc. — because truncation happens in the core Log() method.
// In your test fixture or assembly-level setup:
RequestResponseLogger.MaxContentLength = 2000; // charsContent exceeding the limit is trimmed and a marker is appended:
{first 2000 characters of the original content}
…truncated (8472 chars total)
Set to null (the default) to disable truncation.
Tip: This combines well with per-extension
Verbositysettings. For example, useSummarisedon noisy extensions to suppress content entirely, andMaxContentLengthas a safety net for the rest.
For per-call control, truncate before logging:
const int MaxPayloadLength = 1000;
const string TruncationSuffix = "...(Truncated)";
string? truncatedContent = content is not null && content.Length > MaxPayloadLength
? string.Concat(content.AsSpan(0, MaxPayloadLength), TruncationSuffix)
: content;Alternatively, pass null for content to track the interaction without recording the payload at all.
RequestResponseLog(
string TestName, // Name of the current test
string TestId, // Unique ID of the current test
OneOf<HttpMethod, string> Method, // HTTP method or custom label for the diagram arrow
string? Content, // Request/response body (shown as note in diagram)
Uri Uri, // URI of the target (appears in diagram arrow)
(string Key, string? Value)[] Headers, // Headers (shown in detailed/raw views)
string ServiceName, // Target participant name in the diagram
string CallerName, // Calling participant name in the diagram
RequestResponseType Type, // Request or Response
Guid TraceId, // Groups related request/response pairs
Guid RequestResponseId, // Unique ID for this request/response pair
bool TrackingIgnore, // Whether to exclude from diagram rendering
OneOf<HttpStatusCode, string>? StatusCode = null, // Status (only for Response type)
RequestResponseMetaType MetaType = default // Default = standard styling, Event = blue event styling
)| Parameter | Notes |
|---|---|
TestName / TestId
|
Obtained from your framework's test context (e.g. XUnit2TestTrackingContext.GetCurrentTestInfo(), or TestContext.Current for TUnit/xUnit 3). |
Method |
Pass HttpMethod.Get etc. for HTTP, or a string like "Blob Upload" for non-HTTP. |
Content |
Request/response body. Pass null to omit. Truncate large payloads before passing. |
Uri |
The target URI. The path portion appears in the diagram arrow label. |
Headers |
Pass [] (empty array) if not applicable. |
ServiceName |
The name shown as the target participant in the sequence diagram. |
CallerName |
The name shown as the calling participant in the sequence diagram. |
Type |
RequestResponseType.Request or RequestResponseType.Response. Always log one of each per interaction. |
TraceId |
Use the same Guid for a paired request and response. Groups them in the diagram. |
RequestResponseId |
Use the same Guid for a paired request and response. Identifies the specific pair. |
TrackingIgnore |
Set to true to exclude this entry from diagrams (useful for health checks, etc.). Typically false. |
StatusCode |
Pass an HttpStatusCode or a custom string status. Only meaningful for Response entries. |
MetaType |
Leave as default for standard styling. Only set to Event if using MessageTracker-style event annotations. |
RequestResponseLog is a C# record with several mutable properties that can be set after construction. These are not constructor parameters but standard settable properties:
RequestResponseLogger.Log(new RequestResponseLog(
testName, testId, method, content, uri, headers,
serviceName, callerName, RequestResponseType.Request,
traceId, requestResponseId, false
)
{
Timestamp = DateTimeOffset.UtcNow,
ActivityTraceId = Activity.Current?.TraceId.ToString(),
ActivitySpanId = Activity.Current?.SpanId.ToString()
});| Property | Type | Default | Purpose |
|---|---|---|---|
Timestamp |
DateTimeOffset? |
null |
Controls ordering within the sequence diagram. If not set, entries may appear in an unexpected order. |
ActivityTraceId |
string? |
null |
Correlates with Internal Flow Tracking spans. Required for internal flow popups to show activity for this interaction. |
ActivitySpanId |
string? |
null |
Identifies the specific span for Internal Flow Tracking correlation. |
FocusFields |
string[]? |
null |
Field names to highlight in the diagram (used by DiagramFocus). Typically set automatically by TestTrackingMessageHandler. |
IsActionStart |
bool |
false |
Marks this entry as the boundary between setup and action phases. Typically set automatically by the implicit setup detection. |
Tip: Always set
Timestampwhen logging custom entries. Without it, the diagram may render entries in an unexpected order — especially when mixing custom entries withTestTrackingMessageHandler-generated entries that do set timestamps.
RequestResponseLogger.Log() uses a ConcurrentQueue<T> internally and is safe to call from multiple threads simultaneously.
Many services publish events through an outbox pattern rather than calling an event broker directly during request processing. The typical flow is:
- During the HTTP request — the service writes a business document and an outbox message atomically (e.g. Cosmos DB transactional batch)
- A background service picks up pending outbox messages and dispatches them to the event broker (EventGrid, Kafka, SNS, etc.)
This creates a tracking challenge: the background service (OutboxProcessor, IHostedService) runs outside any HTTP request context, so IHttpContextAccessor.HttpContext is null and test identity headers are unavailable. If you wrap the dispatcher, tracking silently fails.
The solution: Track at the outbox write point (step 1), not the dispatch point (step 2). During the HTTP request, HttpContext is available and test identity headers are present.
This is the key setting — it tells MessageTracker to read test identity from the HTTP request headers propagated by TestTrackingMessageHandler, falling back to CurrentTestInfoFetcher when HttpContext is unavailable:
builder.ConfigureTestServices(services =>
{
services.TrackDependenciesForDiagrams(new XUnitTestTrackingMessageHandlerOptions { ... });
services.TrackMessagesForDiagrams(new MessageTrackerOptions
{
CallerName = "My API",
ServiceName = "Event Grid",
UseHttpContextCorrelation = true,
CurrentTestInfoFetcher = CurrentTestInfo.Fetcher
});
});Create a decorator that wraps your IOutboxWriter (or equivalent) and calls MessageTracker.TrackSendEvent() when an event-bound message is written:
public class TrackedOutboxWriter(IOutboxWriter inner, MessageTracker tracker) : IOutboxWriter
{
public async Task WriteAsync<TDocument, TEvent>(
TDocument document, TEvent @event,
string partitionKey, string destination,
CancellationToken cancellationToken = default)
where TDocument : class
where TEvent : class
{
await inner.WriteAsync(document, @event, partitionKey, destination, cancellationToken);
// Only track writes destined for EventGrid (skip if your outbox
// dispatches to multiple destinations and you only want some tracked)
if (string.Equals(destination, "EventGrid", StringComparison.OrdinalIgnoreCase))
{
tracker.TrackSendEvent(
protocol: "Publish (Event Grid)",
destinationName: "Event Grid",
destinationUri: new Uri($"eventgrid://topics/{typeof(TEvent).Name}"),
payload: @event);
}
}
}builder.ConfigureTestServices(services =>
{
// ... other setup ...
// Wrap IOutboxWriter with the tracking decorator
services.AddDecorator<IOutboxWriter>((sp, inner) =>
new TrackedOutboxWriter(inner, sp.GetRequiredService<MessageTracker>()));
});| Aspect | Explanation |
|---|---|
| Test identity |
UseHttpContextCorrelation = true reads X-TTD-TestName / X-TTD-TestId headers from the current HttpContext, which are present because TestTrackingMessageHandler injected them when the test client sent the request. |
| Correct timing |
IOutboxWriter.WriteAsync() runs inside the HTTP request pipeline — HttpContext is guaranteed available. The background OutboxProcessor runs later with no HttpContext. |
| Event styling |
MessageTracker automatically marks entries with RequestResponseMetaType.Event, producing the light-blue event-styled diagram arrows. |
| Phase-aware | If Phase-Aware Tracking is enabled, MessageTracker respects TrackDuringSetup / TrackDuringAction settings. |
RequestResponseLogger.LogPair() and RequestResponseLog use TestTrackingDiagrams.Tracking.OneOf<HttpMethod, string> for the method parameter and OneOf<HttpStatusCode, string>? for status codes. If your project also references the popular OneOf NuGet package, you'll get:
error CS0104: 'OneOf<,>' is an ambiguous reference between
'OneOf.OneOf<T0, T1>' and 'TestTrackingDiagrams.Tracking.OneOf<TOption1, TOption2>'
TTD provides named wrapper types that are assignment-compatible everywhere OneOf<HttpMethod, string> or OneOf<HttpStatusCode, string> is expected:
using TestTrackingDiagrams.Tracking;
DiagramMethod method = "Blob Upload"; // from string
DiagramMethod method = HttpMethod.Post; // from HttpMethod
DiagramStatusCode status = HttpStatusCode.OK; // from HttpStatusCode
DiagramStatusCode status = "Hit"; // from string
// Works everywhere OneOf is expected:
RequestResponseLogger.LogPair(
testName: "Test", testId: "id",
method: (DiagramMethod)"Cache Get",
uri: new Uri("redis://cache/key"),
serviceName: "Redis", callerName: "API",
statusCode: HttpStatusCode.OK);// Add to your file or global usings
using TtdMethod = TestTrackingDiagrams.Tracking.OneOf<System.Net.Http.HttpMethod, string>;
using TtdStatus = TestTrackingDiagrams.Tracking.OneOf<System.Net.HttpStatusCode, string>;
TtdMethod method = "Blob Upload";TestTrackingDiagrams.Tracking.OneOf<HttpMethod, string> method = "Blob Upload";If your tracking code runs in a context where TestIdentityScope is active or you have a CurrentTestInfoFetcher, you can use the auto-resolving overload that doesn't require testName/testId parameters:
// Resolves test info from fetcher, then falls back to TestIdentityScope.Current
RequestResponseLogger.LogPair(
method: "Blob Upload",
uri: new Uri($"https://{account}.blob.core.windows.net/{container}/{blob}"),
serviceName: "Blob Storage",
callerName: "My Service",
testInfoFetcher: CurrentTestInfo.Fetcher); // optional — falls back to TestIdentityScope
// Inside a TestIdentityScope, the fetcher is optional:
using (TestIdentityScope.Begin(testName, testId))
{
RequestResponseLogger.LogPair(
method: "Background Op",
uri: new Uri("custom://svc/op"),
serviceName: "Svc",
callerName: "Caller");
}IDistributedCache is a common dependency that can't use ReplaceWithTracked<T>() when the consuming framework caches a direct reference at startup (see Integration DispatchProxy Extension#when-replacewithtracked-wont-work). Instead, use a manual decorator:
public class TrackingDistributedCache(
IDistributedCache inner,
IHttpContextAccessor httpContextAccessor,
string serviceName = "Redis",
string callerName = "Service") : IDistributedCache
{
public byte[]? Get(string key) { var r = inner.Get(key); Log("Get", key, r != null); return r; }
public async Task<byte[]?> GetAsync(string key, CancellationToken t = default)
{ var r = await inner.GetAsync(key, t); Log("Get", key, r != null); return r; }
public void Set(string key, byte[] value, DistributedCacheEntryOptions o) { inner.Set(key, value, o); Log("Set", key, null); }
public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions o, CancellationToken t = default)
{ var task = inner.SetAsync(key, value, o, t); Log("Set", key, null); return task; }
public void Remove(string key) { inner.Remove(key); Log("Remove", key, null); }
public Task RemoveAsync(string key, CancellationToken t = default) { var task = inner.RemoveAsync(key, t); Log("Remove", key, null); return task; }
public void Refresh(string key) => inner.Refresh(key);
public Task RefreshAsync(string key, CancellationToken t = default) => inner.RefreshAsync(key, t);
private void Log(string op, string key, bool? hit)
{
var label = hit.HasValue ? $"{op} ({(hit.Value ? "Hit" : "Miss")})" : op;
var testInfo = TestInfoResolver.Resolve(httpContextAccessor, null)
?? TestIdentityScope.Current;
if (testInfo is null) return;
RequestResponseLogger.LogPair(testInfo.Value.Name, testInfo.Value.Id,
(DiagramMethod)label, new Uri($"redis://cache/{key}"),
serviceName, callerName);
}
}Register in your test DI:
services.AddSingleton<IDistributedCache>(sp =>
new TrackingDistributedCache(
new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())),
sp.GetRequiredService<IHttpContextAccessor>(),
serviceName: "Redis",
callerName: "My Service"));Common mistake: Wrapping
IOutboxDispatcherorIEventPublisher<T>instead ofIOutboxWriter. IfIEventPublisher<T>is never registered in DI (because the app uses the outbox pattern exclusively), the tracking decorator has nothing to wrap and silently does nothing. Similarly,IOutboxDispatcher.DispatchAsync()runs inside aBackgroundServicewhereHttpContextisnull.
For a complete example of building a custom tracker, see the CosmosTrackingMessageHandler source code. This demonstrates:
- Pairing request/response logs with matching
TraceIdandRequestResponseId - Using
OneOf<HttpMethod, string>for custom diagram labels - Filtering content by verbosity level
- Gracefully handling missing test context (
CurrentTestInfoFetcher?.Invoke())
v2.28.5+
When background processing is logically part of a test (e.g. change-feed subscribers, hosted service callbacks, outbox dispatchers, timer-triggered jobs), neither HttpContext nor the test framework's TestContext is available. Tracking extensions silently skip these operations.
TestIdentityScope is an AsyncLocal-based ambient scope that propagates test identity into any code path:
using TestTrackingDiagrams.Tracking;
// Wrap background processing that belongs to this test:
using (TestIdentityScope.Begin(testName, testId))
{
await backgroundService.ProcessPendingEventsAsync();
// All tracking extensions now attribute operations to this test
}All tracking extensions resolve test identity in this order:
-
HTTP request headers — propagated by
TestTrackingMessageHandler(for code inside the SUT's request pipeline) -
CurrentTestInfoFetcherdelegate — test framework's AsyncLocal (for code on the test thread) -
TestIdentityScope.Current— explicit manual scope (for background threads)
| Scenario | Best approach |
|---|---|
| Background work triggered by an HTTP request (outbox dispatch, change feed) | Track at the write point during the HTTP request (see Tracking Outbox Event Publishing) |
Background work triggered by a test directly (e.g. await processor.RunOnceAsync()) |
Wrap with TestIdentityScope.Begin(...)
|
Hosted services that poll and process (e.g. IHostedService) |
Wrap the poll invocation in the test with TestIdentityScope.Begin(...)
|
[Fact]
public async Task Order_processing_writes_to_event_store()
{
// Trigger the action
var response = await _client.PostAsync("/api/orders", content);
response.StatusCode.Should().Be(HttpStatusCode.Accepted);
// Background processing — propagate test identity
var testInfo = CurrentTestInfo.Fetcher();
using (TestIdentityScope.Begin(testInfo.Name, testInfo.Id))
{
await _fixture.WaitForEventProcessing();
// Cosmos/Redis/ServiceBus operations during event processing
// are now attributed to this test in the diagrams
}
}Scopes nest correctly — disposing restores the previous identity:
using (TestIdentityScope.Begin("OuterTest", "outer-id"))
{
using (TestIdentityScope.Begin("InnerTest", "inner-id"))
{
// Current = ("InnerTest", "inner-id")
}
// Current = ("OuterTest", "outer-id")
}
// Current = nullTestIdentityScope uses AsyncLocal<T>, which propagates through:
-
awaitcontinuations (same logical context) Task.Run()new Thread().Start()ThreadPool.QueueUserWorkItem()
It does not propagate to threads started with Thread.UnsafeStart() or when ExecutionContext.SuppressFlow() is active.
If fixture operations (container clearing, database seeding) generate unwanted log entries, use TestPhaseContext to mark them as Setup, then set TrackDuringSetup = false on your tracking options:
// In collection fixture InitializeAsync:
public async ValueTask InitializeAsync()
{
TestPhaseContext.Current = TestPhase.Setup;
await ClearMerchantsContainer(); // Not tracked (TrackDuringSetup = false)
await SeedTestData(); // Not tracked
TestPhaseContext.Current = TestPhase.Unknown; // Reset for tests
}Combined with the tracking options:
new CosmosTrackingMessageHandlerOptions
{
ServiceName = "CosmosDB",
CallerName = "My API",
CurrentTestInfoFetcher = CurrentTestInfo.Fetcher,
TrackDuringSetup = false // Suppresses tracking when TestPhaseContext.Current == Setup
};Note:
TestPhaseContextisAsyncLocal-based — it only affects the current async context. Other tests running in parallel are unaffected.
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 ClickHouse 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
- Merging Parallel Reports
Reference