diff --git a/src/OpenTelemetry.Api/Internal/SemanticConventions.cs b/src/OpenTelemetry.Api/Internal/SemanticConventions.cs index a9c760378c3..2fb9b0022fe 100644 --- a/src/OpenTelemetry.Api/Internal/SemanticConventions.cs +++ b/src/OpenTelemetry.Api/Internal/SemanticConventions.cs @@ -110,5 +110,17 @@ internal static class SemanticConventions public const string AttributeExceptionType = "exception.type"; public const string AttributeExceptionMessage = "exception.message"; public const string AttributeExceptionStacktrace = "exception.stacktrace"; + + // Http v1.21.0 https://github.com/open-telemetry/opentelemetry-specification/blob/v1.21.0/specification/trace/semantic_conventions/http.md + public const string AttributeClientSocketPort = "client.socket.port"; // replaces: "net.peer.port" (AttributeNetPeerPort) + public const string AttributeHttpRequestMethod = "http.request.method"; // replaces: "http.method" (AttributeHttpMethod) + public const string AttributeHttpResponseStatusCode = "http.response.status_code"; // replaces: "http.status_code" (AttributeHttpStatusCode) + public const string AttributeNetworkProtocolVersion = "network.protocol.version"; // replaces: "http.flavor" (AttributeHttpFlavor) + public const string AttributeServerAddress = "server.address"; // replaces: "net.host.name" (AttributeNetHostName) + public const string AttributeServerPort = "server.port"; // replaces: "net.host.port" (AttributeNetHostPort) + public const string AttributeUrlPath = "url.path"; // replaces: "http.target" (AttributeHttpTarget) + public const string AttributeUrlScheme = "url.scheme"; // replaces: "http.scheme" (AttributeHttpScheme) + public const string AttributeUrlQuery = "url.query"; + public const string AttributeUserAgentOriginal = "user_agent.original"; // replaces: "http.user_agent" (AttributeHttpUserAgent) } } diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md index c40c65cb89f..301d158d93a 100644 --- a/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +* Updated [Http Semantic Conventions](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.21.0/specification/trace/semantic_conventions/http.md). + * This library can emit either old, new, or both attributes. Users can control + which attributes are emitted by setting the environment variable + `OTEL_SEMCONV_STABILITY_OPT_IN`. + ## 1.5.0-beta.1 Released 2023-Jun-05 diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInListener.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInListener.cs index d50241a3ba8..809473ad3aa 100644 --- a/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInListener.cs +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInListener.cs @@ -197,29 +197,66 @@ public void OnStartActivity(Activity activity, object payload) var path = (request.PathBase.HasValue || request.Path.HasValue) ? (request.PathBase + request.Path).ToString() : "/"; activity.DisplayName = path; - // see the spec https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md - if (request.Host.HasValue) + // see the spec https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/trace/semantic_conventions/http.md + if (this.httpSemanticConvention.HasFlag(HttpSemanticConvention.Old)) { - activity.SetTag(SemanticConventions.AttributeNetHostName, request.Host.Host); + if (request.Host.HasValue) + { + activity.SetTag(SemanticConventions.AttributeNetHostName, request.Host.Host); + + if (request.Host.Port is not null && request.Host.Port != 80 && request.Host.Port != 443) + { + activity.SetTag(SemanticConventions.AttributeNetHostPort, request.Host.Port); + } + } - if (request.Host.Port is not null && request.Host.Port != 80 && request.Host.Port != 443) + activity.SetTag(SemanticConventions.AttributeHttpMethod, request.Method); + activity.SetTag(SemanticConventions.AttributeHttpScheme, request.Scheme); + activity.SetTag(SemanticConventions.AttributeHttpTarget, path); + activity.SetTag(SemanticConventions.AttributeHttpUrl, GetUri(request)); + activity.SetTag(SemanticConventions.AttributeHttpFlavor, HttpTagHelper.GetFlavorTagValueFromProtocol(request.Protocol)); + + if (request.Headers.TryGetValue("User-Agent", out var values)) { - activity.SetTag(SemanticConventions.AttributeNetHostPort, request.Host.Port); + var userAgent = values.Count > 0 ? values[0] : null; + if (!string.IsNullOrEmpty(userAgent)) + { + activity.SetTag(SemanticConventions.AttributeHttpUserAgent, userAgent); + } } } - activity.SetTag(SemanticConventions.AttributeHttpMethod, request.Method); - activity.SetTag(SemanticConventions.AttributeHttpScheme, request.Scheme); - activity.SetTag(SemanticConventions.AttributeHttpTarget, path); - activity.SetTag(SemanticConventions.AttributeHttpUrl, GetUri(request)); - activity.SetTag(SemanticConventions.AttributeHttpFlavor, HttpTagHelper.GetFlavorTagValueFromProtocol(request.Protocol)); - - if (request.Headers.TryGetValue("User-Agent", out var values)) + // see the spec https://github.com/open-telemetry/opentelemetry-specification/blob/v1.21.0/specification/trace/semantic_conventions/http.md + if (this.httpSemanticConvention.HasFlag(HttpSemanticConvention.New)) { - var userAgent = values.Count > 0 ? values[0] : null; - if (!string.IsNullOrEmpty(userAgent)) + if (request.Host.HasValue) + { + activity.SetTag(SemanticConventions.AttributeServerAddress, request.Host.Host); + + if (request.Host.Port is not null && request.Host.Port != 80 && request.Host.Port != 443) + { + activity.SetTag(SemanticConventions.AttributeServerPort, request.Host.Port); + } + } + + if (request.QueryString.HasValue) { - activity.SetTag(SemanticConventions.AttributeHttpUserAgent, userAgent); + // QueryString should be sanitized. see: https://github.com/open-telemetry/opentelemetry-dotnet/issues/4571 + activity.SetTag(SemanticConventions.AttributeUrlQuery, request.QueryString.Value); + } + + activity.SetTag(SemanticConventions.AttributeHttpRequestMethod, request.Method); + activity.SetTag(SemanticConventions.AttributeUrlScheme, request.Scheme); + activity.SetTag(SemanticConventions.AttributeUrlPath, path); + activity.SetTag(SemanticConventions.AttributeNetworkProtocolVersion, HttpTagHelper.GetFlavorTagValueFromProtocol(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); + } } } @@ -247,12 +284,20 @@ public void OnStopActivity(Activity activity, object payload) var response = context.Response; - activity.SetTag(SemanticConventions.AttributeHttpStatusCode, TelemetryHelper.GetBoxedStatusCode(response.StatusCode)); + if (this.httpSemanticConvention.HasFlag(HttpSemanticConvention.Old)) + { + activity.SetTag(SemanticConventions.AttributeHttpStatusCode, TelemetryHelper.GetBoxedStatusCode(response.StatusCode)); + } + + if (this.httpSemanticConvention.HasFlag(HttpSemanticConvention.New)) + { + activity.SetTag(SemanticConventions.AttributeHttpResponseStatusCode, TelemetryHelper.GetBoxedStatusCode(response.StatusCode)); + } #if !NETSTANDARD2_0 if (this.options.EnableGrpcAspNetCoreSupport && TryGetGrpcMethod(activity, out var grpcMethod)) { - AddGrpcAttributes(activity, grpcMethod, context); + this.AddGrpcAttributes(activity, grpcMethod, context); } else if (activity.Status == ActivityStatusCode.Unset) { @@ -429,7 +474,7 @@ private static bool TryGetGrpcMethod(Activity activity, out string grpcMethod) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void AddGrpcAttributes(Activity activity, string grpcMethod, HttpContext context) + private void AddGrpcAttributes(Activity activity, string grpcMethod, HttpContext context) { // The RPC semantic conventions indicate the span name // should not have a leading forward slash. @@ -439,10 +484,19 @@ private static void AddGrpcAttributes(Activity activity, string grpcMethod, Http activity.SetTag(SemanticConventions.AttributeRpcSystem, GrpcTagHelper.RpcSystemGrpc); if (context.Connection.RemoteIpAddress != null) { + // TODO: This attribute was changed in v1.13.0 https://github.com/open-telemetry/opentelemetry-specification/pull/2614 activity.SetTag(SemanticConventions.AttributeNetPeerIp, context.Connection.RemoteIpAddress.ToString()); } - activity.SetTag(SemanticConventions.AttributeNetPeerPort, context.Connection.RemotePort); + if (this.httpSemanticConvention.HasFlag(HttpSemanticConvention.Old)) + { + activity.SetTag(SemanticConventions.AttributeNetPeerPort, context.Connection.RemotePort); + } + + if (this.httpSemanticConvention.HasFlag(HttpSemanticConvention.New)) + { + activity.SetTag(SemanticConventions.AttributeServerPort, context.Connection.RemotePort); + } bool validConversion = GrpcTagHelper.TryGetGrpcStatusCodeFromActivity(activity, out int status); if (validConversion) diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs index 810225daa25..17d9e77738e 100644 --- a/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs +++ b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs @@ -82,20 +82,44 @@ public override void OnEventWritten(string name, object payload) TagList tags = default; - tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpFlavor, HttpTagHelper.GetFlavorTagValueFromProtocol(context.Request.Protocol))); - tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpScheme, context.Request.Scheme)); - tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpMethod, context.Request.Method)); - tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpStatusCode, TelemetryHelper.GetBoxedStatusCode(context.Response.StatusCode))); + // see the spec https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/trace/semantic_conventions/http.md + if (this.httpSemanticConvention.HasFlag(HttpSemanticConvention.Old)) + { + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpFlavor, HttpTagHelper.GetFlavorTagValueFromProtocol(context.Request.Protocol))); + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpScheme, context.Request.Scheme)); + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpMethod, context.Request.Method)); + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpStatusCode, TelemetryHelper.GetBoxedStatusCode(context.Response.StatusCode))); + + if (context.Request.Host.HasValue) + { + tags.Add(new KeyValuePair(SemanticConventions.AttributeNetHostName, context.Request.Host.Host)); + + if (context.Request.Host.Port is not null && context.Request.Host.Port != 80 && context.Request.Host.Port != 443) + { + tags.Add(new KeyValuePair(SemanticConventions.AttributeNetHostPort, context.Request.Host.Port)); + } + } + } - if (context.Request.Host.HasValue) + // see the spec https://github.com/open-telemetry/opentelemetry-specification/blob/v1.21.0/specification/trace/semantic_conventions/http.md + if (this.httpSemanticConvention.HasFlag(HttpSemanticConvention.New)) { - tags.Add(new KeyValuePair(SemanticConventions.AttributeNetHostName, context.Request.Host.Host)); + tags.Add(new KeyValuePair(SemanticConventions.AttributeNetworkProtocolVersion, HttpTagHelper.GetFlavorTagValueFromProtocol(context.Request.Protocol))); + tags.Add(new KeyValuePair(SemanticConventions.AttributeUrlScheme, context.Request.Scheme)); + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpRequestMethod, context.Request.Method)); + tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpResponseStatusCode, TelemetryHelper.GetBoxedStatusCode(context.Response.StatusCode))); - if (context.Request.Host.Port is not null && context.Request.Host.Port != 80 && context.Request.Host.Port != 443) + if (context.Request.Host.HasValue) { - tags.Add(new KeyValuePair(SemanticConventions.AttributeNetHostPort, context.Request.Host.Port)); + tags.Add(new KeyValuePair(SemanticConventions.AttributeServerAddress, context.Request.Host.Host)); + + if (context.Request.Host.Port is not null && context.Request.Host.Port != 80 && context.Request.Host.Port != 443) + { + tags.Add(new KeyValuePair(SemanticConventions.AttributeServerPort, context.Request.Host.Port)); + } } } + #if NET6_0_OR_GREATER var route = (context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText; if (!string.IsNullOrEmpty(route)) diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/IncomingRequestsCollectionsIsAccordingToTheSpecTests.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/IncomingRequestsCollectionsIsAccordingToTheSpecTests.cs index 8500ac00a22..af9e63e2677 100644 --- a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/IncomingRequestsCollectionsIsAccordingToTheSpecTests.cs +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/IncomingRequestsCollectionsIsAccordingToTheSpecTests.cs @@ -43,7 +43,7 @@ public IncomingRequestsCollectionsIsAccordingToTheSpecTests(WebApplicationFactor [InlineData("/api/values", "?query=1", null, 503, null)] [InlineData("/api/exception", null, null, 503, null)] [InlineData("/api/exception", null, null, 503, null, true)] - public async Task SuccessfulTemplateControllerCallGeneratesASpan( + public async Task SuccessfulTemplateControllerCallGeneratesASpan_Old( string urlPath, string query, string userAgent, @@ -51,100 +51,109 @@ public async Task SuccessfulTemplateControllerCallGeneratesASpan( string reasonPhrase, bool recordException = false) { - var exportedItems = new List(); + try + { + Environment.SetEnvironmentVariable("OTEL_SEMCONV_STABILITY_OPT_IN", "none"); - // Arrange - using (var client = this.factory - .WithWebHostBuilder(builder => - { - builder.ConfigureTestServices((IServiceCollection services) => + var exportedItems = new List(); + + // Arrange + using (var client = this.factory + .WithWebHostBuilder(builder => { - services.AddSingleton(new TestCallbackMiddlewareImpl(statusCode, reasonPhrase)); - services.AddOpenTelemetry() - .WithTracing(builder => builder - .AddAspNetCoreInstrumentation(options => options.RecordException = recordException) - .AddInMemoryExporter(exportedItems)); - }); - builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); - }) - .CreateClient()) - { - try + builder.ConfigureTestServices((IServiceCollection services) => + { + services.AddSingleton(new TestCallbackMiddlewareImpl(statusCode, reasonPhrase)); + services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddAspNetCoreInstrumentation(options => options.RecordException = recordException) + .AddInMemoryExporter(exportedItems)); + }); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) { - if (!string.IsNullOrEmpty(userAgent)) + try { - client.DefaultRequestHeaders.Add("User-Agent", userAgent); + if (!string.IsNullOrEmpty(userAgent)) + { + client.DefaultRequestHeaders.Add("User-Agent", userAgent); + } + + // Act + var path = urlPath; + if (query != null) + { + path += query; + } + + using var response = await client.GetAsync(path).ConfigureAwait(false); + } + catch (Exception) + { + // ignore errors } - // Act - var path = urlPath; - if (query != null) + for (var i = 0; i < 10; i++) { - path += query; + if (exportedItems.Count == 1) + { + break; + } + + // 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 breezing room for the End callback to complete + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); } + } + + Assert.Single(exportedItems); + var activity = exportedItems[0]; - using var response = await client.GetAsync(path).ConfigureAwait(false); + Assert.Equal(ActivityKind.Server, activity.Kind); + Assert.Equal("localhost", activity.GetTagValue(SemanticConventions.AttributeNetHostName)); + Assert.Equal("GET", activity.GetTagValue(SemanticConventions.AttributeHttpMethod)); + Assert.Equal("1.1", activity.GetTagValue(SemanticConventions.AttributeHttpFlavor)); + Assert.Equal("http", activity.GetTagValue(SemanticConventions.AttributeHttpScheme)); + Assert.Equal(urlPath, activity.GetTagValue(SemanticConventions.AttributeHttpTarget)); + Assert.Equal($"http://localhost{urlPath}{query}", activity.GetTagValue(SemanticConventions.AttributeHttpUrl)); + Assert.Equal(statusCode, activity.GetTagValue(SemanticConventions.AttributeHttpStatusCode)); + + if (statusCode == 503) + { + Assert.Equal(ActivityStatusCode.Error, activity.Status); } - catch (Exception) + else { - // ignore errors + Assert.Equal(ActivityStatusCode.Unset, activity.Status); } - for (var i = 0; i < 10; i++) + // Instrumentation is not expected to set status description + // as the reason can be inferred from SemanticConventions.AttributeHttpStatusCode + if (!urlPath.EndsWith("exception")) { - if (exportedItems.Count == 1) - { - break; - } - - // 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 breezing room for the End callback to complete - await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + Assert.True(string.IsNullOrEmpty(activity.StatusDescription)); + } + else + { + Assert.Equal("exception description", activity.StatusDescription); } - } - - Assert.Single(exportedItems); - var activity = exportedItems[0]; - Assert.Equal(ActivityKind.Server, activity.Kind); - Assert.Equal("localhost", activity.GetTagValue(SemanticConventions.AttributeNetHostName)); - Assert.Equal("GET", activity.GetTagValue(SemanticConventions.AttributeHttpMethod)); - Assert.Equal("1.1", activity.GetTagValue(SemanticConventions.AttributeHttpFlavor)); - Assert.Equal("http", activity.GetTagValue(SemanticConventions.AttributeHttpScheme)); - Assert.Equal(urlPath, activity.GetTagValue(SemanticConventions.AttributeHttpTarget)); - Assert.Equal($"http://localhost{urlPath}{query}", activity.GetTagValue(SemanticConventions.AttributeHttpUrl)); - Assert.Equal(statusCode, activity.GetTagValue(SemanticConventions.AttributeHttpStatusCode)); + if (recordException) + { + Assert.Single(activity.Events); + Assert.Equal("exception", activity.Events.First().Name); + } - if (statusCode == 503) - { - Assert.Equal(ActivityStatusCode.Error, activity.Status); - } - else - { - Assert.Equal(ActivityStatusCode.Unset, activity.Status); - } + ValidateTagValue(activity, SemanticConventions.AttributeHttpUserAgent, userAgent); - // Instrumentation is not expected to set status description - // as the reason can be inferred from SemanticConventions.AttributeHttpStatusCode - if (!urlPath.EndsWith("exception")) - { - Assert.True(string.IsNullOrEmpty(activity.StatusDescription)); + activity.Dispose(); } - else + finally { - Assert.Equal("exception description", activity.StatusDescription); + Environment.SetEnvironmentVariable("OTEL_SEMCONV_STABILITY_OPT_IN", null); } - - if (recordException) - { - Assert.Single(activity.Events); - Assert.Equal("exception", activity.Events.First().Name); - } - - ValidateTagValue(activity, SemanticConventions.AttributeHttpUserAgent, userAgent); - - activity.Dispose(); } private static void ValidateTagValue(Activity activity, string attribute, string expectedValue) diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/IncomingRequestsCollectionsIsAccordingToTheSpecTests_Dupe.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/IncomingRequestsCollectionsIsAccordingToTheSpecTests_Dupe.cs new file mode 100644 index 00000000000..01e3cb8ce93 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/IncomingRequestsCollectionsIsAccordingToTheSpecTests_Dupe.cs @@ -0,0 +1,204 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Diagnostics; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Trace; +using TestApp.AspNetCore; +using Xunit; + +namespace OpenTelemetry.Instrumentation.AspNetCore.Tests +{ + public class IncomingRequestsCollectionsIsAccordingToTheSpecTests_Dupe + : IClassFixture> + { + private readonly WebApplicationFactory factory; + + public IncomingRequestsCollectionsIsAccordingToTheSpecTests_Dupe(WebApplicationFactory factory) + { + this.factory = factory; + } + + [Theory] + [InlineData("/api/values", null, "user-agent", 503, "503")] + [InlineData("/api/values", "?query=1", null, 503, null)] + [InlineData("/api/exception", null, null, 503, null)] + [InlineData("/api/exception", null, null, 503, null, true)] + public async Task SuccessfulTemplateControllerCallGeneratesASpan_Dupe( + string urlPath, + string query, + string userAgent, + int statusCode, + string reasonPhrase, + bool recordException = false) + { + try + { + Environment.SetEnvironmentVariable("OTEL_SEMCONV_STABILITY_OPT_IN", "http/dup"); + + var exportedItems = new List(); + + // Arrange + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices((IServiceCollection services) => + { + services.AddSingleton(new TestCallbackMiddlewareImpl(statusCode, reasonPhrase)); + services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddAspNetCoreInstrumentation(options => options.RecordException = recordException) + .AddInMemoryExporter(exportedItems)); + }); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + try + { + if (!string.IsNullOrEmpty(userAgent)) + { + client.DefaultRequestHeaders.Add("User-Agent", userAgent); + } + + // Act + var path = urlPath; + if (query != null) + { + path += query; + } + + using var response = await client.GetAsync(path).ConfigureAwait(false); + } + catch (Exception) + { + // ignore errors + } + + for (var i = 0; i < 10; i++) + { + if (exportedItems.Count == 1) + { + break; + } + + // 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 breezing room for the End callback to complete + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + } + } + + Assert.Single(exportedItems); + var activity = exportedItems[0]; + + Assert.Equal(ActivityKind.Server, activity.Kind); + Assert.Equal("localhost", activity.GetTagValue(SemanticConventions.AttributeServerAddress)); + Assert.Equal("localhost", activity.GetTagValue(SemanticConventions.AttributeNetHostName)); + Assert.Equal("GET", activity.GetTagValue(SemanticConventions.AttributeHttpRequestMethod)); + Assert.Equal("GET", activity.GetTagValue(SemanticConventions.AttributeHttpMethod)); + Assert.Equal("1.1", activity.GetTagValue(SemanticConventions.AttributeNetworkProtocolVersion)); + Assert.Equal("1.1", activity.GetTagValue(SemanticConventions.AttributeHttpFlavor)); + Assert.Equal("http", activity.GetTagValue(SemanticConventions.AttributeUrlScheme)); + Assert.Equal("http", activity.GetTagValue(SemanticConventions.AttributeHttpScheme)); + Assert.Equal(urlPath, activity.GetTagValue(SemanticConventions.AttributeUrlPath)); + Assert.Equal(urlPath, activity.GetTagValue(SemanticConventions.AttributeHttpTarget)); + Assert.Equal($"http://localhost{urlPath}{query}", activity.GetTagValue(SemanticConventions.AttributeHttpUrl)); + Assert.Equal(query, activity.GetTagValue(SemanticConventions.AttributeUrlQuery)); + Assert.Equal(statusCode, activity.GetTagValue(SemanticConventions.AttributeHttpResponseStatusCode)); + Assert.Equal(statusCode, activity.GetTagValue(SemanticConventions.AttributeHttpStatusCode)); + + if (statusCode == 503) + { + Assert.Equal(ActivityStatusCode.Error, activity.Status); + } + else + { + Assert.Equal(ActivityStatusCode.Unset, activity.Status); + } + + // Instrumentation is not expected to set status description + // as the reason can be inferred from SemanticConventions.AttributeHttpStatusCode + if (!urlPath.EndsWith("exception")) + { + Assert.True(string.IsNullOrEmpty(activity.StatusDescription)); + } + else + { + Assert.Equal("exception description", activity.StatusDescription); + } + + if (recordException) + { + Assert.Single(activity.Events); + Assert.Equal("exception", activity.Events.First().Name); + } + + ValidateTagValue(activity, SemanticConventions.AttributeUserAgentOriginal, userAgent); + + activity.Dispose(); + } + finally + { + Environment.SetEnvironmentVariable("OTEL_SEMCONV_STABILITY_OPT_IN", null); + } + } + + private static void ValidateTagValue(Activity activity, string attribute, string expectedValue) + { + if (string.IsNullOrEmpty(expectedValue)) + { + Assert.Null(activity.GetTagValue(attribute)); + } + else + { + Assert.Equal(expectedValue, activity.GetTagValue(attribute)); + } + } + + public class TestCallbackMiddlewareImpl : CallbackMiddleware.CallbackMiddlewareImpl + { + private readonly int statusCode; + private readonly string reasonPhrase; + + public TestCallbackMiddlewareImpl(int statusCode, string reasonPhrase) + { + this.statusCode = statusCode; + this.reasonPhrase = reasonPhrase; + } + + public override async Task ProcessAsync(HttpContext context) + { + context.Response.StatusCode = this.statusCode; + context.Response.HttpContext.Features.Get().ReasonPhrase = this.reasonPhrase; + await context.Response.WriteAsync("empty").ConfigureAwait(false); + + if (context.Request.Path.Value.EndsWith("exception")) + { + throw new Exception("exception description"); + } + + return false; + } + } + } +} diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/IncomingRequestsCollectionsIsAccordingToTheSpecTests_New.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/IncomingRequestsCollectionsIsAccordingToTheSpecTests_New.cs new file mode 100644 index 00000000000..47895a14754 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/IncomingRequestsCollectionsIsAccordingToTheSpecTests_New.cs @@ -0,0 +1,197 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Diagnostics; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Trace; +using TestApp.AspNetCore; +using Xunit; + +namespace OpenTelemetry.Instrumentation.AspNetCore.Tests +{ + public class IncomingRequestsCollectionsIsAccordingToTheSpecTests_New + : IClassFixture> + { + private readonly WebApplicationFactory factory; + + public IncomingRequestsCollectionsIsAccordingToTheSpecTests_New(WebApplicationFactory factory) + { + this.factory = factory; + } + + [Theory] + [InlineData("/api/values", null, "user-agent", 503, "503")] + [InlineData("/api/values", "?query=1", null, 503, null)] + [InlineData("/api/exception", null, null, 503, null)] + [InlineData("/api/exception", null, null, 503, null, true)] + public async Task SuccessfulTemplateControllerCallGeneratesASpan_New( + string urlPath, + string query, + string userAgent, + int statusCode, + string reasonPhrase, + bool recordException = false) + { + try + { + Environment.SetEnvironmentVariable("OTEL_SEMCONV_STABILITY_OPT_IN", "http"); + + var exportedItems = new List(); + + // Arrange + using (var client = this.factory + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices((IServiceCollection services) => + { + services.AddSingleton(new TestCallbackMiddlewareImpl(statusCode, reasonPhrase)); + services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddAspNetCoreInstrumentation(options => options.RecordException = recordException) + .AddInMemoryExporter(exportedItems)); + }); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + }) + .CreateClient()) + { + try + { + if (!string.IsNullOrEmpty(userAgent)) + { + client.DefaultRequestHeaders.Add("User-Agent", userAgent); + } + + // Act + var path = urlPath; + if (query != null) + { + path += query; + } + + using var response = await client.GetAsync(path).ConfigureAwait(false); + } + catch (Exception) + { + // ignore errors + } + + for (var i = 0; i < 10; i++) + { + if (exportedItems.Count == 1) + { + break; + } + + // 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 breezing room for the End callback to complete + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + } + } + + Assert.Single(exportedItems); + var activity = exportedItems[0]; + + Assert.Equal(ActivityKind.Server, activity.Kind); + Assert.Equal("localhost", activity.GetTagValue(SemanticConventions.AttributeServerAddress)); + Assert.Equal("GET", activity.GetTagValue(SemanticConventions.AttributeHttpRequestMethod)); + Assert.Equal("1.1", activity.GetTagValue(SemanticConventions.AttributeNetworkProtocolVersion)); + Assert.Equal("http", activity.GetTagValue(SemanticConventions.AttributeUrlScheme)); + Assert.Equal(urlPath, activity.GetTagValue(SemanticConventions.AttributeUrlPath)); + Assert.Equal(query, activity.GetTagValue(SemanticConventions.AttributeUrlQuery)); + Assert.Equal(statusCode, activity.GetTagValue(SemanticConventions.AttributeHttpResponseStatusCode)); + + if (statusCode == 503) + { + Assert.Equal(ActivityStatusCode.Error, activity.Status); + } + else + { + Assert.Equal(ActivityStatusCode.Unset, activity.Status); + } + + // Instrumentation is not expected to set status description + // as the reason can be inferred from SemanticConventions.AttributeHttpStatusCode + if (!urlPath.EndsWith("exception")) + { + Assert.True(string.IsNullOrEmpty(activity.StatusDescription)); + } + else + { + Assert.Equal("exception description", activity.StatusDescription); + } + + if (recordException) + { + Assert.Single(activity.Events); + Assert.Equal("exception", activity.Events.First().Name); + } + + ValidateTagValue(activity, SemanticConventions.AttributeUserAgentOriginal, userAgent); + + activity.Dispose(); + } + finally + { + Environment.SetEnvironmentVariable("OTEL_SEMCONV_STABILITY_OPT_IN", null); + } + } + + private static void ValidateTagValue(Activity activity, string attribute, string expectedValue) + { + if (string.IsNullOrEmpty(expectedValue)) + { + Assert.Null(activity.GetTagValue(attribute)); + } + else + { + Assert.Equal(expectedValue, activity.GetTagValue(attribute)); + } + } + + public class TestCallbackMiddlewareImpl : CallbackMiddleware.CallbackMiddlewareImpl + { + private readonly int statusCode; + private readonly string reasonPhrase; + + public TestCallbackMiddlewareImpl(int statusCode, string reasonPhrase) + { + this.statusCode = statusCode; + this.reasonPhrase = reasonPhrase; + } + + public override async Task ProcessAsync(HttpContext context) + { + context.Response.StatusCode = this.statusCode; + context.Response.HttpContext.Features.Get().ReasonPhrase = this.reasonPhrase; + await context.Response.WriteAsync("empty").ConfigureAwait(false); + + if (context.Request.Path.Value.EndsWith("exception")) + { + throw new Exception("exception description"); + } + + return false; + } + } + } +}