From c42900e30db89734122b9ebd3b88bacd72fe14ef Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 20 Jun 2026 20:37:52 -0400 Subject: [PATCH 1/2] SMOODEV-2023: .NET opt-in ASP.NET Core + HttpClient auto-instrumentation Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Otel/ObservabilitySdk.cs | 34 +++++ .../SmooAI.Observability.csproj | 9 ++ .../OtelInstrumentationTests.cs | 136 ++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 dotnet/tests/SmooAI.Observability.Tests/OtelInstrumentationTests.cs diff --git a/dotnet/src/SmooAI.Observability/Otel/ObservabilitySdk.cs b/dotnet/src/SmooAI.Observability/Otel/ObservabilitySdk.cs index 54212a5..13b718c 100644 --- a/dotnet/src/SmooAI.Observability/Otel/ObservabilitySdk.cs +++ b/dotnet/src/SmooAI.Observability/Otel/ObservabilitySdk.cs @@ -54,6 +54,24 @@ public sealed class SetupOtelOptions /// metrics from, beyond the SDK's default meter. /// public IReadOnlyList? AdditionalMeterNames { get; set; } + + /// + /// Opt-in: collect ASP.NET Core HTTP server spans + metrics via + /// OpenTelemetry.Instrumentation.AspNetCore. Off by default to keep the + /// SDK lean — consumers who serve HTTP set this to true and get + /// incoming-request traces/metrics without hand-wiring + /// . Only takes effect when the + /// matching exporter endpoint is configured. + /// + public bool EnableAspNetCoreInstrumentation { get; set; } + + /// + /// Opt-in: collect outbound + /// client spans + metrics via OpenTelemetry.Instrumentation.Http. + /// Off by default. Only takes effect when the matching exporter endpoint is + /// configured. + /// + public bool EnableHttpInstrumentation { get; set; } } /// @@ -184,6 +202,14 @@ private static OtelSdkHandle Build(SetupOtelOptions options) { tracerBuilder.AddSource(options.AdditionalActivitySources.ToArray()); } + if (options.EnableAspNetCoreInstrumentation) + { + tracerBuilder.AddAspNetCoreInstrumentation(); + } + if (options.EnableHttpInstrumentation) + { + tracerBuilder.AddHttpClientInstrumentation(); + } tracerBuilder.AddOtlpExporter(o => ConfigureExporter(o, options.OtlpTracesEndpoint!, options)); tracerProvider = tracerBuilder.Build(); } @@ -198,6 +224,14 @@ private static OtelSdkHandle Build(SetupOtelOptions options) { meterBuilder.AddMeter(options.AdditionalMeterNames.ToArray()); } + if (options.EnableAspNetCoreInstrumentation) + { + meterBuilder.AddAspNetCoreInstrumentation(); + } + if (options.EnableHttpInstrumentation) + { + meterBuilder.AddHttpClientInstrumentation(); + } meterBuilder.AddOtlpExporter((exporterOptions, readerOptions) => { ConfigureExporter(exporterOptions, options.OtlpMetricsEndpoint!, options); diff --git a/dotnet/src/SmooAI.Observability/SmooAI.Observability.csproj b/dotnet/src/SmooAI.Observability/SmooAI.Observability.csproj index b87c458..c4a3a5b 100644 --- a/dotnet/src/SmooAI.Observability/SmooAI.Observability.csproj +++ b/dotnet/src/SmooAI.Observability/SmooAI.Observability.csproj @@ -31,6 +31,15 @@ + + + diff --git a/dotnet/tests/SmooAI.Observability.Tests/OtelInstrumentationTests.cs b/dotnet/tests/SmooAI.Observability.Tests/OtelInstrumentationTests.cs new file mode 100644 index 0000000..1752a5e --- /dev/null +++ b/dotnet/tests/SmooAI.Observability.Tests/OtelInstrumentationTests.cs @@ -0,0 +1,136 @@ +using System.Diagnostics; +using SmooAI.Observability.Otel; + +namespace SmooAI.Observability.Tests; + +/// +/// Verifies the opt-in ASP.NET Core + HttpClient auto-instrumentation wiring in +/// . We assert on subscription rather than on +/// emitted spans: an reports +/// == true only when a tracer provider +/// is subscribed to it, so it's a reliable proxy for "the instrumentation +/// registered its source". +/// +public class OtelInstrumentationTests +{ + // Source names the OTel instrumentation packages subscribe to. Stable across + // the 1.x line: the HttpClient instrumentation listens to System.Net.Http and + // the ASP.NET Core instrumentation listens to Microsoft.AspNetCore. + private const string HttpClientSourceName = "System.Net.Http"; + private const string AspNetCoreSourceName = "Microsoft.AspNetCore"; + + [Fact] + public void Default_DoesNotSubscribeToInstrumentationSources() + { + ObservabilitySdk.ResetForTests(); + using var http = new ActivitySource(HttpClientSourceName); + using var aspnet = new ActivitySource(AspNetCoreSourceName); + + // Sanity: no listeners before setup. + Assert.False(http.HasListeners()); + Assert.False(aspnet.HasListeners()); + + var handle = ObservabilitySdk.Setup(new SetupOtelOptions + { + ServiceName = "svc", + OtlpTracesEndpoint = "https://ingest.test/v1/traces", + // EnableAspNetCoreInstrumentation / EnableHttpInstrumentation default to false. + }); + + Assert.False(http.HasListeners()); + Assert.False(aspnet.HasListeners()); + + handle.Dispose(); + ObservabilitySdk.ResetForTests(); + } + + [Fact] + public void EnableHttpInstrumentation_SubscribesToHttpClientSource() + { + ObservabilitySdk.ResetForTests(); + using var http = new ActivitySource(HttpClientSourceName); + using var aspnet = new ActivitySource(AspNetCoreSourceName); + + var handle = ObservabilitySdk.Setup(new SetupOtelOptions + { + ServiceName = "svc", + OtlpTracesEndpoint = "https://ingest.test/v1/traces", + EnableHttpInstrumentation = true, + }); + + Assert.True(http.HasListeners()); + // Only HTTP requested — ASP.NET Core stays off. + Assert.False(aspnet.HasListeners()); + + handle.Dispose(); + ObservabilitySdk.ResetForTests(); + } + + [Fact] + public void EnableAspNetCoreInstrumentation_SubscribesToAspNetCoreSource() + { + ObservabilitySdk.ResetForTests(); + using var http = new ActivitySource(HttpClientSourceName); + using var aspnet = new ActivitySource(AspNetCoreSourceName); + + var handle = ObservabilitySdk.Setup(new SetupOtelOptions + { + ServiceName = "svc", + OtlpTracesEndpoint = "https://ingest.test/v1/traces", + EnableAspNetCoreInstrumentation = true, + }); + + Assert.True(aspnet.HasListeners()); + // Only ASP.NET Core requested — HTTP client stays off. + Assert.False(http.HasListeners()); + + handle.Dispose(); + ObservabilitySdk.ResetForTests(); + } + + [Fact] + public void EnableBoth_SubscribesToBothSources() + { + ObservabilitySdk.ResetForTests(); + using var http = new ActivitySource(HttpClientSourceName); + using var aspnet = new ActivitySource(AspNetCoreSourceName); + + var handle = ObservabilitySdk.Setup(new SetupOtelOptions + { + ServiceName = "svc", + OtlpTracesEndpoint = "https://ingest.test/v1/traces", + OtlpMetricsEndpoint = "https://ingest.test/v1/metrics", + EnableAspNetCoreInstrumentation = true, + EnableHttpInstrumentation = true, + }); + + Assert.True(http.HasListeners()); + Assert.True(aspnet.HasListeners()); + + handle.Dispose(); + ObservabilitySdk.ResetForTests(); + } + + [Fact] + public void Enable_WithoutTracesEndpoint_DoesNotBuildTracerOrSubscribe() + { + ObservabilitySdk.ResetForTests(); + using var http = new ActivitySource(HttpClientSourceName); + using var aspnet = new ActivitySource(AspNetCoreSourceName); + + // No traces endpoint -> no tracer provider -> instrumentation cannot subscribe. + var handle = ObservabilitySdk.Setup(new SetupOtelOptions + { + ServiceName = "svc", + OtlpMetricsEndpoint = "https://ingest.test/v1/metrics", + EnableAspNetCoreInstrumentation = true, + EnableHttpInstrumentation = true, + }); + + Assert.False(http.HasListeners()); + Assert.False(aspnet.HasListeners()); + + handle.Dispose(); + ObservabilitySdk.ResetForTests(); + } +} From c9af31ef7db01d68cabc328327fd195d0292d57d Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 20 Jun 2026 20:47:45 -0400 Subject: [PATCH 2/2] SMOODEV-2023: reference Microsoft.AspNetCore.App in tests (fix CI-only instrumentation-subscription failure) --- .../SmooAI.Observability.Tests.csproj | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dotnet/tests/SmooAI.Observability.Tests/SmooAI.Observability.Tests.csproj b/dotnet/tests/SmooAI.Observability.Tests/SmooAI.Observability.Tests.csproj index 4f6b7d4..f816764 100644 --- a/dotnet/tests/SmooAI.Observability.Tests/SmooAI.Observability.Tests.csproj +++ b/dotnet/tests/SmooAI.Observability.Tests/SmooAI.Observability.Tests.csproj @@ -28,4 +28,15 @@ + + + + +