OpenTelemetry
NATS.Net has built-in distributed tracing and metrics support through System.Diagnostics.Activity and
System.Diagnostics.Metrics.Meter, the standard .NET APIs for OpenTelemetry. Activities are created
automatically for publish and subscribe operations, trace context is propagated through message headers
so send and receive spans are linked across services, and a set of standard messaging metrics is emitted
when a meter listener is attached.
The activity source name and the meter name are both NATS.Net.
Setting Up Tracing
To collect traces, register a listener for the NATS.Net activity source. You can use the OpenTelemetry SDK
with an exporter (Jaeger, Zipkin, OTLP, etc.) or a plain ActivityListener for lightweight scenarios:
// The NATS.Net client uses System.Diagnostics.Activity for tracing.
// No additional packages are needed to enable tracing — just add an
// ActivityListener or configure an OpenTelemetry TracerProvider that
// listens to the "NATS.Net" source.
// Using OpenTelemetry SDK (install OpenTelemetry and an exporter):
//
// using var tracerProvider = Sdk.CreateTracerProviderBuilder()
// .AddSource("NATS.Net") // listen for NATS activities
// .AddSource("MyApp") // listen for your own activities
// .AddOtlpExporter() // export to Jaeger, Zipkin, etc.
// .Build();
// Or using a plain ActivityListener (no extra packages):
using ActivityListener listener = new ActivityListener
{
ShouldListenTo = source => source.Name == "NATS.Net",
Sample = (ref ActivityCreationOptions<ActivityContext> _) =>
ActivitySamplingResult.AllDataAndRecorded,
ActivityStarted = activity =>
Console.WriteLine($"Started: {activity.OperationName}"),
ActivityStopped = activity =>
Console.WriteLine($"Stopped: {activity.OperationName}"),
};
ActivitySource.AddActivityListener(listener);
Setting Up Metrics
Metrics are emitted through the same NATS.Net name. No measurements are recorded until a listener
subscribes; the runtime cost is a single boolean check per operation when no listener is attached:
// The NATS.Net client emits metrics through System.Diagnostics.Metrics.Meter
// under the same "NATS.Net" name used for activities. Metrics are opt-in:
// nothing is recorded until something subscribes to the meter.
// Using the OpenTelemetry SDK and the NATS.Client.OpenTelemetry package:
//
// using var meterProvider = Sdk.CreateMeterProviderBuilder()
// .AddNatsClientInstrumentation() // or .AddMeter("NATS.Net")
// .AddOtlpExporter()
// .Build();
// Or using a plain MeterListener (no extra packages):
using System.Diagnostics.Metrics.MeterListener meterListener = new()
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == "NATS.Net")
listener.EnableMeasurementEvents(instrument);
},
};
meterListener.SetMeasurementEventCallback<long>((inst, value, tags, _) =>
Console.WriteLine($"{inst.Name}: {value}"));
meterListener.SetMeasurementEventCallback<double>((inst, value, tags, _) =>
Console.WriteLine($"{inst.Name}: {value}"));
meterListener.Start();
Automatic Trace Context Propagation
When you publish a message, the client injects the current trace context into the message headers. When a subscriber reads the message, the receive activity is automatically parented to the send activity, giving you end-to-end traces across services with no extra code:
await using NatsConnection nats = new NatsConnection();
// Publish and subscribe — activities are created automatically
await using var sub = await nats.SubscribeCoreAsync<string>("orders.new");
await nats.PublishAsync("orders.new", "order-123");
// The message carries trace context in its headers, so the
// receive activity is automatically linked to the send activity.
await foreach (NatsMsg<string> msg in sub.Msgs.ReadAllAsync())
{
Console.WriteLine($"Received: {msg.Data}");
break;
}
Starting Custom Activities
You can start child activities under a message's trace context using the StartActivity extension method.
This is useful for tracking processing work that happens after a message is received:
await using NatsConnection nats = new NatsConnection();
await using var sub = await nats.SubscribeCoreAsync<string>("work.items");
await nats.PublishAsync("work.items", "item-456");
await foreach (NatsMsg<string> msg in sub.Msgs.ReadAllAsync())
{
// Start a child activity under the message's trace context
using Activity? activity = msg.StartActivity("ProcessWorkItem");
// The activity is linked to the original publish span
Console.WriteLine($"Processing: {msg.Data}");
break;
}
The StartActivity method is available on both NatsMsg<T>
and INatsJSMsg<T> for JetStream messages.
Filtering
Use NatsInstrumentationOptions.Default.Filter to skip
telemetry for specific requests. When the filter returns false, no activity is created:
// Filter lets you skip telemetry for specific subjects
NatsInstrumentationOptions.Default.Filter = context =>
{
// Skip internal/health-check subjects
if (context.Subject.StartsWith("_INBOX."))
return false;
return true;
};
When the NATS.Client.OpenTelemetry package is installed, FilterSubjects builds the predicate from
NATS subject patterns (* matches one token, > matches one or more trailing tokens) instead of writing
the matching by hand. Include patterns allow-list subjects; exclude patterns drop them and win over
include. The predicate is combined (logical AND) with any filter already set:
Sdk.CreateTracerProviderBuilder()
.AddNatsClientInstrumentation(options => options.FilterSubjects(
include: ["orders.>"],
exclude: ["orders.internal.>"]))
.Build();
Enriching Activities
Use NatsInstrumentationOptions.Default.Enrich to add
custom tags to every activity:
// Enrich lets you add custom tags to every activity
NatsInstrumentationOptions.Default.Enrich = (activity, context) =>
{
activity.SetTag("app.environment", "production");
if (context.QueueGroup is not null)
activity.SetTag("app.queue_group", context.QueueGroup);
};
The activity passed as the first argument is also Activity.Current for the duration of the callback, on
the receive path as well as the send path, so helpers that read the ambient activity work inside Enrich.
That holds for the callback only: receive activities are created on the connection's read loop and are kept
off the ambient context otherwise, and the read loop's own context is put back when the callback returns.
Filter, Enrich and SpanDestinationNameFormatter all run inside publish and inside message
construction on receive, so none of them is allowed to break the operation it is instrumenting. A Filter
that throws means the operation is not collected, an Enrich that throws is ignored and leaves the
activity with whatever it set before throwing, and a formatter that throws falls back to the default span
name.
Span Names and Redacting Subjects
Span names are <destination> <operation>, for example orders.new publish. The destination defaults to
the first two tokens of the subject, so orders.new.eu.de becomes orders.new, and inbox subjects are
collapsed to the constant inbox before anything else runs.
If you need spans but not subjects in their names, set
NatsInstrumentationOptions.Default.SpanDestinationNameFormatter:
// Span names carry the first two subject tokens by default. A formatter replaces
// that truncation rather than running after it, so it is up to you to keep the
// names it returns low cardinality.
NatsInstrumentationOptions.Default.SpanDestinationNameFormatter = subject =>
subject.StartsWith("tenant.", StringComparison.Ordinal) ? "tenant" : subject;
The formatter replaces the default two-token truncation rather than running after it, so a formatter that returns the subject unchanged produces full-subject span names and it is up to you to keep them low cardinality. It never sees inbox subjects, which are already collapsed.
The formatter only affects span names. Subjects also appear in tags, which the formatter does not touch:
| Tag | Contents |
|---|---|
messaging.destination.name |
Delivered subject, inbox collapsed |
messaging.destination_publish.name |
Delivered subject, inbox collapsed |
messaging.nats.message.subject |
Delivered subject, inbox collapsed |
messaging.destination.template |
Subscription subject, inbox collapsed |
messaging.nats.message.reply_to |
Reply-to subject, inbox collapsed |
To remove or rewrite those, use Enrich, which runs after the tags are set:
// The formatter does not touch tags. Enrich runs after the client has set them,
// so SetTag overwrites the ones carrying subjects.
NatsInstrumentationOptions.Default.Enrich = (activity, _) =>
{
activity.SetTag("messaging.destination.name", "redacted");
activity.SetTag("messaging.nats.message.subject", "redacted");
};
Metrics carry no subject at all, so nothing needs redacting there.
Baggage Propagation
W3C Baggage lets you attach arbitrary key/value context (a tenant ID, a correlation ID, etc.) to a trace and have it flow across service boundaries alongside trace context.
Baggage propagation is off by default. Baggage can carry sensitive (PII) or high-cardinality data, and
message headers count against size limits (core headers toward the max_payload, 1MB by default;
JetStream caps the header block at 64KB), so it needs to be an explicit opt-in:
// Baggage propagation is off by default because baggage can carry
// sensitive or high-cardinality data. Opt in explicitly:
NatsInstrumentationOptions.Default.PropagateBaggage = true;
// Optionally allow-list which baggage keys cross the NATS boundary:
NatsInstrumentationOptions.Default.BaggageKeyFilter = key => key is "tenant.id" or "correlation.id";
// Received baggage is restored onto the receive activity and also
// exposed on the Filter/Enrich callback context:
NatsInstrumentationOptions.Default.Enrich = (activity, context) =>
{
if (context.Baggage is { } baggage)
{
foreach (var entry in baggage)
{
Console.WriteLine($"baggage: {entry.Key}={entry.Value}");
}
}
};
When enabled, baggage is written to the message as a W3C baggage header on publish and, on receive,
extracted from that header, restored onto the receive activity's Activity.Baggage, and exposed via
NatsInstrumentationContext.Baggage to the Filter
and Enrich callbacks. Child activities created with StartActivity also inherit the restored baggage.
By default the send-side baggage is read from the current send activity's Activity.Baggage. If your
application keeps baggage elsewhere — for example OpenTelemetry's Baggage.Current API — use
NatsInstrumentationOptions.Default.BaggageSource to
bridge it (requires the OpenTelemetry.Api package):
NatsInstrumentationOptions.Default.BaggageSource = () => Baggage.Current.GetBaggage();
When PropagateBaggage is enabled and the source has baggage, NATS.Net owns the baggage header: it
overwrites any existing value, or removes the header entirely if BaggageKeyFilter rejects every key.
If the source has no baggage, an application-set baggage header passes through untouched.
Like trace context, injecting baggage requires a send activity to exist — there must be a listener on
the NATS.Net source that isn't filtered out.
Note
Independent of this feature, the ambient DistributedContextPropagator also writes Activity baggage
during trace-context injection: the legacy propagator (the default up to System.Diagnostics.DiagnosticSource 9)
writes a non-standard Correlation-Context header, and the W3C propagator (the default from
System.Diagnostics.DiagnosticSource 10 / .NET 10) writes an unfiltered W3C baggage header. Enabling
PropagateBaggage makes NATS.Net take ownership of the baggage header (so BaggageKeyFilter applies);
applications that want strict control over the wire format should configure
DistributedContextPropagator.Current (e.g. CreateNoOutputPropagator() or a custom propagator).
Semantic Conventions
NATS.Net follows the OpenTelemetry Semantic Conventions for Messaging. The following attributes are set on activities:
| Attribute | Example | Description |
|---|---|---|
messaging.system |
nats |
Always nats |
messaging.operation |
publish / subscribe / request / receive |
Operation type, matching the value the metrics use for the same operation |
messaging.destination.name |
orders.new |
Subject name |
messaging.nats.message.reply_to |
inbox |
Reply-to subject, if the message has one |
messaging.client_id |
42 |
NATS client ID |
server.address |
localhost |
Server host |
server.port |
4222 |
Server port |
network.protocol.name |
nats |
Protocol name |
network.transport |
tcp |
Transport protocol |
network.peer.address |
localhost |
Remote host |
network.peer.port |
4222 |
Remote port |
network.local.address |
127.0.0.1 |
Local IP |
Span kind is Producer for publish and request, Consumer for receive, and Client for
subscribe, which produces nothing and is a control plane call to the server.
A receive activity is created for every message the client takes off the wire, whether it is handed to the
application (core subscriptions, JetStream consume and fetch, service endpoints), consumed by the client
itself (key/value watchers, object store reads), or a status frame the client handles internally
(no-responder 503 replies, JetStream heartbeats and flow control). It ends when whatever owns the message
is done with it, which for a subscription is the read that hands the message to the application.
messaging.client.consumed.messages is narrower: it counts only messages delivered to the application, so
receive span counts and that counter are expected to disagree.
Receive activities include additional attributes:
| Attribute | Example | Description |
|---|---|---|
messaging.destination.template |
orders.* |
Subject the subscription was created with |
messaging.destination.temporary |
false |
true when the subscription is on this connection's inbox |
messaging.destination_publish.name |
orders.new |
Subject the message was published to |
messaging.nats.message.subject |
orders.new |
Delivered subject |
messaging.message.body.size |
1024 |
Message body size in bytes |
messaging.message.envelope.size |
1280 |
Total message size in bytes |
messaging.consumer.group.name |
workers |
Queue group, omitted when the subscription joined none |
Every subject-valued tag has inbox subjects collapsed to inbox; see
Span Names and Redacting Subjects.
Service Endpoint Spans
A service endpoint is the server side of a request, so its receive activity is the span the handling belongs
to and not just the delivery. It stays open until the handler returns, so its duration covers handler time,
and it is Activity.Current while the handler runs, so activities the handler starts nest under it without
any wiring.
An exception out of the handler is recorded on that span as an exception event and sets its status to
Error. That includes NatsSvcEndpointException, which is also the way an endpoint reports an error back
to the caller.
Replies Carrying Stored Trace Context
A receive activity normally takes its parent from the trace context carried in the message headers, and falls back to the request it is a reply to when the message carries none.
Messages returned verbatim from storage are the exception. A JetStream direct get replays the stored
message headers, including the traceparent written when the value was published, which may be hours
old and belong to a trace the backend has long since closed. When a reply carries a trace id different
from the request waiting on it, the receive span is parented from the request and the message's context
is recorded as a span link instead.
The read stays in one trace and the connection to the write is still visible.
A responder that continues the caller's trace shares its trace id, so ordinary request/reply is unaffected.
Metrics
The following instruments are exposed on the NATS.Net meter:
| Name | Type | Unit | Description |
|---|---|---|---|
messaging.client.published.messages |
Counter | {message} |
Messages published by the client |
messaging.client.consumed.messages |
Counter | {message} |
Messages received by the client |
messaging.client.operation.duration |
Histogram | s |
Duration of publish, request, and subscribe operations |
nats.client.active_subscriptions |
UpDownCounter | {subscription} |
Active NatsSubBase instances. Under SharedInbox request/reply mode each in-flight RequestAsync registers a transient reply subscription with the shared inbox muxer and is included here; Direct mode uses a reply task and is not counted |
nats.client.reconnects |
Counter | {reconnect} |
Successful reconnects since process start |
nats.client.sent.bytes |
Counter | By |
Bytes sent in published messages (body + headers) |
nats.client.received.bytes |
Counter | By |
Bytes received in consumed messages (body + headers) |
nats.client.messages.dropped |
Counter | {message} |
Messages evicted from a full subscription channel (slow consumer) |
All instruments carry these tags:
| Tag | Example | Description |
|---|---|---|
messaging.system |
nats |
Always nats |
messaging.operation |
publish / receive / subscribe / request / reconnect |
Operation type |
server.address |
localhost |
Server host |
server.port |
4222 |
Server port |
network.protocol.name |
nats |
Protocol name |
network.transport |
tcp |
Transport protocol |
messaging.client.operation.duration adds error.type (full exception type name) when the operation fails.
messaging.client.consumed.messages and nats.client.received.bytes count only messages delivered to the
application, per the OTel definition ("messages delivered to the application"). NATS status and control
frames consumed internally by the client are excluded: no-responder 503 replies and JetStream heartbeats,
flow-control, and protocol notifications. The two counters stay consistent, so received.bytes / consumed.messages
reflects average delivered message size.
nats.client.received.bytes is a NATS-specific counter with no semantic convention to match, and the bytes
of an excluded status frame were physically on the wire, so it could be argued either way. Deliberately
excluding them keeps it aligned with consumed.messages, which is what makes the ratio above meaningful.
Use nats.client.messages.dropped and the connection's own statistics if you need wire-level accounting.
Histogram Buckets
messaging.client.operation.duration ships advisory bucket boundaries (0.005s to 10s) through
InstrumentAdvice, which the OpenTelemetry SDK applies by default, so no view is required for sensible
latency buckets. To override them, add a view on the meter provider (this needs the OpenTelemetry SDK
package, not just OpenTelemetry.Api):
Sdk.CreateMeterProviderBuilder()
.AddNatsClientInstrumentation()
.AddView(
"messaging.client.operation.duration",
new ExplicitBucketHistogramConfiguration
{
Boundaries = [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
})
.Build();