From 1fca14411c2e4331696730f511aef569e2cc824a Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Fri, 29 May 2026 11:46:15 +0100 Subject: [PATCH 1/3] otel: add OpenTelemetry package --- NATS.Net.slnx | 1 + src/NATS.Client.Core/NatsTelemetry.cs | 12 +++++++ .../NATS.Client.OpenTelemetry.csproj | 17 +++++++++ .../NatsInstrumentationExtensions.cs | 22 ++++++++++++ .../NATS.Net.OpenTelemetry.Tests.csproj | 2 ++ .../NatsInstrumentationExtensionsTest.cs | 35 +++++++++++++++++++ 6 files changed, 89 insertions(+) create mode 100644 src/NATS.Client.Core/NatsTelemetry.cs create mode 100644 src/NATS.Client.OpenTelemetry/NATS.Client.OpenTelemetry.csproj create mode 100644 src/NATS.Client.OpenTelemetry/NatsInstrumentationExtensions.cs create mode 100644 tests/NATS.Net.OpenTelemetry.Tests/NatsInstrumentationExtensionsTest.cs diff --git a/NATS.Net.slnx b/NATS.Net.slnx index 53b3565f4..ffab1159c 100644 --- a/NATS.Net.slnx +++ b/NATS.Net.slnx @@ -64,6 +64,7 @@ + diff --git a/src/NATS.Client.Core/NatsTelemetry.cs b/src/NATS.Client.Core/NatsTelemetry.cs new file mode 100644 index 000000000..6c8583617 --- /dev/null +++ b/src/NATS.Client.Core/NatsTelemetry.cs @@ -0,0 +1,12 @@ +namespace NATS.Client.Core; + +/// +/// Telemetry identifiers for NATS .NET. Use these when configuring OpenTelemetry +/// or any other listener directly. The same name is used for both the +/// and the +/// . +/// +public static class NatsTelemetry +{ + public const string SourceName = "NATS.Net"; +} diff --git a/src/NATS.Client.OpenTelemetry/NATS.Client.OpenTelemetry.csproj b/src/NATS.Client.OpenTelemetry/NATS.Client.OpenTelemetry.csproj new file mode 100644 index 000000000..e25ee2dae --- /dev/null +++ b/src/NATS.Client.OpenTelemetry/NATS.Client.OpenTelemetry.csproj @@ -0,0 +1,17 @@ + + + + + opentelemetry;tracing;metrics;observability;nats + OpenTelemetry instrumentation for NATS .NET. Adds the ActivitySource and Meter for the client to a TracerProviderBuilder or MeterProviderBuilder. + + + + + + + + + + + diff --git a/src/NATS.Client.OpenTelemetry/NatsInstrumentationExtensions.cs b/src/NATS.Client.OpenTelemetry/NatsInstrumentationExtensions.cs new file mode 100644 index 000000000..791ebd843 --- /dev/null +++ b/src/NATS.Client.OpenTelemetry/NatsInstrumentationExtensions.cs @@ -0,0 +1,22 @@ +using NATS.Client.Core; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace NATS.Client.OpenTelemetry; + +public static class NatsInstrumentationExtensions +{ + /// + /// Adds the NATS .NET client to the tracer provider, + /// enabling distributed tracing for publish, subscribe, and request/reply operations. + /// + public static TracerProviderBuilder AddNatsClientInstrumentation(this TracerProviderBuilder builder) => + builder.AddSource(NatsTelemetry.SourceName); + + /// + /// Adds the NATS .NET client to the meter provider, + /// enabling messaging metrics (published/consumed counters, operation duration, and more). + /// + public static MeterProviderBuilder AddNatsClientInstrumentation(this MeterProviderBuilder builder) => + builder.AddMeter(NatsTelemetry.SourceName); +} diff --git a/tests/NATS.Net.OpenTelemetry.Tests/NATS.Net.OpenTelemetry.Tests.csproj b/tests/NATS.Net.OpenTelemetry.Tests/NATS.Net.OpenTelemetry.Tests.csproj index 55caf094c..10a4bb095 100644 --- a/tests/NATS.Net.OpenTelemetry.Tests/NATS.Net.OpenTelemetry.Tests.csproj +++ b/tests/NATS.Net.OpenTelemetry.Tests/NATS.Net.OpenTelemetry.Tests.csproj @@ -11,6 +11,7 @@ + @@ -34,6 +35,7 @@ + diff --git a/tests/NATS.Net.OpenTelemetry.Tests/NatsInstrumentationExtensionsTest.cs b/tests/NATS.Net.OpenTelemetry.Tests/NatsInstrumentationExtensionsTest.cs new file mode 100644 index 000000000..f48502593 --- /dev/null +++ b/tests/NATS.Net.OpenTelemetry.Tests/NatsInstrumentationExtensionsTest.cs @@ -0,0 +1,35 @@ +using NATS.Client.OpenTelemetry; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace NATS.Client.Core.Tests; + +public class NatsInstrumentationExtensionsTest +{ + [Fact] + public void AddNatsClientInstrumentation_builds_tracer_provider() + { + using var provider = Sdk.CreateTracerProviderBuilder() + .AddNatsClientInstrumentation() + .Build(); + + provider.Should().NotBeNull(); + } + + [Fact] + public void AddNatsClientInstrumentation_builds_meter_provider() + { + using var provider = Sdk.CreateMeterProviderBuilder() + .AddNatsClientInstrumentation() + .Build(); + + provider.Should().NotBeNull(); + } + + [Fact] + public void SourceName_is_NATS_Net() + { + NatsTelemetry.SourceName.Should().Be("NATS.Net"); + } +} From 176e3679a775a2761a4d6b75fb8f6105adbfd879 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Fri, 12 Jun 2026 12:46:03 +0100 Subject: [PATCH 2/3] otel: add options configure overload Mirrors AddRabbitMQInstrumentation(configure): exposes a discoverable entry point to set Filter and Enrich instead of reaching into the Core static. Writes the process-wide NatsInstrumentationOptions.Default, so configuration is global, not per provider. --- .../NatsInstrumentationExtensions.cs | 16 ++++++++++++ .../NatsInstrumentationExtensionsTest.cs | 26 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/NATS.Client.OpenTelemetry/NatsInstrumentationExtensions.cs b/src/NATS.Client.OpenTelemetry/NatsInstrumentationExtensions.cs index 791ebd843..0ac563ce7 100644 --- a/src/NATS.Client.OpenTelemetry/NatsInstrumentationExtensions.cs +++ b/src/NATS.Client.OpenTelemetry/NatsInstrumentationExtensions.cs @@ -1,3 +1,4 @@ +using System; using NATS.Client.Core; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; @@ -10,9 +11,24 @@ public static class NatsInstrumentationExtensions /// Adds the NATS .NET client to the tracer provider, /// enabling distributed tracing for publish, subscribe, and request/reply operations. /// + /// The to add the source to. + /// The supplied for chaining. public static TracerProviderBuilder AddNatsClientInstrumentation(this TracerProviderBuilder builder) => builder.AddSource(NatsTelemetry.SourceName); + /// + /// Adds the NATS .NET client to the tracer provider and + /// configures the shared (filter and enrich callbacks). + /// + /// The to add the source to. + /// Action that mutates the process-wide . + /// The supplied for chaining. + public static TracerProviderBuilder AddNatsClientInstrumentation(this TracerProviderBuilder builder, Action configure) + { + configure?.Invoke(NatsInstrumentationOptions.Default); + return builder.AddSource(NatsTelemetry.SourceName); + } + /// /// Adds the NATS .NET client to the meter provider, /// enabling messaging metrics (published/consumed counters, operation duration, and more). diff --git a/tests/NATS.Net.OpenTelemetry.Tests/NatsInstrumentationExtensionsTest.cs b/tests/NATS.Net.OpenTelemetry.Tests/NatsInstrumentationExtensionsTest.cs index f48502593..aacb42ced 100644 --- a/tests/NATS.Net.OpenTelemetry.Tests/NatsInstrumentationExtensionsTest.cs +++ b/tests/NATS.Net.OpenTelemetry.Tests/NatsInstrumentationExtensionsTest.cs @@ -27,6 +27,32 @@ public void AddNatsClientInstrumentation_builds_meter_provider() provider.Should().NotBeNull(); } + [Fact] + public void AddNatsClientInstrumentation_with_configure_sets_options() + { + var configured = false; + try + { + using var provider = Sdk.CreateTracerProviderBuilder() + .AddNatsClientInstrumentation(options => + { + configured = true; + options.Filter = _ => true; + options.Enrich = (_, _) => { }; + }) + .Build(); + + configured.Should().BeTrue(); + NatsInstrumentationOptions.Default.Filter.Should().NotBeNull(); + NatsInstrumentationOptions.Default.Enrich.Should().NotBeNull(); + } + finally + { + NatsInstrumentationOptions.Default.Filter = null; + NatsInstrumentationOptions.Default.Enrich = null; + } + } + [Fact] public void SourceName_is_NATS_Net() { From eb453951fb561d0ed666bdcb474718562ad83d54 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Tue, 23 Jun 2026 11:54:21 +0100 Subject: [PATCH 3/3] otel: add subject-pattern trace filter helper FilterSubjects compiles NATS subject patterns (include/exclude, with * and > wildcards) into a NatsInstrumentationOptions.Filter predicate, combined with any existing filter. Document it alongside the operation.duration histogram bucket view, which stays out of the package to avoid pulling in the OpenTelemetry SDK dependency. --- .../NatsInstrumentationOptionsExtensions.cs | 108 ++++++++++++++++++ .../NatsInstrumentationExtensionsTest.cs | 64 +++++++++++ .../documentation/advanced/opentelemetry.md | 32 ++++++ 3 files changed, 204 insertions(+) create mode 100644 src/NATS.Client.OpenTelemetry/NatsInstrumentationOptionsExtensions.cs diff --git a/src/NATS.Client.OpenTelemetry/NatsInstrumentationOptionsExtensions.cs b/src/NATS.Client.OpenTelemetry/NatsInstrumentationOptionsExtensions.cs new file mode 100644 index 000000000..8a039c8cc --- /dev/null +++ b/src/NATS.Client.OpenTelemetry/NatsInstrumentationOptionsExtensions.cs @@ -0,0 +1,108 @@ +using System; +using NATS.Client.Core; + +namespace NATS.Client.OpenTelemetry; + +/// +/// Extension methods for . +/// +public static class NatsInstrumentationOptionsExtensions +{ + /// + /// Restricts tracing to operations whose subject matches the given NATS subject patterns. + /// + /// The options to configure. + /// + /// Subject patterns to trace. An operation is traced only if its subject matches at least one + /// pattern. When null or empty, every subject is eligible (still subject to ). + /// + /// + /// Subject patterns to skip. An operation matching any of these is not traced, even when it also + /// matches an include pattern. A common use is dropping inbox traffic with _INBOX.>. + /// + /// The same instance for chaining. + /// + /// Patterns use NATS subject wildcards: * matches a single token and > matches one or + /// more trailing tokens. The resulting predicate is combined (logical AND) with any existing + /// , so a previously configured filter still applies. + /// + public static NatsInstrumentationOptions FilterSubjects( + this NatsInstrumentationOptions options, + string[]? include = null, + string[]? exclude = null) + { + if (options is null) + throw new ArgumentNullException(nameof(options)); + + var includeTokens = Tokenize(include); + var excludeTokens = Tokenize(exclude); + + // Nothing to filter on; leave any existing filter untouched. + if (includeTokens is null && excludeTokens is null) + return options; + + var previous = options.Filter; + options.Filter = context => + { + if (previous is not null && !previous(context)) + return false; + + var subject = context.Subject; + + if (excludeTokens is not null) + { + foreach (var pattern in excludeTokens) + { + if (Matches(subject, pattern)) + return false; + } + } + + if (includeTokens is not null) + { + foreach (var pattern in includeTokens) + { + if (Matches(subject, pattern)) + return true; + } + + return false; + } + + return true; + }; + + return options; + } + + private static string[][]? Tokenize(string[]? patterns) + { + if (patterns is null || patterns.Length == 0) + return null; + + var result = new string[patterns.Length][]; + for (var i = 0; i < patterns.Length; i++) + result[i] = patterns[i].Split('.'); + + return result; + } + + // NATS subject match: '*' matches exactly one token, '>' matches one or more trailing tokens. + private static bool Matches(string subject, string[] pattern) + { + var tokens = subject.Split('.'); + for (var i = 0; i < pattern.Length; i++) + { + if (pattern[i] == ">") + return tokens.Length > i; + + if (i >= tokens.Length) + return false; + + if (pattern[i] != "*" && !string.Equals(pattern[i], tokens[i], StringComparison.Ordinal)) + return false; + } + + return tokens.Length == pattern.Length; + } +} diff --git a/tests/NATS.Net.OpenTelemetry.Tests/NatsInstrumentationExtensionsTest.cs b/tests/NATS.Net.OpenTelemetry.Tests/NatsInstrumentationExtensionsTest.cs index aacb42ced..0fce374ca 100644 --- a/tests/NATS.Net.OpenTelemetry.Tests/NatsInstrumentationExtensionsTest.cs +++ b/tests/NATS.Net.OpenTelemetry.Tests/NatsInstrumentationExtensionsTest.cs @@ -58,4 +58,68 @@ public void SourceName_is_NATS_Net() { NatsTelemetry.SourceName.Should().Be("NATS.Net"); } + + [Theory] + [InlineData("orders.new", true)] + [InlineData("orders.new.eu", true)] + [InlineData("orders", false)] // '>' needs at least one trailing token + [InlineData("payments.new", false)] + public void FilterSubjects_include_matches_only_listed(string subject, bool expected) + { + var options = new NatsInstrumentationOptions().FilterSubjects(include: ["orders.>"]); + + options.Filter!(Context(subject)).Should().Be(expected); + } + + [Theory] + [InlineData("_INBOX.abc.def", false)] + [InlineData("foo.bar", true)] + public void FilterSubjects_exclude_drops_listed(string subject, bool expected) + { + var options = new NatsInstrumentationOptions().FilterSubjects(exclude: ["_INBOX.>"]); + + options.Filter!(Context(subject)).Should().Be(expected); + } + + [Theory] + [InlineData("foo.bar", true)] + [InlineData("foo.bar.baz", false)] // '*' matches a single token only + [InlineData("foo", false)] + public void FilterSubjects_single_token_wildcard(string subject, bool expected) + { + var options = new NatsInstrumentationOptions().FilterSubjects(include: ["foo.*"]); + + options.Filter!(Context(subject)).Should().Be(expected); + } + + [Fact] + public void FilterSubjects_exclude_wins_over_include() + { + var options = new NatsInstrumentationOptions().FilterSubjects(include: ["orders.>"], exclude: ["orders.internal.>"]); + + options.Filter!(Context("orders.new")).Should().BeTrue(); + options.Filter!(Context("orders.internal.audit")).Should().BeFalse(); + } + + [Fact] + public void FilterSubjects_composes_with_existing_filter() + { + var options = new NatsInstrumentationOptions { Filter = ctx => ctx.Subject.StartsWith("orders.", StringComparison.Ordinal) }; + options.FilterSubjects(exclude: ["orders.internal.>"]); + + options.Filter!(Context("orders.new")).Should().BeTrue(); + options.Filter!(Context("orders.internal.audit")).Should().BeFalse(); // dropped by subject exclude + options.Filter!(Context("payments.new")).Should().BeFalse(); // dropped by the pre-existing filter + } + + [Fact] + public void FilterSubjects_without_patterns_leaves_filter_unset() + { + var options = new NatsInstrumentationOptions().FilterSubjects(); + + options.Filter.Should().BeNull(); + } + + private static NatsInstrumentationContext Context(string subject) => + new(subject, Headers: null, ReplyTo: null, QueueGroup: null, BodySize: null, Size: null, Connection: null, ParentContext: default); } diff --git a/tools/site_src/documentation/advanced/opentelemetry.md b/tools/site_src/documentation/advanced/opentelemetry.md index c062ca001..8b5b93198 100644 --- a/tools/site_src/documentation/advanced/opentelemetry.md +++ b/tools/site_src/documentation/advanced/opentelemetry.md @@ -47,6 +47,19 @@ telemetry for specific requests. When the filter returns `false`, no activity is [!code-csharp[](../../../../tests/NATS.Net.DocsExamples/Advanced/OpenTelemetryPage.cs#filter)] +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: + +```csharp +Sdk.CreateTracerProviderBuilder() + .AddNatsClientInstrumentation(options => options.FilterSubjects( + include: ["orders.>"], + exclude: ["orders.internal.>"])) + .Build(); +``` + ## Enriching Activities Use [`NatsInstrumentationOptions.Default.Enrich`](xref:NATS.Client.Core.NatsInstrumentationOptions) to add @@ -108,3 +121,22 @@ All instruments carry these tags: | `network.transport` | `tcp` | Transport protocol | `messaging.client.operation.duration` adds `error.type` (full exception type name) when the operation fails. + +### 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`): + +```csharp +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(); +```