Skip to content

Commit b932bca

Browse files
authored
Fix duplicate error.type tags (#55211)
1 parent 473da40 commit b932bca

File tree

9 files changed

+255
-8
lines changed

9 files changed

+255
-8
lines changed

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,8 @@ public void RequestEnd(string protocol, bool isHttps, string scheme, string meth
6969
{
7070
tags.Add("http.route", route);
7171
}
72-
// This exception is only present if there is an unhandled exception.
73-
// An exception caught by ExceptionHandlerMiddleware and DeveloperExceptionMiddleware isn't thrown to here. Instead, those middleware add error.type to custom tags.
74-
if (exception != null)
75-
{
76-
tags.Add("error.type", exception.GetType().FullName);
77-
}
72+
73+
// Add before some built in tags so custom tags are prioritized when dealing with duplicates.
7874
if (customTags != null)
7975
{
8076
for (var i = 0; i < customTags.Count; i++)
@@ -83,6 +79,15 @@ public void RequestEnd(string protocol, bool isHttps, string scheme, string meth
8379
}
8480
}
8581

82+
// This exception is only present if there is an unhandled exception.
83+
// An exception caught by ExceptionHandlerMiddleware and DeveloperExceptionMiddleware isn't thrown to here. Instead, those middleware add error.type to custom tags.
84+
if (exception != null)
85+
{
86+
// Exception tag could have been added by middleware. If an exception is later thrown in request pipeline
87+
// then we don't want to add a duplicate tag here because that breaks some metrics systems.
88+
tags.TryAddTag("error.type", exception.GetType().FullName);
89+
}
90+
8691
var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp);
8792
_requestDuration.Record(duration.TotalSeconds, tags);
8893
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<Compile Include="$(SharedSourceRoot)StackTrace\**\*.cs" />
1717
<Compile Include="$(SharedSourceRoot)ErrorPage\**\*.cs" />
1818
<Compile Include="$(SharedSourceRoot)StaticWebAssets\**\*.cs" LinkBase="StaticWebAssets" />
19+
<Compile Include="$(SharedSourceRoot)Metrics\MetricsExtensions.cs" />
1920
</ItemGroup>
2021

2122
<ItemGroup>

src/Middleware/Diagnostics/src/DiagnosticsTelemetry.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ public static void ReportUnhandledException(ILogger logger, HttpContext context,
1515

1616
if (context.Features.Get<IHttpMetricsTagsFeature>() is { } tagsFeature)
1717
{
18-
tagsFeature.Tags.Add(new KeyValuePair<string, object?>("error.type", ex.GetType().FullName));
18+
// Multiple exception middleware could be registered that have already added the tag.
19+
// We don't want to add a duplicate tag here because that breaks some metrics systems.
20+
tagsFeature.TryAddTag("error.type", ex.GetType().FullName);
1921
}
2022
}
2123
}

src/Middleware/Diagnostics/src/Microsoft.AspNetCore.Diagnostics.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<Compile Include="$(SharedSourceRoot)TypeNameHelper\*.cs" />
1818
<Compile Include="$(SharedSourceRoot)Reroute.cs" />
1919
<Compile Include="$(SharedSourceRoot)HttpExtensions.cs" />
20+
<Compile Include="$(SharedSourceRoot)Metrics\MetricsExtensions.cs" />
2021
</ItemGroup>
2122

2223
<ItemGroup>

src/Middleware/Diagnostics/test/FunctionalTests/ExceptionHandlerSampleTest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Licensed to the .NET Foundation under one or more agreements.
1+
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Net;

src/Middleware/Diagnostics/test/UnitTests/ExceptionHandlerTest.cs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
using Microsoft.Extensions.Hosting;
1616
using Microsoft.Extensions.Logging;
1717
using Microsoft.Extensions.Logging.Testing;
18+
using System.Net.Http;
1819

1920
namespace Microsoft.AspNetCore.Diagnostics;
2021

@@ -965,4 +966,136 @@ public async Task UnhandledError_ExceptionNameTagAdded()
965966
Assert.Equal("System.Exception", (string)m.Tags["error.type"]);
966967
});
967968
}
969+
970+
[Fact]
971+
public async Task UnhandledError_MultipleHandlers_ExceptionNameTagAddedOnce()
972+
{
973+
// Arrange
974+
var meterFactory = new TestMeterFactory();
975+
using var instrumentCollector = new MetricCollector<double>(meterFactory, "Microsoft.AspNetCore.Hosting", "http.server.request.duration");
976+
977+
using var host = new HostBuilder()
978+
.ConfigureServices(s =>
979+
{
980+
s.AddSingleton<IMeterFactory>(meterFactory);
981+
})
982+
.ConfigureWebHost(webHostBuilder =>
983+
{
984+
webHostBuilder
985+
.UseTestServer()
986+
.Configure(app =>
987+
{
988+
// Second error and handler
989+
app.UseExceptionHandler(new ExceptionHandlerOptions()
990+
{
991+
ExceptionHandler = httpContext =>
992+
{
993+
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
994+
return Task.CompletedTask;
995+
}
996+
});
997+
app.Use(async (context, next) =>
998+
{
999+
await next();
1000+
throw new InvalidOperationException("Test exception2");
1001+
});
1002+
1003+
// First error and handler
1004+
app.UseExceptionHandler(new ExceptionHandlerOptions()
1005+
{
1006+
ExceptionHandler = httpContext =>
1007+
{
1008+
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
1009+
return Task.CompletedTask;
1010+
}
1011+
});
1012+
app.Run(context =>
1013+
{
1014+
throw new Exception("Test exception1");
1015+
});
1016+
});
1017+
}).Build();
1018+
1019+
await host.StartAsync();
1020+
1021+
var server = host.GetTestServer();
1022+
1023+
// Act
1024+
var response = await server.CreateClient().GetAsync("/path");
1025+
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
1026+
1027+
await instrumentCollector.WaitForMeasurementsAsync(minCount: 1).DefaultTimeout();
1028+
1029+
// Assert
1030+
Assert.Collection(
1031+
instrumentCollector.GetMeasurementSnapshot(),
1032+
m =>
1033+
{
1034+
Assert.True(m.Value > 0);
1035+
Assert.Equal(500, (int)m.Tags["http.response.status_code"]);
1036+
Assert.Equal("System.Exception", (string)m.Tags["error.type"]);
1037+
});
1038+
}
1039+
1040+
[Fact]
1041+
public async Task UnhandledError_ErrorAfterHandler_ExceptionNameTagAddedOnce()
1042+
{
1043+
// Arrange
1044+
var meterFactory = new TestMeterFactory();
1045+
using var instrumentCollector = new MetricCollector<double>(meterFactory, "Microsoft.AspNetCore.Hosting", "http.server.request.duration");
1046+
1047+
using var host = new HostBuilder()
1048+
.ConfigureServices(s =>
1049+
{
1050+
s.AddSingleton<IMeterFactory>(meterFactory);
1051+
})
1052+
.ConfigureWebHost(webHostBuilder =>
1053+
{
1054+
webHostBuilder
1055+
.UseTestServer()
1056+
.Configure(app =>
1057+
{
1058+
// Second error
1059+
app.Use(async (context, next) =>
1060+
{
1061+
await next();
1062+
1063+
throw new InvalidOperationException("Test exception2");
1064+
});
1065+
1066+
// First error and handler
1067+
app.UseExceptionHandler(new ExceptionHandlerOptions()
1068+
{
1069+
ExceptionHandler = httpContext =>
1070+
{
1071+
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
1072+
return httpContext.Response.WriteAsync("Custom handler");
1073+
}
1074+
});
1075+
app.Run(context =>
1076+
{
1077+
throw new Exception("Test exception1");
1078+
});
1079+
});
1080+
}).Build();
1081+
1082+
await host.StartAsync();
1083+
1084+
var server = host.GetTestServer();
1085+
1086+
// Act
1087+
await Assert.ThrowsAsync<HttpRequestException>(async () => await server.CreateClient().GetAsync("/path"));
1088+
1089+
await instrumentCollector.WaitForMeasurementsAsync(minCount: 1).DefaultTimeout();
1090+
1091+
// Assert
1092+
Assert.Collection(
1093+
instrumentCollector.GetMeasurementSnapshot(),
1094+
m =>
1095+
{
1096+
Assert.True(m.Value > 0);
1097+
Assert.Equal(404, (int)m.Tags["http.response.status_code"]);
1098+
Assert.Equal("System.Exception", (string)m.Tags["error.type"]);
1099+
});
1100+
}
9681101
}

src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/ExceptionHandlerSample.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
<Reference Include="Microsoft.AspNetCore.Server.IISIntegration" />
1111
<Reference Include="Microsoft.AspNetCore.Server.Kestrel" />
1212
<Reference Include="Microsoft.AspNetCore.StaticFiles" />
13+
<Reference Include="Microsoft.AspNetCore.WebSockets" />
1314
</ItemGroup>
1415
</Project>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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+
using System.Diagnostics;
5+
using System.Text;
6+
using System.Text.Encodings.Web;
7+
using Microsoft.AspNetCore.Diagnostics;
8+
using Microsoft.AspNetCore.Http.Metadata;
9+
10+
namespace ExceptionHandlerSample;
11+
12+
// Note that this class isn't used in tests as TestServer doesn't have the right behavior to test web sockets
13+
// in the way we need. But leaving here so it can be used in Program.cs when starting the app manually.
14+
public class StartupWithWebSocket
15+
{
16+
public void ConfigureServices(IServiceCollection services)
17+
{
18+
}
19+
20+
public void Configure(IApplicationBuilder app)
21+
{
22+
app.UseExceptionHandler(options => { }); // Exception handling middleware introduces duplicate tag
23+
app.UseWebSockets();
24+
25+
app.Use(async (HttpContext context, Func<Task> next) =>
26+
{
27+
try
28+
{
29+
if (context.WebSockets.IsWebSocketRequest)
30+
{
31+
using var ws = await context.WebSockets.AcceptWebSocketAsync();
32+
await ws.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes("Hello")), System.Net.WebSockets.WebSocketMessageType.Text, true, context.RequestAborted);
33+
await ws.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.NormalClosure, "done", context.RequestAborted);
34+
throw new InvalidOperationException("Throw after websocket request completion to produce the bug");
35+
}
36+
else
37+
{
38+
await context.Response.WriteAsync($"Not a web socket request. PID: {Process.GetCurrentProcess().Id}");
39+
}
40+
}
41+
catch (Exception ex)
42+
{
43+
_ = ex;
44+
throw;
45+
}
46+
});
47+
}
48+
}
49+
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
using System.Diagnostics;
5+
using Microsoft.AspNetCore.Http.Features;
6+
7+
namespace Microsoft.AspNetCore.Http;
8+
9+
internal static class MetricsExtensions
10+
{
11+
public static bool TryAddTag(this IHttpMetricsTagsFeature feature, string name, object? value)
12+
{
13+
var tags = feature.Tags;
14+
15+
// Tags is internally represented as a List<T>.
16+
// Prefer looping through the list to avoid allocating an enumerator.
17+
if (tags is List<KeyValuePair<string, object?>> list)
18+
{
19+
foreach (var tag in list)
20+
{
21+
if (tag.Key == name)
22+
{
23+
return false;
24+
}
25+
}
26+
}
27+
else
28+
{
29+
foreach (var tag in tags)
30+
{
31+
if (tag.Key == name)
32+
{
33+
return false;
34+
}
35+
}
36+
}
37+
38+
tags.Add(new KeyValuePair<string, object?>(name, value));
39+
return true;
40+
}
41+
42+
public static bool TryAddTag(this ref TagList tags, string name, object? value)
43+
{
44+
for (var i = 0; i < tags.Count; i++)
45+
{
46+
if (tags[i].Key == name)
47+
{
48+
return false;
49+
}
50+
}
51+
52+
tags.Add(new KeyValuePair<string, object?>(name, value));
53+
return true;
54+
}
55+
}

0 commit comments

Comments
 (0)