diff --git a/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs b/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs index 67f584462e6e..f648a4eee1a8 100644 --- a/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs +++ b/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Shared; using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Hosting; @@ -57,10 +58,10 @@ public HostingApplicationDiagnostics( private static bool GetSuppressActivityOpenTelemetryData() { - // Default to true if the switch isn't set. + // Default to false if the switch isn't set. if (!AppContext.TryGetSwitch("Microsoft.AspNetCore.Hosting.SuppressActivityOpenTelemetryData", out var enabled)) { - return true; + return false; } return enabled; @@ -233,7 +234,7 @@ public void RequestEnd(HttpContext httpContext, Exception? exception, HostingApp // can capture the activity as a metric exemplar. if (activity is not null) { - StopActivity(httpContext, activity, context.HasDiagnosticListener); + StopActivity(httpContext, activity, exception, context.HasDiagnosticListener); } if (context.EventLogEnabled) @@ -434,6 +435,13 @@ private void RecordRequestStartMetrics(HttpContext httpContext) return null; } + if (!SuppressActivityOpenTelemetryData) + { + // Set the initial display name to just the HTTP method. + // It will be updated to include the route if one is matched. + activity.DisplayName = HostingTelemetryHelpers.GetActivityDisplayName(httpContext.Request.Method); + } + _diagnosticListener.OnActivityImport(activity, httpContext); if (_diagnosticListener.IsEnabled(ActivityStartKey)) @@ -457,6 +465,8 @@ private static TagList CreateInitializeActivityTags(HttpContext httpContext) // Missing values recommended by the spec are: // - url.query (need configuration around redaction to do properly) // - http.request.header. + // + // Note that these tags are added even if Activity.IsAllDataRequested is false, as they may be used in sampling decisions. var request = httpContext.Request; var creationTags = new TagList(); @@ -473,7 +483,7 @@ private static TagList CreateInitializeActivityTags(HttpContext httpContext) HostingTelemetryHelpers.SetActivityHttpMethodTags(ref creationTags, request.Method); - if (request.Headers.TryGetValue("User-Agent", out var values)) + if (request.Headers.TryGetValue(HeaderNames.UserAgent, out var values)) { var userAgent = values.Count > 0 ? values[0] : null; if (!string.IsNullOrEmpty(userAgent)) @@ -491,8 +501,13 @@ private static TagList CreateInitializeActivityTags(HttpContext httpContext) } [MethodImpl(MethodImplOptions.NoInlining)] - private void StopActivity(HttpContext httpContext, Activity activity, bool hasDiagnosticListener) + private void StopActivity(HttpContext httpContext, Activity activity, Exception? exception, bool hasDiagnosticListener) { + if (!SuppressActivityOpenTelemetryData && activity.IsAllDataRequested) + { + SetActivityEndTags(httpContext, activity, exception); + } + if (hasDiagnosticListener) { StopActivity(activity, httpContext); @@ -503,6 +518,38 @@ private void StopActivity(HttpContext httpContext, Activity activity, bool hasDi } } + private static void SetActivityEndTags(HttpContext httpContext, Activity activity, Exception? exception) + { + var response = httpContext.Response; + + activity.SetTag(HostingTelemetryHelpers.AttributeHttpResponseStatusCode, HostingTelemetryHelpers.GetBoxedStatusCode(response.StatusCode)); + + if (HostingTelemetryHelpers.TryGetHttpVersion(httpContext.Request.Protocol, out var httpVersion)) + { + activity.SetTag(HostingTelemetryHelpers.AttributeNetworkProtocolVersion, httpVersion); + } + + var endpoint = HttpExtensions.GetOriginalEndpoint(httpContext); + var route = endpoint?.Metadata.GetMetadata()?.Route; + if (route is not null) + { + var resolvedRoute = RouteDiagnosticsHelpers.ResolveHttpRoute(route); + activity.SetTag(HostingTelemetryHelpers.AttributeHttpRoute, resolvedRoute); + activity.DisplayName = HostingTelemetryHelpers.GetActivityDisplayName(httpContext.Request.Method, resolvedRoute); + } + + if (exception != null) + { + activity.SetTag(HostingTelemetryHelpers.AttributeErrorType, exception.GetType().FullName); + activity.SetStatus(ActivityStatusCode.Error, exception.Message); + } + else if (HostingTelemetryHelpers.IsErrorStatusCode(response.StatusCode)) + { + activity.SetTag(HostingTelemetryHelpers.AttributeErrorType, response.StatusCode.ToString(CultureInfo.InvariantCulture)); + activity.SetStatus(ActivityStatusCode.Error); + } + } + // These are versions of DiagnosticSource.Start/StopActivity that don't allocate strings per call (see https://github.com/dotnet/corefx/issues/37055) // DynamicDependency matches the properties selected in: // https://github.com/dotnet/diagnostics/blob/7cc6fbef613cdfe5ff64393120d59d7a15e98bd6/src/Microsoft.Diagnostics.Monitoring.EventPipe/Configuration/HttpRequestSourceConfiguration.cs#L20-L33 diff --git a/src/Hosting/Hosting/src/Internal/HostingMetrics.cs b/src/Hosting/Hosting/src/Internal/HostingMetrics.cs index 2615bf5608f7..68387b6fbe5f 100644 --- a/src/Hosting/Hosting/src/Internal/HostingMetrics.cs +++ b/src/Hosting/Hosting/src/Internal/HostingMetrics.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Diagnostics.Metrics; +using System.Globalization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Shared; @@ -56,7 +57,7 @@ public void RequestEnd(string protocol, string scheme, string method, string? ro { if (HostingTelemetryHelpers.TryGetHttpVersion(protocol, out var httpVersion)) { - tags.Add("network.protocol.version", httpVersion); + tags.Add(HostingTelemetryHelpers.AttributeNetworkProtocolVersion, httpVersion); } if (unhandledRequest) { @@ -64,10 +65,10 @@ public void RequestEnd(string protocol, string scheme, string method, string? ro } // Add information gathered during request. - tags.Add("http.response.status_code", HostingTelemetryHelpers.GetBoxedStatusCode(statusCode)); + tags.Add(HostingTelemetryHelpers.AttributeHttpResponseStatusCode, HostingTelemetryHelpers.GetBoxedStatusCode(statusCode)); if (route != null) { - tags.Add("http.route", RouteDiagnosticsHelpers.ResolveHttpRoute(route)); + tags.Add(HostingTelemetryHelpers.AttributeHttpRoute, RouteDiagnosticsHelpers.ResolveHttpRoute(route)); } // Add before some built in tags so custom tags are prioritized when dealing with duplicates. @@ -85,7 +86,12 @@ public void RequestEnd(string protocol, string scheme, string method, string? ro { // Exception tag could have been added by middleware. If an exception is later thrown in request pipeline // then we don't want to add a duplicate tag here because that breaks some metrics systems. - tags.TryAddTag("error.type", exception.GetType().FullName); + tags.TryAddTag(HostingTelemetryHelpers.AttributeErrorType, exception.GetType().FullName); + } + else if (HostingTelemetryHelpers.IsErrorStatusCode(statusCode)) + { + // Add error.type for 5xx status codes when there's no exception. + tags.TryAddTag(HostingTelemetryHelpers.AttributeErrorType, statusCode.ToString(CultureInfo.InvariantCulture)); } var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp); @@ -102,7 +108,7 @@ public void Dispose() private static void InitializeRequestTags(ref TagList tags, string scheme, string method) { - tags.Add("url.scheme", scheme); - tags.Add("http.request.method", HostingTelemetryHelpers.GetNormalizedHttpMethod(method)); + tags.Add(HostingTelemetryHelpers.AttributeUrlScheme, scheme); + tags.Add(HostingTelemetryHelpers.AttributeHttpRequestMethod, HostingTelemetryHelpers.GetNormalizedHttpMethod(method)); } } diff --git a/src/Hosting/Hosting/src/Internal/HostingTelemetryHelpers.cs b/src/Hosting/Hosting/src/Internal/HostingTelemetryHelpers.cs index e415869e9cff..b172a4b74b4d 100644 --- a/src/Hosting/Hosting/src/Internal/HostingTelemetryHelpers.cs +++ b/src/Hosting/Hosting/src/Internal/HostingTelemetryHelpers.cs @@ -11,14 +11,17 @@ namespace Microsoft.AspNetCore.Hosting; internal static class HostingTelemetryHelpers { // Semantic Conventions for HTTP. - // Note: Not all telemetry code is using these const attribute names yet. public const string AttributeHttpRequestMethod = "http.request.method"; public const string AttributeHttpRequestMethodOriginal = "http.request.method_original"; + public const string AttributeHttpResponseStatusCode = "http.response.status_code"; + public const string AttributeHttpRoute = "http.route"; public const string AttributeUrlScheme = "url.scheme"; public const string AttributeUrlPath = "url.path"; public const string AttributeServerAddress = "server.address"; public const string AttributeServerPort = "server.port"; public const string AttributeUserAgentOriginal = "user_agent.original"; + public const string AttributeNetworkProtocolVersion = "network.protocol.version"; + public const string AttributeErrorType = "error.type"; // The value "_OTHER" is used for non-standard HTTP methods. // https://github.com/open-telemetry/semantic-conventions/blob/v1.23.0/docs/http/http-spans.md#common-attributes @@ -69,7 +72,7 @@ public static bool TryGetServerPort(HostString host, string scheme, [NotNullWhen public static object GetBoxedStatusCode(int statusCode) { - object[] boxes = BoxedStatusCodes; + var boxes = BoxedStatusCodes; return (uint)statusCode < (uint)boxes.Length ? boxes[statusCode] ??= statusCode : statusCode; @@ -129,4 +132,18 @@ public static void SetActivityHttpMethodTags(ref TagList tags, string originalHt tags.Add(AttributeHttpRequestMethodOriginal, originalHttpMethod); } } + + /// + /// Determines if the status code indicates a server error (5xx). + /// Client errors (4xx) are not considered server errors. + /// + public static bool IsErrorStatusCode(int statusCode) => statusCode >= 500 && statusCode <= 599; + + public static string GetActivityDisplayName(string originalHttpMethod, string? httpRoute = null) + { + var normalizedHttpMethod = GetNormalizedHttpMethod(originalHttpMethod); + var namePrefix = normalizedHttpMethod == OtherHttpMethod ? "HTTP" : normalizedHttpMethod; + + return string.IsNullOrEmpty(httpRoute) ? namePrefix : $"{namePrefix} {httpRoute}"; + } } diff --git a/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs b/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs index 032eab752397..73ad24d8358a 100644 --- a/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs +++ b/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Diagnostics.Metrics; using System.Diagnostics.Tracing; +using System.Globalization; using System.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -260,8 +261,8 @@ public void Metrics_RequestChanges_OriginalValuesUsed() m => { Assert.Equal(1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }); context.HttpContext.Request.Protocol = "HTTP/2"; @@ -276,14 +277,14 @@ public void Metrics_RequestChanges_OriginalValuesUsed() m => { Assert.Equal(1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }, m => { Assert.Equal(-1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }); Assert.Empty(context.MetricsTagsFeature.TagsList); @@ -319,8 +320,8 @@ public void Metrics_Route_RouteTagReported() m => { Assert.Equal(1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }); context.HttpContext.SetEndpoint(new Endpoint( @@ -335,20 +336,20 @@ public void Metrics_Route_RouteTagReported() m => { Assert.Equal(1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }, m => { Assert.Equal(-1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }); Assert.Collection(requestDurationCollector.GetMeasurementSnapshot(), m => { Assert.True(m.Value > 0); - Assert.Equal("hello/{name}", m.Tags["http.route"]); + Assert.Equal("hello/{name}", m.Tags[HostingTelemetryHelpers.AttributeHttpRoute]); }); } @@ -384,8 +385,8 @@ public void Metrics_Route_RouteTagIsRootWhenEmpty() m => { Assert.Equal(1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }); context.HttpContext.SetEndpoint(new Endpoint( @@ -400,20 +401,20 @@ public void Metrics_Route_RouteTagIsRootWhenEmpty() m => { Assert.Equal(1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }, m => { Assert.Equal(-1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }); Assert.Collection(requestDurationCollector.GetMeasurementSnapshot(), m => { Assert.True(m.Value > 0); - Assert.Equal("/", m.Tags["http.route"]); + Assert.Equal("/", m.Tags[HostingTelemetryHelpers.AttributeHttpRoute]); }); } @@ -444,8 +445,8 @@ public void Metrics_DisableHttpMetricsWithMetadata_NoMetrics() m => { Assert.Equal(1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }); context.HttpContext.SetEndpoint(new Endpoint( @@ -460,14 +461,14 @@ public void Metrics_DisableHttpMetricsWithMetadata_NoMetrics() m => { Assert.Equal(1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }, m => { Assert.Equal(-1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }); Assert.Empty(requestDurationCollector.GetMeasurementSnapshot()); } @@ -499,8 +500,8 @@ public void Metrics_DisableHttpMetricsWithFeature_NoMetrics() m => { Assert.Equal(1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }); context.HttpContext.Features.Get().MetricsDisabled = true; @@ -515,14 +516,14 @@ public void Metrics_DisableHttpMetricsWithFeature_NoMetrics() m => { Assert.Equal(1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }, m => { Assert.Equal(-1, m.Value); - Assert.Equal("http", m.Tags["url.scheme"]); - Assert.Equal("POST", m.Tags["http.request.method"]); + Assert.Equal("http", m.Tags[HostingTelemetryHelpers.AttributeUrlScheme]); + Assert.Equal("POST", m.Tags[HostingTelemetryHelpers.AttributeHttpRequestMethod]); }); Assert.Empty(requestDurationCollector.GetMeasurementSnapshot()); Assert.False(context.MetricsTagsFeature.MetricsDisabled); @@ -530,7 +531,12 @@ public void Metrics_DisableHttpMetricsWithFeature_NoMetrics() private sealed class TestRouteDiagnosticsMetadata : IRouteDiagnosticsMetadata { - public string Route { get; } = "hello/{name}"; + public TestRouteDiagnosticsMetadata(string route = "hello/{name}") + { + Route = route; + } + + public string Route { get; } } [Fact] @@ -1081,10 +1087,10 @@ public void ActivityOnImportHookIsCalled() } [Fact] - public void ActivityListenersAreCalled() + public void ActivityListeners_SuppressActivityTags_NoTagsAdded() { var testSource = new ActivitySource(Path.GetRandomFileName()); - var hostingApplication = CreateApplication(out var features, activitySource: testSource); + var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: true); var parentSpanId = ""; var tags = new List>(); using var listener = new ActivityListener @@ -1123,7 +1129,7 @@ public void ActivityListenersAreCalled() } [Fact] - public void ActivityListeners_DontSuppressActivityTags_TagsAdded() + public void ActivityListeners_TagsAdded() { var testSource = new ActivitySource(Path.GetRandomFileName()); var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: false); @@ -1214,6 +1220,379 @@ public void ActivityListeners_DefaultPorts(string scheme, int? expectedPort) Assert.Equal(expectedPort, (int?)actualPort); } + [Fact] + public void ActivityListeners_PropagationDataSample_EndTagsNotAdded() + { + // When Activity.IsAllDataRequested is false (PropagationData sample result), + // end tags should NOT be added as they are only relevant when all data is requested. + var testSource = new ActivitySource(Path.GetRandomFileName()); + var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: false, configure: c => + { + c.Request.Protocol = "HTTP/1.1"; + c.Request.Scheme = "http"; + c.Request.Method = "GET"; + c.Request.Path = "/hello"; + c.Response.StatusCode = 500; + }); + + Activity stoppedActivity = null; + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, testSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.PropagationData, + ActivityStopped = activity => stoppedActivity = activity + }; + + ActivitySource.AddActivityListener(listener); + + var context = hostingApplication.CreateContext(features); + context.HttpContext.SetEndpoint(new Endpoint( + c => Task.CompletedTask, + new EndpointMetadataCollection(new TestRouteDiagnosticsMetadata()), + "Test endpoint")); + + var exception = new InvalidOperationException("Test exception"); + hostingApplication.DisposeContext(context, exception); + + Assert.NotNull(stoppedActivity); + Assert.False(stoppedActivity.IsAllDataRequested); + + var tags = stoppedActivity.TagObjects.ToDictionary(); + + // Verify sampling tags are still present + Assert.True(tags.ContainsKey(HostingTelemetryHelpers.AttributeHttpRequestMethod)); + Assert.True(tags.ContainsKey(HostingTelemetryHelpers.AttributeUrlScheme)); + Assert.True(tags.ContainsKey(HostingTelemetryHelpers.AttributeUrlPath)); + + // Verify end tags are NOT present since IsAllDataRequested is false + Assert.False(tags.ContainsKey(HostingTelemetryHelpers.AttributeHttpResponseStatusCode)); + Assert.False(tags.ContainsKey(HostingTelemetryHelpers.AttributeNetworkProtocolVersion)); + Assert.False(tags.ContainsKey(HostingTelemetryHelpers.AttributeHttpRoute)); + Assert.False(tags.ContainsKey(HostingTelemetryHelpers.AttributeErrorType)); + Assert.Equal(ActivityStatusCode.Unset, stoppedActivity.Status); + } + + [Fact] + public void ActivityListeners_EndTagsAdded() + { + var testSource = new ActivitySource(Path.GetRandomFileName()); + var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: false, configure: c => + { + c.Request.Protocol = "HTTP/1.1"; + c.Request.Scheme = "http"; + c.Request.Method = "GET"; + c.Request.Path = "/hello"; + c.Response.StatusCode = 200; + }); + + Activity stoppedActivity = null; + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, testSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => stoppedActivity = activity + }; + + ActivitySource.AddActivityListener(listener); + + var context = hostingApplication.CreateContext(features); + context.HttpContext.SetEndpoint(new Endpoint( + c => Task.CompletedTask, + new EndpointMetadataCollection(new TestRouteDiagnosticsMetadata()), + "Test endpoint")); + hostingApplication.DisposeContext(context, null); + + Assert.NotNull(stoppedActivity); + var tags = stoppedActivity.TagObjects.ToDictionary(); + + Assert.Equal(200, tags[HostingTelemetryHelpers.AttributeHttpResponseStatusCode]); + Assert.Equal("1.1", tags[HostingTelemetryHelpers.AttributeNetworkProtocolVersion]); + Assert.Equal("hello/{name}", tags[HostingTelemetryHelpers.AttributeHttpRoute]); + Assert.False(tags.ContainsKey(HostingTelemetryHelpers.AttributeErrorType)); + Assert.Equal(ActivityStatusCode.Unset, stoppedActivity.Status); + } + + [Fact] + public void ActivityListeners_ErrorStatusCodeSetsErrorType() + { + var testSource = new ActivitySource(Path.GetRandomFileName()); + var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: false, configure: c => + { + c.Request.Protocol = "HTTP/2"; + c.Request.Scheme = "https"; + c.Request.Method = "POST"; + c.Request.Path = "/api/test"; + c.Response.StatusCode = 500; + }); + + Activity stoppedActivity = null; + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, testSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => stoppedActivity = activity + }; + + ActivitySource.AddActivityListener(listener); + + var context = hostingApplication.CreateContext(features); + hostingApplication.DisposeContext(context, null); + + Assert.NotNull(stoppedActivity); + var tags = stoppedActivity.TagObjects.ToDictionary(); + + Assert.Equal(500, tags[HostingTelemetryHelpers.AttributeHttpResponseStatusCode]); + Assert.Equal("2", tags[HostingTelemetryHelpers.AttributeNetworkProtocolVersion]); + Assert.Equal("500", tags[HostingTelemetryHelpers.AttributeErrorType]); + Assert.Equal(ActivityStatusCode.Error, stoppedActivity.Status); + } + + [Fact] + public void ActivityListeners_ExceptionSetsErrorType() + { + var testSource = new ActivitySource(Path.GetRandomFileName()); + var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: false, configure: c => + { + c.Request.Protocol = "HTTP/1.1"; + c.Request.Scheme = "http"; + c.Request.Method = "GET"; + c.Request.Path = "/error"; + c.Response.StatusCode = 500; + }); + + Activity stoppedActivity = null; + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, testSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => stoppedActivity = activity + }; + + ActivitySource.AddActivityListener(listener); + + var context = hostingApplication.CreateContext(features); + var exception = new InvalidOperationException("Test exception"); + hostingApplication.DisposeContext(context, exception); + + Assert.NotNull(stoppedActivity); + var tags = stoppedActivity.TagObjects.ToDictionary(); + + Assert.Equal(500, tags[HostingTelemetryHelpers.AttributeHttpResponseStatusCode]); + Assert.Equal("1.1", tags[HostingTelemetryHelpers.AttributeNetworkProtocolVersion]); + Assert.Equal("System.InvalidOperationException", tags[HostingTelemetryHelpers.AttributeErrorType]); + Assert.Equal(ActivityStatusCode.Error, stoppedActivity.Status); + Assert.Equal("Test exception", stoppedActivity.StatusDescription); + } + + [Theory] + [InlineData("HTTP/1.0", "1.0")] + [InlineData("HTTP/1.1", "1.1")] + [InlineData("HTTP/2", "2")] + [InlineData("HTTP/3", "3")] + public void ActivityListeners_HttpVersionMapped(string protocol, string expectedVersion) + { + var testSource = new ActivitySource(Path.GetRandomFileName()); + var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: false, configure: c => + { + c.Request.Protocol = protocol; + c.Request.Scheme = "http"; + c.Request.Method = "GET"; + c.Request.Path = "/"; + c.Response.StatusCode = 200; + }); + + Activity stoppedActivity = null; + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, testSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => stoppedActivity = activity + }; + + ActivitySource.AddActivityListener(listener); + + var context = hostingApplication.CreateContext(features); + hostingApplication.DisposeContext(context, null); + + Assert.NotNull(stoppedActivity); + var tags = stoppedActivity.TagObjects.ToDictionary(); + + Assert.Equal(expectedVersion, tags[HostingTelemetryHelpers.AttributeNetworkProtocolVersion]); + } + + [Fact] + public void ActivityListeners_SuppressActivityTags_NoEndTagsAdded() + { + var testSource = new ActivitySource(Path.GetRandomFileName()); + var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: true, configure: c => + { + c.Request.Protocol = "HTTP/1.1"; + c.Request.Scheme = "http"; + c.Request.Method = "GET"; + c.Request.Path = "/hello"; + c.Response.StatusCode = 200; + }); + + Activity stoppedActivity = null; + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, testSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => stoppedActivity = activity + }; + + ActivitySource.AddActivityListener(listener); + + var context = hostingApplication.CreateContext(features); + context.HttpContext.SetEndpoint(new Endpoint( + c => Task.CompletedTask, + new EndpointMetadataCollection(new TestRouteDiagnosticsMetadata()), + "Test endpoint")); + hostingApplication.DisposeContext(context, null); + + Assert.NotNull(stoppedActivity); + var tags = stoppedActivity.TagObjects.ToDictionary(); + + // No end tags should be added when suppressed + Assert.False(tags.ContainsKey(HostingTelemetryHelpers.AttributeHttpResponseStatusCode)); + Assert.False(tags.ContainsKey(HostingTelemetryHelpers.AttributeNetworkProtocolVersion)); + Assert.False(tags.ContainsKey(HostingTelemetryHelpers.AttributeHttpRoute)); + Assert.False(tags.ContainsKey(HostingTelemetryHelpers.AttributeErrorType)); + } + + [Theory] + [InlineData(500)] + [InlineData(503)] + [InlineData(599)] + public void ActivityListeners_ErrorStatusCodesSetErrorType(int statusCode) + { + var testSource = new ActivitySource(Path.GetRandomFileName()); + var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: false, configure: c => + { + c.Request.Protocol = "HTTP/1.1"; + c.Request.Scheme = "http"; + c.Request.Method = "GET"; + c.Request.Path = "/"; + c.Response.StatusCode = statusCode; + }); + + Activity stoppedActivity = null; + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, testSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => stoppedActivity = activity + }; + + ActivitySource.AddActivityListener(listener); + + var context = hostingApplication.CreateContext(features); + hostingApplication.DisposeContext(context, null); + + Assert.NotNull(stoppedActivity); + var tags = stoppedActivity.TagObjects.ToDictionary(); + + Assert.Equal(statusCode, tags[HostingTelemetryHelpers.AttributeHttpResponseStatusCode]); + Assert.Equal(statusCode.ToString(CultureInfo.InvariantCulture), tags[HostingTelemetryHelpers.AttributeErrorType]); + Assert.Equal(ActivityStatusCode.Error, stoppedActivity.Status); + } + + [Theory] + [InlineData(200)] + [InlineData(201)] + [InlineData(204)] + [InlineData(301)] + [InlineData(399)] + [InlineData(400)] + [InlineData(404)] + [InlineData(499)] + [InlineData(600)] + [InlineData(999)] + public void ActivityListeners_SuccessStatusCodesNoErrorType(int statusCode) + { + var testSource = new ActivitySource(Path.GetRandomFileName()); + var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: false, configure: c => + { + c.Request.Protocol = "HTTP/1.1"; + c.Request.Scheme = "http"; + c.Request.Method = "GET"; + c.Request.Path = "/"; + c.Response.StatusCode = statusCode; + }); + + Activity stoppedActivity = null; + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, testSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => stoppedActivity = activity + }; + + ActivitySource.AddActivityListener(listener); + + var context = hostingApplication.CreateContext(features); + hostingApplication.DisposeContext(context, null); + + Assert.NotNull(stoppedActivity); + var tags = stoppedActivity.TagObjects.ToDictionary(); + + Assert.Equal(statusCode, tags[HostingTelemetryHelpers.AttributeHttpResponseStatusCode]); + Assert.False(tags.ContainsKey(HostingTelemetryHelpers.AttributeErrorType)); + Assert.Equal(ActivityStatusCode.Unset, stoppedActivity.Status); + } + + [Theory] + [InlineData("GET", null, "GET")] + [InlineData("get", null, "GET")] + [InlineData("POST", null, "POST")] + [InlineData("PUT", null, "PUT")] + [InlineData("DELETE", null, "DELETE")] + [InlineData("PATCH", null, "PATCH")] + [InlineData("HEAD", null, "HEAD")] + [InlineData("OPTIONS", null, "OPTIONS")] + [InlineData("TRACE", null, "TRACE")] + [InlineData("CONNECT", null, "CONNECT")] + [InlineData("CUSTOM", null, "HTTP")] + [InlineData("weird", null, "HTTP")] + [InlineData("GET", "hello/{name}", "GET hello/{name}")] + [InlineData("POST", "hello/{name}", "POST hello/{name}")] + [InlineData("CUSTOM", "hello/{name}", "HTTP hello/{name}")] + public void ActivityListeners_DisplayName(string method, string route, string expectedDisplayName) + { + var testSource = new ActivitySource(Path.GetRandomFileName()); + var hostingApplication = CreateApplication(out var features, activitySource: testSource, suppressActivityOpenTelemetryData: false, configure: c => + { + c.Request.Protocol = "HTTP/1.1"; + c.Request.Scheme = "http"; + c.Request.Method = method; + c.Request.Path = "/api/test"; + c.Response.StatusCode = 200; + }); + + Activity stoppedActivity = null; + using var listener = new ActivityListener + { + ShouldListenTo = activitySource => ReferenceEquals(activitySource, testSource), + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => stoppedActivity = activity + }; + + ActivitySource.AddActivityListener(listener); + + var context = hostingApplication.CreateContext(features); + if (route is not null) + { + context.HttpContext.SetEndpoint(new Endpoint( + c => Task.CompletedTask, + new EndpointMetadataCollection(new TestRouteDiagnosticsMetadata(route)), + "Test endpoint")); + } + hostingApplication.DisposeContext(context, null); + + Assert.NotNull(stoppedActivity); + Assert.Equal(expectedDisplayName, stoppedActivity.DisplayName); + } + [Fact] public void RequestLogs() { diff --git a/src/Hosting/Hosting/test/HostingApplicationTests.cs b/src/Hosting/Hosting/test/HostingApplicationTests.cs index f05deaf8e3df..aea50f7eb2f5 100644 --- a/src/Hosting/Hosting/test/HostingApplicationTests.cs +++ b/src/Hosting/Hosting/test/HostingApplicationTests.cs @@ -109,7 +109,8 @@ public void IHttpActivityFeatureIsPopulated() var activityFeature = context.HttpContext.Features.Get(); Assert.NotNull(activityFeature); Assert.NotNull(activityFeature.Activity); - Assert.Equal(HostingApplicationDiagnostics.ActivityName, activityFeature.Activity.DisplayName); + Assert.Equal(HostingApplicationDiagnostics.ActivityName, activityFeature.Activity.OperationName); + Assert.Equal("HTTP", activityFeature.Activity.DisplayName); var initialActivity = Activity.Current; // Create nested dummy Activity @@ -156,7 +157,8 @@ public void IHttpActivityFeatureNotUsedFromFeatureCollection() var contextFeature = context.HttpContext.Features.Get(); Assert.NotNull(contextFeature); Assert.NotNull(contextFeature.Activity); - Assert.Equal(HostingApplicationDiagnostics.ActivityName, contextFeature.Activity.DisplayName); + Assert.Equal(HostingApplicationDiagnostics.ActivityName, contextFeature.Activity.OperationName); + Assert.Equal("HTTP", contextFeature.Activity.DisplayName); Assert.NotEqual(overridenFeature, contextFeature); } diff --git a/src/Hosting/Hosting/test/HostingMetricsTests.cs b/src/Hosting/Hosting/test/HostingMetricsTests.cs index 77547bb1e462..e1f8c0d75def 100644 --- a/src/Hosting/Hosting/test/HostingMetricsTests.cs +++ b/src/Hosting/Hosting/test/HostingMetricsTests.cs @@ -92,15 +92,15 @@ public void MultipleRequests() static void AssertRequestDuration(CollectedMeasurement measurement, string httpVersion, int statusCode, string exceptionName = null, bool? unhandledRequest = null) { Assert.True(measurement.Value > 0); - Assert.Equal(httpVersion, (string)measurement.Tags["network.protocol.version"]); - Assert.Equal(statusCode, (int)measurement.Tags["http.response.status_code"]); + Assert.Equal(httpVersion, (string)measurement.Tags[HostingTelemetryHelpers.AttributeNetworkProtocolVersion]); + Assert.Equal(statusCode, (int)measurement.Tags[HostingTelemetryHelpers.AttributeHttpResponseStatusCode]); if (exceptionName == null) { - Assert.False(measurement.Tags.ContainsKey("error.type")); + Assert.False(measurement.Tags.ContainsKey(HostingTelemetryHelpers.AttributeErrorType)); } else { - Assert.Equal(exceptionName, (string)measurement.Tags["error.type"]); + Assert.Equal(exceptionName, (string)measurement.Tags[HostingTelemetryHelpers.AttributeErrorType]); } if (unhandledRequest ?? false) { @@ -177,6 +177,90 @@ public void IHttpMetricsTagsFeatureNotUsedFromFeatureCollection() Assert.NotEqual(overridenFeature, contextFeature); } + [Theory] + [InlineData(500)] + [InlineData(503)] + [InlineData(599)] + public void RequestDuration_ServerErrorStatusCode_ErrorTypeSet(int statusCode) + { + // Arrange + var meterFactory = new TestMeterFactory(); + var hostingApplication = CreateApplication(meterFactory: meterFactory); + var httpContext = new DefaultHttpContext(); + + using var requestDurationCollector = new MetricCollector(meterFactory, HostingMetrics.MeterName, "http.server.request.duration"); + + // Act + httpContext.Request.Protocol = HttpProtocol.Http11; + var context = hostingApplication.CreateContext(httpContext.Features); + context.HttpContext.Response.StatusCode = statusCode; + hostingApplication.DisposeContext(context, null); + + // Assert + var measurements = requestDurationCollector.GetMeasurementSnapshot(); + Assert.Single(measurements); + + var measurement = measurements[0]; + Assert.Equal(statusCode, (int)measurement.Tags[HostingTelemetryHelpers.AttributeHttpResponseStatusCode]); + Assert.Equal(statusCode.ToString(System.Globalization.CultureInfo.InvariantCulture), (string)measurement.Tags[HostingTelemetryHelpers.AttributeErrorType]); + } + + [Theory] + [InlineData(200)] + [InlineData(301)] + [InlineData(400)] + [InlineData(404)] + [InlineData(499)] + public void RequestDuration_NonServerErrorStatusCode_NoErrorType(int statusCode) + { + // Arrange + var meterFactory = new TestMeterFactory(); + var hostingApplication = CreateApplication(meterFactory: meterFactory); + var httpContext = new DefaultHttpContext(); + + using var requestDurationCollector = new MetricCollector(meterFactory, HostingMetrics.MeterName, "http.server.request.duration"); + + // Act + httpContext.Request.Protocol = HttpProtocol.Http11; + var context = hostingApplication.CreateContext(httpContext.Features); + context.HttpContext.Response.StatusCode = statusCode; + hostingApplication.DisposeContext(context, null); + + // Assert + var measurements = requestDurationCollector.GetMeasurementSnapshot(); + Assert.Single(measurements); + + var measurement = measurements[0]; + Assert.Equal(statusCode, (int)measurement.Tags[HostingTelemetryHelpers.AttributeHttpResponseStatusCode]); + Assert.False(measurement.Tags.ContainsKey(HostingTelemetryHelpers.AttributeErrorType)); + } + + [Fact] + public void RequestDuration_ExceptionWithServerErrorStatusCode_ExceptionTakesPrecedence() + { + // Arrange + var meterFactory = new TestMeterFactory(); + var hostingApplication = CreateApplication(meterFactory: meterFactory); + var httpContext = new DefaultHttpContext(); + + using var requestDurationCollector = new MetricCollector(meterFactory, HostingMetrics.MeterName, "http.server.request.duration"); + + // Act + httpContext.Request.Protocol = HttpProtocol.Http11; + var context = hostingApplication.CreateContext(httpContext.Features); + context.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + hostingApplication.DisposeContext(context, new InvalidOperationException("Test error")); + + // Assert + var measurements = requestDurationCollector.GetMeasurementSnapshot(); + Assert.Single(measurements); + + var measurement = measurements[0]; + Assert.Equal(StatusCodes.Status500InternalServerError, (int)measurement.Tags[HostingTelemetryHelpers.AttributeHttpResponseStatusCode]); + // When there's an exception, it should be used as error.type, not the status code + Assert.Equal("System.InvalidOperationException", (string)measurement.Tags[HostingTelemetryHelpers.AttributeErrorType]); + } + private sealed class TestHttpMetricsTagsFeature : IHttpMetricsTagsFeature { public ICollection> Tags { get; } = new Collection>(); diff --git a/src/Shared/Diagnostics/RouteDiagnosticsHelpers.cs b/src/Shared/Diagnostics/RouteDiagnosticsHelpers.cs index 3084f3caf57d..3b97bcba2d4a 100644 --- a/src/Shared/Diagnostics/RouteDiagnosticsHelpers.cs +++ b/src/Shared/Diagnostics/RouteDiagnosticsHelpers.cs @@ -11,7 +11,7 @@ public static string ResolveHttpRoute(string route) // 1. It is potentially confusing, "What does empty string mean?" // 2. Some telemetry tools have problems with empty string values, e.g. https://github.com/dotnet/aspnetcore/pull/62432 // - // The fix is to resolve empty string route to "/" in metrics. + // The fix is to resolve empty string route to "/" in telemetry attributes. return string.IsNullOrEmpty(route) ? "/" : route; } }