Skip to content

Service Bus Tracking Patterns

Aryeh Citron edited this page Apr 30, 2026 · 4 revisions

This page covers patterns for tracking Service Bus message publishing and consumption in diagrams.

Azure SDK Service Bus? If your code uses the Azure ServiceBusClient SDK directly, use TestTrackingDiagrams.Extensions.ServiceBus — see Integration ServiceBus Extension. This page covers custom patterns for platform abstractions and in-memory messaging.


Overview

Service Bus tracking uses the MessageTracker class registered via TrackMessagesForDiagrams(). There are two common patterns:

Pattern Use When
TrackSendEvent() You want event-styled arrows (light blue, "Responded" label) — standard for fire-and-forget publishes
TrackMessageRequest() + TrackMessageResponse() You want standard-styled arrows (white, status labels) — useful when the publish is a synchronous side effect of an HTTP request

See Event & Message Tracking#When to Use MessageTracker vs TestTrackingMessageHandler for a visual comparison of the arrow styles.


Setup

1. Register MessageTracker

services.TrackMessagesForDiagrams(new MessageTrackerOptions
{
    CallerName = "My Service",
    ServiceName = "Service Bus",
    UseHttpContextCorrelation = true,
    CurrentTestInfoFetcher = CurrentTestInfo.Fetcher,
});
Property Description
CallerName The service publishing the message (shown as the source in diagrams)
ServiceName The message broker (typically "Service Bus")
UseHttpContextCorrelation When true, reads tracking headers from HttpContext to correlate async messages back to the originating test
CurrentTestInfoFetcher Fallback when HttpContext is not available (e.g. timer-triggered or background publishing)

2. Create a Tracking Handler

For messaging abstractions that expose BeforePublish/AfterPublish events, bridge them to MessageTracker:

public class ServiceBusMessageTrackingHandler
{
    private readonly MessageTracker _tracker;
    private readonly AsyncLocal<Guid> _pendingCorrelationId = new();

    public ServiceBusMessageTrackingHandler(MessageTracker tracker) =>
        _tracker = tracker;

    public void Attach(IMessageSender sender)
    {
        sender.BeforePublish += OnBeforePublish;
        sender.AfterPublish += OnAfterPublish;
    }

    private void OnBeforePublish(object? sender, PublishMessageArgs args)
    {
        var destination = args.QueueOrTopic ?? "unknown";
        var correlationId = _tracker.TrackMessageRequest(
            protocol: "Send (Service Bus)",
            destinationName: "Service Bus",
            destinationUri: new Uri($"servicebus://service-bus/{destination}"),
            payload: args.Message ?? new { });

        _pendingCorrelationId.Value = correlationId;
    }

    private void OnAfterPublish(object? sender, PublishMessageArgs args)
    {
        var correlationId = _pendingCorrelationId.Value;
        if (correlationId == Guid.Empty) return;

        var destination = args.QueueOrTopic ?? "unknown";
        _tracker.TrackMessageResponse(
            protocol: "Send (Service Bus)",
            destinationName: "Service Bus",
            destinationUri: new Uri($"servicebus://service-bus/{destination}"),
            requestResponseId: correlationId);

        _pendingCorrelationId.Value = Guid.Empty;
    }
}

Why AsyncLocal<Guid>? The BeforePublish and AfterPublish events fire synchronously around the SendMessage call, but multiple tests may send messages concurrently. AsyncLocal ensures each async flow gets its own correlation ID.

Why check for Guid.Empty? If BeforePublish isn't fired for certain message types, AfterPublish should be a no-op rather than logging an orphaned response.

Simpler Alternative: AfterPublish Only

If you prefer event-styled arrows and want simpler code, track only in AfterPublish:

public void Attach(IMessageSender sender)
{
    sender.AfterPublish += (_, args) =>
    {
        try
        {
            var destination = args.QueueOrTopic ?? "unknown";
            _tracker.TrackSendEvent(
                protocol: "Publish (Service Bus)",
                destinationName: "Service Bus",
                destinationUri: new Uri($"servicebus://service-bus/{destination}"),
                payload: args.Message);
        }
        catch
        {
            // Tracking is best-effort — never fail a test due to tracking
        }
    };
}

3. Wire Up After Hosts Are Initialised

// MessageTracker is registered in the API host by TrackMessagesForDiagrams()
var messageTracker = WebFactory.Services.GetService<MessageTracker>();
if (messageTracker is not null)
{
    var handler = new ServiceBusMessageTrackingHandler(messageTracker);
    handler.Attach(messageSender);
}

Timing: The tracker must be wired after both the API host and any Function host are initialised. See Multi-Host Test Architectures#Cross-Container Tracker Bridging.


Atomic Tracking (Avoiding Unpaired Requests)

A common pitfall is tracking the request in BeforePublish and the response in AfterPublish separately:

// ❌ WRONG — if publish throws, AfterPublish never fires → unpaired request in diagnostics
private void OnBeforePublish(object? sender, PublishMessageArgs args)
{
    RequestResponseLogger.Log(/* request entry */);
}
private void OnAfterPublish(object? sender, PublishMessageArgs args)
{
    RequestResponseLogger.Log(/* response entry */);  // Never called if publish throws
}

The fix: Use MessageTracker.TrackSendEvent() (logs both atomically) or stash args in BeforePublish and track both entries in AfterPublish as shown above.


Attributing Messages to Different Callers

In Given/When/Then tests, setup messages should appear as coming from "Caller" while action messages should appear as coming from the service. Use an AsyncLocal-based scope:

public class ServiceBusMessageTrackingHandler
{
    private readonly MessageTracker _serviceTracker;
    private readonly MessageTracker _callerTracker;
    private readonly AsyncLocal<MessageTracker?> _activeTracker = new();

    public ServiceBusMessageTrackingHandler(MessageTracker serviceTracker)
    {
        _serviceTracker = serviceTracker;
        _callerTracker = new MessageTracker(new MessageTrackerOptions
        {
            CallerName = "Caller",
            ServiceName = "Service Bus",
            CurrentTestInfoFetcher = serviceTracker.Options.CurrentTestInfoFetcher,
        });
    }

    /// <summary>
    /// Returns a scope that attributes subsequent messages to "Caller" instead of the service.
    /// </summary>
    public IDisposable AsExternalCaller() => new CallerScope(this);

    private MessageTracker ActiveTracker => _activeTracker.Value ?? _serviceTracker;

    private sealed class CallerScope(ServiceBusMessageTrackingHandler handler) : IDisposable
    {
        public CallerScope(ServiceBusMessageTrackingHandler handler) : this(handler)
            => handler._activeTracker.Value = handler._callerTracker;

        public void Dispose() => handler._activeTracker.Value = null;
    }
}

Usage during test setup:

using (trackingHandler.AsExternalCaller())
{
    await SeedTestData(); // Messages attributed to "Caller"
}
// After Dispose, messages are attributed to "My Service"

Tracking Message Consumption

For tracking when a function or handler consumes a message, use TrackConsumeEvent():

messageTracker.TrackConsumeEvent(
    protocol: "Consume (Service Bus)",
    sourceName: "Service Bus",
    sourceUri: new Uri($"servicebus://service-bus/{queueOrTopic}"),
    payload: message);

See Event & Message Tracking#Tracking Message Consumption (TrackConsumeEvent) for full details, including cross-host duplicate guards.


Function Trigger Attribution

When a test triggers an Azure Function via HTTP POST, the Function's HttpContext has no tracking headers — so Cosmos/HTTP operations inside the Function are attributed to "Unknown".

Fix: Include test tracking headers on the trigger request:

var request = new HttpRequestMessage(HttpMethod.Post, "/api/functions/MyTrigger")
{
    Content = new StringContent(string.Empty)
};

var (testName, testId) = CurrentTestInfo.Fetcher();
request.Headers.Add("test-tracking-current-test-name", testName);
request.Headers.Add("test-tracking-current-test-id", testId);
request.Headers.Add("test-tracking-trace-id", Guid.NewGuid().ToString());

await functionClient.SendAsync(request);

Alternatively, use TestIdentityScope.Begin() — see Background Thread Correlation.

Home


Demo


Getting Started

Common Tasks

Integration Guides

Extensions

Configuration

Features

Reference

Clone this wiki locally