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
dotnet add package Emit.OpenTelemetryWire 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
| Source | Description |
|---|---|
Emit.Outbox | Spans for outbox enqueue and delivery |
Emit.Consumer | Spans for inbound message processing |
Emit.Provider.kafka | Kafka-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.
- When
ProduceAsyncis called inside an active activity, Emit captures thetraceparentand stores it in the outbox entry’sHeaders. - When the outbox worker delivers the entry to the broker, the
traceparentheader is forwarded as a message header. - When the consumer processes the message, Emit extracts the
traceparentand 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:
| Phase | When |
|---|---|
produce | ProduceAsync was called |
process | Outbox worker is delivering the entry |
consume | Message is being processed by a consumer |
dlq_publish | Message is being forwarded to the DLQ |
dlq_replay | DLQ 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");}