diff --git a/src/Middleware/Diagnostics/src/DiagnosticsTelemetry.cs b/src/Middleware/Diagnostics/src/DiagnosticsTelemetry.cs index 36ab75c70ad2..62e29c93d032 100644 --- a/src/Middleware/Diagnostics/src/DiagnosticsTelemetry.cs +++ b/src/Middleware/Diagnostics/src/DiagnosticsTelemetry.cs @@ -1,8 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Diagnostics; diff --git a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandledType.cs b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandledType.cs new file mode 100644 index 000000000000..c15ef9061aba --- /dev/null +++ b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandledType.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Diagnostics; + +/// +/// The result of handling an exception with the . +/// +public enum ExceptionHandledType +{ + /// + /// Exception was unhandled. + /// + Unhandled, + /// + /// Exception was handled by an service instance registered in the DI container. + /// + ExceptionHandlerService, + /// + /// Exception was handled by an instance registered in the DI container. + /// + ProblemDetailsService, + /// + /// Exception was handled by by . + /// + ExceptionHandlerDelegate, + /// + /// Exception was handled by by . + /// + ExceptionHandlingPath +} diff --git a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs index c8361522d1ef..f6f5bed77f60 100644 --- a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs +++ b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs @@ -127,13 +127,12 @@ private async Task HandleException(HttpContext context, ExceptionDispatchInfo ed return; } - DiagnosticsTelemetry.ReportUnhandledException(_logger, context, edi.SourceException); - // We can't do anything if the response has already started, just abort. if (context.Response.HasStarted) { _logger.ResponseStartedErrorHandler(); + DiagnosticsTelemetry.ReportUnhandledException(_logger, context, edi.SourceException); _metrics.RequestException(exceptionName, ExceptionResult.Skipped, handler: null); edi.Throw(); } @@ -168,52 +167,97 @@ private async Task HandleException(HttpContext context, ExceptionDispatchInfo ed context.Response.StatusCode = _options.StatusCodeSelector?.Invoke(edi.SourceException) ?? DefaultStatusCode; context.Response.OnStarting(_clearCacheHeadersDelegate, context.Response); - string? handler = null; - var handled = false; + string? handlerTag = null; + var result = ExceptionHandledType.Unhandled; foreach (var exceptionHandler in _exceptionHandlers) { - handled = await exceptionHandler.TryHandleAsync(context, edi.SourceException, context.RequestAborted); - if (handled) + if (await exceptionHandler.TryHandleAsync(context, edi.SourceException, context.RequestAborted)) { - handler = exceptionHandler.GetType().FullName; + result = ExceptionHandledType.ExceptionHandlerService; + handlerTag = exceptionHandler.GetType().FullName; break; } } - if (!handled) + if (result == ExceptionHandledType.Unhandled) { if (_options.ExceptionHandler is not null) { await _options.ExceptionHandler!(context); + + // If the response has started, assume exception handler was successful. + if (context.Response.HasStarted) + { + if (_options.ExceptionHandlingPath.HasValue) + { + result = ExceptionHandledType.ExceptionHandlingPath; + handlerTag = _options.ExceptionHandlingPath.Value; + } + else + { + result = ExceptionHandledType.ExceptionHandlerDelegate; + } + } } else { - handled = await _problemDetailsService!.TryWriteAsync(new() + if (await _problemDetailsService!.TryWriteAsync(new() { HttpContext = context, AdditionalMetadata = exceptionHandlerFeature.Endpoint?.Metadata, ProblemDetails = { Status = context.Response.StatusCode }, Exception = edi.SourceException, - }); - if (handled) + })) { - handler = _problemDetailsService.GetType().FullName; + result = ExceptionHandledType.ProblemDetailsService; + handlerTag = _problemDetailsService.GetType().FullName; } } } - // If the response has already started, assume exception handler was successful. - if (context.Response.HasStarted || handled || _options.StatusCodeSelector != null || context.Response.StatusCode != StatusCodes.Status404NotFound || _options.AllowStatusCode404Response) + + if (result != ExceptionHandledType.Unhandled || _options.StatusCodeSelector != null || context.Response.StatusCode != StatusCodes.Status404NotFound || _options.AllowStatusCode404Response) { - const string eventName = "Microsoft.AspNetCore.Diagnostics.HandledException"; - if (_diagnosticListener.IsEnabled() && _diagnosticListener.IsEnabled(eventName)) + var suppressDiagnostics = false; + + // Customers may prefer to handle the exception and to do their own diagnostics. + // In that case, it can be undesirable for the middleware to log the exception at an error level. + // Run the configured callback to determine if exception diagnostics in the middleware should be suppressed. + if (_options.SuppressDiagnosticsCallback is { } suppressCallback) + { + var suppressDiagnosticsContext = new ExceptionHandlerSuppressDiagnosticsContext + { + HttpContext = context, + Exception = edi.SourceException, + ExceptionHandledBy = result + }; + suppressDiagnostics = suppressCallback(suppressDiagnosticsContext); + } + else + { + // Default behavior is to suppress diagnostics if the exception was handled by an IExceptionHandler service instance. + suppressDiagnostics = result == ExceptionHandledType.ExceptionHandlerService; + } + + if (!suppressDiagnostics) { - WriteDiagnosticEvent(_diagnosticListener, eventName, new { httpContext = context, exception = edi.SourceException }); + // Note: Microsoft.AspNetCore.Diagnostics.HandledException is used by AppInsights to log errors. + // The diagnostics event is run together with standard exception logging. + const string eventName = "Microsoft.AspNetCore.Diagnostics.HandledException"; + if (_diagnosticListener.IsEnabled() && _diagnosticListener.IsEnabled(eventName)) + { + WriteDiagnosticEvent(_diagnosticListener, eventName, new { httpContext = context, exception = edi.SourceException }); + } + + DiagnosticsTelemetry.ReportUnhandledException(_logger, context, edi.SourceException); } - _metrics.RequestException(exceptionName, ExceptionResult.Handled, handler); + _metrics.RequestException(exceptionName, ExceptionResult.Handled, handlerTag); return; } + // Exception is unhandled. Record diagnostics for the unhandled exception before it is wrapped. + DiagnosticsTelemetry.ReportUnhandledException(_logger, context, edi.SourceException); + edi = ExceptionDispatchInfo.Capture(new InvalidOperationException($"The exception handler configured on {nameof(ExceptionHandlerOptions)} produced a 404 status response. " + $"This {nameof(InvalidOperationException)} containing the original exception was thrown since this is often due to a misconfigured {nameof(ExceptionHandlerOptions.ExceptionHandlingPath)}. " + $"If the exception handler is expected to return 404 status responses then set {nameof(ExceptionHandlerOptions.AllowStatusCode404Response)} to true.", edi.SourceException)); @@ -222,6 +266,9 @@ private async Task HandleException(HttpContext context, ExceptionDispatchInfo ed { // Suppress secondary exceptions, re-throw the original. _logger.ErrorHandlerException(ex2); + + // There was an error handling the exception. Log original unhandled exception. + DiagnosticsTelemetry.ReportUnhandledException(_logger, context, edi.SourceException); } finally { diff --git a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerOptions.cs b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerOptions.cs index f345e19e8514..a38352d93715 100644 --- a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerOptions.cs +++ b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerOptions.cs @@ -1,8 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.Tracing; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Builder; @@ -40,10 +42,38 @@ public class ExceptionHandlerOptions public bool AllowStatusCode404Response { get; set; } /// - /// Gets or sets a delegate used to map an exception to a http status code. + /// Gets or sets a delegate used to map an exception to an HTTP status code. /// /// /// If is null, the default exception status code 500 is used. /// public Func? StatusCodeSelector { get; set; } + + /// + /// Gets or sets a callback that can return to suppress diagnostics in . + /// + /// If is null, the default behavior is to suppress diagnostics if the exception was handled by + /// an service instance registered in the DI container. + /// To always record diagnostics for handled exceptions, set a callback that returns . + /// + /// + /// This callback is only run if the exception was handled by the middleware. + /// Unhandled exceptions and exceptions thrown after the response has started are always logged. + /// + /// + /// Suppressed diagnostics include: + /// + /// + /// + /// Logging UnhandledException to . + /// + /// + /// Writing the Microsoft.AspNetCore.Diagnostics.HandledException event to . + /// + /// + /// Adding the error.type tag to the http.server.request.duration metric. + /// + /// + /// + public Func? SuppressDiagnosticsCallback { get; set; } } diff --git a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerSuppressDiagnosticsContext.cs b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerSuppressDiagnosticsContext.cs new file mode 100644 index 000000000000..2a02bc5f4986 --- /dev/null +++ b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerSuppressDiagnosticsContext.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Diagnostics; + +/// +/// The context used to determine whether should record diagnostics for an exception. +/// +public sealed class ExceptionHandlerSuppressDiagnosticsContext +{ + /// + /// Gets the of the current request. + /// + public required HttpContext HttpContext { get; init; } + + /// + /// Gets the that the exception handler middleware is processing. + /// + public required Exception Exception { get; init; } + + /// + /// Gets the result of exception handling by . + /// + public required ExceptionHandledType ExceptionHandledBy { get; init; } +} diff --git a/src/Middleware/Diagnostics/src/PublicAPI.Unshipped.txt b/src/Middleware/Diagnostics/src/PublicAPI.Unshipped.txt index 13c61eb5eab2..158b0cf33ead 100644 --- a/src/Middleware/Diagnostics/src/PublicAPI.Unshipped.txt +++ b/src/Middleware/Diagnostics/src/PublicAPI.Unshipped.txt @@ -1,4 +1,20 @@ #nullable enable +Microsoft.AspNetCore.Builder.ExceptionHandlerOptions.SuppressDiagnosticsCallback.get -> System.Func? +Microsoft.AspNetCore.Builder.ExceptionHandlerOptions.SuppressDiagnosticsCallback.set -> void Microsoft.AspNetCore.Builder.StatusCodePagesOptions.CreateScopeForErrors.get -> bool Microsoft.AspNetCore.Builder.StatusCodePagesOptions.CreateScopeForErrors.set -> void -static Microsoft.AspNetCore.Builder.StatusCodePagesExtensions.UseStatusCodePagesWithReExecute(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, string! pathFormat, bool createScopeForErrors, string? queryFormat = null) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! \ No newline at end of file +Microsoft.AspNetCore.Diagnostics.ExceptionHandledType +Microsoft.AspNetCore.Diagnostics.ExceptionHandledType.ExceptionHandlerDelegate = 3 -> Microsoft.AspNetCore.Diagnostics.ExceptionHandledType +Microsoft.AspNetCore.Diagnostics.ExceptionHandledType.ExceptionHandlerService = 1 -> Microsoft.AspNetCore.Diagnostics.ExceptionHandledType +Microsoft.AspNetCore.Diagnostics.ExceptionHandledType.ExceptionHandlingPath = 4 -> Microsoft.AspNetCore.Diagnostics.ExceptionHandledType +Microsoft.AspNetCore.Diagnostics.ExceptionHandledType.ProblemDetailsService = 2 -> Microsoft.AspNetCore.Diagnostics.ExceptionHandledType +Microsoft.AspNetCore.Diagnostics.ExceptionHandledType.Unhandled = 0 -> Microsoft.AspNetCore.Diagnostics.ExceptionHandledType +Microsoft.AspNetCore.Diagnostics.ExceptionHandlerSuppressDiagnosticsContext +Microsoft.AspNetCore.Diagnostics.ExceptionHandlerSuppressDiagnosticsContext.Exception.get -> System.Exception! +Microsoft.AspNetCore.Diagnostics.ExceptionHandlerSuppressDiagnosticsContext.Exception.init -> void +Microsoft.AspNetCore.Diagnostics.ExceptionHandlerSuppressDiagnosticsContext.ExceptionHandledBy.get -> Microsoft.AspNetCore.Diagnostics.ExceptionHandledType +Microsoft.AspNetCore.Diagnostics.ExceptionHandlerSuppressDiagnosticsContext.ExceptionHandledBy.init -> void +Microsoft.AspNetCore.Diagnostics.ExceptionHandlerSuppressDiagnosticsContext.ExceptionHandlerSuppressDiagnosticsContext() -> void +Microsoft.AspNetCore.Diagnostics.ExceptionHandlerSuppressDiagnosticsContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext! +Microsoft.AspNetCore.Diagnostics.ExceptionHandlerSuppressDiagnosticsContext.HttpContext.init -> void +static Microsoft.AspNetCore.Builder.StatusCodePagesExtensions.UseStatusCodePagesWithReExecute(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, string! pathFormat, bool createScopeForErrors, string? queryFormat = null) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! diff --git a/src/Middleware/Diagnostics/test/UnitTests/ExceptionHandlerMiddlewareTest.cs b/src/Middleware/Diagnostics/test/UnitTests/ExceptionHandlerMiddlewareTest.cs index b5513d9cf0e1..eaf419889b72 100644 --- a/src/Middleware/Diagnostics/test/UnitTests/ExceptionHandlerMiddlewareTest.cs +++ b/src/Middleware/Diagnostics/test/UnitTests/ExceptionHandlerMiddlewareTest.cs @@ -22,6 +22,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Options; using Moq; @@ -33,6 +34,7 @@ public class ExceptionHandlerMiddlewareTest : LoggedTest public async Task ExceptionIsSetOnProblemDetailsContext() { // Arrange + ExceptionHandlerSuppressDiagnosticsContext suppressContext = null; using var host = new HostBuilder() .ConfigureServices(services => { @@ -53,7 +55,14 @@ public async Task ExceptionIsSetOnProblemDetailsContext() .UseTestServer() .Configure(app => { - app.UseExceptionHandler(); + app.UseExceptionHandler(new ExceptionHandlerOptions + { + SuppressDiagnosticsCallback = context => + { + suppressContext = context; + return true; + } + }); app.Run(context => { throw new Exception("Test exception"); @@ -75,12 +84,16 @@ public async Task ExceptionIsSetOnProblemDetailsContext() var body = await response.Content.ReadFromJsonAsync(); var originalExceptionMessage = ((JsonElement)body.Extensions["OriginalExceptionMessage"]).GetString(); Assert.Equal("Test exception", originalExceptionMessage); + + Assert.IsType(suppressContext.Exception); + Assert.Equal(ExceptionHandledType.ProblemDetailsService, suppressContext.ExceptionHandledBy); } [Fact] public async Task Invoke_ExceptionThrownResultsInClearedRouteValuesAndEndpoint() { // Arrange + var sink = new TestSink(); var httpContext = CreateHttpContext(); httpContext.SetEndpoint(new Endpoint((_) => Task.CompletedTask, new EndpointMetadataCollection(), "Test")); httpContext.Request.RouteValues["John"] = "Doe"; @@ -92,10 +105,48 @@ public async Task Invoke_ExceptionThrownResultsInClearedRouteValuesAndEndpoint() Assert.Null(context.GetEndpoint()); return Task.CompletedTask; }); - var middleware = CreateMiddleware(_ => throw new InvalidOperationException(), optionsAccessor); + var middleware = CreateMiddleware(_ => throw new InvalidOperationException(), optionsAccessor, loggerFactory: new TestLoggerFactory(sink, true)); // Act & Assert await middleware.Invoke(httpContext); + + Assert.Collection(sink.Writes, w => Assert.Equal("UnhandledException", w.EventId.Name)); + } + + [Theory] + [InlineData(ExceptionHandledType.ExceptionHandlerDelegate, false)] + [InlineData(ExceptionHandledType.ProblemDetailsService, true)] + public async Task Invoke_HasExceptionHandler_SuppressDiagnostics_CallbackRun(ExceptionHandledType suppressResult, bool logged) + { + // Arrange + var sink = new TestSink(); + var httpContext = CreateHttpContext(); + + var optionsAccessor = CreateOptionsAccessor( + exceptionHandler: context => + { + context.Features.Set(new TestHttpResponseFeature()); + return Task.CompletedTask; + }, + suppressDiagnosticsCallback: c => c.ExceptionHandledBy == suppressResult); + var middleware = CreateMiddleware(_ => throw new InvalidOperationException(), optionsAccessor, loggerFactory: new TestLoggerFactory(sink, true)); + + // Act & Assert + await middleware.Invoke(httpContext); + + if (logged) + { + Assert.Collection(sink.Writes, w => Assert.Equal("UnhandledException", w.EventId.Name)); + } + else + { + Assert.Empty(sink.Writes); + } + } + + private sealed class TestHttpResponseFeature : HttpResponseFeature + { + public override bool HasStarted => true; } [Fact] @@ -126,6 +177,7 @@ public async Task Invoke_ExceptionHandlerCaptureRouteValuesAndEndpoint() public async Task IExceptionHandlers_CallNextIfNotHandled() { // Arrange + var sink = new TestSink(); var httpContext = CreateHttpContext(); var optionsAccessor = CreateOptionsAccessor(); @@ -137,7 +189,7 @@ public async Task IExceptionHandlers_CallNextIfNotHandled() new TestExceptionHandler(true, "3"), }; - var middleware = CreateMiddleware(_ => throw new InvalidOperationException(), optionsAccessor, exceptionHandlers); + var middleware = CreateMiddleware(_ => throw new InvalidOperationException(), optionsAccessor, exceptionHandlers, loggerFactory: new TestLoggerFactory(sink, true)); // Act & Assert await middleware.Invoke(httpContext); @@ -145,6 +197,56 @@ public async Task IExceptionHandlers_CallNextIfNotHandled() Assert.True(httpContext.Items.ContainsKey("1")); Assert.True(httpContext.Items.ContainsKey("2")); Assert.True(httpContext.Items.ContainsKey("3")); + + // IExceptionHandlers handling an exception suppress diagnostics by default. + Assert.Empty(sink.Writes); + } + + [Theory] + [InlineData(null)] + [InlineData(true)] + [InlineData(false)] + public async Task IExceptionHandlers_SuppressDiagnostics_TestLogs(bool? suppressDiagnostics) + { + // Arrange + var sink = new TestSink(); + var httpContext = CreateHttpContext(); + + var metricsTagsFeature = new TestHttpMetricsTagsFeature(); + httpContext.Features.Set(metricsTagsFeature); + + Func suppressDiagnosticsCallback = null; + if (suppressDiagnostics != null) + { + suppressDiagnosticsCallback = c => suppressDiagnostics.Value; + } + + var optionsAccessor = CreateOptionsAccessor(suppressDiagnosticsCallback: suppressDiagnosticsCallback); + + var exceptionHandlers = new List + { + new TestExceptionHandler(true, "1") + }; + + var middleware = CreateMiddleware(_ => throw new InvalidOperationException(), optionsAccessor, exceptionHandlers, loggerFactory: new TestLoggerFactory(sink, true)); + + // Act & Assert + await middleware.Invoke(httpContext); + + Assert.True(httpContext.Items.ContainsKey("1")); + + if (suppressDiagnostics == null || suppressDiagnostics == true) + { + Assert.Empty(sink.Writes); + Assert.Empty(metricsTagsFeature.Tags); + } + else + { + Assert.Collection(sink.Writes, w => Assert.Equal("UnhandledException", w.EventId.Name)); + var errorTag = Assert.Single(metricsTagsFeature.Tags); + Assert.Equal("error.type", errorTag.Key); + Assert.Equal("System.InvalidOperationException", errorTag.Value); + } } [Fact] @@ -445,6 +547,32 @@ public async Task Metrics_ExceptionThrown_Unhandled_Reported() m => AssertRequestException(m, "System.InvalidOperationException", "unhandled")); } + [Fact] + public async Task Metrics_ExceptionThrown_ErrorPathHandled_Reported() + { + // Arrange + var httpContext = CreateHttpContext(); + var optionsAccessor = CreateOptionsAccessor( + exceptionHandler: context => + { + context.Features.Set(new TestHttpResponseFeature()); + return Task.CompletedTask; + }, + exceptionHandlingPath: "/error"); + var meterFactory = new TestMeterFactory(); + var middleware = CreateMiddleware(_ => throw new InvalidOperationException(), optionsAccessor, meterFactory: meterFactory); + var meter = meterFactory.Meters.Single(); + + using var diagnosticsRequestExceptionCollector = new MetricCollector(meterFactory, DiagnosticsMetrics.MeterName, "aspnetcore.diagnostics.exceptions"); + + // Act + await middleware.Invoke(httpContext); + + // Assert + Assert.Collection(diagnosticsRequestExceptionCollector.GetMeasurementSnapshot(), + m => AssertRequestException(m, "System.InvalidOperationException", "handled", "/error")); + } + private static void AssertRequestException(CollectedMeasurement measurement, string exceptionName, string result, string handler = null) { Assert.Equal(1, measurement.Value); @@ -490,7 +618,8 @@ private HttpContext CreateHttpContext() private IOptions CreateOptionsAccessor( RequestDelegate exceptionHandler = null, - string exceptionHandlingPath = null) + string exceptionHandlingPath = null, + Func suppressDiagnosticsCallback = null) { exceptionHandler ??= c => Task.CompletedTask; var options = new ExceptionHandlerOptions() @@ -498,6 +627,10 @@ private IOptions CreateOptionsAccessor( ExceptionHandler = exceptionHandler, ExceptionHandlingPath = exceptionHandlingPath, }; + if (suppressDiagnosticsCallback != null) + { + options.SuppressDiagnosticsCallback = suppressDiagnosticsCallback; + } var optionsAccessor = Mock.Of>(o => o.Value == options); return optionsAccessor; } @@ -506,14 +639,15 @@ private ExceptionHandlerMiddlewareImpl CreateMiddleware( RequestDelegate next, IOptions options, IEnumerable exceptionHandlers = null, - IMeterFactory meterFactory = null) + IMeterFactory meterFactory = null, + ILoggerFactory loggerFactory = null) { next ??= c => Task.CompletedTask; var listener = new DiagnosticListener("Microsoft.AspNetCore"); var middleware = new ExceptionHandlerMiddlewareImpl( next, - NullLoggerFactory.Instance, + loggerFactory ?? NullLoggerFactory.Instance, options, listener, exceptionHandlers ?? Enumerable.Empty(), @@ -529,4 +663,12 @@ public object GetService(Type serviceType) throw new NotImplementedException(); } } + + private sealed class TestHttpMetricsTagsFeature : IHttpMetricsTagsFeature + { + public List> TagsList { get; } = new List>(); + + public ICollection> Tags => TagsList; + public bool MetricsDisabled { get; set; } + } }