Skip to content

Commit efe6eda

Browse files
authored
[aspnetcore] Restore metrics instrumentation in netstandard builds (#2403)
1 parent 92b48e1 commit efe6eda

File tree

6 files changed

+242
-8
lines changed

6 files changed

+242
-8
lines changed

src/OpenTelemetry.Instrumentation.AspNetCore/AspNetCoreInstrumentationMeterProviderBuilderExtensions.cs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Copyright The OpenTelemetry Authors
22
// SPDX-License-Identifier: Apache-2.0
33

4+
#if !NET
5+
using OpenTelemetry.Instrumentation.AspNetCore;
6+
using OpenTelemetry.Instrumentation.AspNetCore.Implementation;
7+
#endif
48
using OpenTelemetry.Internal;
59

610
namespace OpenTelemetry.Metrics;
@@ -18,15 +22,27 @@ public static class AspNetCoreInstrumentationMeterProviderBuilderExtensions
1822
public static MeterProviderBuilder AddAspNetCoreInstrumentation(
1923
this MeterProviderBuilder builder)
2024
{
21-
#if NETSTANDARD2_0_OR_GREATER
22-
if (Environment.Version.Major < 8)
23-
{
24-
throw new PlatformNotSupportedException("Metrics instrumentation is not supported when executing on .NET 7 and lower.");
25-
}
25+
Guard.ThrowIfNull(builder);
26+
27+
#if NET
28+
return builder.ConfigureMeters();
29+
#else
30+
// Note: Warm-up the status code and method mapping.
31+
_ = TelemetryHelper.BoxedStatusCodes;
32+
_ = TelemetryHelper.RequestDataHelper;
33+
34+
builder.AddMeter(HttpInMetricsListener.InstrumentationName);
2635

36+
#pragma warning disable CA2000
37+
builder.AddInstrumentation(new AspNetCoreMetrics());
38+
#pragma warning restore CA2000
39+
40+
return builder;
2741
#endif
28-
Guard.ThrowIfNull(builder);
42+
}
2943

44+
internal static MeterProviderBuilder ConfigureMeters(this MeterProviderBuilder builder)
45+
{
3046
return builder
3147
.AddMeter("Microsoft.AspNetCore.Hosting")
3248
.AddMeter("Microsoft.AspNetCore.Server.Kestrel")
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#if !NET
5+
using OpenTelemetry.Instrumentation.AspNetCore.Implementation;
6+
7+
namespace OpenTelemetry.Instrumentation.AspNetCore;
8+
9+
/// <summary>
10+
/// Asp.Net Core Requests instrumentation.
11+
/// </summary>
12+
internal sealed class AspNetCoreMetrics : IDisposable
13+
{
14+
private static readonly HashSet<string> DiagnosticSourceEvents =
15+
[
16+
"Microsoft.AspNetCore.Hosting.HttpRequestIn",
17+
"Microsoft.AspNetCore.Hosting.HttpRequestIn.Start",
18+
"Microsoft.AspNetCore.Hosting.HttpRequestIn.Stop",
19+
"Microsoft.AspNetCore.Diagnostics.UnhandledException",
20+
"Microsoft.AspNetCore.Hosting.UnhandledException"
21+
];
22+
23+
private readonly Func<string, object?, object?, bool> isEnabled = (eventName, _, _)
24+
=> DiagnosticSourceEvents.Contains(eventName);
25+
26+
private readonly DiagnosticSourceSubscriber diagnosticSourceSubscriber;
27+
28+
internal AspNetCoreMetrics()
29+
{
30+
var metricsListener = new HttpInMetricsListener("Microsoft.AspNetCore");
31+
this.diagnosticSourceSubscriber = new DiagnosticSourceSubscriber(metricsListener, this.isEnabled, AspNetCoreInstrumentationEventSource.Log.UnknownErrorProcessingEvent);
32+
this.diagnosticSourceSubscriber.Subscribe();
33+
}
34+
35+
/// <inheritdoc/>
36+
public void Dispose()
37+
{
38+
this.diagnosticSourceSubscriber?.Dispose();
39+
}
40+
}
41+
#endif

src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
## Unreleased
44

5+
* Metric support for the .NET Standard target was removed by mistake in 1.10.0.
6+
This functionality has been restored.
7+
([#2403](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/2403))
8+
59
## 1.10.0
610

711
Released 2024-Dec-09
812

913
* Drop support for .NET 6 as this target is no longer supported.
10-
([#2138](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/2138),
11-
([#2360](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/2360))
14+
([#2138](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/2138))
1215

1316
* Updated OpenTelemetry core component version(s) to `1.10.0`.
1417
([#2317](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/2317))
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
using System.Diagnostics;
5+
using System.Diagnostics.CodeAnalysis;
6+
using System.Diagnostics.Metrics;
7+
using System.Reflection;
8+
using Microsoft.AspNetCore.Http;
9+
#if NET
10+
using Microsoft.AspNetCore.Diagnostics;
11+
using Microsoft.AspNetCore.Routing;
12+
#endif
13+
using OpenTelemetry.Internal;
14+
using OpenTelemetry.Trace;
15+
16+
namespace OpenTelemetry.Instrumentation.AspNetCore.Implementation;
17+
18+
internal sealed class HttpInMetricsListener : ListenerHandler
19+
{
20+
internal const string HttpServerRequestDurationMetricName = "http.server.request.duration";
21+
22+
internal const string OnUnhandledHostingExceptionEvent = "Microsoft.AspNetCore.Hosting.UnhandledException";
23+
internal const string OnUnhandledDiagnosticsExceptionEvent = "Microsoft.AspNetCore.Diagnostics.UnhandledException";
24+
25+
internal static readonly AssemblyName AssemblyName = typeof(HttpInListener).Assembly.GetName();
26+
internal static readonly string InstrumentationName = AssemblyName.Name!;
27+
internal static readonly string InstrumentationVersion = AssemblyName.Version!.ToString();
28+
internal static readonly Meter Meter = new(InstrumentationName, InstrumentationVersion);
29+
30+
private const string OnStopEvent = "Microsoft.AspNetCore.Hosting.HttpRequestIn.Stop";
31+
32+
private static readonly PropertyFetcher<Exception> ExceptionPropertyFetcher = new("Exception");
33+
private static readonly PropertyFetcher<HttpContext> HttpContextPropertyFetcher = new("HttpContext");
34+
private static readonly object ErrorTypeHttpContextItemsKey = new();
35+
36+
private static readonly Histogram<double> HttpServerRequestDuration = Meter.CreateHistogram<double>(HttpServerRequestDurationMetricName, "s", "Duration of HTTP server requests.");
37+
38+
internal HttpInMetricsListener(string name)
39+
: base(name)
40+
{
41+
}
42+
43+
public static void OnExceptionEventWritten(string name, object? payload)
44+
{
45+
// We need to use reflection here as the payload type is not a defined public type.
46+
if (!TryFetchException(payload, out var exc) || !TryFetchHttpContext(payload, out var ctx))
47+
{
48+
AspNetCoreInstrumentationEventSource.Log.NullPayload(nameof(HttpInMetricsListener), nameof(OnExceptionEventWritten), HttpServerRequestDurationMetricName);
49+
return;
50+
}
51+
52+
ctx.Items.Add(ErrorTypeHttpContextItemsKey, exc.GetType().FullName);
53+
54+
// See https://github.com/dotnet/aspnetcore/blob/690d78279e940d267669f825aa6627b0d731f64c/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs#L252
55+
// and https://github.com/dotnet/aspnetcore/blob/690d78279e940d267669f825aa6627b0d731f64c/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs#L174
56+
// this makes sure that top-level properties on the payload object are always preserved.
57+
#if NET
58+
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The ASP.NET Core framework guarantees that top level properties are preserved")]
59+
#endif
60+
static bool TryFetchException(object? payload, [NotNullWhen(true)] out Exception? exc)
61+
{
62+
return ExceptionPropertyFetcher.TryFetch(payload, out exc) && exc != null;
63+
}
64+
#if NET
65+
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The ASP.NET Core framework guarantees that top level properties are preserved")]
66+
#endif
67+
static bool TryFetchHttpContext(object? payload, [NotNullWhen(true)] out HttpContext? ctx)
68+
{
69+
return HttpContextPropertyFetcher.TryFetch(payload, out ctx) && ctx != null;
70+
}
71+
}
72+
73+
public static void OnStopEventWritten(string name, object? payload)
74+
{
75+
if (payload is not HttpContext context)
76+
{
77+
AspNetCoreInstrumentationEventSource.Log.NullPayload(nameof(HttpInMetricsListener), nameof(OnStopEventWritten), HttpServerRequestDurationMetricName);
78+
return;
79+
}
80+
81+
TagList tags = default;
82+
83+
// see the spec https://github.com/open-telemetry/semantic-conventions/blob/v1.21.0/docs/http/http-spans.md
84+
tags.Add(new KeyValuePair<string, object?>(SemanticConventions.AttributeNetworkProtocolVersion, RequestDataHelper.GetHttpProtocolVersion(context.Request.Protocol)));
85+
tags.Add(new KeyValuePair<string, object?>(SemanticConventions.AttributeUrlScheme, context.Request.Scheme));
86+
tags.Add(new KeyValuePair<string, object?>(SemanticConventions.AttributeHttpResponseStatusCode, TelemetryHelper.GetBoxedStatusCode(context.Response.StatusCode)));
87+
88+
var httpMethod = TelemetryHelper.RequestDataHelper.GetNormalizedHttpMethod(context.Request.Method);
89+
tags.Add(new KeyValuePair<string, object?>(SemanticConventions.AttributeHttpRequestMethod, httpMethod));
90+
91+
#if NET
92+
// Check the exception handler feature first in case the endpoint was overwritten
93+
var route = (context.Features.Get<IExceptionHandlerPathFeature>()?.Endpoint as RouteEndpoint ??
94+
context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText;
95+
if (!string.IsNullOrEmpty(route))
96+
{
97+
tags.Add(new KeyValuePair<string, object?>(SemanticConventions.AttributeHttpRoute, route));
98+
}
99+
#endif
100+
if (context.Items.TryGetValue(ErrorTypeHttpContextItemsKey, out var errorType))
101+
{
102+
tags.Add(new KeyValuePair<string, object?>(SemanticConventions.AttributeErrorType, errorType));
103+
}
104+
105+
// We are relying here on ASP.NET Core to set duration before writing the stop event.
106+
// https://github.com/dotnet/aspnetcore/blob/d6fa351048617ae1c8b47493ba1abbe94c3a24cf/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs#L449
107+
// TODO: Follow up with .NET team if we can continue to rely on this behavior.
108+
HttpServerRequestDuration.Record(Activity.Current!.Duration.TotalSeconds, tags);
109+
}
110+
111+
public override void OnEventWritten(string name, object? payload)
112+
{
113+
switch (name)
114+
{
115+
case OnUnhandledDiagnosticsExceptionEvent:
116+
case OnUnhandledHostingExceptionEvent:
117+
{
118+
OnExceptionEventWritten(name, payload);
119+
}
120+
121+
break;
122+
case OnStopEvent:
123+
{
124+
OnStopEventWritten(name, payload);
125+
}
126+
127+
break;
128+
default:
129+
break;
130+
}
131+
}
132+
}

src/OpenTelemetry.Instrumentation.AspNetCore/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,29 @@ public void ConfigureServices(IServiceCollection services)
113113
}
114114
```
115115

116+
Following list of attributes are added by default on
117+
`http.server.request.duration` metric. See
118+
[http-metrics](https://github.com/open-telemetry/semantic-conventions/tree/v1.23.0/docs/http/http-metrics.md)
119+
for more details about each individual attribute. `.NET8.0` and above supports
120+
additional metrics, see [list of metrics produced](#list-of-metrics-produced) for
121+
more details.
122+
123+
* `error.type`
124+
* `http.response.status_code`
125+
* `http.request.method`
126+
* `http.route`
127+
* `network.protocol.version`
128+
* `url.scheme`
129+
116130
#### List of metrics produced
117131

132+
When the application targets `.NET6.0` or `.NET7.0`, the instrumentation emits
133+
the following metric:
134+
135+
| Name | Details |
136+
|-----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
137+
| `http.server.request.duration` | [Specification](https://github.com/open-telemetry/semantic-conventions/blob/release/v1.23.x/docs/http/http-metrics.md#metric-httpserverrequestduration) |
138+
118139
Starting from `.NET8.0`, metrics instrumentation is natively implemented, and
119140
the ASP.NET Core library has incorporated support for [built-in
120141
metrics](https://learn.microsoft.com/dotnet/core/diagnostics/built-in-metrics-aspnetcore)
@@ -143,6 +164,16 @@ to achieve this.
143164
> There is no difference in features or emitted metrics when enabling metrics
144165
using `AddMeter()` or `AddAspNetCoreInstrumentation()` on `.NET8.0` and newer
145166
versions.
167+
<!-- This comment is to make sure the two notes above and below are not merged -->
168+
> [!NOTE]
169+
> The `http.server.request.duration` metric is emitted in `seconds` as per the
170+
semantic convention. While the convention [recommends using custom histogram
171+
buckets](https://github.com/open-telemetry/semantic-conventions/blob/release/v1.23.x/docs/http/http-metrics.md)
172+
, this feature is not yet available via .NET Metrics API. A
173+
[workaround](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4820)
174+
has been included in OTel SDK starting version `1.6.0` which applies recommended
175+
buckets by default for `http.server.request.duration`. This applies to all
176+
targeted frameworks.
146177

147178
## Advanced configuration
148179

test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
// Copyright The OpenTelemetry Authors
22
// SPDX-License-Identifier: Apache-2.0
33

4+
#if NET
45
using System.Threading.RateLimiting;
56
using Microsoft.AspNetCore.Builder;
7+
#endif
68
using Microsoft.AspNetCore.Hosting;
9+
#if NET
710
using Microsoft.AspNetCore.Http;
11+
#endif
812
using Microsoft.AspNetCore.Mvc.Testing;
13+
#if NET
914
using Microsoft.AspNetCore.RateLimiting;
15+
#endif
16+
#if NET
1017
using Microsoft.Extensions.DependencyInjection;
18+
using Microsoft.Extensions.Hosting;
19+
#endif
1120
using Microsoft.Extensions.Logging;
1221
using OpenTelemetry.Metrics;
1322
using OpenTelemetry.Trace;
@@ -29,6 +38,7 @@ public void AddAspNetCoreInstrumentation_BadArgs()
2938
Assert.Throws<ArgumentNullException>(builder!.AddAspNetCoreInstrumentation);
3039
}
3140

41+
#if NET
3242
[Fact]
3343
public async Task ValidateNet8MetricsAsync()
3444
{
@@ -168,6 +178,7 @@ static string GetTicks()
168178

169179
await app.DisposeAsync();
170180
}
181+
#endif
171182

172183
[Theory]
173184
[InlineData("/api/values/2", "api/Values/{id}", null, 200)]

0 commit comments

Comments
 (0)