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);
};
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 / receive |
Operation type |
messaging.destination.name |
orders.new |
Subject name |
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 |
Receive activities include additional attributes:
| Attribute | Example | Description |
|---|---|---|
messaging.destination.template |
orders.* |
Subscription subject pattern |
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 (if used) |
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) |
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.
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();