[ASP.NET Core] Add support for .NET8.0 specific metrics (#4934)

This commit is contained in:
Vishwesh Bankwar 2023-10-20 12:40:38 -07:00 committed by GitHub
parent 50d3af0aff
commit 38e21a99bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 272 additions and 33 deletions

View File

@ -14,6 +14,7 @@
// limitations under the License.
// </copyright>
#if !NET8_0_OR_GREATER
using System.Diagnostics.Metrics;
using System.Reflection;
using OpenTelemetry.Instrumentation.AspNetCore.Implementation;
@ -59,3 +60,4 @@ internal sealed class AspNetCoreMetrics : IDisposable
this.meter?.Dispose();
}
}
#endif

View File

@ -76,3 +76,4 @@ public class AspNetCoreMetricsInstrumentationOptions
/// </summary>
public AspNetCoreMetricEnrichmentFunc Enrich { get; set; }
}

View File

@ -31,6 +31,61 @@
and
[metrics](https://github.com/open-telemetry/semantic-conventions/blob/2bad9afad58fbd6b33cc683d1ad1f006e35e4a5d/docs/http/http-metrics.md).
* Following metrics will now be enabled by default when targeting `.NET8.0` or
newer framework:
* **Meter** : `Microsoft.AspNetCore.Hosting`
* `http.server.request.duration`
* `http.server.active_requests`
* **Meter** : `Microsoft.AspNetCore.Server.Kestrel`
* `kestrel.active_connections`
* `kestrel.connection.duration`
* `kestrel.rejected_connections`
* `kestrel.queued_connections`
* `kestrel.queued_requests`
* `kestrel.upgraded_connections`
* `kestrel.tls_handshake.duration`
* `kestrel.active_tls_handshakes`
* **Meter** : `Microsoft.AspNetCore.Http.Connections`
* `signalr.server.connection.duration`
* `signalr.server.active_connections`
* **Meter** : `Microsoft.AspNetCore.Routing`
* `aspnetcore.routing.match_attempts`
* **Meter** : `Microsoft.AspNetCore.Diagnostics`
* `aspnetcore.diagnostics.exceptions`
* **Meter** : `Microsoft.AspNetCore.RateLimiting`
* `aspnetcore.rate_limiting.active_request_leases`
* `aspnetcore.rate_limiting.request_lease.duration`
* `aspnetcore.rate_limiting.queued_requests`
* `aspnetcore.rate_limiting.request.time_in_queue`
* `aspnetcore.rate_limiting.requests`
For details about each individual metric check [ASP.NET Core
docs
page](https://learn.microsoft.com/dotnet/core/diagnostics/built-in-metrics-aspnetcore).
**NOTES**:
* When targeting `.NET8.0` framework or newer, `http.server.request.duration` metric
will only follow
[v1.22.0](https://github.com/open-telemetry/semantic-conventions/blob/v1.22.0/docs/http/http-metrics.md#metric-httpclientrequestduration)
semantic conventions specification. Ability to switch behavior to older
conventions using `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable is
not available.
* Users can opt-out of metrics that are not required using
[views](https://github.com/open-telemetry/opentelemetry-dotnet/tree/main/docs/metrics/customizing-the-sdk#drop-an-instrument).
([#4934](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4934))
* Added `network.protocol.name` dimension to `http.server.request.duration`
metric. This change only affects users setting `OTEL_SEMCONV_STABILITY_OPT_IN`
to `http` or `http/dup`.
([#4934](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4934))
## 1.5.1-beta.1
Released 2023-Jul-20

View File

@ -92,4 +92,10 @@ internal sealed class AspNetCoreInstrumentationEventSource : EventSource
{
this.WriteEvent(5, handlerName, eventName, ex);
}
[Event(6, Message = "'{0}' is not supported for .NET8.0 and above targets", Level = EventLevel.Warning)]
public void UnsupportedOption(string optionName)
{
this.WriteEvent(6, optionName);
}
}

View File

@ -14,6 +14,7 @@
// limitations under the License.
// </copyright>
#if !NET8_0_OR_GREATER
using System.Diagnostics;
using System.Diagnostics.Metrics;
using Microsoft.AspNetCore.Http;
@ -32,6 +33,7 @@ internal sealed class HttpInMetricsListener : ListenerHandler
private const string OnStopEvent = "Microsoft.AspNetCore.Hosting.HttpRequestIn.Stop";
private const string EventName = "OnStopActivity";
private const string NetworkProtocolName = "http";
private readonly Meter meter;
private readonly AspNetCoreMetricsInstrumentationOptions options;
@ -184,6 +186,7 @@ internal sealed class HttpInMetricsListener : ListenerHandler
TagList tags = default;
// see the spec https://github.com/open-telemetry/semantic-conventions/blob/v1.21.0/docs/http/http-spans.md
tags.Add(new KeyValuePair<string, object>(SemanticConventions.AttributeNetworkProtocolName, NetworkProtocolName));
tags.Add(new KeyValuePair<string, object>(SemanticConventions.AttributeNetworkProtocolVersion, HttpTagHelper.GetFlavorTagValueFromProtocol(context.Request.Protocol)));
tags.Add(new KeyValuePair<string, object>(SemanticConventions.AttributeUrlScheme, context.Request.Scheme));
tags.Add(new KeyValuePair<string, object>(SemanticConventions.AttributeHttpRequestMethod, context.Request.Method));
@ -214,3 +217,4 @@ internal sealed class HttpInMetricsListener : ListenerHandler
this.httpServerRequestDuration.Record(Activity.Current.Duration.TotalSeconds, tags);
}
}
#endif

View File

@ -14,8 +14,10 @@
// limitations under the License.
// </copyright>
#if !NET8_0_OR_GREATER
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
#endif
using OpenTelemetry.Instrumentation.AspNetCore;
using OpenTelemetry.Instrumentation.AspNetCore.Implementation;
using OpenTelemetry.Internal;
@ -34,7 +36,15 @@ public static class MeterProviderBuilderExtensions
/// <returns>The instance of <see cref="MeterProviderBuilder"/> to chain the calls.</returns>
public static MeterProviderBuilder AddAspNetCoreInstrumentation(
this MeterProviderBuilder builder)
=> AddAspNetCoreInstrumentation(builder, name: null, configureAspNetCoreInstrumentationOptions: null);
{
Guard.ThrowIfNull(builder);
#if NET8_0_OR_GREATER
return builder.ConfigureMeters();
#else
return AddAspNetCoreInstrumentation(builder, name: null, configureAspNetCoreInstrumentationOptions: null);
#endif
}
/// <summary>
/// Enables the incoming requests automatic data collection for ASP.NET Core.
@ -61,6 +71,11 @@ public static class MeterProviderBuilderExtensions
{
Guard.ThrowIfNull(builder);
#if NET8_0_OR_GREATER
AspNetCoreInstrumentationEventSource.Log.UnsupportedOption(nameof(AspNetCoreMetricsInstrumentationOptions));
return builder.ConfigureMeters();
#else
// Note: Warm-up the status code mapping.
_ = TelemetryHelper.BoxedStatusCodes;
@ -90,5 +105,17 @@ public static class MeterProviderBuilderExtensions
});
return builder;
#endif
}
internal static MeterProviderBuilder ConfigureMeters(this MeterProviderBuilder builder)
{
return builder
.AddMeter("Microsoft.AspNetCore.Hosting")
.AddMeter("Microsoft.AspNetCore.Server.Kestrel")
.AddMeter("Microsoft.AspNetCore.Http.Connections")
.AddMeter("Microsoft.AspNetCore.Routing")
.AddMeter("Microsoft.AspNetCore.Diagnostics")
.AddMeter("Microsoft.AspNetCore.RateLimiting");
}
}

View File

@ -120,6 +120,7 @@ internal static class SemanticConventions
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 AttributeNetworkProtocolName = "network.protocol.name";
public const string AttributeServerAddress = "server.address"; // replaces: "net.host.name" (AttributeNetHostName) and "net.peer.name" (AttributeNetPeerName)
public const string AttributeServerPort = "server.port"; // replaces: "net.host.port" (AttributeNetHostPort) and "net.peer.port" (AttributeNetPeerPort)
public const string AttributeServerSocketAddress = "server.socket.address"; // replaces: "net.peer.ip" (AttributeNetPeerIp)

View File

@ -65,6 +65,7 @@ public class DependencyInjectionConfigTests
Assert.True(optionsPickedFromDI);
}
#if !NET8_0_OR_GREATER
[Theory]
[InlineData(null)]
[InlineData("CustomName")]
@ -95,4 +96,5 @@ public class DependencyInjectionConfigTests
Assert.True(optionsPickedFromDI);
}
#endif
}

View File

@ -14,11 +14,22 @@
// limitations under the License.
// </copyright>
#if !NET8_0_OR_GREATER
using System.Diagnostics;
#endif
#if NET8_0_OR_GREATER
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Builder;
#endif
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
#if NET8_0_OR_GREATER
using Microsoft.AspNetCore.RateLimiting;
#endif
#if !NET8_0_OR_GREATER
using Microsoft.AspNetCore.TestHost;
#endif
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@ -50,56 +61,133 @@ public class MetricTests
Assert.Throws<ArgumentNullException>(() => builder.AddAspNetCoreInstrumentation());
}
#if NET8_0_OR_GREATER
[Fact]
public async Task RequestMetricIsCaptured_Old()
public async Task ValidateNet8MetricsAsync()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string> { [SemanticConventionOptInKeyName] = null })
.Build();
var metricItems = new List<Metric>();
this.meterProvider = Sdk.CreateMeterProviderBuilder()
.ConfigureServices(services => services.AddSingleton<IConfiguration>(configuration))
.AddAspNetCoreInstrumentation()
.AddInMemoryExporter(metricItems)
.Build();
using (var client = this.factory
.WithWebHostBuilder(builder =>
{
builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders());
})
.CreateClient())
{
using var response1 = await client.GetAsync("/api/values").ConfigureAwait(false);
using var response2 = await client.GetAsync("/api/values/2").ConfigureAwait(false);
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
var app = builder.Build();
response1.EnsureSuccessStatusCode();
response2.EnsureSuccessStatusCode();
}
app.MapGet("/", () => "Hello");
// We need to let End callback execute as it is executed AFTER response was returned.
_ = app.RunAsync();
using var client = new HttpClient();
var res = await client.GetStringAsync("http://localhost:5000/").ConfigureAwait(false);
Assert.NotNull(res);
// We need to let metric 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
// giving some breezing room for the callbacks to complete
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
this.meterProvider.Dispose();
var requestMetrics = metricItems
.Where(item => item.Name == "http.server.duration")
var requestDurationMetric = metricItems
.Count(item => item.Name == "http.server.request.duration");
var activeRequestsMetric = metricItems.
Count(item => item.Name == "http.server.active_requests");
var routeMatchingMetric = metricItems.
Count(item => item.Name == "aspnetcore.routing.match_attempts");
var kestrelActiveConnectionsMetric = metricItems.
Count(item => item.Name == "kestrel.active_connections");
var kestrelQueuedConnectionMetric = metricItems.
Count(item => item.Name == "kestrel.queued_connections");
Assert.Equal(1, requestDurationMetric);
Assert.Equal(1, activeRequestsMetric);
Assert.Equal(1, routeMatchingMetric);
Assert.Equal(1, kestrelActiveConnectionsMetric);
Assert.Equal(1, kestrelQueuedConnectionMetric);
// TODO
// kestrel.queued_requests
// kestrel.upgraded_connections
// kestrel.rejected_connections
// kestrel.tls_handshake.duration
// kestrel.active_tls_handshakes
await app.DisposeAsync();
}
[Fact]
public async Task ValidateNet8RateLimitingMetricsAsync()
{
var metricItems = new List<Metric>();
this.meterProvider = Sdk.CreateMeterProviderBuilder()
.AddAspNetCoreInstrumentation()
.AddInMemoryExporter(metricItems)
.Build();
var builder = WebApplication.CreateBuilder();
builder.Services.AddRateLimiter(_ => _
.AddFixedWindowLimiter(policyName: "fixed", options =>
{
options.PermitLimit = 4;
options.Window = TimeSpan.FromSeconds(12);
options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
options.QueueLimit = 2;
}));
builder.Logging.ClearProviders();
var app = builder.Build();
app.UseRateLimiter();
static string GetTicks() => (DateTime.Now.Ticks & 0x11111).ToString("00000");
app.MapGet("/", () => Results.Ok($"Hello {GetTicks()}"))
.RequireRateLimiting("fixed");
_ = app.RunAsync();
using var client = new HttpClient();
var res = await client.GetStringAsync("http://localhost:5000/").ConfigureAwait(false);
Assert.NotNull(res);
// We need to let metric 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 callbacks to complete
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
this.meterProvider.Dispose();
var activeRequestleasesMetric = metricItems
.Where(item => item.Name == "aspnetcore.rate_limiting.active_request_leases")
.ToArray();
var metric = Assert.Single(requestMetrics);
Assert.Equal("ms", metric.Unit);
var metricPoints = GetMetricPoints(metric);
Assert.Equal(2, metricPoints.Count);
var requestLeaseDurationMetric = metricItems.
Where(item => item.Name == "aspnetcore.rate_limiting.request_lease.duration")
.ToArray();
AssertMetricPoints_Old(
metricPoints: metricPoints,
expectedRoutes: new List<string> { "api/Values", "api/Values/{id}" },
expectedTagsCount: 6);
var limitingRequestsMetric = metricItems.
Where(item => item.Name == "aspnetcore.rate_limiting.requests")
.ToArray();
Assert.Single(activeRequestleasesMetric);
Assert.Single(requestLeaseDurationMetric);
Assert.Single(limitingRequestsMetric);
// TODO
// aspnetcore.rate_limiting.request.time_in_queue
// aspnetcore.rate_limiting.queued_requests
await app.DisposeAsync();
}
#endif
[Fact]
public async Task RequestMetricIsCaptured_New()
@ -150,7 +238,59 @@ public class MetricTests
AssertMetricPoints_New(
metricPoints: metricPoints,
expectedRoutes: new List<string> { "api/Values", "api/Values/{id}" },
expectedTagsCount: 5);
expectedTagsCount: 6);
}
#if !NET8_0_OR_GREATER
[Fact]
public async Task RequestMetricIsCaptured_Old()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string> { [SemanticConventionOptInKeyName] = null })
.Build();
var metricItems = new List<Metric>();
this.meterProvider = Sdk.CreateMeterProviderBuilder()
.ConfigureServices(services => services.AddSingleton<IConfiguration>(configuration))
.AddAspNetCoreInstrumentation()
.AddInMemoryExporter(metricItems)
.Build();
using (var client = this.factory
.WithWebHostBuilder(builder =>
{
builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders());
})
.CreateClient())
{
using var response1 = await client.GetAsync("/api/values").ConfigureAwait(false);
using var response2 = await client.GetAsync("/api/values/2").ConfigureAwait(false);
response1.EnsureSuccessStatusCode();
response2.EnsureSuccessStatusCode();
}
// 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);
this.meterProvider.Dispose();
var requestMetrics = metricItems
.Where(item => item.Name == "http.server.duration")
.ToArray();
var metric = Assert.Single(requestMetrics);
Assert.Equal("ms", metric.Unit);
var metricPoints = GetMetricPoints(metric);
Assert.Equal(2, metricPoints.Count);
AssertMetricPoints_Old(
metricPoints: metricPoints,
expectedRoutes: new List<string> { "api/Values", "api/Values/{id}" },
expectedTagsCount: 6);
}
[Fact]
@ -218,7 +358,7 @@ public class MetricTests
AssertMetricPoints_New(
metricPoints: metricPoints,
expectedRoutes: new List<string> { "api/Values", "api/Values/{id}" },
expectedTagsCount: 5);
expectedTagsCount: 6);
}
[Fact]
@ -323,6 +463,7 @@ public class MetricTests
Assert.Contains(tagsToAdd[0], tags);
Assert.Contains(tagsToAdd[1], tags);
}
#endif
public void Dispose()
{