Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,12 @@ public void OnStopActivity(Activity activity, object? payload)
var response = context.Response;

#if !NETSTANDARD
var routePattern = (context.Features.Get<IExceptionHandlerPathFeature>()?.Endpoint as RouteEndpoint ??
context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText;
if (!string.IsNullOrEmpty(routePattern))
var endpoint = context.Features.Get<IExceptionHandlerPathFeature>()?.Endpoint as RouteEndpoint
?? context.GetEndpoint() as RouteEndpoint;

if (endpoint != null)
{
TelemetryHelper.RequestDataHelper.SetActivityDisplayName(activity, context.Request.Method, routePattern);
activity.SetTag(SemanticConventions.AttributeHttpRoute, routePattern);
activity.SetRouteAttributeTag(endpoint, context.Request);
}
#endif

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,12 @@ public static void OnStopEventWritten(string name, object? payload)

#if NET
// Check the exception handler feature first in case the endpoint was overwritten
var route = (context.Features.Get<IExceptionHandlerPathFeature>()?.Endpoint as RouteEndpoint ??
context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText;
if (!string.IsNullOrEmpty(route))
var endpoint = context.Features.Get<IExceptionHandlerPathFeature>()?.Endpoint as RouteEndpoint
?? context.GetEndpoint() as RouteEndpoint;

if (endpoint != null)
{
tags.Add(new KeyValuePair<string, object?>(SemanticConventions.AttributeHttpRoute, route));
tags.AddRouteAttribute(endpoint, context.Request);
}
#endif
if (context.Items.TryGetValue(ErrorTypeHttpContextItemsKey, out var errorType))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

#if !NETSTANDARD

using System.Diagnostics;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using OpenTelemetry.Trace;

namespace OpenTelemetry.Instrumentation.AspNetCore.Implementation;

internal static class RouteAttributeHelper
{
public static void AddRouteAttribute(this TagList tags, RouteEndpoint endpoint, HttpRequest request)
{
var routePattern = GetRoutePattern(endpoint.RoutePattern, request.RouteValues);

if (!string.IsNullOrEmpty(routePattern))
{
tags.Add(new KeyValuePair<string, object?>(SemanticConventions.AttributeHttpRoute, routePattern));
}
}

public static void SetRouteAttributeTag(this Activity activity, RouteEndpoint endpoint, HttpRequest request)
{
var routePattern = GetRoutePattern(endpoint.RoutePattern, request.RouteValues);

if (!string.IsNullOrEmpty(routePattern))
{
TelemetryHelper.RequestDataHelper.SetActivityDisplayName(activity, request.Method, routePattern);
activity.SetTag(SemanticConventions.AttributeHttpRoute, routePattern);
}
}

private static string GetRoutePattern(RoutePattern routePattern, RouteValueDictionary routeValues)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need a very large suite of unit tests to ensure every possible route is correctly converted into a string.

Copy link
Contributor

@JamesNK JamesNK Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated, but I think an empty string route should resolve to /. An empty string by itself is confusing. We're already doing that with http.route tag in metrics based on customer feedback.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This always runs, right? Should it only run if the route needs to change? i.e. there are area/controller/action/page attributes and a value is present in the requests route values collection.

{
if (routePattern.PathSegments.Count == 0)
{
// RazorPage default route
if (routePattern.Defaults.ContainsKey("page"))
{
return routePattern.Defaults["page"]?.ToString()?.Trim('/')
?? string.Empty;
}

return string.Empty;
}

var sb = new StringBuilder();

foreach (var segment in routePattern.PathSegments)
{
foreach (var part in segment.Parts)
{
if (part is RoutePatternLiteralPart literalPart)
{
sb.Append(literalPart.Content);
sb.Append('/');
}
else if (part is RoutePatternParameterPart parameterPart)
{
switch (parameterPart.Name)
{
case "area":
case "controller":
case "action":
routePattern.RequiredValues.TryGetValue(parameterPart.Name, out var parameterValue);
if (parameterValue != null)
{
sb.Append(parameterValue);
sb.Append('/');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not look for separator part for adding separator?

break;
}

goto default;
default:
if (!parameterPart.IsOptional ||
(parameterPart.IsOptional && routeValues.ContainsKey(parameterPart.Name)))
{
sb.Append('{');
sb.Append(parameterPart.Name);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if there are default values? Or it is catch all? Or has constraints? Or has optional path extension.

There are also complex segments. Are those being handled correctly? See https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-9.0#complex-segments

Maybe these scenarios are already handled correctly, but unit tests to verify are important.

sb.Append('}');
sb.Append('/');
}

break;
}
}
}
}

// Remove the trailing '/'
return sb.ToString(0, sb.Length - 1);
}
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"path": "/",
"expectedStatusCode": 200,
"currentHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}",
"expectedHttpRoute": "ConventionalRoute/Default/{id?}"
"expectedHttpRoute": "ConventionalRoute/Default"
},
{
"name": "Non-default action with route parameter and query string",
Expand All @@ -15,7 +15,7 @@
"path": "/ConventionalRoute/ActionWithStringParameter/2?num=3",
"expectedStatusCode": 200,
"currentHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}",
"expectedHttpRoute": "ConventionalRoute/ActionWithStringParameter/{id?}"
"expectedHttpRoute": "ConventionalRoute/ActionWithStringParameter/{id}"
},
{
"name": "Non-default action with query string",
Expand All @@ -24,7 +24,7 @@
"path": "/ConventionalRoute/ActionWithStringParameter?num=3",
"expectedStatusCode": 200,
"currentHttpRoute": "{controller=ConventionalRoute}/{action=Default}/{id?}",
"expectedHttpRoute": "ConventionalRoute/ActionWithStringParameter/{id?}"
"expectedHttpRoute": "ConventionalRoute/ActionWithStringParameter"
},
{
"name": "Not Found (404)",
Expand All @@ -42,7 +42,7 @@
"path": "/SomePath/SomeString/2",
"expectedStatusCode": 200,
"currentHttpRoute": null,
"expectedHttpRoute": "SomePath/{id}/{num:int}"
"expectedHttpRoute": "SomePath/{id}/{num}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The route constraint is being lost

},
{
"name": "Path that does not match parameter constraint",
Expand All @@ -60,7 +60,7 @@
"path": "/MyArea",
"expectedStatusCode": 200,
"currentHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}",
"expectedHttpRoute": "{area:exists}/ControllerForMyArea/Default/{id?}"
"expectedHttpRoute": "MyArea/ControllerForMyArea/Default"
},
{
"name": "Area using `area:exists`, non-default action",
Expand All @@ -69,7 +69,7 @@
"path": "/MyArea/ControllerForMyArea/NonDefault",
"expectedStatusCode": 200,
"currentHttpRoute": "{area:exists}/{controller=ControllerForMyArea}/{action=Default}/{id?}",
"expectedHttpRoute": "{area:exists}/ControllerForMyArea/NonDefault/{id?}"
"expectedHttpRoute": "MyArea/ControllerForMyArea/NonDefault"
Copy link
Contributor

@JamesNK JamesNK Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't you just modifying area, controller and page? Isn't the expected route "MyArea/ControllerForMyArea/NonDefault/{id?}". Or is it removed because it's optional?

I don't think removing it has any value. It's still a dynamic placeholder in the route and should be present according to the spec.

},
{
"name": "Area w/o `area:exists`, default controller/action",
Expand All @@ -78,7 +78,7 @@
"path": "/SomePrefix",
"expectedStatusCode": 200,
"currentHttpRoute": "SomePrefix/{controller=AnotherArea}/{action=Index}/{id?}",
"expectedHttpRoute": "SomePrefix/AnotherArea/Index/{id?}"
"expectedHttpRoute": "SomePrefix/AnotherArea/Index"
},
{
"name": "Default action",
Expand Down Expand Up @@ -133,7 +133,7 @@
"path": "/",
"expectedStatusCode": 200,
"currentHttpRoute": "",
"expectedHttpRoute": "/Index"
"expectedHttpRoute": "Index"
},
{
"name": "Index page",
Expand All @@ -142,7 +142,7 @@
"path": "/Index",
"expectedStatusCode": 200,
"currentHttpRoute": "Index",
"expectedHttpRoute": "/Index"
"expectedHttpRoute": "Index"
},
{
"name": "Throws exception",
Expand All @@ -151,7 +151,7 @@
"path": "/PageThatThrowsException",
"expectedStatusCode": 500,
"currentHttpRoute": "PageThatThrowsException",
"expectedHttpRoute": "/PageThatThrowsException"
"expectedHttpRoute": "PageThatThrowsException"
},
{
"name": "Static content",
Expand All @@ -169,7 +169,7 @@
"path": "/MinimalApi",
"expectedStatusCode": 200,
"currentHttpRoute": null,
"expectedHttpRoute": "/MinimalApi"
"expectedHttpRoute": "MinimalApi"
},
{
"name": "Action with parameter",
Expand All @@ -178,7 +178,7 @@
"path": "/MinimalApi/123",
"expectedStatusCode": 200,
"currentHttpRoute": null,
"expectedHttpRoute": "/MinimalApi/{id}"
"expectedHttpRoute": "MinimalApi/{id}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if a minimal api route has a parameter called action? Will it be rewritten? There is nothing stopping a minimal API having a parameter with that name and it isn't a static part of the route.

},
{
"name": "Action without parameter (MapGroup)",
Expand All @@ -188,7 +188,7 @@
"path": "/MinimalApiUsingMapGroup",
"expectedStatusCode": 200,
"currentHttpRoute": null,
"expectedHttpRoute": "/MinimalApiUsingMapGroup/"
"expectedHttpRoute": "MinimalApiUsingMapGroup"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why remove preceeding /?

},
{
"name": "Action with parameter (MapGroup)",
Expand All @@ -198,7 +198,7 @@
"path": "/MinimalApiUsingMapGroup/123",
"expectedStatusCode": 200,
"currentHttpRoute": null,
"expectedHttpRoute": "/MinimalApiUsingMapGroup/{id}"
"expectedHttpRoute": "MinimalApiUsingMapGroup/{id}"
},
{
"name": "Exception Handled by Exception Handler Middleware",
Expand All @@ -207,6 +207,6 @@
"path": "/Exception",
"expectedStatusCode": 500,
"currentHttpRoute": null,
"expectedHttpRoute": "/Exception"
"expectedHttpRoute": "Exception"
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public async Task TestHttpRoute_Traces(TestCase testCase)

// TODO: The CurrentHttpRoute property will go away. They only serve to capture status quo.
// If CurrentHttpRoute is null, then that means we already conform to the correct behavior.
var expectedHttpRoute = testCase.CurrentHttpRoute ?? testCase.ExpectedHttpRoute;
var expectedHttpRoute = testCase.ExpectedHttpRoute ?? testCase.CurrentHttpRoute;
Assert.Equal(expectedHttpRoute, activityHttpRoute);

// Activity.DisplayName should be a combination of http.method + http.route attributes, see:
Expand Down
Loading