Jelajahi Sumber

Add AuthN/AuthZ metrics (#59557)

Mackinnon Buck 1 tahun lalu
induk
melakukan
e4525465b8

+ 3 - 1
src/Http/Authentication.Core/src/AuthenticationCoreServiceCollectionExtensions.cs

@@ -20,10 +20,12 @@ public static class AuthenticationCoreServiceCollectionExtensions
     {
         ArgumentNullException.ThrowIfNull(services);
 
-        services.TryAddScoped<IAuthenticationService, AuthenticationService>();
+        services.AddMetrics();
+        services.TryAddScoped<IAuthenticationService, AuthenticationServiceImpl>();
         services.TryAddSingleton<IClaimsTransformation, NoopClaimsTransformation>(); // Can be replaced with scoped ones that use DbContext
         services.TryAddScoped<IAuthenticationHandlerProvider, AuthenticationHandlerProvider>();
         services.TryAddSingleton<IAuthenticationSchemeProvider, AuthenticationSchemeProvider>();
+        services.TryAddSingleton<AuthenticationMetrics>();
         return services;
     }
 

+ 204 - 0
src/Http/Authentication.Core/src/AuthenticationMetrics.cs

@@ -0,0 +1,204 @@
+// 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;
+using System.Diagnostics.Metrics;
+using System.Runtime.CompilerServices;
+using Microsoft.AspNetCore.Http;
+
+namespace Microsoft.AspNetCore.Authentication;
+
+internal sealed class AuthenticationMetrics
+{
+    public const string MeterName = "Microsoft.AspNetCore.Authentication";
+
+    private readonly Meter _meter;
+    private readonly Histogram<double> _authenticatedRequestDuration;
+    private readonly Counter<long> _challengeCount;
+    private readonly Counter<long> _forbidCount;
+    private readonly Counter<long> _signInCount;
+    private readonly Counter<long> _signOutCount;
+
+    public AuthenticationMetrics(IMeterFactory meterFactory)
+    {
+        _meter = meterFactory.Create(MeterName);
+
+        _authenticatedRequestDuration = _meter.CreateHistogram<double>(
+            "aspnetcore.authentication.authenticate.duration",
+            unit: "s",
+            description: "The authentication duration for a request.",
+            advice: new() { HistogramBucketBoundaries = MetricsConstants.ShortSecondsBucketBoundaries });
+
+        _challengeCount = _meter.CreateCounter<long>(
+            "aspnetcore.authentication.challenges",
+            unit: "{request}",
+            description: "The total number of times a scheme is challenged.");
+
+        _forbidCount = _meter.CreateCounter<long>(
+            "aspnetcore.authentication.forbids",
+            unit: "{request}",
+            description: "The total number of times an authenticated user attempts to access a resource they are not permitted to access.");
+
+        _signInCount = _meter.CreateCounter<long>(
+            "aspnetcore.authentication.sign_ins",
+            unit: "{request}",
+            description: "The total number of times a principal is signed in.");
+
+        _signOutCount = _meter.CreateCounter<long>(
+            "aspnetcore.authentication.sign_outs",
+            unit: "{request}",
+            description: "The total number of times a scheme is signed out.");
+    }
+
+    public void AuthenticatedRequestCompleted(string? scheme, AuthenticateResult? result, Exception? exception, long startTimestamp, long currentTimestamp)
+    {
+        if (_authenticatedRequestDuration.Enabled)
+        {
+            AuthenticatedRequestCompletedCore(scheme, result, exception, startTimestamp, currentTimestamp);
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private void AuthenticatedRequestCompletedCore(string? scheme, AuthenticateResult? result, Exception? exception, long startTimestamp, long currentTimestamp)
+    {
+        var tags = new TagList();
+
+        if (scheme is not null)
+        {
+            AddSchemeTag(ref tags, scheme);
+        }
+
+        if (result is not null)
+        {
+            tags.Add("aspnetcore.authentication.result", result switch
+            {
+                { None: true } => "none",
+                { Succeeded: true } => "success",
+                { Failure: not null } => "failure",
+                _ => "_OTHER", // _OTHER is commonly used fallback for an extra or unexpected value. Shouldn't reach here.
+            });
+        }
+
+        if (exception is not null)
+        {
+            AddErrorTag(ref tags, exception);
+        }
+
+        var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp);
+        _authenticatedRequestDuration.Record(duration.TotalSeconds, tags);
+    }
+
+    public void ChallengeCompleted(string? scheme, Exception? exception)
+    {
+        if (_challengeCount.Enabled)
+        {
+            ChallengeCompletedCore(scheme, exception);
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private void ChallengeCompletedCore(string? scheme, Exception? exception)
+    {
+        var tags = new TagList();
+
+        if (scheme is not null)
+        {
+            AddSchemeTag(ref tags, scheme);
+        }
+
+        if (exception is not null)
+        {
+            AddErrorTag(ref tags, exception);
+        }
+
+        _challengeCount.Add(1, tags);
+    }
+
+    public void ForbidCompleted(string? scheme, Exception? exception)
+    {
+        if (_forbidCount.Enabled)
+        {
+            ForbidCompletedCore(scheme, exception);
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private void ForbidCompletedCore(string? scheme, Exception? exception)
+    {
+        var tags = new TagList();
+
+        if (scheme is not null)
+        {
+            AddSchemeTag(ref tags, scheme);
+        }
+
+        if (exception is not null)
+        {
+            AddErrorTag(ref tags, exception);
+        }
+
+        _forbidCount.Add(1, tags);
+    }
+
+    public void SignInCompleted(string? scheme, Exception? exception)
+    {
+        if (_signInCount.Enabled)
+        {
+            SignInCompletedCore(scheme, exception);
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private void SignInCompletedCore(string? scheme, Exception? exception)
+    {
+        var tags = new TagList();
+
+        if (scheme is not null)
+        {
+            AddSchemeTag(ref tags, scheme);
+        }
+
+        if (exception is not null)
+        {
+            AddErrorTag(ref tags, exception);
+        }
+
+        _signInCount.Add(1, tags);
+    }
+
+    public void SignOutCompleted(string? scheme, Exception? exception)
+    {
+        if (_signOutCount.Enabled)
+        {
+            SignOutCompletedCore(scheme, exception);
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private void SignOutCompletedCore(string? scheme, Exception? exception)
+    {
+        var tags = new TagList();
+
+        if (scheme is not null)
+        {
+            AddSchemeTag(ref tags, scheme);
+        }
+
+        if (exception is not null)
+        {
+            AddErrorTag(ref tags, exception);
+        }
+
+        _signOutCount.Add(1, tags);
+    }
+
+    private static void AddSchemeTag(ref TagList tags, string scheme)
+    {
+        tags.Add("aspnetcore.authentication.scheme", scheme);
+    }
+
+    private static void AddErrorTag(ref TagList tags, Exception exception)
+    {
+        tags.Add("error.type", exception.GetType().FullName);
+    }
+}

+ 14 - 35
src/Http/Authentication.Core/src/AuthenticationService.cs

@@ -22,7 +22,11 @@ public class AuthenticationService : IAuthenticationService
     /// <param name="handlers">The <see cref="IAuthenticationHandlerProvider"/>.</param>
     /// <param name="transform">The <see cref="IClaimsTransformation"/>.</param>
     /// <param name="options">The <see cref="AuthenticationOptions"/>.</param>
-    public AuthenticationService(IAuthenticationSchemeProvider schemes, IAuthenticationHandlerProvider handlers, IClaimsTransformation transform, IOptions<AuthenticationOptions> options)
+    public AuthenticationService(
+        IAuthenticationSchemeProvider schemes,
+        IAuthenticationHandlerProvider handlers,
+        IClaimsTransformation transform,
+        IOptions<AuthenticationOptions> options)
     {
         Schemes = schemes;
         Handlers = handlers;
@@ -68,11 +72,7 @@ public class AuthenticationService : IAuthenticationService
             }
         }
 
-        var handler = await Handlers.GetHandlerAsync(context, scheme);
-        if (handler == null)
-        {
-            throw await CreateMissingHandlerException(scheme);
-        }
+        var handler = await Handlers.GetHandlerAsync(context, scheme) ?? throw await CreateMissingHandlerException(scheme);
 
         // Handlers should not return null, but we'll be tolerant of null values for legacy reasons.
         var result = (await handler.AuthenticateAsync()) ?? AuthenticateResult.NoResult();
@@ -81,7 +81,7 @@ public class AuthenticationService : IAuthenticationService
         {
             var principal = result.Principal!;
             var doTransform = true;
-            _transformCache ??= new HashSet<ClaimsPrincipal>();
+            _transformCache ??= [];
             if (_transformCache.Contains(principal))
             {
                 doTransform = false;
@@ -94,6 +94,7 @@ public class AuthenticationService : IAuthenticationService
             }
             return AuthenticateResult.Success(new AuthenticationTicket(principal, result.Properties, result.Ticket!.AuthenticationScheme));
         }
+
         return result;
     }
 
@@ -116,12 +117,7 @@ public class AuthenticationService : IAuthenticationService
             }
         }
 
-        var handler = await Handlers.GetHandlerAsync(context, scheme);
-        if (handler == null)
-        {
-            throw await CreateMissingHandlerException(scheme);
-        }
-
+        var handler = await Handlers.GetHandlerAsync(context, scheme) ?? throw await CreateMissingHandlerException(scheme);
         await handler.ChallengeAsync(properties);
     }
 
@@ -144,12 +140,7 @@ public class AuthenticationService : IAuthenticationService
             }
         }
 
-        var handler = await Handlers.GetHandlerAsync(context, scheme);
-        if (handler == null)
-        {
-            throw await CreateMissingHandlerException(scheme);
-        }
-
+        var handler = await Handlers.GetHandlerAsync(context, scheme) ?? throw await CreateMissingHandlerException(scheme);
         await handler.ForbidAsync(properties);
     }
 
@@ -187,14 +178,8 @@ public class AuthenticationService : IAuthenticationService
             }
         }
 
-        var handler = await Handlers.GetHandlerAsync(context, scheme);
-        if (handler == null)
-        {
-            throw await CreateMissingSignInHandlerException(scheme);
-        }
-
-        var signInHandler = handler as IAuthenticationSignInHandler;
-        if (signInHandler == null)
+        var handler = await Handlers.GetHandlerAsync(context, scheme) ?? throw await CreateMissingSignInHandlerException(scheme);
+        if (handler is not IAuthenticationSignInHandler signInHandler)
         {
             throw await CreateMismatchedSignInHandlerException(scheme, handler);
         }
@@ -221,14 +206,8 @@ public class AuthenticationService : IAuthenticationService
             }
         }
 
-        var handler = await Handlers.GetHandlerAsync(context, scheme);
-        if (handler == null)
-        {
-            throw await CreateMissingSignOutHandlerException(scheme);
-        }
-
-        var signOutHandler = handler as IAuthenticationSignOutHandler;
-        if (signOutHandler == null)
+        var handler = await Handlers.GetHandlerAsync(context, scheme) ?? throw await CreateMissingSignOutHandlerException(scheme);
+        if (handler is not IAuthenticationSignOutHandler signOutHandler)
         {
             throw await CreateMismatchedSignOutHandlerException(scheme, handler);
         }

+ 96 - 0
src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs

@@ -0,0 +1,96 @@
+// 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;
+using System.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.AspNetCore.Authentication;
+
+internal sealed class AuthenticationServiceImpl(
+    IAuthenticationSchemeProvider schemes,
+    IAuthenticationHandlerProvider handlers,
+    IClaimsTransformation transform,
+    IOptions<AuthenticationOptions> options,
+    AuthenticationMetrics metrics)
+    : AuthenticationService(schemes, handlers, transform, options)
+{
+    public override async Task<AuthenticateResult> AuthenticateAsync(HttpContext context, string? scheme)
+    {
+        AuthenticateResult result;
+        var startTimestamp = Stopwatch.GetTimestamp();
+        try
+        {
+            result = await base.AuthenticateAsync(context, scheme);
+        }
+        catch (Exception ex)
+        {
+            metrics.AuthenticatedRequestCompleted(scheme, result: null, ex, startTimestamp, currentTimestamp: Stopwatch.GetTimestamp());
+            throw;
+        }
+
+        metrics.AuthenticatedRequestCompleted(scheme, result, exception: result.Failure, startTimestamp, currentTimestamp: Stopwatch.GetTimestamp());
+        return result;
+    }
+
+    public override async Task ChallengeAsync(HttpContext context, string? scheme, AuthenticationProperties? properties)
+    {
+        try
+        {
+            await base.ChallengeAsync(context, scheme, properties);
+        }
+        catch (Exception ex)
+        {
+            metrics.ChallengeCompleted(scheme, ex);
+            throw;
+        }
+
+        metrics.ChallengeCompleted(scheme, exception: null);
+    }
+
+    public override async Task ForbidAsync(HttpContext context, string? scheme, AuthenticationProperties? properties)
+    {
+        try
+        {
+            await base.ForbidAsync(context, scheme, properties);
+        }
+        catch (Exception ex)
+        {
+            metrics.ForbidCompleted(scheme, ex);
+            throw;
+        }
+
+        metrics.ForbidCompleted(scheme, exception: null);
+    }
+
+    public override async Task SignInAsync(HttpContext context, string? scheme, ClaimsPrincipal principal, AuthenticationProperties? properties)
+    {
+        try
+        {
+            await base.SignInAsync(context, scheme, principal, properties);
+        }
+        catch (Exception ex)
+        {
+            metrics.SignInCompleted(scheme, ex);
+            throw;
+        }
+
+        metrics.SignInCompleted(scheme, exception: null);
+    }
+
+    public override async Task SignOutAsync(HttpContext context, string? scheme, AuthenticationProperties? properties)
+    {
+        try
+        {
+            await base.SignOutAsync(context, scheme, properties);
+        }
+        catch (Exception ex)
+        {
+            metrics.SignOutCompleted(scheme, ex);
+            throw;
+        }
+
+        metrics.SignOutCompleted(scheme, exception: null);
+    }
+}

+ 9 - 0
src/Http/Authentication.Core/src/Microsoft.AspNetCore.Authentication.Core.csproj

@@ -10,10 +10,19 @@
     <IsTrimmable>true</IsTrimmable>
   </PropertyGroup>
 
+  <ItemGroup>
+    <Compile Include="$(SharedSourceRoot)Metrics\MetricsConstants.cs" />
+  </ItemGroup>
+
   <ItemGroup>
     <Reference Include="Microsoft.AspNetCore.Authentication.Abstractions" />
     <Reference Include="Microsoft.AspNetCore.Http" />
     <Reference Include="Microsoft.AspNetCore.Http.Extensions" />
+    <Reference Include="Microsoft.Extensions.Diagnostics" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.Authentication.Test" />
   </ItemGroup>
 
 </Project>

+ 356 - 0
src/Security/Authentication/test/AuthenticationMetricsTest.cs

@@ -0,0 +1,356 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.InternalTesting;
+using Microsoft.Extensions.Options;
+using Microsoft.Extensions.Diagnostics.Metrics.Testing;
+using Moq;
+
+namespace Microsoft.AspNetCore.Authentication;
+
+public class AuthenticationMetricsTest
+{
+    [Fact]
+    public async Task Authenticate_Success()
+    {
+        // Arrange
+        var authenticationHandler = new Mock<IAuthenticationHandler>();
+        authenticationHandler.Setup(h => h.AuthenticateAsync()).Returns(Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(), "custom"))));
+
+        var meterFactory = new TestMeterFactory();
+        var httpContext = new DefaultHttpContext();
+        var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory);
+        var meter = meterFactory.Meters.Single();
+
+        using var authenticationRequestsCollector = new MetricCollector<double>(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.authenticate.duration");
+
+        // Act
+        await authenticationService.AuthenticateAsync(httpContext, scheme: "custom");
+
+        // Assert
+        Assert.Equal(AuthenticationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(authenticationRequestsCollector.GetMeasurementSnapshot());
+        Assert.True(measurement.Value > 0);
+        Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]);
+        Assert.Equal("success", (string)measurement.Tags["aspnetcore.authentication.result"]);
+        Assert.False(measurement.Tags.ContainsKey("error.type"));
+    }
+
+    [Fact]
+    public async Task Authenticate_Failure()
+    {
+        // Arrange
+        var authenticationHandler = new Mock<IAuthenticationHandler>();
+        authenticationHandler.Setup(h => h.AuthenticateAsync()).Returns(Task.FromResult(AuthenticateResult.Fail("Authentication failed")));
+
+        var meterFactory = new TestMeterFactory();
+        var httpContext = new DefaultHttpContext();
+        var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory);
+        var meter = meterFactory.Meters.Single();
+
+        using var authenticationRequestsCollector = new MetricCollector<double>(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.authenticate.duration");
+
+        // Act
+        await authenticationService.AuthenticateAsync(httpContext, scheme: "custom");
+
+        // Assert
+        Assert.Equal(AuthenticationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(authenticationRequestsCollector.GetMeasurementSnapshot());
+        Assert.True(measurement.Value > 0);
+        Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]);
+        Assert.Equal("failure", (string)measurement.Tags["aspnetcore.authentication.result"]);
+        Assert.Equal("Microsoft.AspNetCore.Authentication.AuthenticationFailureException", (string)measurement.Tags["error.type"]);
+    }
+
+    [Fact]
+    public async Task Authenticate_NoResult()
+    {
+        // Arrange
+        var authenticationHandler = new Mock<IAuthenticationHandler>();
+        authenticationHandler.Setup(h => h.AuthenticateAsync()).Returns(Task.FromResult(AuthenticateResult.NoResult()));
+
+        var meterFactory = new TestMeterFactory();
+        var httpContext = new DefaultHttpContext();
+        var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory);
+        var meter = meterFactory.Meters.Single();
+
+        using var authenticationRequestsCollector = new MetricCollector<double>(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.authenticate.duration");
+
+        // Act
+        await authenticationService.AuthenticateAsync(httpContext, scheme: "custom");
+
+        // Assert
+        Assert.Equal(AuthenticationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(authenticationRequestsCollector.GetMeasurementSnapshot());
+        Assert.True(measurement.Value > 0);
+        Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]);
+        Assert.Equal("none", (string)measurement.Tags["aspnetcore.authentication.result"]);
+        Assert.False(measurement.Tags.ContainsKey("error.type"));
+    }
+
+    [Fact]
+    public async Task Authenticate_ExceptionThrownInHandler()
+    {
+        // Arrange
+        var authenticationHandler = new Mock<IAuthenticationHandler>();
+        authenticationHandler.Setup(h => h.AuthenticateAsync()).Throws(new InvalidOperationException("An error occurred during authentication"));
+
+        var meterFactory = new TestMeterFactory();
+        var httpContext = new DefaultHttpContext();
+        var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory);
+        var meter = meterFactory.Meters.Single();
+
+        using var authenticationRequestsCollector = new MetricCollector<double>(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.authenticate.duration");
+
+        // Act
+        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => authenticationService.AuthenticateAsync(httpContext, scheme: "custom"));
+
+        // Assert
+        Assert.Equal("An error occurred during authentication", ex.Message);
+        Assert.Equal(AuthenticationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(authenticationRequestsCollector.GetMeasurementSnapshot());
+        Assert.True(measurement.Value > 0);
+        Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]);
+        Assert.Equal("System.InvalidOperationException", (string)measurement.Tags["error.type"]);
+        Assert.False(measurement.Tags.ContainsKey("aspnetcore.authentication.result"));
+    }
+
+    [Fact]
+    public async Task Challenge()
+    {
+        // Arrange
+        var meterFactory = new TestMeterFactory();
+        var httpContext = new DefaultHttpContext();
+        var authenticationService = CreateAuthenticationService(Mock.Of<IAuthenticationHandler>(), meterFactory);
+        var meter = meterFactory.Meters.Single();
+
+        using var challengesCollector = new MetricCollector<long>(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.challenges");
+
+        // Act
+        await authenticationService.ChallengeAsync(httpContext, scheme: "custom", properties: null);
+
+        // Assert
+        Assert.Equal(AuthenticationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(challengesCollector.GetMeasurementSnapshot());
+        Assert.Equal(1, measurement.Value);
+        Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]);
+    }
+
+    [Fact]
+    public async Task Challenge_ExceptionThrownInHandler()
+    {
+        // Arrange
+        var authenticationHandler = new Mock<IAuthenticationHandler>();
+        authenticationHandler.Setup(h => h.ChallengeAsync(It.IsAny<AuthenticationProperties>())).Throws(new InvalidOperationException("An error occurred during challenge"));
+
+        var meterFactory = new TestMeterFactory();
+        var httpContext = new DefaultHttpContext();
+        var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory);
+        var meter = meterFactory.Meters.Single();
+
+        using var challengesCollector = new MetricCollector<long>(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.challenges");
+
+        // Act
+        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => authenticationService.ChallengeAsync(httpContext, scheme: "custom", properties: null));
+
+        // Assert
+        Assert.Equal("An error occurred during challenge", ex.Message);
+        Assert.Equal(AuthenticationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(challengesCollector.GetMeasurementSnapshot());
+        Assert.Equal(1, measurement.Value);
+        Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]);
+        Assert.Equal("System.InvalidOperationException", (string)measurement.Tags["error.type"]);
+    }
+
+    [Fact]
+    public async Task Forbid()
+    {
+        // Arrange
+        var meterFactory = new TestMeterFactory();
+        var httpContext = new DefaultHttpContext();
+        var authenticationService = CreateAuthenticationService(Mock.Of<IAuthenticationHandler>(), meterFactory);
+        var meter = meterFactory.Meters.Single();
+
+        using var forbidsCollector = new MetricCollector<long>(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.forbids");
+
+        // Act
+        await authenticationService.ForbidAsync(httpContext, scheme: "custom", properties: null);
+
+        // Assert
+        Assert.Equal(AuthenticationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(forbidsCollector.GetMeasurementSnapshot());
+        Assert.Equal(1, measurement.Value);
+        Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]);
+    }
+
+    [Fact]
+    public async Task Forbid_ExceptionThrownInHandler()
+    {
+        // Arrange
+        var authenticationHandler = new Mock<IAuthenticationHandler>();
+        authenticationHandler.Setup(h => h.ForbidAsync(It.IsAny<AuthenticationProperties>())).Throws(new InvalidOperationException("An error occurred during forbid"));
+
+        var meterFactory = new TestMeterFactory();
+        var httpContext = new DefaultHttpContext();
+        var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory);
+        var meter = meterFactory.Meters.Single();
+
+        using var forbidsCollector = new MetricCollector<long>(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.forbids");
+
+        // Act
+        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => authenticationService.ForbidAsync(httpContext, scheme: "custom", properties: null));
+
+        // Assert
+        Assert.Equal("An error occurred during forbid", ex.Message);
+        Assert.Equal(AuthenticationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(forbidsCollector.GetMeasurementSnapshot());
+        Assert.Equal(1, measurement.Value);
+        Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]);
+        Assert.Equal("System.InvalidOperationException", (string)measurement.Tags["error.type"]);
+    }
+
+    [Fact]
+    public async Task SignIn()
+    {
+        // Arrange
+        var meterFactory = new TestMeterFactory();
+        var httpContext = new DefaultHttpContext();
+        var authenticationService = CreateAuthenticationService(Mock.Of<IAuthenticationSignInHandler>(), meterFactory);
+        var meter = meterFactory.Meters.Single();
+
+        using var signInsCollector = new MetricCollector<long>(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.sign_ins");
+
+        // Act
+        await authenticationService.SignInAsync(httpContext, scheme: "custom", new ClaimsPrincipal(), properties: null);
+
+        // Assert
+        Assert.Equal(AuthenticationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(signInsCollector.GetMeasurementSnapshot());
+        Assert.Equal(1, measurement.Value);
+        Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]);
+    }
+
+    [Fact]
+    public async Task SignIn_ExceptionThrownInHandler()
+    {
+        // Arrange
+        var authenticationHandler = new Mock<IAuthenticationSignInHandler>();
+        authenticationHandler.Setup(h => h.SignInAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<AuthenticationProperties>())).Throws(new InvalidOperationException("An error occurred during sign in"));
+
+        var meterFactory = new TestMeterFactory();
+        var httpContext = new DefaultHttpContext();
+        var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory);
+        var meter = meterFactory.Meters.Single();
+
+        using var signInsCollector = new MetricCollector<long>(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.sign_ins");
+
+        // Act
+        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => authenticationService.SignInAsync(httpContext, scheme: "custom", new ClaimsPrincipal(), properties: null));
+
+        // Assert
+        Assert.Equal("An error occurred during sign in", ex.Message);
+        Assert.Equal(AuthenticationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(signInsCollector.GetMeasurementSnapshot());
+        Assert.Equal(1, measurement.Value);
+        Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]);
+        Assert.Equal("System.InvalidOperationException", (string)measurement.Tags["error.type"]);
+    }
+
+    [Fact]
+    public async Task SignOut()
+    {
+        // Arrange
+        var httpContext = new DefaultHttpContext();
+        var meterFactory = new TestMeterFactory();
+        var authenticationService = CreateAuthenticationService(Mock.Of<IAuthenticationSignOutHandler>(), meterFactory);
+        var meter = meterFactory.Meters.Single();
+
+        using var signOutsCollector = new MetricCollector<long>(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.sign_outs");
+
+        // Act
+        await authenticationService.SignOutAsync(httpContext, scheme: "custom", properties: null);
+
+        // Assert
+        Assert.Equal(AuthenticationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(signOutsCollector.GetMeasurementSnapshot());
+        Assert.Equal(1, measurement.Value);
+        Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]);
+    }
+
+    [Fact]
+    public async Task SignOut_ExceptionThrownInHandler()
+    {
+        // Arrange
+        var authenticationHandler = new Mock<IAuthenticationSignOutHandler>();
+        authenticationHandler.Setup(h => h.SignOutAsync(It.IsAny<AuthenticationProperties>())).Throws(new InvalidOperationException("An error occurred during sign out"));
+
+        var httpContext = new DefaultHttpContext();
+        var meterFactory = new TestMeterFactory();
+        var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory);
+        var meter = meterFactory.Meters.Single();
+
+        using var signOutsCollector = new MetricCollector<long>(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.sign_outs");
+
+        // Act
+        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => authenticationService.SignOutAsync(httpContext, scheme: "custom", properties: null));
+
+        // Assert
+        Assert.Equal("An error occurred during sign out", ex.Message);
+        Assert.Equal(AuthenticationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(signOutsCollector.GetMeasurementSnapshot());
+        Assert.Equal(1, measurement.Value);
+        Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]);
+        Assert.Equal("System.InvalidOperationException", (string)measurement.Tags["error.type"]);
+    }
+
+    private static AuthenticationServiceImpl CreateAuthenticationService(IAuthenticationHandler authenticationHandler, TestMeterFactory meterFactory)
+    {
+        var authenticationHandlerProvider = new Mock<IAuthenticationHandlerProvider>();
+        authenticationHandlerProvider.Setup(p => p.GetHandlerAsync(It.IsAny<HttpContext>(), "custom")).Returns(Task.FromResult(authenticationHandler));
+
+        var claimsTransform = new Mock<IClaimsTransformation>();
+        claimsTransform.Setup(t => t.TransformAsync(It.IsAny<ClaimsPrincipal>())).Returns((ClaimsPrincipal p) => Task.FromResult(p));
+
+        var options = Options.Create(new AuthenticationOptions
+        {
+            DefaultSignInScheme = "custom",
+            RequireAuthenticatedSignIn = false,
+        });
+
+        var metrics = new AuthenticationMetrics(meterFactory);
+        var authenticationService = new AuthenticationServiceImpl(
+            Mock.Of<IAuthenticationSchemeProvider>(),
+            authenticationHandlerProvider.Object,
+            claimsTransform.Object,
+            options,
+            metrics);
+
+        return authenticationService;
+    }
+}

+ 2 - 0
src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj

@@ -14,6 +14,7 @@
 
   <ItemGroup>
     <Compile Include="$(SharedSourceRoot)test\Certificates\Certificates.cs" />
+    <Compile Include="$(SharedSourceRoot)Metrics\TestMeterFactory.cs" />
 
     <Content Include="WsFederation\federationmetadata.xml">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@@ -49,6 +50,7 @@
     <Reference Include="Microsoft.AspNetCore.Authentication.WsFederation" />
     <Reference Include="Microsoft.AspNetCore.HttpOverrides" />
     <Reference Include="Microsoft.AspNetCore.TestHost" />
+    <Reference Include="Microsoft.Extensions.Diagnostics.Testing" />
     <Reference Include="Microsoft.Extensions.TimeProvider.Testing" />
     <Reference Include="Microsoft.Net.Http.Headers" />
   </ItemGroup>

+ 66 - 0
src/Security/Authorization/Core/src/AuthorizationMetrics.cs

@@ -0,0 +1,66 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Security.Claims;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.Authorization;
+
+internal sealed class AuthorizationMetrics
+{
+    public const string MeterName = "Microsoft.AspNetCore.Authorization";
+
+    private readonly Meter _meter;
+    private readonly Counter<long> _authorizedRequestCount;
+
+    public AuthorizationMetrics(IMeterFactory meterFactory)
+    {
+        _meter = meterFactory.Create(MeterName);
+
+        _authorizedRequestCount = _meter.CreateCounter<long>(
+            "aspnetcore.authorization.attempts",
+            unit: "{request}",
+            description: "The total number of requests for which authorization was attempted.");
+    }
+
+    public void AuthorizedRequestCompleted(ClaimsPrincipal user, string? policyName, AuthorizationResult? result, Exception? exception)
+    {
+        if (_authorizedRequestCount.Enabled)
+        {
+            AuthorizedRequestCompletedCore(user, policyName, result, exception);
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private void AuthorizedRequestCompletedCore(ClaimsPrincipal user, string? policyName, AuthorizationResult? result, Exception? exception)
+    {
+        var tags = new TagList([
+            new("user.is_authenticated", user.Identity?.IsAuthenticated ?? false)
+        ]);
+
+        if (policyName is not null)
+        {
+            tags.Add("aspnetcore.authorization.policy", policyName);
+        }
+
+        if (result is not null)
+        {
+            var resultTagValue = result.Succeeded ? "success" : "failure";
+            tags.Add("aspnetcore.authorization.result", resultTagValue);
+        }
+
+        if (exception is not null)
+        {
+            tags.Add("error.type", exception.GetType().FullName);
+        }
+
+        _authorizedRequestCount.Add(1, tags);
+    }
+}

+ 4 - 1
src/Security/Authorization/Core/src/AuthorizationServiceCollectionExtensions.cs

@@ -27,7 +27,10 @@ public static class AuthorizationServiceCollectionExtensions
         // aren't included by default.
         services.AddOptions();
 
-        services.TryAdd(ServiceDescriptor.Transient<IAuthorizationService, DefaultAuthorizationService>());
+        services.AddMetrics();
+
+        services.TryAdd(ServiceDescriptor.Singleton<AuthorizationMetrics, AuthorizationMetrics>());
+        services.TryAdd(ServiceDescriptor.Transient<IAuthorizationService, DefaultAuthorizationServiceImpl>());
         services.TryAdd(ServiceDescriptor.Transient<IAuthorizationPolicyProvider, DefaultAuthorizationPolicyProvider>());
         services.TryAdd(ServiceDescriptor.Transient<IAuthorizationHandlerProvider, DefaultAuthorizationHandlerProvider>());
         services.TryAdd(ServiceDescriptor.Transient<IAuthorizationEvaluator, DefaultAuthorizationEvaluator>());

+ 8 - 7
src/Security/Authorization/Core/src/DefaultAuthorizationService.cs

@@ -98,13 +98,14 @@ public class DefaultAuthorizationService : IAuthorizationService
     /// </returns>
     public virtual async Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName)
     {
-        ArgumentNullThrowHelper.ThrowIfNull(policyName);
-
-        var policy = await _policyProvider.GetPolicyAsync(policyName).ConfigureAwait(false);
-        if (policy == null)
-        {
-            throw new InvalidOperationException($"No policy found: {policyName}.");
-        }
+        var policy = await GetPolicyAsync(policyName).ConfigureAwait(false);
         return await this.AuthorizeAsync(user, resource, policy).ConfigureAwait(false);
     }
+
+    // For use in DefaultAuthorizationServiceImpl.
+    private protected async Task<AuthorizationPolicy> GetPolicyAsync(string policyName)
+    {
+        ArgumentNullThrowHelper.ThrowIfNull(policyName);
+        return await _policyProvider.GetPolicyAsync(policyName).ConfigureAwait(false) ?? throw new InvalidOperationException($"No policy found: {policyName}.");
+    }
 }

+ 62 - 0
src/Security/Authorization/Core/src/DefaultAuthorizationServiceImpl.cs

@@ -0,0 +1,62 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Shared;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.AspNetCore.Authorization;
+
+internal sealed class DefaultAuthorizationServiceImpl(
+    IAuthorizationPolicyProvider policyProvider,
+    IAuthorizationHandlerProvider handlers,
+    ILogger<DefaultAuthorizationService> logger,
+    IAuthorizationHandlerContextFactory contextFactory,
+    IAuthorizationEvaluator evaluator,
+    IOptions<AuthorizationOptions> options,
+    AuthorizationMetrics metrics)
+    : DefaultAuthorizationService(policyProvider, handlers, logger, contextFactory, evaluator, options)
+{
+    public override async Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, IEnumerable<IAuthorizationRequirement> requirements)
+    {
+        AuthorizationResult result;
+        try
+        {
+            result = await base.AuthorizeAsync(user, resource, requirements).ConfigureAwait(false);
+        }
+        catch (Exception ex)
+        {
+            metrics.AuthorizedRequestCompleted(user, policyName: null, result: null, ex);
+            throw;
+        }
+
+        metrics.AuthorizedRequestCompleted(user, policyName: null, result, exception: null);
+        return result;
+    }
+
+    public override async Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName)
+    {
+        AuthorizationResult result;
+        try
+        {
+            var policy = await GetPolicyAsync(policyName).ConfigureAwait(false);
+
+            // Note that we deliberately call the base method of the other overload here.
+            // This is because the base implementation for this overload dispatches to the other overload,
+            // which would cause metrics to be recorded twice.
+            result = await base.AuthorizeAsync(user, resource, policy.Requirements).ConfigureAwait(false);
+        }
+        catch (Exception ex)
+        {
+            metrics.AuthorizedRequestCompleted(user, policyName, result: null, ex);
+            throw;
+        }
+
+        metrics.AuthorizedRequestCompleted(user, policyName, result, exception: null);
+        return result;
+    }
+}

+ 5 - 0
src/Security/Authorization/Core/src/Microsoft.AspNetCore.Authorization.csproj

@@ -17,6 +17,7 @@ Microsoft.AspNetCore.Authorization.AuthorizeAttribute</Description>
   <ItemGroup>
     <Reference Include="Microsoft.AspNetCore.Metadata" />
     <Reference Include="Microsoft.Extensions.Logging.Abstractions" />
+    <Reference Include="Microsoft.Extensions.Diagnostics" />
     <Reference Include="Microsoft.Extensions.Options" />
   </ItemGroup>
 
@@ -31,4 +32,8 @@ Microsoft.AspNetCore.Authorization.AuthorizeAttribute</Description>
     <Compile Include="$(SharedSourceRoot)Debugger\DebuggerHelpers.cs" LinkBase="Shared" />
   </ItemGroup>
 
+  <ItemGroup>
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.Authorization.Test" />
+  </ItemGroup>
+
 </Project>

+ 206 - 0
src/Security/Authorization/test/AuthorizationMetricsTest.cs

@@ -0,0 +1,206 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Security.Claims;
+using Microsoft.AspNetCore.InternalTesting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.Metrics.Testing;
+
+namespace Microsoft.AspNetCore.Authorization.Test;
+
+public class AuthorizationMetricsTest
+{
+    [Fact]
+    public async Task Authorize_WithPolicyName_Success()
+    {
+        // Arrange
+        var meterFactory = new TestMeterFactory();
+        var authorizationService = BuildAuthorizationService(meterFactory);
+        var meter = meterFactory.Meters.Single();
+        var user = new ClaimsPrincipal(new ClaimsIdentity([new Claim("Permission", "CanViewPage")], authenticationType: "someAuthentication"));
+
+        using var authorizedRequestsCollector = new MetricCollector<long>(meterFactory, AuthorizationMetrics.MeterName, "aspnetcore.authorization.attempts");
+
+        // Act
+        await authorizationService.AuthorizeAsync(user, "Basic");
+
+        // Assert
+        Assert.Equal(AuthorizationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(authorizedRequestsCollector.GetMeasurementSnapshot());
+        Assert.Equal(1, measurement.Value);
+        Assert.Equal("Basic", (string)measurement.Tags["aspnetcore.authorization.policy"]);
+        Assert.Equal("success", (string)measurement.Tags["aspnetcore.authorization.result"]);
+        Assert.True((bool)measurement.Tags["user.is_authenticated"]);
+    }
+
+    [Fact]
+    public async Task Authorize_WithPolicyName_Failure()
+    {
+        // Arrange
+        var meterFactory = new TestMeterFactory();
+        var authorizationService = BuildAuthorizationService(meterFactory);
+        var meter = meterFactory.Meters.Single();
+        var user = new ClaimsPrincipal(new ClaimsIdentity([])); // Will fail due to missing required claim
+
+        using var authorizedRequestsCollector = new MetricCollector<long>(meterFactory, AuthorizationMetrics.MeterName, "aspnetcore.authorization.attempts");
+
+        // Act
+        await authorizationService.AuthorizeAsync(user, "Basic");
+
+        // Assert
+        Assert.Equal(AuthorizationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(authorizedRequestsCollector.GetMeasurementSnapshot());
+        Assert.Equal(1, measurement.Value);
+        Assert.Equal("Basic", (string)measurement.Tags["aspnetcore.authorization.policy"]);
+        Assert.Equal("failure", (string)measurement.Tags["aspnetcore.authorization.result"]);
+        Assert.False((bool)measurement.Tags["user.is_authenticated"]);
+    }
+
+    [Fact]
+    public async Task Authorize_WithPolicyName_PolicyNotFound()
+    {
+        // Arrange
+        var meterFactory = new TestMeterFactory();
+        var authorizationService = BuildAuthorizationService(meterFactory);
+        var meter = meterFactory.Meters.Single();
+        var user = new ClaimsPrincipal(new ClaimsIdentity([])); // Will fail due to missing required claim
+
+        using var authorizedRequestsCollector = new MetricCollector<long>(meterFactory, AuthorizationMetrics.MeterName, "aspnetcore.authorization.attempts");
+
+        // Act
+        await Assert.ThrowsAsync<InvalidOperationException>(() => authorizationService.AuthorizeAsync(user, "UnknownPolicy"));
+
+        // Assert
+        Assert.Equal(AuthorizationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(authorizedRequestsCollector.GetMeasurementSnapshot());
+        Assert.Equal(1, measurement.Value);
+        Assert.Equal("UnknownPolicy", (string)measurement.Tags["aspnetcore.authorization.policy"]);
+        Assert.Equal("System.InvalidOperationException", (string)measurement.Tags["error.type"]);
+        Assert.False((bool)measurement.Tags["user.is_authenticated"]);
+        Assert.False(measurement.Tags.ContainsKey("aspnetcore.authorization.result"));
+    }
+
+    [Fact]
+    public async Task Authorize_WithoutPolicyName_Success()
+    {
+        // Arrange
+        var meterFactory = new TestMeterFactory();
+        var authorizationService = BuildAuthorizationService(meterFactory, services =>
+        {
+            services.AddSingleton<IAuthorizationHandler>(new AlwaysHandler(succeed: true));
+        });
+        var meter = meterFactory.Meters.Single();
+        var user = new ClaimsPrincipal(new ClaimsIdentity([]));
+
+        using var authorizedRequestsCollector = new MetricCollector<long>(meterFactory, AuthorizationMetrics.MeterName, "aspnetcore.authorization.attempts");
+
+        // Act
+        await authorizationService.AuthorizeAsync(user, resource: null, new TestRequirement());
+
+        // Assert
+        Assert.Equal(AuthorizationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(authorizedRequestsCollector.GetMeasurementSnapshot());
+        Assert.Equal(1, measurement.Value);
+        Assert.Equal("success", (string)measurement.Tags["aspnetcore.authorization.result"]);
+        Assert.False((bool)measurement.Tags["user.is_authenticated"]);
+        Assert.False(measurement.Tags.ContainsKey("aspnetcore.authorization.policy"));
+    }
+
+    [Fact]
+    public async Task Authorize_WithoutPolicyName_Failure()
+    {
+        // Arrange
+        var meterFactory = new TestMeterFactory();
+        var authorizationService = BuildAuthorizationService(meterFactory); // Will fail because there is no handler registered
+        var meter = meterFactory.Meters.Single();
+        var user = new ClaimsPrincipal(new ClaimsIdentity([]));
+
+        using var authorizedRequestsCollector = new MetricCollector<long>(meterFactory, AuthorizationMetrics.MeterName, "aspnetcore.authorization.attempts");
+
+        // Act
+        await authorizationService.AuthorizeAsync(user, resource: null, new TestRequirement());
+
+        // Assert
+        Assert.Equal(AuthorizationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(authorizedRequestsCollector.GetMeasurementSnapshot());
+        Assert.Equal(1, measurement.Value);
+        Assert.Equal("failure", (string)measurement.Tags["aspnetcore.authorization.result"]);
+        Assert.False((bool)measurement.Tags["user.is_authenticated"]);
+        Assert.False(measurement.Tags.ContainsKey("aspnetcore.authorization.policy"));
+    }
+
+    [Fact]
+    public async Task Authorize_WithoutPolicyName_ExceptionThrownInHandler()
+    {
+        // Arrange
+        var meterFactory = new TestMeterFactory();
+        var authorizationService = BuildAuthorizationService(meterFactory, services =>
+        {
+            services.AddSingleton<IAuthorizationHandler>(new AlwaysThrowHandler());
+        });
+        var meter = meterFactory.Meters.Single();
+        var user = new ClaimsPrincipal(new ClaimsIdentity([]));
+
+        using var authorizedRequestsCollector = new MetricCollector<long>(meterFactory, AuthorizationMetrics.MeterName, "aspnetcore.authorization.attempts");
+
+        // Act
+        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => authorizationService.AuthorizeAsync(user, resource: null, new TestRequirement()));
+
+        // Assert
+        Assert.Equal("An error occurred in the authorization handler", ex.Message);
+        Assert.Equal(AuthorizationMetrics.MeterName, meter.Name);
+        Assert.Null(meter.Version);
+
+        var measurement = Assert.Single(authorizedRequestsCollector.GetMeasurementSnapshot());
+        Assert.Equal(1, measurement.Value);
+        Assert.Equal("System.InvalidOperationException", (string)measurement.Tags["error.type"]);
+        Assert.False((bool)measurement.Tags["user.is_authenticated"]);
+        Assert.False(measurement.Tags.ContainsKey("aspnetcore.authorization.policy"));
+        Assert.False(measurement.Tags.ContainsKey("aspnetcore.authorization.result"));
+    }
+
+    private static IAuthorizationService BuildAuthorizationService(TestMeterFactory meterFactory, Action<IServiceCollection> setupServices = null)
+    {
+        var services = new ServiceCollection();
+        services.AddSingleton(new AuthorizationMetrics(meterFactory));
+        services.AddAuthorizationBuilder()
+            .AddPolicy("Basic", policy => policy.RequireClaim("Permission", "CanViewPage"));
+        services.AddLogging();
+        services.AddOptions();
+        setupServices?.Invoke(services);
+        return services.BuildServiceProvider().GetRequiredService<IAuthorizationService>();
+    }
+
+    private sealed class AlwaysHandler(bool succeed) : AuthorizationHandler<TestRequirement>
+    {
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, TestRequirement requirement)
+        {
+            if (succeed)
+            {
+                context.Succeed(requirement);
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+
+    private sealed class AlwaysThrowHandler : AuthorizationHandler<TestRequirement>
+    {
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, TestRequirement requirement)
+        {
+            throw new InvalidOperationException("An error occurred in the authorization handler");
+        }
+    }
+
+    private sealed class TestRequirement : IAuthorizationRequirement;
+}

+ 5 - 0
src/Security/Authorization/test/Microsoft.AspNetCore.Authorization.Test.csproj

@@ -10,7 +10,12 @@
     <Reference Include="Microsoft.AspNetCore.Authorization.Policy" />
     <Reference Include="Microsoft.AspNetCore.Http" />
     <Reference Include="Microsoft.Extensions.DependencyInjection" />
+    <Reference Include="Microsoft.Extensions.Diagnostics.Testing" />
     <Reference Include="Microsoft.Extensions.Logging" />
   </ItemGroup>
 
+  <ItemGroup>
+    <Compile Include="$(SharedSourceRoot)Metrics\TestMeterFactory.cs" />
+  </ItemGroup>
+
 </Project>

+ 3 - 0
src/Security/startvscode.cmd

@@ -0,0 +1,3 @@
+@ECHO OFF
+
+%~dp0..\..\startvscode.cmd %~dp0