Skip to content

Distributed Tracing

Emit instruments the full message lifecycle with OpenTelemetry activities. A trace started in your HTTP handler flows through the outbox and lands in the consumer span, giving you an end-to-end view in any compatible backend (Tempo, Zipkin, Datadog, and others).

Setup

Terminal window
dotnet add package Emit.OpenTelemetry

Wire up the tracer provider:

builder.Services.AddOpenTelemetry()
.WithTracing(tracing =>
{
tracing.AddEmitInstrumentation();
tracing.AddOtlpExporter(); // or your preferred exporter
});

AddEmitInstrumentation registers all Emit activity sources. No additional configuration is required.

Activity sources

SourceDescription
Emit.OutboxSpans for outbox enqueue and delivery
Emit.ConsumerSpans for inbound message processing
Emit.Provider.kafkaKafka-specific producer and consumer spans

The Emit.Provider.* wildcard captures any additional provider sources added in the future.

Trace propagation

Emit propagates the W3C traceparent header across the async outbox boundary. This is worth understanding because produce and consume happen in different processes at different times, yet the trace stays connected.

  1. When ProduceAsync is called inside an active activity, Emit captures the traceparent and stores it in the outbox entry’s Headers.
  2. When the outbox worker delivers the entry to the broker, the traceparent header is forwarded as a message header.
  3. When the consumer processes the message, Emit extracts the traceparent and starts a linked consumer span.

The result is a connected trace even across the async gap between producer and consumer.

What gets traced

Produce pipeline: a span is started when ProduceAsync is called and completed when the outbox entry is written (or the direct produce completes). The span includes messaging.system (derived from the DestinationAddress URI scheme, e.g. "kafka") and messaging.destination.name (the topic or entity name extracted from the URI).

Outbox delivery: the worker creates a span when it picks up an entry and delivers it to the broker. The span is linked to the original produce span via traceparent.

Consume pipeline: a span is started when a message is received and completed when ConsumeAsync returns. It is linked to the producer span. The SourceAddress is propagated via the emit-source-address header so the consumer can trace back to the originating broker.

Retries: each retry attempt gets its own emit.consume.retry span.

Dead-letter: when a message is forwarded to the DLQ, an emit.dlq.publish span is created and linked to the original consumer span.

Tracing options

Configure tracing behavior via ConfigureTracing on the Emit builder:

builder.Services.AddEmit(emit =>
{
emit.ConfigureTracing(tracing =>
{
tracing.Configure(options =>
{
options.Enabled = true; // set false to suppress all Emit spans
options.CreateRootActivities = true; // false = only trace within an existing span
options.PropagateBaggage = true; // propagate W3C baggage across message boundaries
options.MaxBaggageSizeBytes = 8192; // excess items are dropped with a warning
});
});
});

Set CreateRootActivities = false if you only want Emit to emit spans when your code is already inside an active trace. This is useful for keeping background-worker spans from polluting your tracing backend.

Activity enrichment

Add application-specific tags to every Emit span by implementing IActivityEnricher:

public class TenantActivityEnricher : IActivityEnricher
{
private readonly ITenantContext _tenant;
public TenantActivityEnricher(ITenantContext tenant) => _tenant = tenant;
public void Enrich(Activity activity, EnrichmentContext context)
{
activity.SetTag("tenant.id", _tenant.TenantId);
}
}

Register it in DI, then add it to the tracing configuration:

builder.Services.AddScoped<IActivityEnricher, TenantActivityEnricher>();
emit.ConfigureTracing(tracing =>
{
tracing.AddEnricher<TenantActivityEnricher>();
});

For inline enrichment without a dedicated class, register a delegate directly:

emit.ConfigureTracing(tracing =>
{
tracing.EnrichActivity((activity, context) =>
{
activity.SetTag("env", "production");
});
});

Enrichers are invoked after Emit sets its own standard tags. Every Emit activity includes emit.node.id as a standard tag identifying the node that created the span; it is set from INodeIdentity and does not need to be added manually.

EnrichmentContext.Phase tells you which pipeline stage created the span:

PhaseWhen
produceProduceAsync was called
processOutbox worker is delivering the entry
consumeMessage is being processed by a consumer
dlq_publishMessage is being forwarded to the DLQ
dlq_replayDLQ message is being replayed

Accessing the current activity in middleware

The current activity is available from Activity.Current:

var activity = Activity.Current;
if (activity is not null)
{
activity.SetTag("custom.tag", "value");
}