File: UserManagerMetrics.cs
Web Access
Project: src\src\Identity\Extensions.Core\src\Microsoft.Extensions.Identity.Core.csproj (Microsoft.Extensions.Identity.Core)
// 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.Diagnostics;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Threading.Tasks;
using static Microsoft.AspNetCore.Identity.UserManagerMetrics;
 
namespace Microsoft.AspNetCore.Identity;
 
internal sealed class UserManagerMetrics : IDisposable
{
    public const string MeterName = "Microsoft.AspNetCore.Identity";
 
    public const string CreateCounterName = "aspnetcore.identity.user.create";
    public const string UpdateCounterName = "aspnetcore.identity.user.update";
    public const string DeleteCounterName = "aspnetcore.identity.user.delete";
    public const string CheckPasswordCounterName = "aspnetcore.identity.user.check_password";
    public const string VerifyTokenCounterName = "aspnetcore.identity.user.verify_token";
    public const string GenerateTokenCounterName = "aspnetcore.identity.user.generate_token";
 
    private readonly Meter _meter;
    private readonly Counter<long> _createCounter;
    private readonly Counter<long> _updateCounter;
    private readonly Counter<long> _deleteCounter;
    private readonly Counter<long> _checkPasswordCounter;
    private readonly Counter<long> _verifyTokenCounter;
    private readonly Counter<long> _generateTokenCounter;
 
    public UserManagerMetrics(IMeterFactory meterFactory)
    {
        _meter = meterFactory.Create(MeterName);
        _createCounter = _meter.CreateCounter<long>(CreateCounterName, "count", "The number of users created.");
        _updateCounter = _meter.CreateCounter<long>(UpdateCounterName, "count", "The number of user updates.");
        _deleteCounter = _meter.CreateCounter<long>(DeleteCounterName, "count", "The number of users deleted.");
        _checkPasswordCounter = _meter.CreateCounter<long>(CheckPasswordCounterName, "count", "The number of check password attempts. Only checks whether the password is valid and not whether the user account is in a state that can log in.");
        _verifyTokenCounter = _meter.CreateCounter<long>(VerifyTokenCounterName, "count", "The number of token verification attempts.");
        _generateTokenCounter = _meter.CreateCounter<long>(GenerateTokenCounterName, "count", "The number of token generation attempts.");
    }
 
    internal void CreateUser(string userType, IdentityResult? result, Exception? exception = null)
    {
        if (!_createCounter.Enabled)
        {
            return;
        }
 
        var tags = new TagList
        {
            { "aspnetcore.identity.user_type", userType }
        };
        AddIdentityResultTags(ref tags, result);
        AddExceptionTags(ref tags, exception);
 
        _createCounter.Add(1, tags);
    }
 
    internal void UpdateUser(string userType, IdentityResult? result, UserUpdateType updateType, Exception? exception = null)
    {
        if (!_updateCounter.Enabled)
        {
            return;
        }
 
        var tags = new TagList
        {
            { "aspnetcore.identity.user_type", userType },
            { "aspnetcore.identity.user.update_type", GetUpdateType(updateType) },
        };
        AddIdentityResultTags(ref tags, result);
        AddExceptionTags(ref tags, exception);
 
        _updateCounter.Add(1, tags);
    }
 
    internal void DeleteUser(string userType, IdentityResult? result, Exception? exception = null)
    {
        if (!_deleteCounter.Enabled)
        {
            return;
        }
 
        var tags = new TagList
        {
            { "aspnetcore.identity.user_type", userType }
        };
        AddIdentityResultTags(ref tags, result);
        AddExceptionTags(ref tags, exception);
 
        _deleteCounter.Add(1, tags);
    }
 
    internal void CheckPassword(string userType, bool? userMissing, PasswordVerificationResult? result, Exception? exception = null)
    {
        if (!_checkPasswordCounter.Enabled)
        {
            return;
        }
 
        var tags = new TagList
        {
            { "aspnetcore.identity.user_type", userType },
        };
        if (userMissing != null || result != null)
        {
            tags.Add("aspnetcore.identity.user.password_result", GetPasswordResult(result, passwordMissing: null, userMissing));
        }
        AddExceptionTags(ref tags, exception);
 
        _checkPasswordCounter.Add(1, tags);
    }
 
    internal void VerifyToken(string userType, bool? result, string purpose, Exception? exception = null)
    {
        if (!_verifyTokenCounter.Enabled)
        {
            return;
        }
 
        var tags = new TagList
        {
            { "aspnetcore.identity.user_type", userType },
            { "aspnetcore.identity.token_purpose", GetTokenPurpose(purpose) },
        };
        if (result != null)
        {
            tags.Add("aspnetcore.identity.token_verified", result == true ? "success" : "failure");
        }
        AddExceptionTags(ref tags, exception);
 
        _verifyTokenCounter.Add(1, tags);
    }
 
    internal void GenerateToken(string userType, string purpose, Exception? exception = null)
    {
        if (!_generateTokenCounter.Enabled)
        {
            return;
        }
 
        var tags = new TagList
        {
            { "aspnetcore.identity.user_type", userType },
            { "aspnetcore.identity.token_purpose", GetTokenPurpose(purpose) },
        };
        AddExceptionTags(ref tags, exception);
 
        _generateTokenCounter.Add(1, tags);
    }
 
    private static string GetTokenPurpose(string purpose)
    {
        // Purpose could be any value and can't be used as a tag value. However, there are known purposes
        // on UserManager that we can detect and use as a tag value. Some could have a ':' in them followed by user data.
        // We need to trim them to content before ':' and then match to known values.
        ReadOnlySpan<char> trimmedPurpose = purpose;
        var colonIndex = purpose.IndexOf(':');
        if (colonIndex >= 0)
        {
            trimmedPurpose = purpose.AsSpan(0, colonIndex);
        }
        
        return trimmedPurpose switch
        {
            "ResetPassword" => "reset_password",
            "ChangePhoneNumber" => "change_phone_number",
            "EmailConfirmation" => "email_confirmation",
            "ChangeEmail" => "change_email",
            "TwoFactor" => "two_factor",
            _ => "_UNKNOWN"
        };
    }
 
    private static void AddIdentityResultTags(ref TagList tags, IdentityResult? result)
    {
        if (result == null)
        {
            return;
        }
 
        tags.Add("aspnetcore.identity.result", result.Succeeded ? "success" : "failure");
        if (!result.Succeeded && result.Errors.FirstOrDefault()?.Code is { Length: > 0 } code)
        {
            tags.Add("aspnetcore.identity.result_error_code", code);
        }
    }
 
    private static void AddExceptionTags(ref TagList tags, Exception? exception)
    {
        if (exception != null)
        {
            tags.Add("error.type", exception.GetType().FullName!);
        }
    }
 
    private static string GetPasswordResult(PasswordVerificationResult? result, bool? passwordMissing, bool? userMissing)
    {
        return (result, passwordMissing ?? false, userMissing ?? false) switch
        {
            (PasswordVerificationResult.Success, false, false) => "success",
            (PasswordVerificationResult.SuccessRehashNeeded, false, false) => "success_rehash_needed",
            (PasswordVerificationResult.Failed, false, false) => "failure",
            (null, true, false) => "password_missing",
            (null, false, true) => "user_missing",
            _ => "_UNKNOWN"
        };
    }
 
    private static string GetUpdateType(UserUpdateType updateType)
    {
        return updateType switch
        {
            UserUpdateType.Update => "update",
            UserUpdateType.UserName => "user_name",
            UserUpdateType.AddPassword => "add_password",
            UserUpdateType.ChangePassword => "change_password",
            UserUpdateType.SecurityStamp => "security_stamp",
            UserUpdateType.ResetPassword => "reset_password",
            UserUpdateType.RemoveLogin => "remove_login",
            UserUpdateType.AddLogin => "add_login",
            UserUpdateType.AddClaims => "add_claims",
            UserUpdateType.ReplaceClaim => "replace_claim",
            UserUpdateType.RemoveClaims => "remove_claims",
            UserUpdateType.AddToRoles => "add_to_roles",
            UserUpdateType.RemoveFromRoles => "remove_from_roles",
            UserUpdateType.SetEmail => "set_email",
            UserUpdateType.ConfirmEmail => "confirm_email",
            UserUpdateType.PasswordRehash => "password_rehash",
            UserUpdateType.RemovePassword => "remove_password",
            UserUpdateType.ChangeEmail => "change_email",
            UserUpdateType.SetPhoneNumber => "set_phone_number",
            UserUpdateType.ChangePhoneNumber => "change_phone_number",
            UserUpdateType.SetTwoFactorEnabled => "set_two_factor_enabled",
            UserUpdateType.SetLockoutEnabled => "set_lockout_enabled",
            UserUpdateType.SetLockoutEndDate => "set_lockout_end_date",
            UserUpdateType.AccessFailed => "access_failed",
            UserUpdateType.ResetAccessFailedCount => "reset_access_failed_count",
            UserUpdateType.SetAuthenticationToken => "set_authentication_token",
            UserUpdateType.RemoveAuthenticationToken => "remove_authentication_token",
            UserUpdateType.ResetAuthenticatorKey => "reset_authenticator_key",
            UserUpdateType.GenerateNewTwoFactorRecoveryCodes => "generate_new_two_factor_recovery_codes",
            UserUpdateType.RedeemTwoFactorRecoveryCode => "redeem_two_factor_recovery_code",
            UserUpdateType.SetPasskey => "set_passkey",
            UserUpdateType.RemovePasskey => "remove_passkey",
            _ => "_UNKNOWN"
        };
    }
 
    public void Dispose()
    {
        _meter.Dispose();
    }
}