Skip to content

Distributed Tracing

Modulus messaging propagates W3C trace context (traceparent/tracestate) across the broker, so a request that publishes an event and the handlers that consume it — possibly seconds later, in another process — appear in one distributed trace.

What gets emitted

SpanActivitySourceKindWhen
{event} publishModulus.MessagingProducerDirect IMessageBus.Publish
outbox.dispatchModulus.Messaging.OutboxProducerOutbox processor publishing a stored row
{event} processModulus.MessagingConsumerTransport delivery through the consumer pipeline

Enable them in OpenTelemetry alongside the existing meter:

csharp
builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing
        .AddSource("Modulus.Messaging")         // publish + consume spans
        .AddSource("Modulus.Messaging.Outbox")  // outbox dispatch spans
        .AddSource("Modulus.Mediator"))         // TracingBehavior, if enabled
    .WithMetrics(metrics => metrics.AddMeter("Modulus.Messaging"));

The source and header names are available as constants on MessagingDiagnostics.

How context flows

Direct publishIMessageBus.Publish starts a Producer span and injects its context into the envelope's Headers, which each transport maps to its native mechanism (RabbitMQ AMQP headers, Azure Service Bus application properties; the in-memory transport passes the envelope through unchanged).

Through the outbox — the save and the publish happen at different times, so the context is persisted and re-linked:

  1. IOutboxStore.Save captures the ambient activity (your request's span) into the row's TraceParent/TraceState columns.
  2. When the outbox processor dispatches the row, its outbox.dispatch Producer span links to that saved context (an ActivityLink, not a parent — the originating request finished long ago; links are the OTel shape for deferred producers).
  3. The envelope carries the dispatch span's context, so consumer latency attributes to the dispatch while the originating request stays one link-hop away.

Consume — the consumer pipeline starts one Consumer span per delivery, parented on the context extracted from the envelope headers. It wraps the whole in-process retry loop, and Activity.Current flows into your handlers — mediator calls instrumented by TracingBehavior nest under it automatically. Tags: modulus.message_id, modulus.message_type, modulus.outcome (acknowledge/dead_letter), modulus.attempt on retries; dead-letters set the span status to error.

Interop — on Azure Service Bus, messages from non-Modulus publishers instrumented by the Azure SDK are honored via their Diagnostic-Id property, so those consumers still join the producer's trace.

Schema note

OutboxMessages gains two nullable columns (TraceParent, max 55; TraceState, max 512). Consumer-owned migrations apply — generate a follow-up migration after upgrading (see the migrations guide). Rows written before the migration (or with no active trace at save time) have null context and behave exactly as before.

Custom headers

TransportEnvelope.Headers is a general string-to-string bag: anything you place there rides the same broker mechanisms. Custom IMessageTransport implementations should round-trip it; transports that ignore it lose trace propagation but nothing else.

See Also

Released under the MIT License.