Table of Contents

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();