diff --git a/hosts/AspNetIdentity/Pages/Account/Login/Index.cshtml.cs b/hosts/AspNetIdentity/Pages/Account/Login/Index.cshtml.cs index cef9b69c1..c67227940 100644 --- a/hosts/AspNetIdentity/Pages/Account/Login/Index.cshtml.cs +++ b/hosts/AspNetIdentity/Pages/Account/Login/Index.cshtml.cs @@ -102,6 +102,7 @@ public async Task OnPost() { var user = await _userManager.FindByNameAsync(Input.Username!); await _events.RaiseAsync(new UserLoginSuccessEvent(user!.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId)); + Telemetry.Metrics.UserLogin(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider); if (context != null) { @@ -135,7 +136,9 @@ public async Task OnPost() } } - await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, "invalid credentials", clientId:context?.Client.ClientId)); + const string error = "invalid credentials"; + await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, error, clientId:context?.Client.ClientId)); + Telemetry.Metrics.UserLoginFailure(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider, error); ModelState.AddModelError(string.Empty, LoginOptions.InvalidCredentialsErrorMessage); } diff --git a/hosts/AspNetIdentity/Pages/Account/Logout/Index.cshtml.cs b/hosts/AspNetIdentity/Pages/Account/Logout/Index.cshtml.cs index 77d79256f..d9a5231d2 100644 --- a/hosts/AspNetIdentity/Pages/Account/Logout/Index.cshtml.cs +++ b/hosts/AspNetIdentity/Pages/Account/Logout/Index.cshtml.cs @@ -75,12 +75,13 @@ public async Task OnPost() // delete local authentication cookie await _signInManager.SignOutAsync(); - // raise the logout event - await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); - // see if we need to trigger federated logout var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value; + // raise the logout event + await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); + Telemetry.Metrics.UserLogout(idp); + // if it's a local login we can ignore this workflow if (idp != null && idp != Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider) { diff --git a/hosts/AspNetIdentity/Pages/Consent/Index.cshtml.cs b/hosts/AspNetIdentity/Pages/Consent/Index.cshtml.cs index 2abdee20a..e6daedcd7 100644 --- a/hosts/AspNetIdentity/Pages/Consent/Index.cshtml.cs +++ b/hosts/AspNetIdentity/Pages/Consent/Index.cshtml.cs @@ -66,6 +66,7 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -88,6 +89,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); } else { diff --git a/hosts/AspNetIdentity/Pages/Device/Index.cshtml.cs b/hosts/AspNetIdentity/Pages/Device/Index.cshtml.cs index f84bbb159..29cf3af0c 100644 --- a/hosts/AspNetIdentity/Pages/Device/Index.cshtml.cs +++ b/hosts/AspNetIdentity/Pages/Device/Index.cshtml.cs @@ -78,6 +78,7 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -100,6 +101,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); } else { diff --git a/hosts/AspNetIdentity/Pages/ExternalLogin/Callback.cshtml.cs b/hosts/AspNetIdentity/Pages/ExternalLogin/Callback.cshtml.cs index 2f1163188..695137710 100644 --- a/hosts/AspNetIdentity/Pages/ExternalLogin/Callback.cshtml.cs +++ b/hosts/AspNetIdentity/Pages/ExternalLogin/Callback.cshtml.cs @@ -97,6 +97,7 @@ public async Task OnGet() // check if external login is in the context of an OIDC request var context = await _interaction.GetAuthorizationContextAsync(returnUrl); await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.Id, user.UserName, true, context?.Client.ClientId)); + Telemetry.Metrics.UserLogin(context?.Client.ClientId, provider!); if (context != null) { diff --git a/hosts/AspNetIdentity/Pages/Grants/Index.cshtml.cs b/hosts/AspNetIdentity/Pages/Grants/Index.cshtml.cs index dceca1034..a38545744 100644 --- a/hosts/AspNetIdentity/Pages/Grants/Index.cshtml.cs +++ b/hosts/AspNetIdentity/Pages/Grants/Index.cshtml.cs @@ -75,6 +75,7 @@ public async Task OnPost() { await _interaction.RevokeUserConsentAsync(ClientId); await _events.RaiseAsync(new GrantsRevokedEvent(User.GetSubjectId(), ClientId)); + Telemetry.Metrics.GrantsRevoked(ClientId); return RedirectToPage("/Grants/Index"); } diff --git a/hosts/AspNetIdentity/Pages/Telemetry.cs b/hosts/AspNetIdentity/Pages/Telemetry.cs new file mode 100644 index 000000000..cdc142833 --- /dev/null +++ b/hosts/AspNetIdentity/Pages/Telemetry.cs @@ -0,0 +1,168 @@ +using System.Diagnostics.Metrics; + +namespace IdentityServerHost.Pages; + +#pragma warning disable CA1034 // Nested types should not be visible +#pragma warning disable CA1724 // Type names should not match namespaces + +/// +/// Telemetry helpers for the UI +/// +public static class Telemetry +{ + private static readonly string ServiceVersion = typeof(Telemetry).Assembly.GetName().Version!.ToString(); + + /// + /// Service name for telemetry. + /// + public static readonly string ServiceName = typeof(Telemetry).Assembly.GetName().Name!; + + /// + /// Metrics configuration + /// + public static class Metrics + { + /// + /// Name of Counters + /// + public static class Counters + { + /// + /// consent_granted + /// + public const string ConsentGranted = "consent_granted"; + + /// + /// consent_denied + /// + public const string ConsentDenied = "consent_denied"; + + /// + /// grants_revoked + /// + public const string GrantsRevoked = "grants_revoked"; + + /// + /// user_login + /// + public const string UserLogin = "user_login"; + + /// + /// user_login_failure + /// + public const string UserLoginFailure = "user_login_failure"; + + /// + /// user_logout + /// + public const string UserLogout = "user_logout"; + } + + /// + /// Name of tags + /// + public static class Tags + { + /// + /// client + /// + public const string Client = "client"; + + /// + /// error + /// + public const string Error = "error"; + + /// + /// idp + /// + public const string Idp = "idp"; + + /// + /// remember + /// + public const string Remember = "remember"; + + /// + /// scope + /// + public const string Scope = "scope"; + } + + /// + /// Meter for the IdentityServer host project + /// + private static readonly Meter Meter = new Meter(ServiceName, ServiceVersion); + + private static Counter ConsentGrantedCounter = Meter.CreateCounter(Counters.ConsentGranted); + + /// + /// Helper method to increase counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// + /// Client id + /// Scope names. Each element is added on it's own to the counter + public static void ConsentGranted(string clientId, IEnumerable scopes, bool remember) + { + ArgumentNullException.ThrowIfNull(scopes); + foreach(var scope in scopes) + { + ConsentGrantedCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope), new(Tags.Remember, remember)); + } + } + + private static Counter ConsentDeniedCounter = Meter.CreateCounter(Counters.ConsentDenied); + + /// + /// Helper method to increase counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// + /// Client id + /// Scope names. Each element is added on it's own to the counter + public static void ConsentDenied(string clientId, IEnumerable scopes) + { + ArgumentNullException.ThrowIfNull(scopes); + foreach (var scope in scopes) + { + ConsentDeniedCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope)); + } + } + + private static Counter GrantsRevokedCounter = Meter.CreateCounter(Counters.GrantsRevoked); + + /// + /// Helper method to increase the counter. + /// + /// Client id to revoke for, or null for all. + public static void GrantsRevoked(string? clientId) + => GrantsRevokedCounter.Add(1, tag: new(Tags.Client, clientId)); + + private static Counter UserLoginCounter = Meter.CreateCounter(Counters.UserLogin); + + /// + /// Helper method to increase counter. + /// + /// Client Id, if available + public static void UserLogin(string? clientId, string idp) + => UserLoginCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp)); + + private static Counter UserLoginFailureCounter = Meter.CreateCounter(Counters.UserLoginFailure); + + /// + /// Helper method to increase + /// Client Id, if available + /// Error message + public static void UserLoginFailure(string? clientId, string idp, string error) + => UserLoginFailureCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp), new(Tags.Error, error)); + + private static Counter UserLogoutCounter = Meter.CreateCounter(Counters.UserLogout); + + /// + /// Helper method to increase the counter. + /// + /// Idp/authentication scheme for external authentication, or "local" for built in. + public static void UserLogout(string? idp) + => UserLogoutCounter.Add(1, tag: new(Tags.Idp, idp)); + } +} diff --git a/hosts/Configuration/Pages/Account/Login/Index.cshtml.cs b/hosts/Configuration/Pages/Account/Login/Index.cshtml.cs index 9e07c5b65..249f75b05 100644 --- a/hosts/Configuration/Pages/Account/Login/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Account/Login/Index.cshtml.cs @@ -97,6 +97,7 @@ public async Task OnPost() { var user = _users.FindByUsername(Input.Username); await _events.RaiseAsync(new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username, clientId: context?.Client.ClientId)); + Telemetry.Metrics.UserLogin(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider); // only set explicit expiration here if user chooses "remember me". // otherwise we rely upon expiration configured in cookie middleware. @@ -144,7 +145,9 @@ public async Task OnPost() } } - await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, "invalid credentials", clientId:context?.Client.ClientId)); + const string error = "invalid credentials"; + await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, error, clientId:context?.Client.ClientId)); + Telemetry.Metrics.UserLoginFailure(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider, error); ModelState.AddModelError(string.Empty, LoginOptions.InvalidCredentialsErrorMessage); } diff --git a/hosts/Configuration/Pages/Account/Logout/Index.cshtml.cs b/hosts/Configuration/Pages/Account/Logout/Index.cshtml.cs index 9f9c4dc1e..aef1de3d8 100644 --- a/hosts/Configuration/Pages/Account/Logout/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Account/Logout/Index.cshtml.cs @@ -71,12 +71,13 @@ public async Task OnPost() // delete local authentication cookie await HttpContext.SignOutAsync(); - // raise the logout event - await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); - // see if we need to trigger federated logout var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value; + // raise the logout event + await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); + Telemetry.Metrics.UserLogout(idp); + // if it's a local login we can ignore this workflow if (idp != null && idp != Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider) { diff --git a/hosts/Configuration/Pages/Ciba/Consent.cshtml.cs b/hosts/Configuration/Pages/Ciba/Consent.cshtml.cs index 4ebf8cd53..38dd76f82 100644 --- a/hosts/Configuration/Pages/Ciba/Consent.cshtml.cs +++ b/hosts/Configuration/Pages/Ciba/Consent.cshtml.cs @@ -69,6 +69,7 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -90,6 +91,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, result.ScopesValuesConsented, false)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, result.ScopesValuesConsented, false); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(result.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); } else { diff --git a/hosts/Configuration/Pages/Consent/Index.cshtml.cs b/hosts/Configuration/Pages/Consent/Index.cshtml.cs index 2abdee20a..e6daedcd7 100644 --- a/hosts/Configuration/Pages/Consent/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Consent/Index.cshtml.cs @@ -66,6 +66,7 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -88,6 +89,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); } else { diff --git a/hosts/Configuration/Pages/Device/Index.cshtml.cs b/hosts/Configuration/Pages/Device/Index.cshtml.cs index f84bbb159..29cf3af0c 100644 --- a/hosts/Configuration/Pages/Device/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Device/Index.cshtml.cs @@ -78,6 +78,7 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -100,6 +101,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); } else { diff --git a/hosts/Configuration/Pages/ExternalLogin/Callback.cshtml.cs b/hosts/Configuration/Pages/ExternalLogin/Callback.cshtml.cs index 937ced8e9..281131bc2 100644 --- a/hosts/Configuration/Pages/ExternalLogin/Callback.cshtml.cs +++ b/hosts/Configuration/Pages/ExternalLogin/Callback.cshtml.cs @@ -106,6 +106,7 @@ public async Task OnGet() // check if external login is in the context of an OIDC request var context = await _interaction.GetAuthorizationContextAsync(returnUrl); await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.SubjectId, user.Username, true, context?.Client.ClientId)); + Telemetry.Metrics.UserLogin(context?.Client.ClientId, provider!); if (context != null) { diff --git a/hosts/Configuration/Pages/Grants/Index.cshtml.cs b/hosts/Configuration/Pages/Grants/Index.cshtml.cs index dceca1034..a38545744 100644 --- a/hosts/Configuration/Pages/Grants/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Grants/Index.cshtml.cs @@ -75,6 +75,7 @@ public async Task OnPost() { await _interaction.RevokeUserConsentAsync(ClientId); await _events.RaiseAsync(new GrantsRevokedEvent(User.GetSubjectId(), ClientId)); + Telemetry.Metrics.GrantsRevoked(ClientId); return RedirectToPage("/Grants/Index"); } diff --git a/hosts/Configuration/Pages/Telemetry.cs b/hosts/Configuration/Pages/Telemetry.cs new file mode 100644 index 000000000..cdc142833 --- /dev/null +++ b/hosts/Configuration/Pages/Telemetry.cs @@ -0,0 +1,168 @@ +using System.Diagnostics.Metrics; + +namespace IdentityServerHost.Pages; + +#pragma warning disable CA1034 // Nested types should not be visible +#pragma warning disable CA1724 // Type names should not match namespaces + +/// +/// Telemetry helpers for the UI +/// +public static class Telemetry +{ + private static readonly string ServiceVersion = typeof(Telemetry).Assembly.GetName().Version!.ToString(); + + /// + /// Service name for telemetry. + /// + public static readonly string ServiceName = typeof(Telemetry).Assembly.GetName().Name!; + + /// + /// Metrics configuration + /// + public static class Metrics + { + /// + /// Name of Counters + /// + public static class Counters + { + /// + /// consent_granted + /// + public const string ConsentGranted = "consent_granted"; + + /// + /// consent_denied + /// + public const string ConsentDenied = "consent_denied"; + + /// + /// grants_revoked + /// + public const string GrantsRevoked = "grants_revoked"; + + /// + /// user_login + /// + public const string UserLogin = "user_login"; + + /// + /// user_login_failure + /// + public const string UserLoginFailure = "user_login_failure"; + + /// + /// user_logout + /// + public const string UserLogout = "user_logout"; + } + + /// + /// Name of tags + /// + public static class Tags + { + /// + /// client + /// + public const string Client = "client"; + + /// + /// error + /// + public const string Error = "error"; + + /// + /// idp + /// + public const string Idp = "idp"; + + /// + /// remember + /// + public const string Remember = "remember"; + + /// + /// scope + /// + public const string Scope = "scope"; + } + + /// + /// Meter for the IdentityServer host project + /// + private static readonly Meter Meter = new Meter(ServiceName, ServiceVersion); + + private static Counter ConsentGrantedCounter = Meter.CreateCounter(Counters.ConsentGranted); + + /// + /// Helper method to increase counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// + /// Client id + /// Scope names. Each element is added on it's own to the counter + public static void ConsentGranted(string clientId, IEnumerable scopes, bool remember) + { + ArgumentNullException.ThrowIfNull(scopes); + foreach(var scope in scopes) + { + ConsentGrantedCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope), new(Tags.Remember, remember)); + } + } + + private static Counter ConsentDeniedCounter = Meter.CreateCounter(Counters.ConsentDenied); + + /// + /// Helper method to increase counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// + /// Client id + /// Scope names. Each element is added on it's own to the counter + public static void ConsentDenied(string clientId, IEnumerable scopes) + { + ArgumentNullException.ThrowIfNull(scopes); + foreach (var scope in scopes) + { + ConsentDeniedCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope)); + } + } + + private static Counter GrantsRevokedCounter = Meter.CreateCounter(Counters.GrantsRevoked); + + /// + /// Helper method to increase the counter. + /// + /// Client id to revoke for, or null for all. + public static void GrantsRevoked(string? clientId) + => GrantsRevokedCounter.Add(1, tag: new(Tags.Client, clientId)); + + private static Counter UserLoginCounter = Meter.CreateCounter(Counters.UserLogin); + + /// + /// Helper method to increase counter. + /// + /// Client Id, if available + public static void UserLogin(string? clientId, string idp) + => UserLoginCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp)); + + private static Counter UserLoginFailureCounter = Meter.CreateCounter(Counters.UserLoginFailure); + + /// + /// Helper method to increase + /// Client Id, if available + /// Error message + public static void UserLoginFailure(string? clientId, string idp, string error) + => UserLoginFailureCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp), new(Tags.Error, error)); + + private static Counter UserLogoutCounter = Meter.CreateCounter(Counters.UserLogout); + + /// + /// Helper method to increase the counter. + /// + /// Idp/authentication scheme for external authentication, or "local" for built in. + public static void UserLogout(string? idp) + => UserLogoutCounter.Add(1, tag: new(Tags.Idp, idp)); + } +} diff --git a/hosts/EntityFramework/Pages/Account/Login/Index.cshtml.cs b/hosts/EntityFramework/Pages/Account/Login/Index.cshtml.cs index b50b49d53..16262bc3e 100644 --- a/hosts/EntityFramework/Pages/Account/Login/Index.cshtml.cs +++ b/hosts/EntityFramework/Pages/Account/Login/Index.cshtml.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using static System.Runtime.InteropServices.JavaScript.JSType; namespace IdentityServerHost.Pages.Login; @@ -97,6 +98,7 @@ public async Task OnPost() { var user = _users.FindByUsername(Input.Username); await _events.RaiseAsync(new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username, clientId: context?.Client.ClientId)); + Telemetry.Metrics.UserLogin(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider); // only set explicit expiration here if user chooses "remember me". // otherwise we rely upon expiration configured in cookie middleware. @@ -144,7 +146,9 @@ public async Task OnPost() } } - await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, "invalid credentials", clientId:context?.Client.ClientId)); + const string error = "invalid credentials"; + await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, error, clientId:context?.Client.ClientId)); + Telemetry.Metrics.UserLoginFailure(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider, error); ModelState.AddModelError(string.Empty, LoginOptions.InvalidCredentialsErrorMessage); } diff --git a/hosts/EntityFramework/Pages/Account/Logout/Index.cshtml.cs b/hosts/EntityFramework/Pages/Account/Logout/Index.cshtml.cs index 9f9c4dc1e..aef1de3d8 100644 --- a/hosts/EntityFramework/Pages/Account/Logout/Index.cshtml.cs +++ b/hosts/EntityFramework/Pages/Account/Logout/Index.cshtml.cs @@ -71,12 +71,13 @@ public async Task OnPost() // delete local authentication cookie await HttpContext.SignOutAsync(); - // raise the logout event - await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); - // see if we need to trigger federated logout var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value; + // raise the logout event + await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); + Telemetry.Metrics.UserLogout(idp); + // if it's a local login we can ignore this workflow if (idp != null && idp != Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider) { diff --git a/hosts/EntityFramework/Pages/Ciba/Consent.cshtml.cs b/hosts/EntityFramework/Pages/Ciba/Consent.cshtml.cs index 4ebf8cd53..38dd76f82 100644 --- a/hosts/EntityFramework/Pages/Ciba/Consent.cshtml.cs +++ b/hosts/EntityFramework/Pages/Ciba/Consent.cshtml.cs @@ -69,6 +69,7 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -90,6 +91,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, result.ScopesValuesConsented, false)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, result.ScopesValuesConsented, false); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(result.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); } else { diff --git a/hosts/EntityFramework/Pages/Consent/Index.cshtml.cs b/hosts/EntityFramework/Pages/Consent/Index.cshtml.cs index 2abdee20a..e6daedcd7 100644 --- a/hosts/EntityFramework/Pages/Consent/Index.cshtml.cs +++ b/hosts/EntityFramework/Pages/Consent/Index.cshtml.cs @@ -66,6 +66,7 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -88,6 +89,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); } else { diff --git a/hosts/EntityFramework/Pages/Device/Index.cshtml.cs b/hosts/EntityFramework/Pages/Device/Index.cshtml.cs index f84bbb159..29cf3af0c 100644 --- a/hosts/EntityFramework/Pages/Device/Index.cshtml.cs +++ b/hosts/EntityFramework/Pages/Device/Index.cshtml.cs @@ -78,6 +78,7 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -100,6 +101,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); } else { diff --git a/hosts/EntityFramework/Pages/ExternalLogin/Callback.cshtml.cs b/hosts/EntityFramework/Pages/ExternalLogin/Callback.cshtml.cs index 937ced8e9..281131bc2 100644 --- a/hosts/EntityFramework/Pages/ExternalLogin/Callback.cshtml.cs +++ b/hosts/EntityFramework/Pages/ExternalLogin/Callback.cshtml.cs @@ -106,6 +106,7 @@ public async Task OnGet() // check if external login is in the context of an OIDC request var context = await _interaction.GetAuthorizationContextAsync(returnUrl); await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.SubjectId, user.Username, true, context?.Client.ClientId)); + Telemetry.Metrics.UserLogin(context?.Client.ClientId, provider!); if (context != null) { diff --git a/hosts/EntityFramework/Pages/Grants/Index.cshtml.cs b/hosts/EntityFramework/Pages/Grants/Index.cshtml.cs index dceca1034..a38545744 100644 --- a/hosts/EntityFramework/Pages/Grants/Index.cshtml.cs +++ b/hosts/EntityFramework/Pages/Grants/Index.cshtml.cs @@ -75,6 +75,7 @@ public async Task OnPost() { await _interaction.RevokeUserConsentAsync(ClientId); await _events.RaiseAsync(new GrantsRevokedEvent(User.GetSubjectId(), ClientId)); + Telemetry.Metrics.GrantsRevoked(ClientId); return RedirectToPage("/Grants/Index"); } diff --git a/hosts/EntityFramework/Pages/Telemetry.cs b/hosts/EntityFramework/Pages/Telemetry.cs new file mode 100644 index 000000000..cdc142833 --- /dev/null +++ b/hosts/EntityFramework/Pages/Telemetry.cs @@ -0,0 +1,168 @@ +using System.Diagnostics.Metrics; + +namespace IdentityServerHost.Pages; + +#pragma warning disable CA1034 // Nested types should not be visible +#pragma warning disable CA1724 // Type names should not match namespaces + +/// +/// Telemetry helpers for the UI +/// +public static class Telemetry +{ + private static readonly string ServiceVersion = typeof(Telemetry).Assembly.GetName().Version!.ToString(); + + /// + /// Service name for telemetry. + /// + public static readonly string ServiceName = typeof(Telemetry).Assembly.GetName().Name!; + + /// + /// Metrics configuration + /// + public static class Metrics + { + /// + /// Name of Counters + /// + public static class Counters + { + /// + /// consent_granted + /// + public const string ConsentGranted = "consent_granted"; + + /// + /// consent_denied + /// + public const string ConsentDenied = "consent_denied"; + + /// + /// grants_revoked + /// + public const string GrantsRevoked = "grants_revoked"; + + /// + /// user_login + /// + public const string UserLogin = "user_login"; + + /// + /// user_login_failure + /// + public const string UserLoginFailure = "user_login_failure"; + + /// + /// user_logout + /// + public const string UserLogout = "user_logout"; + } + + /// + /// Name of tags + /// + public static class Tags + { + /// + /// client + /// + public const string Client = "client"; + + /// + /// error + /// + public const string Error = "error"; + + /// + /// idp + /// + public const string Idp = "idp"; + + /// + /// remember + /// + public const string Remember = "remember"; + + /// + /// scope + /// + public const string Scope = "scope"; + } + + /// + /// Meter for the IdentityServer host project + /// + private static readonly Meter Meter = new Meter(ServiceName, ServiceVersion); + + private static Counter ConsentGrantedCounter = Meter.CreateCounter(Counters.ConsentGranted); + + /// + /// Helper method to increase counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// + /// Client id + /// Scope names. Each element is added on it's own to the counter + public static void ConsentGranted(string clientId, IEnumerable scopes, bool remember) + { + ArgumentNullException.ThrowIfNull(scopes); + foreach(var scope in scopes) + { + ConsentGrantedCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope), new(Tags.Remember, remember)); + } + } + + private static Counter ConsentDeniedCounter = Meter.CreateCounter(Counters.ConsentDenied); + + /// + /// Helper method to increase counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// + /// Client id + /// Scope names. Each element is added on it's own to the counter + public static void ConsentDenied(string clientId, IEnumerable scopes) + { + ArgumentNullException.ThrowIfNull(scopes); + foreach (var scope in scopes) + { + ConsentDeniedCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope)); + } + } + + private static Counter GrantsRevokedCounter = Meter.CreateCounter(Counters.GrantsRevoked); + + /// + /// Helper method to increase the counter. + /// + /// Client id to revoke for, or null for all. + public static void GrantsRevoked(string? clientId) + => GrantsRevokedCounter.Add(1, tag: new(Tags.Client, clientId)); + + private static Counter UserLoginCounter = Meter.CreateCounter(Counters.UserLogin); + + /// + /// Helper method to increase counter. + /// + /// Client Id, if available + public static void UserLogin(string? clientId, string idp) + => UserLoginCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp)); + + private static Counter UserLoginFailureCounter = Meter.CreateCounter(Counters.UserLoginFailure); + + /// + /// Helper method to increase + /// Client Id, if available + /// Error message + public static void UserLoginFailure(string? clientId, string idp, string error) + => UserLoginFailureCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp), new(Tags.Error, error)); + + private static Counter UserLogoutCounter = Meter.CreateCounter(Counters.UserLogout); + + /// + /// Helper method to increase the counter. + /// + /// Idp/authentication scheme for external authentication, or "local" for built in. + public static void UserLogout(string? idp) + => UserLogoutCounter.Add(1, tag: new(Tags.Idp, idp)); + } +} diff --git a/hosts/main/HostingExtensions.cs b/hosts/main/HostingExtensions.cs index 9e8887330..c3fb57327 100644 --- a/hosts/main/HostingExtensions.cs +++ b/hosts/main/HostingExtensions.cs @@ -48,7 +48,8 @@ internal static WebApplication ConfigureServices(this WebApplicationBuilder buil openTelemetry.WithMetrics(m => m .AddMeter(Telemetry.ServiceName) - .AddPrometheusExporter()); + .AddMeter(Pages.Telemetry.ServiceName) + .AddPrometheusExporter()) ; return builder.Build(); } diff --git a/hosts/main/Pages/Account/Login/Index.cshtml.cs b/hosts/main/Pages/Account/Login/Index.cshtml.cs index b50b49d53..eec8c4a07 100644 --- a/hosts/main/Pages/Account/Login/Index.cshtml.cs +++ b/hosts/main/Pages/Account/Login/Index.cshtml.cs @@ -97,6 +97,7 @@ public async Task OnPost() { var user = _users.FindByUsername(Input.Username); await _events.RaiseAsync(new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username, clientId: context?.Client.ClientId)); + Telemetry.Metrics.UserLogin(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider); // only set explicit expiration here if user chooses "remember me". // otherwise we rely upon expiration configured in cookie middleware. @@ -144,7 +145,9 @@ public async Task OnPost() } } - await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, "invalid credentials", clientId:context?.Client.ClientId)); + const string error = "invalid credentials"; + await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, error, clientId:context?.Client.ClientId)); + Telemetry.Metrics.UserLoginFailure(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider, error); ModelState.AddModelError(string.Empty, LoginOptions.InvalidCredentialsErrorMessage); } diff --git a/hosts/main/Pages/Account/Logout/Index.cshtml.cs b/hosts/main/Pages/Account/Logout/Index.cshtml.cs index 9f9c4dc1e..aef1de3d8 100644 --- a/hosts/main/Pages/Account/Logout/Index.cshtml.cs +++ b/hosts/main/Pages/Account/Logout/Index.cshtml.cs @@ -71,12 +71,13 @@ public async Task OnPost() // delete local authentication cookie await HttpContext.SignOutAsync(); - // raise the logout event - await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); - // see if we need to trigger federated logout var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value; + // raise the logout event + await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); + Telemetry.Metrics.UserLogout(idp); + // if it's a local login we can ignore this workflow if (idp != null && idp != Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider) { diff --git a/hosts/main/Pages/Ciba/Consent.cshtml.cs b/hosts/main/Pages/Ciba/Consent.cshtml.cs index 4ebf8cd53..226135641 100644 --- a/hosts/main/Pages/Ciba/Consent.cshtml.cs +++ b/hosts/main/Pages/Ciba/Consent.cshtml.cs @@ -31,7 +31,7 @@ public class Consent : PageModel } public ViewModel View { get; set; } = default!; - + [BindProperty] public InputModel Input { get; set; } = default!; @@ -50,7 +50,7 @@ public async Task OnGet(string? id) return Page(); } - public async Task OnPost() + public async Task OnPost() { // validate return url is still valid var request = await _interaction.GetLoginRequestByInternalIdAsync(Input.Id ?? throw new ArgumentNullException(nameof(Input.Id))); @@ -69,6 +69,7 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -90,6 +91,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, result.ScopesValuesConsented, false)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, result.ScopesValuesConsented, false); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(result.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); } else { diff --git a/hosts/main/Pages/Consent/Index.cshtml.cs b/hosts/main/Pages/Consent/Index.cshtml.cs index 2abdee20a..e6daedcd7 100644 --- a/hosts/main/Pages/Consent/Index.cshtml.cs +++ b/hosts/main/Pages/Consent/Index.cshtml.cs @@ -66,6 +66,7 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -88,6 +89,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); } else { diff --git a/hosts/main/Pages/Device/Index.cshtml.cs b/hosts/main/Pages/Device/Index.cshtml.cs index f84bbb159..29cf3af0c 100644 --- a/hosts/main/Pages/Device/Index.cshtml.cs +++ b/hosts/main/Pages/Device/Index.cshtml.cs @@ -78,6 +78,7 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -100,6 +101,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); } else { diff --git a/hosts/main/Pages/ExternalLogin/Callback.cshtml.cs b/hosts/main/Pages/ExternalLogin/Callback.cshtml.cs index 937ced8e9..281131bc2 100644 --- a/hosts/main/Pages/ExternalLogin/Callback.cshtml.cs +++ b/hosts/main/Pages/ExternalLogin/Callback.cshtml.cs @@ -106,6 +106,7 @@ public async Task OnGet() // check if external login is in the context of an OIDC request var context = await _interaction.GetAuthorizationContextAsync(returnUrl); await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.SubjectId, user.Username, true, context?.Client.ClientId)); + Telemetry.Metrics.UserLogin(context?.Client.ClientId, provider!); if (context != null) { diff --git a/hosts/main/Pages/Grants/Index.cshtml.cs b/hosts/main/Pages/Grants/Index.cshtml.cs index dceca1034..051f00d57 100644 --- a/hosts/main/Pages/Grants/Index.cshtml.cs +++ b/hosts/main/Pages/Grants/Index.cshtml.cs @@ -52,7 +52,7 @@ public async Task OnGet() ClientLogoUrl = client.LogoUri, ClientUrl = client.ClientUri, Description = grant.Description, - Created = grant.CreationTime, + Created = grant.CreationTime, Expires = grant.Expiration, IdentityGrantNames = resources.IdentityResources.Select(x => x.DisplayName ?? x.Name).ToArray(), ApiGrantNames = resources.ApiScopes.Select(x => x.DisplayName ?? x.Name).ToArray() @@ -75,6 +75,7 @@ public async Task OnPost() { await _interaction.RevokeUserConsentAsync(ClientId); await _events.RaiseAsync(new GrantsRevokedEvent(User.GetSubjectId(), ClientId)); + Telemetry.Metrics.GrantsRevoked(ClientId); return RedirectToPage("/Grants/Index"); } diff --git a/hosts/main/Pages/Telemetry.cs b/hosts/main/Pages/Telemetry.cs new file mode 100644 index 000000000..cdc142833 --- /dev/null +++ b/hosts/main/Pages/Telemetry.cs @@ -0,0 +1,168 @@ +using System.Diagnostics.Metrics; + +namespace IdentityServerHost.Pages; + +#pragma warning disable CA1034 // Nested types should not be visible +#pragma warning disable CA1724 // Type names should not match namespaces + +/// +/// Telemetry helpers for the UI +/// +public static class Telemetry +{ + private static readonly string ServiceVersion = typeof(Telemetry).Assembly.GetName().Version!.ToString(); + + /// + /// Service name for telemetry. + /// + public static readonly string ServiceName = typeof(Telemetry).Assembly.GetName().Name!; + + /// + /// Metrics configuration + /// + public static class Metrics + { + /// + /// Name of Counters + /// + public static class Counters + { + /// + /// consent_granted + /// + public const string ConsentGranted = "consent_granted"; + + /// + /// consent_denied + /// + public const string ConsentDenied = "consent_denied"; + + /// + /// grants_revoked + /// + public const string GrantsRevoked = "grants_revoked"; + + /// + /// user_login + /// + public const string UserLogin = "user_login"; + + /// + /// user_login_failure + /// + public const string UserLoginFailure = "user_login_failure"; + + /// + /// user_logout + /// + public const string UserLogout = "user_logout"; + } + + /// + /// Name of tags + /// + public static class Tags + { + /// + /// client + /// + public const string Client = "client"; + + /// + /// error + /// + public const string Error = "error"; + + /// + /// idp + /// + public const string Idp = "idp"; + + /// + /// remember + /// + public const string Remember = "remember"; + + /// + /// scope + /// + public const string Scope = "scope"; + } + + /// + /// Meter for the IdentityServer host project + /// + private static readonly Meter Meter = new Meter(ServiceName, ServiceVersion); + + private static Counter ConsentGrantedCounter = Meter.CreateCounter(Counters.ConsentGranted); + + /// + /// Helper method to increase counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// + /// Client id + /// Scope names. Each element is added on it's own to the counter + public static void ConsentGranted(string clientId, IEnumerable scopes, bool remember) + { + ArgumentNullException.ThrowIfNull(scopes); + foreach(var scope in scopes) + { + ConsentGrantedCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope), new(Tags.Remember, remember)); + } + } + + private static Counter ConsentDeniedCounter = Meter.CreateCounter(Counters.ConsentDenied); + + /// + /// Helper method to increase counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// + /// Client id + /// Scope names. Each element is added on it's own to the counter + public static void ConsentDenied(string clientId, IEnumerable scopes) + { + ArgumentNullException.ThrowIfNull(scopes); + foreach (var scope in scopes) + { + ConsentDeniedCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope)); + } + } + + private static Counter GrantsRevokedCounter = Meter.CreateCounter(Counters.GrantsRevoked); + + /// + /// Helper method to increase the counter. + /// + /// Client id to revoke for, or null for all. + public static void GrantsRevoked(string? clientId) + => GrantsRevokedCounter.Add(1, tag: new(Tags.Client, clientId)); + + private static Counter UserLoginCounter = Meter.CreateCounter(Counters.UserLogin); + + /// + /// Helper method to increase counter. + /// + /// Client Id, if available + public static void UserLogin(string? clientId, string idp) + => UserLoginCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp)); + + private static Counter UserLoginFailureCounter = Meter.CreateCounter(Counters.UserLoginFailure); + + /// + /// Helper method to increase + /// Client Id, if available + /// Error message + public static void UserLoginFailure(string? clientId, string idp, string error) + => UserLoginFailureCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp), new(Tags.Error, error)); + + private static Counter UserLogoutCounter = Meter.CreateCounter(Counters.UserLogout); + + /// + /// Helper method to increase the counter. + /// + /// Idp/authentication scheme for external authentication, or "local" for built in. + public static void UserLogout(string? idp) + => UserLogoutCounter.Add(1, tag: new(Tags.Idp, idp)); + } +} diff --git a/src/Telemetry/Telemetry.cs b/src/Telemetry/Telemetry.cs index 5d3ccba43..a91c1a756 100644 --- a/src/Telemetry/Telemetry.cs +++ b/src/Telemetry/Telemetry.cs @@ -10,7 +10,7 @@ namespace Duende.IdentityServer; /// -/// Constants for Telemetry +/// Telemetry helpers /// public static class Telemetry {