diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentation.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentation.cs index d7d02ecdc3..a9ec2c8939 100644 --- a/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentation.cs +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentation.cs @@ -19,7 +19,7 @@ internal sealed class AspNetCoreInstrumentation : IDisposable "Microsoft.AspNetCore.Hosting.UnhandledException" ]; - private readonly Func isEnabled = (eventName, _, _) + private readonly Func isEnabled = static (eventName, _, _) => DiagnosticSourceEvents.Contains(eventName); private readonly DiagnosticSourceSubscriber diagnosticSourceSubscriber; @@ -32,7 +32,5 @@ public AspNetCoreInstrumentation(HttpInListener httpInListener) /// public void Dispose() - { - this.diagnosticSourceSubscriber?.Dispose(); - } + => this.diagnosticSourceSubscriber?.Dispose(); } diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationTracerProviderBuilderExtensions.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationTracerProviderBuilderExtensions.cs index d18e17357a..8b2a728b5e 100644 --- a/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationTracerProviderBuilderExtensions.cs +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationTracerProviderBuilderExtensions.cs @@ -75,9 +75,7 @@ public static TracerProviderBuilder AddAspNetCoreInstrumentation( return builder.AddInstrumentation(sp => { var options = sp.GetRequiredService>().Get(name); - - return new AspNetCoreInstrumentation( - new HttpInListener(options)); + return new AspNetCoreInstrumentation(new HttpInListener(options)); }); } @@ -102,9 +100,9 @@ private static void AddAspNetCoreInstrumentationSources( string optionsName, IServiceProvider? serviceProvider = null) { - // For .NET7.0 onwards activity will be created using activitySource. + // For .NET 7.0+ the activity will be created using activitySource. // https://github.com/dotnet/aspnetcore/blob/bf3352f2422bf16fa3ca49021f0e31961ce525eb/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs#L327 - // For .NET6.0 and below, we will continue to use legacy way. + // For .NET 6.0 and below, we will continue to use legacy way. if (HttpInListener.Net7OrGreater) { // TODO: Check with .NET team to see if this can be prevented diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md index 4c934aecb9..89bcc1fdfb 100644 --- a/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +* Avoid duplicative work to add tags to traces when they are already natively supported + by ASP.NET Core itself. When using ASP.NET Core 10, performance can be + improved by setting the `Microsoft.AspNetCore.Hosting.SuppressActivityOpenTelemetryData` + AppContext switch to `false` (its default value is `true`). + ([#3993](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/3993)) + ## 1.15.2 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInListener.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInListener.cs index c452c1fad9..cb1eb98e71 100644 --- a/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInListener.cs +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInListener.cs @@ -30,6 +30,7 @@ internal class HttpInListener : ListenerHandler #pragma warning restore IDE0370 // Suppression is unnecessary internal static readonly ActivitySource ActivitySource = new(ActivitySourceName, Version.ToString()); internal static readonly bool Net7OrGreater = Environment.Version.Major >= 7; + internal static readonly bool Net10OrGreater = Environment.Version.Major >= 10; private const string DiagnosticSourceName = "Microsoft.AspNetCore"; @@ -47,6 +48,7 @@ internal class HttpInListener : ListenerHandler private static readonly PropertyFetcher ExceptionPropertyFetcher = new("Exception"); private readonly AspNetCoreTraceInstrumentationOptions options; + private readonly bool nativeAspNetCoreOpenTelemetryEnabled; public HttpInListener(AspNetCoreTraceInstrumentationOptions options) : base(DiagnosticSourceName) @@ -54,6 +56,7 @@ public HttpInListener(AspNetCoreTraceInstrumentationOptions options) Guard.ThrowIfNull(options); this.options = options; + this.nativeAspNetCoreOpenTelemetryEnabled = AspNetCoreHasNativeOpenTelemetryTags(); } public override void OnEventWritten(string name, object? payload) @@ -63,24 +66,18 @@ public override void OnEventWritten(string name, object? payload) switch (name) { case OnStartEvent: - { - this.OnStartActivity(activity, payload); - } - + this.OnStartActivity(activity, payload); break; - case OnStopEvent: - { - this.OnStopActivity(activity, payload); - } + case OnStopEvent: + this.OnStopActivity(activity, payload); break; + case OnUnhandledHostingExceptionEvent: case OnUnHandledDiagnosticsExceptionEvent: - { - this.OnException(activity, payload); - } - + this.OnException(activity, payload); break; + default: break; } @@ -176,19 +173,38 @@ public void OnStartActivity(Activity activity, object? payload) ActivityInstrumentationHelper.SetKindProperty(activity, ActivityKind.Server); } + // See the spec: https://github.com/open-telemetry/semantic-conventions/blob/v1.40.0/docs/http/http-spans.md var path = (request.PathBase.HasValue || request.Path.HasValue) ? (request.PathBase + request.Path).ToString() : "/"; + TelemetryHelper.RequestDataHelper.SetActivityDisplayName(activity, request.Method); - // see the spec https://github.com/open-telemetry/semantic-conventions/blob/v1.23.0/docs/http/http-spans.md + // ASP.NET Core 10 does not support OTEL_INSTRUMENTATION_HTTP_KNOWN_METHODS so we + // still need to set the HTTP method tag so that any override by the user is honoured. + TelemetryHelper.RequestDataHelper.SetHttpMethodTag(activity, request.Method); - if (request.Host.HasValue) + if (!Net10OrGreater || !this.nativeAspNetCoreOpenTelemetryEnabled) { - activity.SetTag(SemanticConventions.AttributeServerAddress, request.Host.Host); + if (request.Host.HasValue) + { + activity.SetTag(SemanticConventions.AttributeServerAddress, request.Host.Value); + + if (request.Host.Port is { } port) + { + activity.SetTag(SemanticConventions.AttributeServerPort, port); + } + } - if (request.Host.Port.HasValue) + if (request.Headers.TryGetValue("User-Agent", out var values)) { - activity.SetTag(SemanticConventions.AttributeServerPort, request.Host.Port.Value); + var userAgent = values.Count > 0 ? values[0] : null; + if (!string.IsNullOrEmpty(userAgent)) + { + activity.SetTag(SemanticConventions.AttributeUserAgentOriginal, userAgent); + } } + + activity.SetTag(SemanticConventions.AttributeUrlScheme, request.Scheme); + activity.SetTag(SemanticConventions.AttributeUrlPath, path); } if (request.QueryString.HasValue) @@ -203,21 +219,8 @@ public void OnStartActivity(Activity activity, object? payload) } } - TelemetryHelper.RequestDataHelper.SetHttpMethodTag(activity, request.Method); - - activity.SetTag(SemanticConventions.AttributeUrlScheme, request.Scheme); - activity.SetTag(SemanticConventions.AttributeUrlPath, path); activity.SetTag(SemanticConventions.AttributeNetworkProtocolVersion, RequestDataHelper.GetHttpProtocolVersion(request.Protocol)); - if (request.Headers.TryGetValue("User-Agent", out var values)) - { - var userAgent = values.Count > 0 ? values[0] : null; - if (!string.IsNullOrEmpty(userAgent)) - { - activity.SetTag(SemanticConventions.AttributeUserAgentOriginal, userAgent); - } - } - try { this.options.EnrichWithHttpRequest?.Invoke(activity, request); @@ -394,4 +397,28 @@ private static void AddGrpcAttributes(Activity activity, string grpcMethod, Http } } } + + // ASP.NET Core 10 does not generate OpenTelemetry tags by default so we can only take + // the optimal path if the user has explicitly opted-out of suppressing the OpenTelemetry data. + private static bool AspNetCoreHasNativeOpenTelemetryTags() + { +#if NET10_0_OR_GREATER + if (AppContext.TryGetSwitch("Microsoft.AspNetCore.Hosting.SuppressActivityOpenTelemetryData", out var suppressed)) + { + return !suppressed; + } +#endif +#if NET10_0 + // In ASP.NET Core 10 OpenTelemetry tags are suppressed by default, + // see https://github.com/dotnet/aspnetcore/blob/7387de91234d3ef751fa50b3d1bfede4130213ff/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs#L59-L67. + return false; +#elif NET11_0_OR_GREATER + // In ASP.NET Core 11+ OpenTelemetry tags are emitted by default, + // see https://github.com/dotnet/aspnetcore/blob/655f41d52f2fc75992eac41496b8e9cc119e1b54/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs#L59-L67. + return true; +#else + // In ASP.NET Core 8 and 9 the feature switch does not exist and there are no native OpenTelemetry tags + return false; +#endif + } } diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/BasicTests.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/BasicTests.cs index 4a3de6b062..2e2bfb07af 100644 --- a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/BasicTests.cs +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/BasicTests.cs @@ -725,7 +725,6 @@ public async Task ActivitiesStartedInMiddlewareBySettingHostActivityToNullShould Assert.Equal("Microsoft.AspNetCore.Hosting.HttpRequestIn", aspnetcoreframeworkactivity.OperationName); } -#if NET [Fact] public async Task UserRegisteredActivitySourceIsUsedForActivityCreationByAspNetCore() { @@ -766,7 +765,6 @@ void ConfigureTestServices(IServiceCollection services) Assert.Equal("UserRegisteredActivitySource", activity.Source.Name); } -#endif [Theory] [InlineData(1)] @@ -1332,14 +1330,9 @@ private static void WaitForActivityExport(List exportedItems, int coun private static void ValidateAspNetCoreActivity(Activity activityToValidate, string expectedHttpPath) { Assert.Equal(ActivityKind.Server, activityToValidate.Kind); -#if NET Assert.Equal(HttpInListener.AspNetCoreActivitySourceName, activityToValidate.Source.Name); Assert.NotNull(activityToValidate.Source.Version); Assert.Empty(activityToValidate.Source.Version); -#else - Assert.Equal(HttpInListener.ActivitySourceName, activityToValidate.Source.Name); - Assert.Equal(HttpInListener.Version.ToString(), activityToValidate.Source.Version); -#endif Assert.Equal(expectedHttpPath, activityToValidate.GetTagValue(SemanticConventions.AttributeUrlPath) as string); } diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/EndToEndTests.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/EndToEndTests.cs new file mode 100644 index 0000000000..09fc5df6d5 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/EndToEndTests.cs @@ -0,0 +1,114 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Instrumentation.AspNetCore.Implementation; +using OpenTelemetry.Trace; +using Xunit; + +namespace OpenTelemetry.Instrumentation.AspNetCore.Tests; + +[Collection("AspNetCore")] +public sealed class EndToEndTests + : IClassFixture>, IDisposable +{ + private readonly WebApplicationFactory factory; + private TracerProvider? tracerProvider; + + public EndToEndTests(WebApplicationFactory factory) + { + this.factory = factory; + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task HttpRequestActivityIsCorrectWithFeatureSwitch(bool isEnabled) + { + bool? originalValue = null; + + if (AppContext.TryGetSwitch("Microsoft.AspNetCore.Hosting.SuppressActivityOpenTelemetryData", out var existingValue)) + { + originalValue = existingValue; + } + + AppContext.SetSwitch("Microsoft.AspNetCore.Hosting.SuppressActivityOpenTelemetryData", isEnabled); + + try + { + var exportedItems = new List(); + + void ConfigureTestServices(IServiceCollection services) + { + this.tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(exportedItems) + .Build(); + } + + // Arrange + using var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(ConfigureTestServices); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient(); + + client.DefaultRequestHeaders.UserAgent.Add(new("OpenTelemetry.Instrumentation.AspNetCore.Tests", "1.0")); + + _ = await client.GetStringAsync(new Uri("/ping", UriKind.Relative)); + + WaitForActivityExport(exportedItems, 1); + + var activity = Assert.Single(exportedItems); + + ValidateAspNetCoreActivity(activity, "/ping"); + + Assert.Equal("GET /ping", activity.DisplayName); + Assert.Equal("GET", activity.GetTagValue(SemanticConventions.AttributeHttpRequestMethod)); + Assert.Equal("localhost", activity.GetTagValue(SemanticConventions.AttributeServerAddress)); + Assert.Equal("OpenTelemetry.Instrumentation.AspNetCore.Tests/1.0", activity.GetTagValue(SemanticConventions.AttributeUserAgentOriginal)); + Assert.Equal("http", activity.GetTagValue(SemanticConventions.AttributeUrlScheme)); + Assert.Equal("/ping", activity.GetTagValue(SemanticConventions.AttributeUrlPath)); + } + finally + { + if (originalValue is { } previousValue) + { + AppContext.SetSwitch("Microsoft.AspNetCore.Hosting.SuppressActivityOpenTelemetryData", previousValue); + } + } + } + + public void Dispose() + => this.tracerProvider?.Dispose(); + + private static void WaitForActivityExport(List exportedItems, int count) + => Assert.True( + SpinWait.SpinUntil( + () => + { + // We need to let End callback execute as it is executed AFTER response was returned. + // In unit tests environment there may be a lot of parallel unit tests executed, so + // giving some breathing room for the End callback to complete + Thread.Sleep(10); + return exportedItems.Count >= count; + }, + TimeSpan.FromSeconds(5)), + $"Actual: {exportedItems.Count} Expected: {count}"); + + private static void ValidateAspNetCoreActivity(Activity activityToValidate, string expectedHttpPath) + { + Assert.Equal(ActivityKind.Server, activityToValidate.Kind); + Assert.Equal(HttpInListener.AspNetCoreActivitySourceName, activityToValidate.Source.Name); + Assert.NotNull(activityToValidate.Source.Version); + Assert.Empty(activityToValidate.Source.Version); + Assert.Equal(expectedHttpPath, activityToValidate.GetTagValue(SemanticConventions.AttributeUrlPath) as string); + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs index 0ea35508eb..48dd47f26a 100644 --- a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs @@ -1,22 +1,14 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 -#if NET using System.Threading.RateLimiting; using Microsoft.AspNetCore.Builder; -#endif using Microsoft.AspNetCore.Hosting; -#if NET using Microsoft.AspNetCore.Http; -#endif using Microsoft.AspNetCore.Mvc.Testing; -#if NET using Microsoft.AspNetCore.RateLimiting; -#endif -#if NET using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -#endif using Microsoft.Extensions.Logging; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; @@ -38,7 +30,6 @@ public void AddAspNetCoreInstrumentation_BadArgs() Assert.Throws(builder!.AddAspNetCoreInstrumentation); } -#if NET [Fact] public async Task ValidateNetMetricsAsync() { @@ -178,7 +169,6 @@ static string GetTicks() await app.DisposeAsync(); } -#endif [Theory] [InlineData("/api/values/2", "api/Values/{id}", null, 200)] diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/RouteInfo.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/RouteInfo.cs index ad2e92e737..d02ba23b77 100644 --- a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/RouteInfo.cs +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/RouteInfo.cs @@ -3,9 +3,7 @@ using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http; -#if NET using Microsoft.AspNetCore.Http.Metadata; -#endif using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Routing; @@ -38,9 +36,7 @@ public void SetValues(HttpContext context) this.Path = $"{context.Request.Path}{context.Request.QueryString}"; var endpoint = context.GetEndpoint(); this.RawText = (endpoint as RouteEndpoint)?.RoutePattern.RawText; -#if NET this.RouteDiagnosticMetadata = endpoint?.Metadata.GetMetadata()?.Route; -#endif this.RouteData = new Dictionary(); foreach (var value in context.GetRouteData().Values) { @@ -48,8 +44,6 @@ public void SetValues(HttpContext context) } } - public void SetValues(ActionDescriptor actionDescriptor) - { + public void SetValues(ActionDescriptor actionDescriptor) => this.ActionDescriptor ??= new ActionDescriptorInfo(actionDescriptor); - } } diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/TestApplicationFactory.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/TestApplicationFactory.cs index a0e99e5775..478fe00c05 100644 --- a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/TestApplicationFactory.cs +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/RouteTests/TestApplication/TestApplicationFactory.cs @@ -129,11 +129,9 @@ private static WebApplication CreateMinimalApiApplication() app.MapGet("/MinimalApi", () => Results.Ok()); app.MapGet("/MinimalApi/{id}", (int id) => Results.Ok()); -#if NET var api = app.MapGroup("/MinimalApiUsingMapGroup"); api.MapGet("/", () => Results.Ok()); api.MapGet("/{id}", (int id) => Results.Ok()); -#endif return app; } diff --git a/test/TestApp.AspNetCore/Program.cs b/test/TestApp.AspNetCore/Program.cs index 110693b6fa..b3c2de565d 100644 --- a/test/TestApp.AspNetCore/Program.cs +++ b/test/TestApp.AspNetCore/Program.cs @@ -53,6 +53,8 @@ public static void Main(string[] args) app.AddTestMiddleware(); + app.MapGet("/ping", () => "pong"); + app.Run(); } }