[ASP.NET Core] Set `http.request.method` as per spec (#5001)

This commit is contained in:
Vishwesh Bankwar 2023-10-31 11:50:58 -07:00 committed by GitHub
parent b45b8a9a12
commit 5cb7a3fca9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 265 additions and 4 deletions

View File

@ -269,6 +269,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{A49299
src\Shared\PeriodicExportingMetricReaderHelper.cs = src\Shared\PeriodicExportingMetricReaderHelper.cs
src\Shared\PooledList.cs = src\Shared\PooledList.cs
src\Shared\ResourceSemanticConventions.cs = src\Shared\ResourceSemanticConventions.cs
src\Shared\RequestMethodHelper.cs = src\Shared\RequestMethodHelper.cs
src\Shared\SemanticConventions.cs = src\Shared\SemanticConventions.cs
src\Shared\SpanAttributeConstants.cs = src\Shared\SpanAttributeConstants.cs
src\Shared\SpanHelper.cs = src\Shared\SpanHelper.cs

View File

@ -2,6 +2,22 @@
## Unreleased
* Updated `http.request.method` to match specification guidelines.
* For activity, if the method does not belong to one of the [known
values](https://github.com/open-telemetry/semantic-conventions/blob/v1.22.0/docs/http/http-spans.md#:~:text=http.request.method%20has%20the%20following%20list%20of%20well%2Dknown%20values)
then the request method will be set on an additional tag
`http.request.method.original` and `http.request.method` will be set to
`_OTHER`.
* For metrics, if the original method does not belong to one of the [known
values](https://github.com/open-telemetry/semantic-conventions/blob/v1.22.0/docs/http/http-spans.md#:~:text=http.request.method%20has%20the%20following%20list%20of%20well%2Dknown%20values)
then `http.request.method` on `http.server.request.duration` metric will be
set to `_OTHER`
`http.request.method` is set on `http.server.request.duration` metric or
activity when `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable is set to
`http` or `http/dup`.
([#5001](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5001))
## 1.6.0-beta.2
Released 2023-Oct-26

View File

@ -252,7 +252,16 @@ internal class HttpInListener : ListenerHandler
activity.SetTag(SemanticConventions.AttributeUrlQuery, request.QueryString.Value);
}
activity.SetTag(SemanticConventions.AttributeHttpRequestMethod, request.Method);
if (RequestMethodHelper.TryResolveHttpMethod(request.Method, out var httpMethod))
{
activity.SetTag(SemanticConventions.AttributeHttpRequestMethod, httpMethod);
}
else
{
activity.SetTag(SemanticConventions.AttributeHttpRequestMethod, httpMethod);
activity.SetTag(SemanticConventions.AttributeHttpRequestMethodOriginal, request.Method);
}
activity.SetTag(SemanticConventions.AttributeUrlScheme, request.Scheme);
activity.SetTag(SemanticConventions.AttributeUrlPath, path);
activity.SetTag(SemanticConventions.AttributeNetworkProtocolVersion, HttpTagHelper.GetFlavorTagValueFromProtocol(request.Protocol));

View File

@ -18,6 +18,8 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
using Microsoft.AspNetCore.Http;
using OpenTelemetry.Internal;
#if NET6_0_OR_GREATER
using Microsoft.AspNetCore.Routing;
#endif
@ -150,8 +152,9 @@ internal sealed class HttpInMetricsListener : ListenerHandler
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));
tags.Add(new KeyValuePair<string, object>(SemanticConventions.AttributeHttpResponseStatusCode, TelemetryHelper.GetBoxedStatusCode(context.Response.StatusCode)));
RequestMethodHelper.TryResolveHttpMethod(context.Request.Method, out var httpMethod);
tags.Add(new KeyValuePair<string, object>(SemanticConventions.AttributeHttpRequestMethod, httpMethod));
#if NET6_0_OR_GREATER
var route = (context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText;

View File

@ -42,8 +42,9 @@ public static class MeterProviderBuilderExtensions
#if NET8_0_OR_GREATER
return builder.ConfigureMeters();
#else
// Note: Warm-up the status code mapping.
// Note: Warm-up the status code and method mapping.
_ = TelemetryHelper.BoxedStatusCodes;
_ = RequestMethodHelper.KnownMethods;
builder.ConfigureServices(services =>
{

View File

@ -14,6 +14,7 @@
<Compile Include="$(RepoRoot)\src\OpenTelemetry.Instrumentation.GrpcNetClient\GrpcTagHelper.cs" Link="Includes\GrpcTagHelper.cs" />
<Compile Include="$(RepoRoot)\src\OpenTelemetry.Instrumentation.GrpcNetClient\StatusCanonicalCode.cs" Link="Includes\StatusCanonicalCode.cs" />
<Compile Include="$(RepoRoot)\src\Shared\Guard.cs" Link="Includes\Guard.cs" />
<Compile Include="$(RepoRoot)\src\Shared\RequestMethodHelper.cs" Link="Includes\RequestMethodHelper.cs" />
<Compile Include="$(RepoRoot)\src\Shared\EnvironmentVariables\*.cs" Link="Includes\EnvironmentVariables\%(Filename).cs" Condition="'$(TargetFramework)' == 'netstandard2.0' OR '$(TargetFramework)' == 'netstandard2.1'" />
<Compile Include="$(RepoRoot)\src\Shared\HttpSemanticConventionHelper.cs" Link="Includes\HttpSemanticConventionHelper.cs" />
<Compile Include="$(RepoRoot)\src\Shared\Options\*.cs" Link="Includes\Options\%(Filename).cs" />

View File

@ -63,8 +63,9 @@ public static class TracerProviderBuilderExtensions
{
Guard.ThrowIfNull(builder);
// Note: Warm-up the status code mapping.
// Note: Warm-up the status code and method mapping.
_ = TelemetryHelper.BoxedStatusCodes;
_ = RequestMethodHelper.KnownMethods;
name ??= Options.DefaultName;

View File

@ -0,0 +1,77 @@
// <copyright file="RequestMethodHelper.cs" company="OpenTelemetry Authors">
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// </copyright>
#if NET8_0_OR_GREATER
using System.Collections.Frozen;
#endif
namespace OpenTelemetry.Internal;
internal static class RequestMethodHelper
{
#if NET8_0_OR_GREATER
internal static readonly FrozenDictionary<string, string> KnownMethods;
#else
internal static readonly Dictionary<string, string> KnownMethods;
#endif
static RequestMethodHelper()
{
#if NET8_0_OR_GREATER
KnownMethods = FrozenDictionary.ToFrozenDictionary(
new[]
{
KeyValuePair.Create("GET", "GET"),
KeyValuePair.Create("PUT", "PUT"),
KeyValuePair.Create("POST", "POST"),
KeyValuePair.Create("DELETE", "DELETE"),
KeyValuePair.Create("HEAD", "HEAD"),
KeyValuePair.Create("OPTIONS", "OPTIONS"),
KeyValuePair.Create("TRACE", "TRACE"),
KeyValuePair.Create("PATCH", "PATCH"),
KeyValuePair.Create("CONNECT", "CONNECT"),
},
StringComparer.OrdinalIgnoreCase);
#else
KnownMethods = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "GET", "GET" },
{ "PUT", "PUT" },
{ "POST", "POST" },
{ "DELETE", "DELETE" },
{ "HEAD", "HEAD" },
{ "OPTIONS", "OPTIONS" },
{ "TRACE", "TRACE" },
{ "PATCH", "PATCH" },
{ "CONNECT", "CONNECT" },
};
#endif
}
public static bool TryResolveHttpMethod(string method, out string resolvedMethod)
{
if (KnownMethods.TryGetValue(method, out resolvedMethod))
{
// KnownMethods ignores case. Use the value returned by the dictionary to have a consistent case.
return true;
}
// Set to default "_OTHER" as per spec.
// https://github.com/open-telemetry/semantic-conventions/blob/v1.22.0/docs/http/http-spans.md#common-attributes
resolvedMethod = "_OTHER";
return false;
}
}

View File

@ -129,4 +129,5 @@ internal static class SemanticConventions
public const string AttributeUrlScheme = "url.scheme"; // replaces: "http.scheme" (AttributeHttpScheme)
public const string AttributeUrlQuery = "url.query";
public const string AttributeUserAgentOriginal = "user_agent.original"; // replaces: "http.user_agent" (AttributeHttpUserAgent)
public const string AttributeHttpRequestMethodOriginal = "http.request.method_original";
}

View File

@ -21,6 +21,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
@ -32,6 +33,8 @@ using TestApp.AspNetCore;
using TestApp.AspNetCore.Filters;
using Xunit;
using static OpenTelemetry.Internal.HttpSemanticConventionHelper;
namespace OpenTelemetry.Instrumentation.AspNetCore.Tests;
// See https://github.com/aspnet/Docs/tree/master/aspnetcore/test/integration-tests/samples/2.x/IntegrationTestsSample
@ -647,6 +650,78 @@ public sealed class BasicTests
Assert.Equal("api/Values/{id}", aspnetcoreframeworkactivity.DisplayName);
}
[Theory]
[InlineData("CONNECT", "CONNECT")]
[InlineData("DELETE", "DELETE")]
[InlineData("GET", "GET")]
[InlineData("PUT", "PUT")]
[InlineData("HEAD", "HEAD")]
[InlineData("OPTIONS", "OPTIONS")]
[InlineData("PATCH", "PATCH")]
[InlineData("Get", "GET")]
[InlineData("POST", "POST")]
[InlineData("TRACE", "TRACE")]
[InlineData("CUSTOM", "_OTHER")]
public async Task HttpRequestMethodIsSetAsPerSpec(string originalMethod, string expectedMethod)
{
var exportedItems = new List<Activity>();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string> { [SemanticConventionOptInKeyName] = "http" })
.Build();
void ConfigureTestServices(IServiceCollection services)
{
this.tracerProvider = Sdk.CreateTracerProviderBuilder()
.ConfigureServices(services => services.AddSingleton<IConfiguration>(configuration))
.AddAspNetCoreInstrumentation()
.AddInMemoryExporter(exportedItems)
.Build();
}
// Arrange
using var client = this.factory
.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(ConfigureTestServices);
builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders());
})
.CreateClient();
var message = new HttpRequestMessage();
message.Method = new HttpMethod(originalMethod);
try
{
using var response = await client.SendAsync(message).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
catch
{
// ignore error.
}
WaitForActivityExport(exportedItems, 1);
Assert.Single(exportedItems);
var activity = exportedItems[0];
Assert.Contains(activity.TagObjects, t => t.Key == SemanticConventions.AttributeHttpRequestMethod);
if (originalMethod.Equals(expectedMethod, StringComparison.OrdinalIgnoreCase))
{
Assert.DoesNotContain(activity.TagObjects, t => t.Key == SemanticConventions.AttributeHttpRequestMethodOriginal);
}
else
{
Assert.Equal(originalMethod, activity.GetTagValue(SemanticConventions.AttributeHttpRequestMethodOriginal) as string);
}
Assert.Equal(expectedMethod, activity.GetTagValue(SemanticConventions.AttributeHttpRequestMethod) as string);
}
[Fact]
public async Task ActivitiesStartedInMiddlewareBySettingHostActivityToNullShouldNotBeUpdated()
{

View File

@ -237,6 +237,82 @@ public class MetricTests
expectedTagsCount: 6);
}
[Theory]
[InlineData("CONNECT", "CONNECT")]
[InlineData("DELETE", "DELETE")]
[InlineData("GET", "GET")]
[InlineData("PUT", "PUT")]
[InlineData("HEAD", "HEAD")]
[InlineData("OPTIONS", "OPTIONS")]
[InlineData("PATCH", "PATCH")]
[InlineData("Get", "GET")]
[InlineData("POST", "POST")]
[InlineData("TRACE", "TRACE")]
[InlineData("CUSTOM", "_OTHER")]
public async Task HttpRequestMethodIsCapturedAsPerSpec(string originalMethod, string expectedMethod)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string> { [SemanticConventionOptInKeyName] = "http" })
.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();
var message = new HttpRequestMessage();
message.Method = new HttpMethod(originalMethod);
try
{
using var response = await client.SendAsync(message).ConfigureAwait(false);
}
catch
{
// ignore error.
}
// 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.request.duration")
.ToArray();
var metric = Assert.Single(requestMetrics);
Assert.Equal("s", metric.Unit);
var metricPoints = GetMetricPoints(metric);
Assert.Single(metricPoints);
var mp = metricPoints[0];
// Inspect Metric Attributes
var attributes = new Dictionary<string, object>();
foreach (var tag in mp.Tags)
{
attributes[tag.Key] = tag.Value;
}
Assert.Contains(attributes, kvp => kvp.Key == SemanticConventions.AttributeHttpRequestMethod && kvp.Value.ToString() == expectedMethod);
Assert.DoesNotContain(attributes, t => t.Key == SemanticConventions.AttributeHttpRequestMethodOriginal);
}
#if !NET8_0_OR_GREATER
[Fact]
public async Task RequestMetricIsCaptured_Old()