diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7a74c74..9984b24 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,9 +3,12 @@ updates: - package-ecosystem: maven directory: / schedule: - interval: daily - allow: - - dependency-name: org.keycloak:keycloak-parent + interval: monthly + day: monday + # github parses time without quotes to int + # yamllint disable-line rule:quoted-strings + time: "09:00" + timezone: Europe/Berlin - package-ecosystem: github-actions directory: / schedule: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0ab5ab5..f0a851e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -61,9 +61,9 @@ jobs: server-username: SERVER_USERNAME server-password: SERVER_PASSWORD - run: mvn -B -ntp dependency:go-offline - - run: mvn -B -ntp verify -Dcheckstyle.skip + - run: mvn -B -ntp verify -Dcheckstyle.skip -Dmaven.test.redirectTestOutputToFile=false if: ${{ github.ref != 'refs/heads/main' }} - - run: mvn -B -ntp deploy -Dcheckstyle.skip + - run: mvn -B -ntp deploy -Dcheckstyle.skip -Dmaven.test.redirectTestOutputToFile=false if: ${{ github.ref == 'refs/heads/main' }} env: SERVER_USERNAME: ${{ secrets.SONATYPE_NEXUS_USERNAME }} diff --git a/README.md b/README.md index 30c1cd6..3451ad6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,74 @@ # Keycloak Event Metrics -Provides metrics for Keycloak events. +Provides metrics for Keycloak user/admin events. [![Apache License, Version 2.0, January 2004](https://img.shields.io/github/license/kokuwaio/keycloak-event-metrics.svg?label=License)](http://www.apache.org/licenses/) -[![Maven Central](https://img.shields.io/maven-central/v/io.kokuwa.micronaut/keycloak-event-metrics.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:%22io.kokuwa.keycloak%22%20AND%20a:%22keycloak-event-metrics%22) +[![Maven Central](https://img.shields.io/maven-central/v/io.kokuwa.keycloak/keycloak-event-metrics.svg?label=Maven%20Central)](https://central.sonatype.com/search?namespace=io.kokuwa.keycloak&q=keycloak-event-metrics) [![CI](https://img.shields.io/github/actions/workflow/status/kokuwaio/keycloak-event-metrics/ci.yaml?branch=main&label=CI)](https://github.com/kokuwaio/keycloak-event-metrics/actions/workflows/ci.yaml?query=branch%3Amain) + +## What? + +Resuses micrometer from Quarkus distribution to add metrics for Keycloak for events. + +### User Events + +User events are added with key `keycloak_event_user_total` and tags: + +* `type`: [EventType](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/EventType.java#L27) from [Event#type](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/Event.java#L44) +* `realm`: realm id from [Event#realmId](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/Event.java#L46) +* `client`: client id from [Event#clientId](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/Event.java#L48) +* `error`: error from [Event#error](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/Event.java#L56), only present for error types + +Examples: + +```txt +keycloak_event_user_total{client="test",realm="9039a0b5-e8c9-437a-a02e-9d91b04548a4",type="LOGIN",error="",} 2.0 +keycloak_event_user_total{client="test",realm="1fdb3465-1675-49e8-88ad-292e2f42ee72",type="LOGIN",error="",} 1.0 +keycloak_event_user_total{client="test",realm="1fdb3465-1675-49e8-88ad-292e2f42ee72",type="LOGIN_ERROR",error="invalid_user_credentials",} 1.0 +``` + +### Admin Events + +Admin events are added with key `keycloak_event_admin_total` and tags: + +* `realm`: realm id from [AdminEvent#realmId](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/admin/AdminEvent.java#L44) +* `operation`: [OperationType](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/admin/OperationType.java#L27) from [AdminEvent#operationType](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/admin/AdminEvent.java#L53) +* `resource`: [ResourceType](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/admin/ResourceType.java#L24) from [AdminEvent#resourceType](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/admin/AdminEvent.java#L51) +* `error`: error from [AdminEvent#error](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/admin/AdminEvent.java#L59), only present for error types + +Examples: + +```txt +keycloak_event_admin_total{error="",operation="CREATE",realm="1fdb3465-1675-49e8-88ad-292e2f42ee72",resource="USER",} 1.0 +keycloak_event_admin_total{error="",operation="CREATE",realm="9039a0b5-e8c9-437a-a02e-9d91b04548a4",resource="USER",} 1.0 +``` + +## Installation + +### Testcontainers + +For usage in [Testcontainers](https://www.testcontainers.org/) see [KeycloakExtension.java](src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java#L57-L68) + +### Docker + +Dockerfile: + +```Dockerfile +FROM quay.io/keycloak/keycloak:21.0.1 + +ENV KEYCLOAK_ADMIN=admin +ENV KEYCLOAK_ADMIN_PASSWORD=password +ENV KC_HEALTH_ENABLED=true +ENV KC_METRICS_ENABLED=true +ENV KC_LOG_CONSOLE_COLOR=true + +ADD target/keycloak-event-metrics-0.0.1-SNAPSHOT.jar /opt/keycloak/providers +RUN /opt/keycloak/bin/kc.sh build +``` + +Run: + +```sh +docker build . --tag keycloak:metrics +docker run --rm -p8080 keycloak:metrics start-dev +``` diff --git a/pom.xml b/pom.xml index 541ce84..ab51bbd 100644 --- a/pom.xml +++ b/pom.xml @@ -4,10 +4,10 @@ io.kokuwa.keycloak keycloak-event-metrics - 0.0.1-SNAPSHOT + 0.1.0-SNAPSHOT Keycloak Metrics - Provides metrics for Keycloak events + Provides metrics for Keycloak user/admin events https://github.com/kokuwaio/keycloak-event-metrics 2023 @@ -210,6 +210,7 @@ ${version.org.apache.maven.plugins.surefire} true + ${maven.test.redirectTestOutputToFile} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java index 3102a38..99320c6 100644 --- a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java @@ -3,7 +3,6 @@ package io.kokuwa.keycloak.metrics; import java.util.Arrays; import java.util.HashMap; import java.util.Map; -import java.util.Optional; import org.keycloak.events.Event; import org.keycloak.events.admin.AdminEvent; @@ -18,22 +17,6 @@ import io.micrometer.core.instrument.MeterRegistry; */ public class MicrometerEventRecorder { - private static final String PREFIX = "keycloak_"; - private static final String USER_EVENT_PREFIX = PREFIX + "user_event_"; - private static final String ADMIN_EVENT_PREFIX = PREFIX + "admin_event_"; - - private static final String LOGIN_ATTEMPTS = PREFIX + "login_attempts"; - private static final String LOGIN_SUCCESS = PREFIX + "logins"; - private static final String LOGIN_FAILURE = PREFIX + "failed_login_attempts"; - private static final String CLIENT_LOGIN_SUCCESS = PREFIX + "client_logins"; - private static final String CLIENT_LOGIN_FAILURE = PREFIX + "failed_client_login_attempts"; - private static final String REGISTER_SUCCESS = PREFIX + "registrations"; - private static final String REGISTER_FAILURE = PREFIX + "registrations_errors"; - private static final String REFRESH_TOKEN_SUCCESS = PREFIX + "refresh_tokens"; - private static final String REFRESH_TOKEN_FAILURE = PREFIX + "refresh_tokens_errors"; - private static final String CODE_TO_TOKEN_SUCCESS = PREFIX + "code_to_tokens"; - private static final String CODE_TO_TOKEN_FAILURE = PREFIX + "code_to_tokens_errors"; - private final Map counters = new HashMap<>(); private final MeterRegistry registry; @@ -42,61 +25,27 @@ public class MicrometerEventRecorder { } void adminEvent(AdminEvent event) { - counter(ADMIN_EVENT_PREFIX + event.getOperationType().name(), - "realm", event.getRealmId(), - "resource", event.getResourceType() == null ? "" : event.getResourceType().name()); + counter("keycloak_event_admin", + "realm", toBlankIfNull(event.getRealmId()), + "resource", toBlankIfNull(event.getResourceType()), + "operation", toBlankIfNull(event.getOperationType()), + "error", toBlankIfNull(event.getError())); } void userEvent(Event event) { - - var tags = new String[] { - "provider", Optional - .ofNullable(event.getDetails()).orElseGet(Map::of) - .getOrDefault("identity_provider", "keycloak"), - "realm", event.getRealmId() == null ? "" : event.getRealmId(), - "client_id", event.getClientId() == null ? "" : event.getClientId(), - "error", event.getError() == null ? "" : event.getError() }; - - switch (event.getType()) { - case LOGIN: - counter(LOGIN_ATTEMPTS, tags); - counter(LOGIN_SUCCESS, tags); - break; - case LOGIN_ERROR: - counter(LOGIN_ATTEMPTS, tags); - counter(LOGIN_FAILURE, tags); - break; - case CLIENT_LOGIN: - counter(CLIENT_LOGIN_SUCCESS, tags); - break; - case CLIENT_LOGIN_ERROR: - counter(CLIENT_LOGIN_FAILURE, tags); - break; - case REGISTER: - counter(REGISTER_SUCCESS, tags); - break; - case REGISTER_ERROR: - counter(REGISTER_FAILURE, tags); - break; - case REFRESH_TOKEN: - counter(REFRESH_TOKEN_SUCCESS, tags); - break; - case REFRESH_TOKEN_ERROR: - counter(REFRESH_TOKEN_FAILURE, tags); - break; - case CODE_TO_TOKEN: - counter(CODE_TO_TOKEN_SUCCESS, tags); - break; - case CODE_TO_TOKEN_ERROR: - counter(CODE_TO_TOKEN_FAILURE, tags); - break; - default: - counter(USER_EVENT_PREFIX + event.getType().name(), tags); - } + counter("keycloak_event_user", + "realm", toBlankIfNull(event.getRealmId()), + "type", toBlankIfNull(event.getType()), + "client", toBlankIfNull(event.getClientId()), + "error", toBlankIfNull(event.getError())); } private void counter(String counter, String... tags) { counters.computeIfAbsent(counter + Arrays.toString(tags), string -> registry.counter(counter, tags)) .increment(); } + + private String toBlankIfNull(Object value) { + return value == null ? "" : value.toString(); + } } diff --git a/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java b/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java index 0d57db0..c3115fe 100644 --- a/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java +++ b/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java @@ -10,6 +10,7 @@ import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.keycloak.events.EventType; import io.kokuwa.keycloak.metrics.junit.KeycloakClient; import io.kokuwa.keycloak.metrics.junit.KeycloakExtension; @@ -34,15 +35,12 @@ public class KeycloakIT { keycloak.createUser(realmName2, username2, password2); prometheus.scrap(); - var loginAttemptsBefore = prometheus.loginAttempts(); - var loginAttemptsBefore1 = prometheus.loginAttempts(realmId1); - var loginAttemptsBefore2 = prometheus.loginAttempts(realmId2); - var loginSuccessBefore = prometheus.loginSuccess(); - var loginSuccessBefore1 = prometheus.loginSuccess(realmId1); - var loginSuccessBefore2 = prometheus.loginSuccess(realmId2); - var loginFailureBefore = prometheus.loginFailure(); - var loginFailureBefore1 = prometheus.loginFailure(realmId1); - var loginFailureBefore2 = prometheus.loginFailure(realmId2); + var loginBefore = prometheus.userEvent(EventType.LOGIN); + var loginBefore1 = prometheus.userEvent(EventType.LOGIN, realmId1); + var loginBefore2 = prometheus.userEvent(EventType.LOGIN, realmId2); + var loginErrorBefore = prometheus.userEvent(EventType.LOGIN_ERROR); + var loginErrorBefore1 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId1); + var loginErrorBefore2 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId2); assertTrue(keycloak.login(realmName1, username1, password1)); assertTrue(keycloak.login(realmName1, username1, password1)); @@ -50,25 +48,19 @@ public class KeycloakIT { assertFalse(keycloak.login(realmName2, username2, "nope")); prometheus.scrap(); - var loginAttemptsAfter = prometheus.loginAttempts(); - var loginAttemptsAfter1 = prometheus.loginAttempts(realmId1); - var loginAttemptsAfter2 = prometheus.loginAttempts(realmId2); - var loginSuccessAfter = prometheus.loginSuccess(); - var loginSuccessAfter1 = prometheus.loginSuccess(realmId1); - var loginSuccessAfter2 = prometheus.loginSuccess(realmId2); - var loginFailureAfter = prometheus.loginFailure(); - var loginFailureAfter1 = prometheus.loginFailure(realmId1); - var loginFailureAfter2 = prometheus.loginFailure(realmId2); + var loginAfter = prometheus.userEvent(EventType.LOGIN); + var loginAfter1 = prometheus.userEvent(EventType.LOGIN, realmId1); + var loginAfter2 = prometheus.userEvent(EventType.LOGIN, realmId2); + var loginErrorAfter = prometheus.userEvent(EventType.LOGIN_ERROR); + var loginErrorAfter1 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId1); + var loginErrorAfter2 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId2); - assertAll("promethus", - () -> assertEquals(loginAttemptsBefore + 4, loginAttemptsAfter, "login attempts total"), - () -> assertEquals(loginAttemptsBefore1 + 2, loginAttemptsAfter1, "login attempts #1"), - () -> assertEquals(loginAttemptsBefore2 + 2, loginAttemptsAfter2, "login attempts #2"), - () -> assertEquals(loginSuccessBefore + 3, loginSuccessAfter, "login success total"), - () -> assertEquals(loginSuccessBefore1 + 2, loginSuccessAfter1, "login success #1"), - () -> assertEquals(loginSuccessBefore2 + 1, loginSuccessAfter2, "login success #2"), - () -> assertEquals(loginFailureBefore + 1, loginFailureAfter, "login failure total"), - () -> assertEquals(loginFailureBefore1 + 0, loginFailureAfter1, "login failure #1"), - () -> assertEquals(loginFailureBefore2 + 1, loginFailureAfter2, "login failure #2")); + assertAll("prometheus", + () -> assertEquals(loginBefore + 3, loginAfter, "login success total"), + () -> assertEquals(loginBefore1 + 2, loginAfter1, "login success #1"), + () -> assertEquals(loginBefore2 + 1, loginAfter2, "login success #2"), + () -> assertEquals(loginErrorBefore + 1, loginErrorAfter, "login failure total"), + () -> assertEquals(loginErrorBefore1 + 0, loginErrorAfter1, "login failure #1"), + () -> assertEquals(loginErrorBefore2 + 1, loginErrorAfter2, "login failure #2")); } } diff --git a/src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java index fce4562..d249edd 100644 --- a/src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java +++ b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java @@ -7,6 +7,8 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.keycloak.events.EventType; + /** * Client to access Prometheus metric values: * @@ -21,28 +23,21 @@ public class Prometheus { this.client = client; } - public int loginAttempts() { - return scrap("keycloak_login_attempts_total").intValue(); + public int userEvent(EventType type) { + return state.stream() + .filter(metric -> Objects.equals(metric.name(), "keycloak_event_user_total")) + .filter(metric -> Objects.equals(metric.tags().get("type"), type.toString())) + .mapToInt(metric -> metric.value().intValue()) + .sum(); } - public int loginAttempts(String realmName) { - return scrap("keycloak_login_attempts_total", "realm", realmName).intValue(); - } - - public int loginSuccess() { - return scrap("keycloak_logins_total").intValue(); - } - - public int loginSuccess(String realmName) { - return scrap("keycloak_logins_total", "realm", realmName).intValue(); - } - - public int loginFailure() { - return scrap("keycloak_failed_login_attempts_total").intValue(); - } - - public int loginFailure(String realmName) { - return scrap("keycloak_failed_login_attempts_total", "realm", realmName).intValue(); + public int userEvent(EventType type, String realmName) { + return state.stream() + .filter(metric -> Objects.equals(metric.name(), "keycloak_event_user_total")) + .filter(metric -> Objects.equals(metric.tags().get("type"), type.toString())) + .filter(metric -> Objects.equals(metric.tags().get("realm"), realmName)) + .mapToInt(metric -> metric.value().intValue()) + .sum(); } public void scrap() { @@ -63,19 +58,4 @@ public class Prometheus { }) .forEach(state::add); } - - private Double scrap(String name) { - return state.stream() - .filter(metric -> Objects.equals(metric.name(), name)) - .mapToDouble(PrometheusMetric::value) - .sum(); - } - - private Double scrap(String name, String tag, String value) { - return state.stream() - .filter(metric -> Objects.equals(metric.name(), name)) - .filter(metric -> Objects.equals(metric.tags().get(tag), value)) - .mapToDouble(PrometheusMetric::value) - .sum(); - } }