Skip to content

Commit

Permalink
[ASP.NET Core] Set http.request.method as per spec (#5001)
Browse files Browse the repository at this point in the history
  • Loading branch information
vishweshbankwar authored Oct 31, 2023
1 parent b45b8a9 commit 5cb7a3f
Show file tree
Hide file tree
Showing 11 changed files with 265 additions and 4 deletions.
1 change: 1 addition & 0 deletions OpenTelemetry.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/OpenTelemetry.Instrumentation.AspNetCore/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,16 @@ public void OnStartActivity(Activity activity, object payload)
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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -150,8 +152,9 @@ public void OnEventWritten_New(string name, object payload)
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ public static MeterProviderBuilder AddAspNetCoreInstrumentation(
#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 =>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ public static TracerProviderBuilder AddAspNetCoreInstrumentation(
{
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;

Expand Down
77 changes: 77 additions & 0 deletions src/Shared/RequestMethodHelper.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions src/Shared/SemanticConventions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
75 changes: 75 additions & 0 deletions test/OpenTelemetry.Instrumentation.AspNetCore.Tests/BasicTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
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;
Expand All @@ -32,6 +33,8 @@
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
Expand Down Expand Up @@ -647,6 +650,78 @@ void ConfigureTestServices(IServiceCollection services)
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()
{
Expand Down
76 changes: 76 additions & 0 deletions test/OpenTelemetry.Instrumentation.AspNetCore.Tests/MetricTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,82 @@ public async Task RequestMetricIsCaptured_New()
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()
Expand Down

0 comments on commit 5cb7a3f

Please sign in to comment.