From efe6eda4375ebf64cb652bf18669d6cd53f8ab11 Mon Sep 17 00:00:00 2001
From: Alan West <3676547+alanwest@users.noreply.github.com>
Date: Tue, 10 Dec 2024 14:59:56 -0800
Subject: [PATCH] [aspnetcore] Restore metrics instrumentation in netstandard
builds (#2403)
---
...mentationMeterProviderBuilderExtensions.cs | 28 +++-
.../AspNetCoreMetrics.cs | 41 ++++++
.../CHANGELOG.md | 7 +-
.../Implementation/HttpInMetricsListener.cs | 132 ++++++++++++++++++
.../README.md | 31 ++++
.../MetricTests.cs | 11 ++
6 files changed, 242 insertions(+), 8 deletions(-)
create mode 100644 src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreMetrics.cs
create mode 100644 src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs
diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationMeterProviderBuilderExtensions.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationMeterProviderBuilderExtensions.cs
index 9e8f3d9919..0815bef20e 100644
--- a/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationMeterProviderBuilderExtensions.cs
+++ b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationMeterProviderBuilderExtensions.cs
@@ -1,6 +1,10 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
+#if !NET
+using OpenTelemetry.Instrumentation.AspNetCore;
+using OpenTelemetry.Instrumentation.AspNetCore.Implementation;
+#endif
using OpenTelemetry.Internal;
namespace OpenTelemetry.Metrics;
@@ -18,15 +22,27 @@ public static class AspNetCoreInstrumentationMeterProviderBuilderExtensions
public static MeterProviderBuilder AddAspNetCoreInstrumentation(
this MeterProviderBuilder builder)
{
-#if NETSTANDARD2_0_OR_GREATER
- if (Environment.Version.Major < 8)
- {
- throw new PlatformNotSupportedException("Metrics instrumentation is not supported when executing on .NET 7 and lower.");
- }
+ Guard.ThrowIfNull(builder);
+
+#if NET
+ return builder.ConfigureMeters();
+#else
+ // Note: Warm-up the status code and method mapping.
+ _ = TelemetryHelper.BoxedStatusCodes;
+ _ = TelemetryHelper.RequestDataHelper;
+
+ builder.AddMeter(HttpInMetricsListener.InstrumentationName);
+#pragma warning disable CA2000
+ builder.AddInstrumentation(new AspNetCoreMetrics());
+#pragma warning restore CA2000
+
+ return builder;
#endif
- Guard.ThrowIfNull(builder);
+ }
+ internal static MeterProviderBuilder ConfigureMeters(this MeterProviderBuilder builder)
+ {
return builder
.AddMeter("Microsoft.AspNetCore.Hosting")
.AddMeter("Microsoft.AspNetCore.Server.Kestrel")
diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreMetrics.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreMetrics.cs
new file mode 100644
index 0000000000..877f5ece38
--- /dev/null
+++ b/src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreMetrics.cs
@@ -0,0 +1,41 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+#if !NET
+using OpenTelemetry.Instrumentation.AspNetCore.Implementation;
+
+namespace OpenTelemetry.Instrumentation.AspNetCore;
+
+///
+/// Asp.Net Core Requests instrumentation.
+///
+internal sealed class AspNetCoreMetrics : IDisposable
+{
+ private static readonly HashSet DiagnosticSourceEvents =
+ [
+ "Microsoft.AspNetCore.Hosting.HttpRequestIn",
+ "Microsoft.AspNetCore.Hosting.HttpRequestIn.Start",
+ "Microsoft.AspNetCore.Hosting.HttpRequestIn.Stop",
+ "Microsoft.AspNetCore.Diagnostics.UnhandledException",
+ "Microsoft.AspNetCore.Hosting.UnhandledException"
+ ];
+
+ private readonly Func isEnabled = (eventName, _, _)
+ => DiagnosticSourceEvents.Contains(eventName);
+
+ private readonly DiagnosticSourceSubscriber diagnosticSourceSubscriber;
+
+ internal AspNetCoreMetrics()
+ {
+ var metricsListener = new HttpInMetricsListener("Microsoft.AspNetCore");
+ this.diagnosticSourceSubscriber = new DiagnosticSourceSubscriber(metricsListener, this.isEnabled, AspNetCoreInstrumentationEventSource.Log.UnknownErrorProcessingEvent);
+ this.diagnosticSourceSubscriber.Subscribe();
+ }
+
+ ///
+ public void Dispose()
+ {
+ this.diagnosticSourceSubscriber?.Dispose();
+ }
+}
+#endif
diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md
index 30d7ea82ae..7ac9ef4851 100644
--- a/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md
+++ b/src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md
@@ -2,13 +2,16 @@
## Unreleased
+* Metric support for the .NET Standard target was removed by mistake in 1.10.0.
+ This functionality has been restored.
+ ([#2403](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/2403))
+
## 1.10.0
Released 2024-Dec-09
* Drop support for .NET 6 as this target is no longer supported.
- ([#2138](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/2138),
- ([#2360](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/2360))
+ ([#2138](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/2138))
* Updated OpenTelemetry core component version(s) to `1.10.0`.
([#2317](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/2317))
diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs
new file mode 100644
index 0000000000..c9040d0248
--- /dev/null
+++ b/src/OpenTelemetry.Instrumentation.AspNetCore/Implementation/HttpInMetricsListener.cs
@@ -0,0 +1,132 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.Metrics;
+using System.Reflection;
+using Microsoft.AspNetCore.Http;
+#if NET
+using Microsoft.AspNetCore.Diagnostics;
+using Microsoft.AspNetCore.Routing;
+#endif
+using OpenTelemetry.Internal;
+using OpenTelemetry.Trace;
+
+namespace OpenTelemetry.Instrumentation.AspNetCore.Implementation;
+
+internal sealed class HttpInMetricsListener : ListenerHandler
+{
+ internal const string HttpServerRequestDurationMetricName = "http.server.request.duration";
+
+ internal const string OnUnhandledHostingExceptionEvent = "Microsoft.AspNetCore.Hosting.UnhandledException";
+ internal const string OnUnhandledDiagnosticsExceptionEvent = "Microsoft.AspNetCore.Diagnostics.UnhandledException";
+
+ internal static readonly AssemblyName AssemblyName = typeof(HttpInListener).Assembly.GetName();
+ internal static readonly string InstrumentationName = AssemblyName.Name!;
+ internal static readonly string InstrumentationVersion = AssemblyName.Version!.ToString();
+ internal static readonly Meter Meter = new(InstrumentationName, InstrumentationVersion);
+
+ private const string OnStopEvent = "Microsoft.AspNetCore.Hosting.HttpRequestIn.Stop";
+
+ private static readonly PropertyFetcher ExceptionPropertyFetcher = new("Exception");
+ private static readonly PropertyFetcher HttpContextPropertyFetcher = new("HttpContext");
+ private static readonly object ErrorTypeHttpContextItemsKey = new();
+
+ private static readonly Histogram HttpServerRequestDuration = Meter.CreateHistogram(HttpServerRequestDurationMetricName, "s", "Duration of HTTP server requests.");
+
+ internal HttpInMetricsListener(string name)
+ : base(name)
+ {
+ }
+
+ public static void OnExceptionEventWritten(string name, object? payload)
+ {
+ // We need to use reflection here as the payload type is not a defined public type.
+ if (!TryFetchException(payload, out var exc) || !TryFetchHttpContext(payload, out var ctx))
+ {
+ AspNetCoreInstrumentationEventSource.Log.NullPayload(nameof(HttpInMetricsListener), nameof(OnExceptionEventWritten), HttpServerRequestDurationMetricName);
+ return;
+ }
+
+ ctx.Items.Add(ErrorTypeHttpContextItemsKey, exc.GetType().FullName);
+
+ // See https://github.com/dotnet/aspnetcore/blob/690d78279e940d267669f825aa6627b0d731f64c/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs#L252
+ // and https://github.com/dotnet/aspnetcore/blob/690d78279e940d267669f825aa6627b0d731f64c/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs#L174
+ // this makes sure that top-level properties on the payload object are always preserved.
+#if NET
+ [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The ASP.NET Core framework guarantees that top level properties are preserved")]
+#endif
+ static bool TryFetchException(object? payload, [NotNullWhen(true)] out Exception? exc)
+ {
+ return ExceptionPropertyFetcher.TryFetch(payload, out exc) && exc != null;
+ }
+#if NET
+ [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The ASP.NET Core framework guarantees that top level properties are preserved")]
+#endif
+ static bool TryFetchHttpContext(object? payload, [NotNullWhen(true)] out HttpContext? ctx)
+ {
+ return HttpContextPropertyFetcher.TryFetch(payload, out ctx) && ctx != null;
+ }
+ }
+
+ public static void OnStopEventWritten(string name, object? payload)
+ {
+ if (payload is not HttpContext context)
+ {
+ AspNetCoreInstrumentationEventSource.Log.NullPayload(nameof(HttpInMetricsListener), nameof(OnStopEventWritten), HttpServerRequestDurationMetricName);
+ return;
+ }
+
+ 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(SemanticConventions.AttributeNetworkProtocolVersion, RequestDataHelper.GetHttpProtocolVersion(context.Request.Protocol)));
+ tags.Add(new KeyValuePair(SemanticConventions.AttributeUrlScheme, context.Request.Scheme));
+ tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpResponseStatusCode, TelemetryHelper.GetBoxedStatusCode(context.Response.StatusCode)));
+
+ var httpMethod = TelemetryHelper.RequestDataHelper.GetNormalizedHttpMethod(context.Request.Method);
+ tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpRequestMethod, httpMethod));
+
+#if NET
+ // Check the exception handler feature first in case the endpoint was overwritten
+ var route = (context.Features.Get()?.Endpoint as RouteEndpoint ??
+ context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText;
+ if (!string.IsNullOrEmpty(route))
+ {
+ tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpRoute, route));
+ }
+#endif
+ if (context.Items.TryGetValue(ErrorTypeHttpContextItemsKey, out var errorType))
+ {
+ tags.Add(new KeyValuePair(SemanticConventions.AttributeErrorType, errorType));
+ }
+
+ // We are relying here on ASP.NET Core to set duration before writing the stop event.
+ // https://github.com/dotnet/aspnetcore/blob/d6fa351048617ae1c8b47493ba1abbe94c3a24cf/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs#L449
+ // TODO: Follow up with .NET team if we can continue to rely on this behavior.
+ HttpServerRequestDuration.Record(Activity.Current!.Duration.TotalSeconds, tags);
+ }
+
+ public override void OnEventWritten(string name, object? payload)
+ {
+ switch (name)
+ {
+ case OnUnhandledDiagnosticsExceptionEvent:
+ case OnUnhandledHostingExceptionEvent:
+ {
+ OnExceptionEventWritten(name, payload);
+ }
+
+ break;
+ case OnStopEvent:
+ {
+ OnStopEventWritten(name, payload);
+ }
+
+ break;
+ default:
+ break;
+ }
+ }
+}
diff --git a/src/OpenTelemetry.Instrumentation.AspNetCore/README.md b/src/OpenTelemetry.Instrumentation.AspNetCore/README.md
index 74938b4592..12a76ed607 100644
--- a/src/OpenTelemetry.Instrumentation.AspNetCore/README.md
+++ b/src/OpenTelemetry.Instrumentation.AspNetCore/README.md
@@ -113,8 +113,29 @@ public void ConfigureServices(IServiceCollection services)
}
```
+Following list of attributes are added by default on
+`http.server.request.duration` metric. See
+[http-metrics](https://github.com/open-telemetry/semantic-conventions/tree/v1.23.0/docs/http/http-metrics.md)
+for more details about each individual attribute. `.NET8.0` and above supports
+additional metrics, see [list of metrics produced](#list-of-metrics-produced) for
+more details.
+
+* `error.type`
+* `http.response.status_code`
+* `http.request.method`
+* `http.route`
+* `network.protocol.version`
+* `url.scheme`
+
#### List of metrics produced
+When the application targets `.NET6.0` or `.NET7.0`, the instrumentation emits
+the following metric:
+
+| Name | Details |
+|-----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `http.server.request.duration` | [Specification](https://github.com/open-telemetry/semantic-conventions/blob/release/v1.23.x/docs/http/http-metrics.md#metric-httpserverrequestduration) |
+
Starting from `.NET8.0`, metrics instrumentation is natively implemented, and
the ASP.NET Core library has incorporated support for [built-in
metrics](https://learn.microsoft.com/dotnet/core/diagnostics/built-in-metrics-aspnetcore)
@@ -143,6 +164,16 @@ to achieve this.
> There is no difference in features or emitted metrics when enabling metrics
using `AddMeter()` or `AddAspNetCoreInstrumentation()` on `.NET8.0` and newer
versions.
+
+> [!NOTE]
+> The `http.server.request.duration` metric is emitted in `seconds` as per the
+semantic convention. While the convention [recommends using custom histogram
+buckets](https://github.com/open-telemetry/semantic-conventions/blob/release/v1.23.x/docs/http/http-metrics.md)
+, this feature is not yet available via .NET Metrics API. A
+[workaround](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4820)
+has been included in OTel SDK starting version `1.6.0` which applies recommended
+buckets by default for `http.server.request.duration`. This applies to all
+targeted frameworks.
## Advanced configuration
diff --git a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs
index 804332f450..c3f80a9cee 100644
--- a/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs
+++ b/test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs
@@ -1,13 +1,22 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
+#if NET
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Builder;
+#endif
using Microsoft.AspNetCore.Hosting;
+#if NET
using Microsoft.AspNetCore.Http;
+#endif
using Microsoft.AspNetCore.Mvc.Testing;
+#if NET
using Microsoft.AspNetCore.RateLimiting;
+#endif
+#if NET
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+#endif
using Microsoft.Extensions.Logging;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
@@ -29,6 +38,7 @@ public void AddAspNetCoreInstrumentation_BadArgs()
Assert.Throws(builder!.AddAspNetCoreInstrumentation);
}
+#if NET
[Fact]
public async Task ValidateNet8MetricsAsync()
{
@@ -168,6 +178,7 @@ static string GetTicks()
await app.DisposeAsync();
}
+#endif
[Theory]
[InlineData("/api/values/2", "api/Values/{id}", null, 200)]