Skip to content

Tracking Custom Dependencies

Aryeh Citron edited this page May 1, 2026 · 22 revisions

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.

When to Use This Approach

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 separate Log() calls. See Diagnostics and Debugging#RequestResponseLogger.LogPair() for details.

Why not MessageTracker? MessageTracker marks entries with RequestResponseMetaType.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 what TestTrackingMessageHandler produces for HTTP dependencies. See Event & Message Tracking for a visual comparison.


Complete Example: Tracking Blob Storage

Step 1: Create a Tracking Helper

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)
        }
    }
}

Step 2: Call the Tracker from Your Fake

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));
    }
}

Alternative: Tracking Interface-Based Fakes

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,
            dependencyCategory: DependencyCategories.BlobStorage);
    }

    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,
            dependencyCategory: DependencyCategories.BlobStorage);
    }
}

dependencyCategory parameter (v2.28.21+): Pass a value from DependencyCategories to control the participant shape and colour in sequence diagrams. For example, DependencyCategories.BlobStorage renders the participant as database "Blob Storage" #CC6600 instead of the default generic entity. If omitted, the participant renders as a plain entity.

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).


Key Concepts

Pair Your Log Calls

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 Controls the Diagram Label

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 Controls the Participant

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

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.


OneOf<T1, T2> Type Ambiguity

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";

Test Context Availability

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).
  • xUnit 3: TestContext.Current.Test may be null outside a test context.
  • TUnit: TestContext.Current is available during test execution. Use TestContext.Current!.Metadata.DisplayName for the test name and TestContext.Current.Id for the test ID.
  • ReqNRoll + TUnit: Use ReqNRollTestContext.CurrentTestInfo which returns (string Name, string Id)?null outside a scenario context.
  • LightBDD + TUnit: Use ScenarioExecutionContext.CurrentScenario.Info for the test name and RuntimeId.

v2.28.22+: All CurrentTestInfo.Fetcher implementations now throw InvalidOperationException when the test context is unavailable, rather than returning UnknownIdentity. This allows the resolution chain (TestInfoResolver.Resolve(), MessageTracker.GetTestInfo(), LogPair()) to correctly fall through to TestIdentityScope.Current and GlobalFallback. The exceptions are caught by the existing try/catch pattern in all resolvers — tracking is silently skipped (or the next fallback is tried), never crashing.

This is the same pattern used by CosmosTrackingMessageHandler, which checks CurrentTestInfoFetcher?.Invoke() and skips tracking entirely if the result is null.


Payload Size Considerations

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.

Global Content Truncation (v2.25.0+)

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;  // chars

Content 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 Verbosity settings. For example, use Summarised on noisy extensions to suppress content entirely, and MaxContentLength as a safety net for the rest.

Manual Truncation

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 Constructor Reference

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.

Additional Properties

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 Timestamp when logging custom entries. Without it, the diagram may render entries in an unexpected order — especially when mixing custom entries with TestTrackingMessageHandler-generated entries that do set timestamps.

Thread Safety

RequestResponseLogger.Log() uses a ConcurrentQueue<T> internally and is safe to call from multiple threads simultaneously.


Tracking Outbox Event Publishing

Many services publish events through an outbox pattern rather than calling an event broker directly during request processing. The typical flow is:

  1. During the HTTP request — the service writes a business document and an outbox message atomically (e.g. Cosmos DB transactional batch)
  2. 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.

Step 1: Register MessageTracker with UseHttpContextCorrelation

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
    });
});

Step 2: Create an IOutboxWriter Decorator

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);
        }
    }
}

Step 3: Register the Decorator

builder.ConfigureTestServices(services =>
{
    // ... other setup ...

    // Wrap IOutboxWriter with the tracking decorator
    services.AddDecorator<IOutboxWriter>((sp, inner) =>
        new TrackedOutboxWriter(inner, sp.GetRequiredService<MessageTracker>()));
});

Why This Works

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.

Avoiding the OneOf Type Ambiguity

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>'

Option 1: Use DiagramMethod / DiagramStatusCode (Recommended)

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);

Option 2: using Alias

// 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";

Option 3: Fully Qualified Name

TestTrackingDiagrams.Tracking.OneOf<HttpMethod, string> method = "Blob Upload";

Auto-Resolving LogPair (No Test Name/ID Required)

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");
}

Example: Tracking IDistributedCache

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 IOutboxDispatcher or IEventPublisher<T> instead of IOutboxWriter. If IEventPublisher<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 a BackgroundService where HttpContext is null.


Reference Implementation

For a complete example of building a custom tracker, see the CosmosTrackingMessageHandler source code. This demonstrates:

  • Pairing request/response logs with matching TraceId and RequestResponseId
  • Using OneOf<HttpMethod, string> for custom diagram labels
  • Filtering content by verbosity level
  • Gracefully handling missing test context (CurrentTestInfoFetcher?.Invoke())

Tracking Background Processing with TestIdentityScope

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
}

Resolution Order

All tracking extensions resolve test identity in this order:

  1. HTTP request headers — propagated by TestTrackingMessageHandler (for code inside the SUT's request pipeline)
  2. CurrentTestInfoFetcher delegate — test framework's AsyncLocal (for code on the test thread)
  3. TestIdentityScope.Current — explicit manual scope (for background threads)

When to Use

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

Example: Wrapping ForCompletion in a Fixture

[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
    }
}

Nesting

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 = null

AsyncLocal Propagation

TestIdentityScope uses AsyncLocal<T>, which propagates through:

  • await continuations (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.


Suppressing Tracking During Fixture Setup

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: TestPhaseContext is AsyncLocal-based — it only affects the current async context. Other tests running in parallel are unaffected.

Home


Demo


Getting Started

Common Tasks

Integration Guides

Extensions

Configuration

Features

Reference

Clone this wiki locally