Skip to content

Event Annotations

Aryeh Citron edited this page Apr 22, 2026 · 25 revisions

Requests can be marked with RequestResponseMetaType.Event to give them special styling in diagrams. Event-annotated notes are rendered with:

  • A light blue background (#cfecf7)
  • Smaller font size (11px)
  • Rounded corners

This is useful for distinguishing genuinely asynchronous or non-HTTP interactions — such as Kafka events, EventGrid notifications, RabbitMQ messages, SNS/SQS, or webhooks — from standard HTTP request/response flows.

⚠️ Important: Do NOT use MessageTracker for HTTP-based dependencies. If the real production interaction between your SUT and a dependency is HTTP, you must route it through TestTrackingMessageHandler instead. Using MessageTracker for HTTP calls produces event-style diagram arrows (blue notes, no HTTP method, no status code, no headers) that are misleading and inconsistent with the rest of the diagram. See Tracking Dependencies#faking-dependencies-getting-proper-http-tracking for the correct approaches to faking HTTP dependencies (WireMock, JustEat HttpClient Interception, in-memory fake APIs, etc.).

MessageTracker

The MessageTracker class makes it easy to log non-HTTP interactions — such as events, messages, or commands sent via Kafka, EventGrid, RabbitMQ, SNS, or any other transport — so they appear in the generated sequence diagrams alongside regular HTTP traffic.

MessageTracker implements ITrackingComponent, so it automatically registers with the TrackingComponentRegistry and participates in unused-component diagnostics. If you register a MessageTracker but no messages are tracked during a test, the report will flag it as unused.

Registration (Recommended)

Added in v2.21.0. This is the recommended approach for new code. It follows the same Options pattern used by all other TTD extensions and does not require IHttpContextAccessor.

Register MessageTracker using MessageTrackerOptions in your WebApplicationFactory startup:

xUnit 3:

builder.ConfigureTestServices(services =>
{
    services.TrackDependenciesForDiagrams(new XUnitTestTrackingMessageHandlerOptions { ... });
    services.TrackMessagesForDiagrams(new MessageTrackerOptions
    {
        CallingServiceName = "My API",
        ServiceName = "Kafka",   // appears in diagram participant name & component diagnostics
        CurrentTestInfoFetcher = () => (
            TestContext.Current.Test!.TestDisplayName,
            TestContext.Current.Test.UniqueID)
    });
});

xUnit 2:

services.TrackMessagesForDiagrams(new MessageTrackerOptions
{
    CallingServiceName = "My API",
    CurrentTestInfoFetcher = () => XUnit2TestTrackingContext.GetCurrentTestInfo()
});

TUnit:

services.TrackMessagesForDiagrams(new MessageTrackerOptions
{
    CallingServiceName = "My API",
    CurrentTestInfoFetcher = () => (
        TestContext.Current!.Metadata.DisplayName,
        TestContext.Current.Id)
});

MessageTrackerOptions Properties

Property Default Description
ServiceName "MessageBus" The participant name for the messaging service. Appears in diagram labels and ComponentName for diagnostics ("MessageTracker (Kafka)").
CallingServiceName "Caller" The participant name for the service sending messages.
Verbosity Detailed Controls how much detail is logged. See Verbosity below.
CurrentTestInfoFetcher null Delegate returning the current test name and ID. Required — when null, tracking calls are silently skipped.
CurrentStepTypeFetcher null Optional BDD step type fetcher for framework integration.
SerializerOptions null JsonSerializerOptions for serialising payloads. Uses default options when null.

Registration (Legacy)

The legacy registration reads test info from IHttpContextAccessor headers, with an optional testInfoFallback delegate. This approach is still fully supported and backward-compatible.

builder.ConfigureTestServices(services =>
{
    services.TrackDependenciesForDiagrams(new XUnitTestTrackingMessageHandlerOptions { ... });
    services.TrackMessagesForDiagrams(callingServiceName: "My API");
});

This registers MessageTracker as a singleton in DI, along with IHttpContextAccessor (needed to read test-tracking headers from the current request context). When using this approach, the ServiceName defaults to "MessageBus" and verbosity defaults to Detailed.

If your tests trigger behaviour via non-HTTP entry points (service bus messages, background jobs, etc.), you should also pass the testInfoFallback parameter. See Tracking Outside HTTP Request Context below.

Usage

Inject MessageTracker into any fake or stub that simulates publishing or sending messages.

Fire-and-Forget With TrackSendEvent (Recommended)

Added in v2.21.0. For simple fire-and-forget event publishing, TrackSendEvent() logs both the request and response in a single call:

public class FakeEventPublisher : IEventPublisher
{
    private readonly MessageTracker _tracker;

    public FakeEventPublisher(MessageTracker tracker)
    {
        _tracker = tracker;
    }

    public Task PublishAsync(OrderCreatedEvent @event)
    {
        _tracker.TrackSendEvent(
            protocol: "Send (Event Protocol)",
            destinationName: "Order Service",
            destinationUri: new Uri("event://order-service/order_events"),
            payload: @event);

        return Task.CompletedTask;
    }
}

Request/Response Pair (Full Control)

When you need fine-grained control — for example, to log a response payload or handle multi-step flows — use the TrackMessageRequest / TrackMessageResponse pair:

public class FakeEventPublisher : IEventPublisher
{
    private readonly MessageTracker _tracker;

    public FakeEventPublisher(MessageTracker tracker)
    {
        _tracker = tracker;
    }

    public Task PublishAsync(OrderCreatedEvent @event)
    {
        var correlationId = _tracker.TrackMessageRequest(
            protocol: "Send (Event Protocol)",
            destinationName: "Order Service",
            destinationUri: new Uri("event://order-service/order_events"),
            payload: @event);

        _tracker.TrackMessageResponse(
            protocol: "Send (Event Protocol)",
            destinationName: "Order Service",
            destinationUri: new Uri("event://order-service/order_events"),
            requestResponseId: correlationId);

        return Task.CompletedTask;
    }
}

Parameters

Parameter Description
protocol The transport label shown in the diagram (e.g. "Send (Event Protocol)", "Kafka", "SNS", "RabbitMQ").
destinationName The name of the destination service or topic shown in the diagram.
destinationUri A URI representing the destination (e.g. new Uri("event://order-service/order_events")). The path portion appears in the diagram arrow label.
payload The message payload — serialised to JSON and shown in the diagram note.
requestResponseId The correlation ID returned by TrackMessageRequest, used to pair the request and response.
responsePayload Optional response payload for TrackMessageResponse (e.g. an acknowledgement).

Verbosity

Added in v2.21.0.

Control the amount of detail logged for each message via MessageTrackerVerbosity:

Level Behaviour
Raw Serialises payloads exactly as provided — no transformation.
Detailed (Default) Same as Raw — serialises payloads to JSON.
Summarised Omits all payload content. Useful when messages are large or contain sensitive data, and you only want to see the interaction arrows without body content.

Set the verbosity via MessageTrackerOptions:

services.TrackMessagesForDiagrams(new MessageTrackerOptions
{
    CallingServiceName = "My API",
    Verbosity = MessageTrackerVerbosity.Summarised,
    CurrentTestInfoFetcher = () => (
        TestContext.Current.Test!.TestDisplayName,
        TestContext.Current.Test.UniqueID)
});

Or via the legacy registration (which always uses Detailed verbosity — change is not supported with this overload).

Custom JSON Serialisation

If your payloads require specific serialisation settings, pass JsonSerializerOptions via the options object:

services.TrackMessagesForDiagrams(new MessageTrackerOptions
{
    CallingServiceName = "My API",
    SerializerOptions = new JsonSerializerOptions { WriteIndented = true },
    CurrentTestInfoFetcher = () => (
        TestContext.Current.Test!.TestDisplayName,
        TestContext.Current.Test.UniqueID)
});

The legacy registration also supports this:

services.TrackMessagesForDiagrams(
    callingServiceName: "My API",
    serializerOptions: new JsonSerializerOptions { WriteIndented = true });

Tracking Outside HTTP Request Context (testInfoFallback)

This section applies to the legacy registration only. When using the recommended MessageTrackerOptions registration, you provide CurrentTestInfoFetcher directly in the options — there is no separate testInfoFallback parameter. The options-based approach always uses the fetcher delegate, bypassing IHttpContextAccessor entirely.

When using the legacy registration, MessageTracker reads test-tracking headers from IHttpContextAccessor.HttpContext to determine which test a tracked message belongs to. This works when the message is published during HTTP request processing (e.g. your API receives a request and publishes an event as a side-effect).

However, if a message is published outside of an HTTP request context — e.g. triggered by a service bus message, during hosted service startup, background task processing, or test fixture setup — HttpContext will be null and there are no test-tracking headers to read.

This is a common scenario in Function-based or event-driven architectures where tests send service bus messages directly (not via HTTP), and the function handler may publish outbound messages or interact with other services. Without configuration, these interactions will silently fail to be tracked, resulting in missing diagrams.

To handle this, pass a testInfoFallback delegate when registering MessageTracker. This delegate is called whenever HttpContext is unavailable:

xUnit 3:

builder.ConfigureTestServices(services =>
{
    services.TrackDependenciesForDiagrams(new XUnitTestTrackingMessageHandlerOptions { ... });
    services.TrackMessagesForDiagrams(
        callingServiceName: "My API",
        testInfoFallback: () => (
            TestContext.Current.Test!.TestDisplayName,
            TestContext.Current.Test.UniqueID));
});

xUnit 2:

using TestTrackingDiagrams.Tracking;
using TestTrackingDiagrams.xUnit2;

services.TrackMessagesForDiagrams(
    callingServiceName: "My API",
    testInfoFallback: () => XUnit2TestTrackingContext.GetCurrentTestInfo());

TUnit:

using TUnit.Core;

services.TrackMessagesForDiagrams(
    callingServiceName: "My API",
    testInfoFallback: () => (
        TestContext.Current!.Metadata.DisplayName,
        TestContext.Current.Id));

ReqNRoll + TUnit:

using TestTrackingDiagrams.ReqNRoll.TUnit;

services.TrackMessagesForDiagrams(
    callingServiceName: "My API",
    testInfoFallback: () => ReqNRollTestContext.CurrentTestInfo
        ?? throw new InvalidOperationException("No ReqNRoll scenario is currently executing."));

Resolution order: MessageTracker first attempts to read headers from HttpContext. If HttpContext is null (or the test-tracking headers are missing), it falls back to the testInfoFallback delegate. If neither is available, it throws an InvalidOperationException with a descriptive message.

When is this needed? If your tests trigger behaviour via non-HTTP entry points (service bus messages, background jobs, timers) and the handler publishes messages or calls downstream services that are tracked via MessageTracker, you must provide testInfoFallback — otherwise those interactions will not appear in the diagrams.

See also: Tracking Dependencies — Gotcha: HTTP Calls During HttpClient Construction for a similar scenario affecting TestTrackingMessageHandler when HTTP calls occur during HttpClient construction.


When to Use MessageTracker vs TestTrackingMessageHandler

This is one of the most common sources of confusion. The two mechanisms produce visually different outputs in your diagrams, and using the wrong one leads to misleading documentation.

Visual Comparison

HTTP tracking (via TestTrackingMessageHandler) produces:

caller -> scvApi: GET: /v2/identifiers?ssoId=sub
note left                          ← white background, standard note
[Accept=application/json]

end note
scvApi --> caller: 200 OK
note right                         ← white background, status code visible
[Content-Type=application/json]

{ "customerId": "custNbr123" }
end note
  • Arrow label shows the HTTP method (GET:, POST:, etc.)
  • Response shows the HTTP status code (200 OK, 404 Not Found, etc.)
  • Request and response headers are captured
  • Request and response bodies are captured
  • Notes have a white background (standard styling)

Event tracking (via MessageTracker) produces:

caller -> scvApi: HTTP: /v2/identifiers?ssoId=sub
note<<eventNote>> left             ← BLUE background, rounded corners
"sub"
end note
scvApi --> caller: Responded       ← no status code
  • Arrow label shows the protocol string you passed (e.g. "HTTP", "Kafka", "SNS")
  • Response always shows "Responded" — no HTTP status code
  • No headers are captured
  • Only the payload you explicitly pass appears in the note
  • Notes have a light blue background with rounded corners (event styling)

Decision Guide

The real production interaction is... Use Why
HTTP (REST API, GraphQL over HTTP, gRPC-web) TestTrackingMessageHandler Captures full HTTP semantics (method, status, headers, body)
Cosmos DB 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
Kafka event / message MessageTracker No HTTP involved; protocol is Kafka
RabbitMQ / SNS / SQS message MessageTracker No HTTP involved; protocol is the message broker
EventGrid / Azure Service Bus notification MessageTracker Asynchronous messaging, not request/response HTTP
Synchronous SDK fake (Blob Storage, Key Vault, etc.) RequestResponseLogger.Log() No HTTP pipeline; creates standard call-and-return arrows. See Tracking Custom Dependencies
Webhook (your SUT sends an HTTP call to a callback URL) TestTrackingMessageHandler It's still HTTP — track it properly through the pipeline
gRPC (over HTTP/2) TestTrackingMessageHandler gRPC uses HTTP/2 transport — track the HTTP layer

Common Mistake: Wrapping Mocks with MessageTracker

A common pattern that leads to incorrect diagrams is mocking a service client interface and then wrapping the mock to manually log calls via MessageTracker:

// ❌ WRONG — produces event-style (blue) arrows for what is actually an HTTP call
public class TrackingScvProvider : IScvProvider
{
    private readonly IScvProvider _mock;       // NSubstitute mock
    private readonly MessageTracker _tracker;

    public async Task<ScvResponse> GetIdentifiersAsync(string ssoId)
    {
        var id = _tracker.TrackMessageRequest(
            protocol: "HTTP",
            destinationName: "SCV API",
            destinationUri: new Uri($"http://scv/v2/identifiers?ssoId={ssoId}"),
            payload: ssoId);

        var result = await _mock.GetIdentifiersAsync(ssoId);

        _tracker.TrackMessageResponse(
            protocol: "HTTP",
            destinationName: "SCV API",
            destinationUri: new Uri("http://scv/v2/identifiers"),
            requestResponseId: id);

        return result;
    }
}

The fix: Replace the mock with a real HTTP fake (WireMock, JustEat HttpClient Interception, or an in-memory API) and let TestTrackingMessageHandler capture the call automatically. See Tracking Dependencies#faking-dependencies-getting-proper-http-tracking for detailed examples of each approach.


Tips for Tracking In-Memory Message Brokers

When tracking messages through a custom in-memory Service Bus or message broker fake, keep these pitfalls in mind:

Hook at the Broker Level, Not the Sender Level

Many in-memory fakes route messages through multiple paths — e.g. SendMessageAsync() on a sender vs EnqueueMessage() / ScheduleMessage() on the client or broker. If you only hook tracking into the sender, messages published internally by the SUT (routed directly through the broker) will silently not appear in diagrams.

Track where messages actually flow through — typically the client or broker's send/enqueue/schedule methods — not just the sender wrapper.

Avoid Copying MessageTracker at Construction Time

If your fake ServiceBusClient creates senders/receivers before WebApplicationFactory.Services is built, MessageTracker will be null at sender construction time. Senders that capture the tracker in a field at construction will never get the real instance.

Instead, read the tracker from the parent client at send time:

// ❌ Breaks if MessageTracker is set after sender creation
internal MessageTracker? MessageTracker { get; set; }  // copied from client at construction

// ✅ Always reads current value from parent
private void TrackMessage(ServiceBusMessage message)
{
    _client.MessageTracker?.TrackMessageRequest(
        protocol: "Service Bus",
        destinationName: "Service Bus",
        destinationUri: new Uri($"sb://servicebus/{_queueName}"),
        payload: message.Body);
}

Wrap Tracking Calls in try/catch

Tracking failures should never break test execution. For example, XUnit2TestTrackingContext.GetCurrentTestInfo() returns a fallback ("Unknown Test", ...) when no test is active, but your own code around the tracker (e.g. accessing test-specific state) might throw during fixture setup or teardown.

try
{
    _client.MessageTracker?.TrackMessageRequest(...);
}
catch
{
    // Tracking is best-effort — don't break the test
}

Home


Demo


Getting Started

Common Tasks

Integration Guides

Extensions

Configuration

Features

Reference

Clone this wiki locally