Skip to content

Commit b773125

Browse files
ElanissebastienrosJamesNK
authored
Empty "http.route" tags should be set to "/" (#62432)
Co-authored-by: Sébastien Ros <sebastienros@gmail.com> Co-authored-by: James Newton-King <james@newtonking.com>
1 parent 3d0e85b commit b773125

File tree

7 files changed

+116
-2
lines changed

7 files changed

+116
-2
lines changed

src/Hosting/Hosting/src/Internal/HostingMetrics.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Diagnostics.CodeAnalysis;
77
using System.Diagnostics.Metrics;
88
using Microsoft.AspNetCore.Http;
9+
using Microsoft.AspNetCore.Shared;
910

1011
namespace Microsoft.AspNetCore.Hosting;
1112

@@ -68,7 +69,7 @@ public void RequestEnd(string protocol, string scheme, string method, string? ro
6869
tags.Add("http.response.status_code", GetBoxedStatusCode(statusCode));
6970
if (route != null)
7071
{
71-
tags.Add("http.route", route);
72+
tags.Add("http.route", RouteDiagnosticsHelpers.ResolveHttpRoute(route));
7273
}
7374

7475
// Add before some built in tags so custom tags are prioritized when dealing with duplicates.

src/Hosting/Hosting/src/Microsoft.AspNetCore.Hosting.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<Compile Include="$(SharedSourceRoot)Metrics\MetricsExtensions.cs" />
2020
<Compile Include="$(SharedSourceRoot)Metrics\MetricsConstants.cs" />
2121
<Compile Include="$(SharedSourceRoot)Diagnostics\ActivityCreator.cs" />
22+
<Compile Include="$(SharedSourceRoot)Diagnostics\RouteDiagnosticsHelpers.cs" />
2223
</ItemGroup>
2324

2425
<ItemGroup>

src/Hosting/Hosting/test/HostingApplicationDiagnosticsTests.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,71 @@ public void Metrics_Route_RouteTagReported()
352352
});
353353
}
354354

355+
private sealed class EmptyRouteDiagnosticsMetadata : IRouteDiagnosticsMetadata
356+
{
357+
public string Route { get; } = "";
358+
}
359+
360+
[Fact]
361+
public void Metrics_Route_RouteTagIsRootWhenEmpty()
362+
{
363+
// Arrange
364+
var hostingEventSource = new HostingEventSource(Guid.NewGuid().ToString());
365+
366+
var testMeterFactory = new TestMeterFactory();
367+
using var activeRequestsCollector = new MetricCollector<long>(testMeterFactory, HostingMetrics.MeterName, "http.server.active_requests");
368+
using var requestDurationCollector = new MetricCollector<double>(testMeterFactory, HostingMetrics.MeterName, "http.server.request.duration");
369+
370+
// Act
371+
var hostingApplication = CreateApplication(out var features, eventSource: hostingEventSource, meterFactory: testMeterFactory, configure: c =>
372+
{
373+
c.Request.Protocol = "1.1";
374+
c.Request.Scheme = "http";
375+
c.Request.Method = "POST";
376+
c.Request.Host = new HostString("localhost");
377+
c.Request.Path = "";
378+
c.Request.ContentType = "text/plain";
379+
c.Request.ContentLength = 1024;
380+
});
381+
var context = hostingApplication.CreateContext(features);
382+
383+
Assert.Collection(activeRequestsCollector.GetMeasurementSnapshot(),
384+
m =>
385+
{
386+
Assert.Equal(1, m.Value);
387+
Assert.Equal("http", m.Tags["url.scheme"]);
388+
Assert.Equal("POST", m.Tags["http.request.method"]);
389+
});
390+
391+
context.HttpContext.SetEndpoint(new Endpoint(
392+
c => Task.CompletedTask,
393+
new EndpointMetadataCollection(new EmptyRouteDiagnosticsMetadata()),
394+
"Test empty endpoint"));
395+
396+
hostingApplication.DisposeContext(context, null);
397+
398+
// Assert
399+
Assert.Collection(activeRequestsCollector.GetMeasurementSnapshot(),
400+
m =>
401+
{
402+
Assert.Equal(1, m.Value);
403+
Assert.Equal("http", m.Tags["url.scheme"]);
404+
Assert.Equal("POST", m.Tags["http.request.method"]);
405+
},
406+
m =>
407+
{
408+
Assert.Equal(-1, m.Value);
409+
Assert.Equal("http", m.Tags["url.scheme"]);
410+
Assert.Equal("POST", m.Tags["http.request.method"]);
411+
});
412+
Assert.Collection(requestDurationCollector.GetMeasurementSnapshot(),
413+
m =>
414+
{
415+
Assert.True(m.Value > 0);
416+
Assert.Equal("/", m.Tags["http.route"]);
417+
});
418+
}
419+
355420
[Fact]
356421
public void Metrics_DisableHttpMetricsWithMetadata_NoMetrics()
357422
{

src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
<Compile Include="$(SharedSourceRoot)AntiforgeryMetadata.cs" LinkBase="Shared" />
3939
<Compile Include="$(SharedSourceRoot)HttpExtensions.cs" LinkBase="Shared" />
4040
<Compile Include="$(SharedSourceRoot)ContentTypeConstants.cs" LinkBase="Shared" />
41+
<Compile Include="$(SharedSourceRoot)Diagnostics\RouteDiagnosticsHelpers.cs" LinkBase="Shared" />
4142
</ItemGroup>
4243

4344
<ItemGroup>

src/Http/Routing/src/RoutingMetrics.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Diagnostics.Metrics;
5+
using Microsoft.AspNetCore.Shared;
56

67
namespace Microsoft.AspNetCore.Routing;
78

@@ -31,7 +32,7 @@ public RoutingMetrics(IMeterFactory meterFactory)
3132
public void MatchSuccess(string route, bool isFallback)
3233
{
3334
_matchAttemptsCounter.Add(1,
34-
new KeyValuePair<string, object?>("http.route", route),
35+
new KeyValuePair<string, object?>("http.route", RouteDiagnosticsHelpers.ResolveHttpRoute(route)),
3536
new KeyValuePair<string, object?>("aspnetcore.routing.match_status", "success"),
3637
new KeyValuePair<string, object?>("aspnetcore.routing.is_fallback", isFallback ? BoxedTrue : BoxedFalse));
3738
}

src/Http/Routing/test/UnitTests/RoutingMetricsTests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,34 @@ public async Task Match_Success()
4848
m => AssertSuccess(m, "/{hi}", fallback: false));
4949
}
5050

51+
[Fact]
52+
public async Task Match_EmptyRoute_ResolveForwardSlash()
53+
{
54+
// Arrange
55+
var routeEndpointBuilder = new RouteEndpointBuilder(c => Task.CompletedTask, RoutePatternFactory.Parse(string.Empty), order: 0);
56+
var meterFactory = new TestMeterFactory();
57+
var middleware = CreateMiddleware(
58+
matcherFactory: new TestMatcherFactory(true, c =>
59+
{
60+
c.SetEndpoint(routeEndpointBuilder.Build());
61+
}),
62+
meterFactory: meterFactory);
63+
var httpContext = CreateHttpContext();
64+
var meter = meterFactory.Meters.Single();
65+
66+
using var routingMatchAttemptsCollector = new MetricCollector<long>(meterFactory, RoutingMetrics.MeterName, "aspnetcore.routing.match_attempts");
67+
68+
// Act
69+
await middleware.Invoke(httpContext);
70+
71+
// Assert
72+
Assert.Equal(RoutingMetrics.MeterName, meter.Name);
73+
Assert.Null(meter.Version);
74+
75+
Assert.Collection(routingMatchAttemptsCollector.GetMeasurementSnapshot(),
76+
m => AssertSuccess(m, "/", fallback: false));
77+
}
78+
5179
[Theory]
5280
[InlineData(true)]
5381
[InlineData(false)]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Shared;
5+
6+
internal static class RouteDiagnosticsHelpers
7+
{
8+
public static string ResolveHttpRoute(string route)
9+
{
10+
// A route that matches the root of the website could be an empty string. This is problematic.
11+
// 1. It is potentially confusing, "What does empty string mean?"
12+
// 2. Some telemetry tools have problems with empty string values, e.g. https://github.com/dotnet/aspnetcore/pull/62432
13+
//
14+
// The fix is to resolve empty string route to "/" in metrics.
15+
return string.IsNullOrEmpty(route) ? "/" : route;
16+
}
17+
}

0 commit comments

Comments
 (0)