From 67924e53bc60ab28ca7dd5e12ccb6fda8ee4ddf3 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 3 Jun 2024 20:38:13 +0800 Subject: [PATCH] Add disable HTTP metrics endpoint metadata --- .../Internal/HostingApplicationDiagnostics.cs | 8 +- .../Hosting/src/Internal/HostingMetrics.cs | 4 +- .../src/Internal/HttpMetricsTagsFeature.cs | 2 + .../HostingApplicationDiagnosticsTests.cs | 179 +++++++++++++++++- .../Hosting/test/HostingMetricsTests.cs | 1 + .../Metadata/IDisableHttpMetricsMetadata.cs | 11 ++ .../src/PublicAPI.Unshipped.txt | 1 + .../src/DisableHttpMetricsAttribute.cs | 14 ++ ...ricsEndpointConventionBuilderExtensions.cs | 24 +++ .../src/PublicAPI.Unshipped.txt | 4 + .../src/IHttpMetricsTagsFeature.cs | 8 +- .../Http.Features/src/PublicAPI.Unshipped.txt | 2 + src/Http/Routing/src/EndpointNameAttribute.cs | 2 +- 13 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 src/Http/Http.Abstractions/src/Metadata/IDisableHttpMetricsMetadata.cs create mode 100644 src/Http/Http.Extensions/src/DisableHttpMetricsAttribute.cs create mode 100644 src/Http/Http.Extensions/src/HttpMetricsEndpointConventionBuilderExtensions.cs diff --git a/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs b/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs index 6bd24cc8eee5..a641d23e6c4e 100644 --- a/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs +++ b/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs @@ -154,11 +154,12 @@ public void RequestEnd(HttpContext httpContext, Exception? exception, HostingApp if (context.MetricsEnabled) { + Debug.Assert(context.MetricsTagsFeature != null, "MetricsTagsFeature should be set if MetricsEnabled is true."); + var endpoint = HttpExtensions.GetOriginalEndpoint(httpContext); + var disableHttpRequestDurationMetric = endpoint?.Metadata.GetMetadata() != null || context.MetricsTagsFeature.MetricsDisabled; var route = endpoint?.Metadata.GetMetadata()?.Route; - Debug.Assert(context.MetricsTagsFeature != null, "MetricsTagsFeature should be set if MetricsEnabled is true."); - _metrics.RequestEnd( context.MetricsTagsFeature.Protocol!, context.MetricsTagsFeature.Scheme!, @@ -169,7 +170,8 @@ public void RequestEnd(HttpContext httpContext, Exception? exception, HostingApp exception, context.MetricsTagsFeature.TagsList, startTimestamp, - currentTimestamp); + currentTimestamp, + disableHttpRequestDurationMetric); } if (reachedPipelineEnd) diff --git a/src/Hosting/Hosting/src/Internal/HostingMetrics.cs b/src/Hosting/Hosting/src/Internal/HostingMetrics.cs index bdd83e5212be..129542fec15a 100644 --- a/src/Hosting/Hosting/src/Internal/HostingMetrics.cs +++ b/src/Hosting/Hosting/src/Internal/HostingMetrics.cs @@ -42,7 +42,7 @@ public void RequestStart(string scheme, string method) _activeRequestsCounter.Add(1, tags); } - public void RequestEnd(string protocol, string scheme, string method, string? route, int statusCode, bool unhandledRequest, Exception? exception, List>? customTags, long startTimestamp, long currentTimestamp) + public void RequestEnd(string protocol, string scheme, string method, string? route, int statusCode, bool unhandledRequest, Exception? exception, List>? customTags, long startTimestamp, long currentTimestamp, bool disableHttpRequestDurationMetric) { var tags = new TagList(); InitializeRequestTags(ref tags, scheme, method); @@ -53,7 +53,7 @@ public void RequestEnd(string protocol, string scheme, string method, string? ro _activeRequestsCounter.Add(-1, tags); } - if (_requestDuration.Enabled) + if (!disableHttpRequestDurationMetric && _requestDuration.Enabled) { if (TryGetHttpVersion(protocol, out var httpVersion)) { diff --git a/src/Hosting/Hosting/src/Internal/HttpMetricsTagsFeature.cs b/src/Hosting/Hosting/src/Internal/HttpMetricsTagsFeature.cs index e556210b9309..98e06f50ac4d 100644 --- a/src/Hosting/Hosting/src/Internal/HttpMetricsTagsFeature.cs +++ b/src/Hosting/Hosting/src/Internal/HttpMetricsTagsFeature.cs @@ -8,6 +8,7 @@ namespace Microsoft.AspNetCore.Hosting; internal sealed class HttpMetricsTagsFeature : IHttpMetricsTagsFeature { ICollection> IHttpMetricsTagsFeature.Tags => TagsList; + public bool MetricsDisabled { get; set; } public List> TagsList { get; } = new List>(); @@ -20,6 +21,7 @@ internal sealed class HttpMetricsTagsFeature : IHttpMetricsTagsFeature public void Reset() { TagsList.Clear(); + MetricsDisabled = false; Method = null; Scheme = null; diff --git a/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs b/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs index 9f38f34a6ac1..7b868081dd32 100644 --- a/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs +++ b/src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs @@ -5,8 +5,10 @@ using System.Diagnostics.Metrics; using System.Diagnostics.Tracing; using System.Reflection; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Diagnostics.Metrics; @@ -228,7 +230,6 @@ public void Metrics_RequestChanges_OriginalValuesUsed() var testMeterFactory = new TestMeterFactory(); using var activeRequestsCollector = new MetricCollector(testMeterFactory, HostingMetrics.MeterName, "http.server.active_requests"); - using var requestDurationCollector = new MetricCollector(testMeterFactory, HostingMetrics.MeterName, "http.server.request.duration"); // Act var hostingApplication = CreateApplication(out var features, eventSource: hostingEventSource, meterFactory: testMeterFactory, configure: c => @@ -279,6 +280,182 @@ public void Metrics_RequestChanges_OriginalValuesUsed() Assert.Null(context.MetricsTagsFeature.Protocol); } + [Fact] + public void Metrics_Route_RouteTagReported() + { + // Arrange + var hostingEventSource = new HostingEventSource(Guid.NewGuid().ToString()); + + var testMeterFactory = new TestMeterFactory(); + using var activeRequestsCollector = new MetricCollector(testMeterFactory, HostingMetrics.MeterName, "http.server.active_requests"); + using var requestDurationCollector = new MetricCollector(testMeterFactory, HostingMetrics.MeterName, "http.server.request.duration"); + + // Act + var hostingApplication = CreateApplication(out var features, eventSource: hostingEventSource, meterFactory: testMeterFactory, configure: c => + { + c.Request.Protocol = "1.1"; + c.Request.Scheme = "http"; + c.Request.Method = "POST"; + c.Request.Host = new HostString("localhost"); + c.Request.Path = "/hello"; + c.Request.ContentType = "text/plain"; + c.Request.ContentLength = 1024; + }); + var context = hostingApplication.CreateContext(features); + + Assert.Collection(activeRequestsCollector.GetMeasurementSnapshot(), + m => + { + Assert.Equal(1, m.Value); + Assert.Equal("http", m.Tags["url.scheme"]); + Assert.Equal("POST", m.Tags["http.request.method"]); + }); + + context.HttpContext.SetEndpoint(new Endpoint( + c => Task.CompletedTask, + new EndpointMetadataCollection(new TestRouteDiagnosticsMetadata()), + "Test endpoint")); + + hostingApplication.DisposeContext(context, null); + + // Assert + Assert.Collection(activeRequestsCollector.GetMeasurementSnapshot(), + m => + { + Assert.Equal(1, m.Value); + Assert.Equal("http", m.Tags["url.scheme"]); + Assert.Equal("POST", m.Tags["http.request.method"]); + }, + m => + { + Assert.Equal(-1, m.Value); + Assert.Equal("http", m.Tags["url.scheme"]); + Assert.Equal("POST", m.Tags["http.request.method"]); + }); + Assert.Collection(requestDurationCollector.GetMeasurementSnapshot(), + m => + { + Assert.True(m.Value > 0); + Assert.Equal("hello/{name}", m.Tags["http.route"]); + }); + } + + [Fact] + public void Metrics_DisableHttpMetricsWithMetadata_NoMetrics() + { + // Arrange + var hostingEventSource = new HostingEventSource(Guid.NewGuid().ToString()); + + var testMeterFactory = new TestMeterFactory(); + using var activeRequestsCollector = new MetricCollector(testMeterFactory, HostingMetrics.MeterName, "http.server.active_requests"); + using var requestDurationCollector = new MetricCollector(testMeterFactory, HostingMetrics.MeterName, "http.server.request.duration"); + + // Act + var hostingApplication = CreateApplication(out var features, eventSource: hostingEventSource, meterFactory: testMeterFactory, configure: c => + { + c.Request.Protocol = "1.1"; + c.Request.Scheme = "http"; + c.Request.Method = "POST"; + c.Request.Host = new HostString("localhost"); + c.Request.Path = "/hello"; + c.Request.ContentType = "text/plain"; + c.Request.ContentLength = 1024; + }); + var context = hostingApplication.CreateContext(features); + + Assert.Collection(activeRequestsCollector.GetMeasurementSnapshot(), + m => + { + Assert.Equal(1, m.Value); + Assert.Equal("http", m.Tags["url.scheme"]); + Assert.Equal("POST", m.Tags["http.request.method"]); + }); + + context.HttpContext.SetEndpoint(new Endpoint( + c => Task.CompletedTask, + new EndpointMetadataCollection(new TestRouteDiagnosticsMetadata(), new DisableHttpMetricsAttribute()), + "Test endpoint")); + + hostingApplication.DisposeContext(context, null); + + // Assert + Assert.Collection(activeRequestsCollector.GetMeasurementSnapshot(), + m => + { + Assert.Equal(1, m.Value); + Assert.Equal("http", m.Tags["url.scheme"]); + Assert.Equal("POST", m.Tags["http.request.method"]); + }, + m => + { + Assert.Equal(-1, m.Value); + Assert.Equal("http", m.Tags["url.scheme"]); + Assert.Equal("POST", m.Tags["http.request.method"]); + }); + Assert.Empty(requestDurationCollector.GetMeasurementSnapshot()); + } + + [Fact] + public void Metrics_DisableHttpMetricsWithFeature_NoMetrics() + { + // Arrange + var hostingEventSource = new HostingEventSource(Guid.NewGuid().ToString()); + + var testMeterFactory = new TestMeterFactory(); + using var activeRequestsCollector = new MetricCollector(testMeterFactory, HostingMetrics.MeterName, "http.server.active_requests"); + using var requestDurationCollector = new MetricCollector(testMeterFactory, HostingMetrics.MeterName, "http.server.request.duration"); + + // Act + var hostingApplication = CreateApplication(out var features, eventSource: hostingEventSource, meterFactory: testMeterFactory, configure: c => + { + c.Request.Protocol = "1.1"; + c.Request.Scheme = "http"; + c.Request.Method = "POST"; + c.Request.Host = new HostString("localhost"); + c.Request.Path = "/hello"; + c.Request.ContentType = "text/plain"; + c.Request.ContentLength = 1024; + }); + var context = hostingApplication.CreateContext(features); + + Assert.Collection(activeRequestsCollector.GetMeasurementSnapshot(), + m => + { + Assert.Equal(1, m.Value); + Assert.Equal("http", m.Tags["url.scheme"]); + Assert.Equal("POST", m.Tags["http.request.method"]); + }); + + context.HttpContext.Features.Get().MetricsDisabled = true; + + // Assert 1 + Assert.True(context.MetricsTagsFeature.MetricsDisabled); + + hostingApplication.DisposeContext(context, null); + + // Assert 2 + Assert.Collection(activeRequestsCollector.GetMeasurementSnapshot(), + m => + { + Assert.Equal(1, m.Value); + Assert.Equal("http", m.Tags["url.scheme"]); + Assert.Equal("POST", m.Tags["http.request.method"]); + }, + m => + { + Assert.Equal(-1, m.Value); + Assert.Equal("http", m.Tags["url.scheme"]); + Assert.Equal("POST", m.Tags["http.request.method"]); + }); + Assert.Empty(requestDurationCollector.GetMeasurementSnapshot()); + Assert.False(context.MetricsTagsFeature.MetricsDisabled); + } + + private sealed class TestRouteDiagnosticsMetadata : IRouteDiagnosticsMetadata + { + public string Route { get; } = "hello/{name}"; + } + [Fact] public void DisposeContextDoesNotThrowWhenContextScopeIsNull() { diff --git a/src/Hosting/Hosting/test/HostingMetricsTests.cs b/src/Hosting/Hosting/test/HostingMetricsTests.cs index 3a591d20b7d4..77547bb1e462 100644 --- a/src/Hosting/Hosting/test/HostingMetricsTests.cs +++ b/src/Hosting/Hosting/test/HostingMetricsTests.cs @@ -180,6 +180,7 @@ public void IHttpMetricsTagsFeatureNotUsedFromFeatureCollection() private sealed class TestHttpMetricsTagsFeature : IHttpMetricsTagsFeature { public ICollection> Tags { get; } = new Collection>(); + public bool MetricsDisabled { get; set; } } private static HostingApplication CreateApplication(IHttpContextFactory httpContextFactory = null, bool useHttpContextAccessor = false, diff --git a/src/Http/Http.Abstractions/src/Metadata/IDisableHttpMetricsMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IDisableHttpMetricsMetadata.cs new file mode 100644 index 000000000000..7c270b66685b --- /dev/null +++ b/src/Http/Http.Abstractions/src/Metadata/IDisableHttpMetricsMetadata.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http.Metadata; + +/// +/// A marker interface which can be used to identify metadata that disables HTTP request duration metrics. +/// +public interface IDisableHttpMetricsMetadata +{ +} diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index b08a98e0390c..f31484140679 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -4,3 +4,4 @@ Microsoft.AspNetCore.Http.HostString.HostString(string? value) -> void *REMOVED*Microsoft.AspNetCore.Http.HostString.Value.get -> string! Microsoft.AspNetCore.Http.HostString.Value.get -> string? Microsoft.AspNetCore.Http.HttpValidationProblemDetails.HttpValidationProblemDetails(System.Collections.Generic.IEnumerable>! errors) -> void +Microsoft.AspNetCore.Http.Metadata.IDisableHttpMetricsMetadata diff --git a/src/Http/Http.Extensions/src/DisableHttpMetricsAttribute.cs b/src/Http/Http.Extensions/src/DisableHttpMetricsAttribute.cs new file mode 100644 index 000000000000..22cf8412bb8f --- /dev/null +++ b/src/Http/Http.Extensions/src/DisableHttpMetricsAttribute.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http.Metadata; + +namespace Microsoft.AspNetCore.Http; + +/// +/// Specifies that HTTP request duration metrics is disabled for an endpoint. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class DisableHttpMetricsAttribute : Attribute, IDisableHttpMetricsMetadata +{ +} diff --git a/src/Http/Http.Extensions/src/HttpMetricsEndpointConventionBuilderExtensions.cs b/src/Http/Http.Extensions/src/HttpMetricsEndpointConventionBuilderExtensions.cs new file mode 100644 index 000000000000..85f9c7a59822 --- /dev/null +++ b/src/Http/Http.Extensions/src/HttpMetricsEndpointConventionBuilderExtensions.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// HTTP metrics extension methods for . +/// +public static class HttpMetricsEndpointConventionBuilderExtensions +{ + /// + /// Specifies that HTTP request duration metrics is disabled for an endpoint. + /// + /// The type of endpoint convention builder. + /// The endpoint convention builder. + /// The original convention builder parameter. + public static TBuilder DisableHttpMetrics(this TBuilder builder) where TBuilder : IEndpointConventionBuilder + { + builder.Add(b => b.Metadata.Add(new DisableHttpMetricsAttribute())); + return builder; + } +} diff --git a/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt index 61c6112ae413..2913480f0bef 100644 --- a/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt @@ -1,2 +1,6 @@ #nullable enable Microsoft.AspNetCore.Http.HttpValidationProblemDetails.HttpValidationProblemDetails(System.Collections.Generic.IEnumerable>! errors) -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Builder.HttpMetricsEndpointConventionBuilderExtensions +Microsoft.AspNetCore.Http.DisableHttpMetricsAttribute +Microsoft.AspNetCore.Http.DisableHttpMetricsAttribute.DisableHttpMetricsAttribute() -> void +static Microsoft.AspNetCore.Builder.HttpMetricsEndpointConventionBuilderExtensions.DisableHttpMetrics(this TBuilder builder) -> TBuilder diff --git a/src/Http/Http.Features/src/IHttpMetricsTagsFeature.cs b/src/Http/Http.Features/src/IHttpMetricsTagsFeature.cs index b7c556f75f1b..c232c06fdd4e 100644 --- a/src/Http/Http.Features/src/IHttpMetricsTagsFeature.cs +++ b/src/Http/Http.Features/src/IHttpMetricsTagsFeature.cs @@ -4,7 +4,7 @@ namespace Microsoft.AspNetCore.Http.Features; /// -/// Provides access to tags added to the metrics HTTP request counter. This feature isn't set if the counter isn't enabled. +/// Provides access to tags added to HTTP request duration metrics. This feature isn't set if the counter isn't enabled. /// public interface IHttpMetricsTagsFeature { @@ -12,4 +12,10 @@ public interface IHttpMetricsTagsFeature /// Gets the tag collection. /// ICollection> Tags { get; } + + // MetricsDisabled was added after the initial release of this interface and is intentionally a DIM property. + /// + /// Gets or sets a flag that disables recording HTTP request duration metrics for the current HTTP request. + /// + public bool MetricsDisabled { get; set; } } diff --git a/src/Http/Http.Features/src/PublicAPI.Unshipped.txt b/src/Http/Http.Features/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..556a81c6fed3 100644 --- a/src/Http/Http.Features/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Features/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.Http.Features.IHttpMetricsTagsFeature.MetricsDisabled.get -> bool +Microsoft.AspNetCore.Http.Features.IHttpMetricsTagsFeature.MetricsDisabled.set -> void diff --git a/src/Http/Routing/src/EndpointNameAttribute.cs b/src/Http/Routing/src/EndpointNameAttribute.cs index eda3e902c8fa..d1d691644f6d 100644 --- a/src/Http/Routing/src/EndpointNameAttribute.cs +++ b/src/Http/Routing/src/EndpointNameAttribute.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Http;