Skip to content

Tracking Custom Dependencies

Aryeh Citron edited this page Apr 16, 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
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
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 CallingServiceName = "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,
                CallingServiceName,
                RequestResponseType.Request,
                traceId,
                requestResponseId,
                false
            ));

            RequestResponseLogger.Log(new RequestResponseLog(
                testInfo.Name,
                testInfo.Id,
                method,
                null,
                requestUri,
                [],
                ServiceName,
                CallingServiceName,
                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));
    }
}

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), rather than throwing.
  • 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.

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.


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.

Unlike CosmosTrackingMessageHandler (which provides a Verbosity setting — use CosmosTrackingVerbosity.Summarised to suppress response bodies), RequestResponseLogger.Log() has no built-in truncation. The caller must handle it:

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.


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

Home


Demo


Getting Started

Common Tasks

Integration Guides

Extensions

Configuration

Features

Reference

Clone this wiki locally