diff --git a/README.md b/README.md index 873d865..120d06b 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,23 @@ keycloak_event_admin_total{error="",operation="CREATE",realm="1fdb3465-1675-49e8 keycloak_event_admin_total{error="",operation="CREATE",realm="9039a0b5-e8c9-437a-a02e-9d91b04548a4",resource="USER",} 1.0 ``` +## Configuration + +### `KC_METRICS_EVENT_REPLACE_IDS` + +If set to `true` than replace model ids with names: + +* [RealmModel#getId()](https://github.com/keycloak/keycloak/blob/main/server-spi/src/main/java/org/keycloak/models/RealmModel.java#L82) with [RealmModel#getName()](https://github.com/keycloak/keycloak/blob/main/server-spi/src/main/java/org/keycloak/models/RealmModel.java#L84) +* [ClientModel#getId()](https://github.com/keycloak/keycloak/blob/main/server-spi/src/main/java/org/keycloak/models/ClientModel.java#L106) with [ClientModel#getClientId()](https://github.com/keycloak/keycloak/blob/main/server-spi/src/main/java/org/keycloak/models/ClientModel.java#L112) + +Metrics: + +```txt +keycloak_event_user_total{client="test-client",error="",realm="test-realm",type="LOGIN",} 2.0 +keycloak_event_user_total{client="other-client",error="",realm="other-realm",type="LOGIN",} 1.0 +keycloak_event_user_total{client="other-client",error="invalid_user_credentials",realm="other-realm",type="LOGIN_ERROR",} 1.0 +``` + ## Installation ### Testcontainers diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java index dc04ac4..fba4baf 100644 --- a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java @@ -1,8 +1,12 @@ package io.kokuwa.keycloak.metrics; +import java.util.Optional; + +import org.jboss.logging.Logger; import org.keycloak.events.Event; import org.keycloak.events.EventListenerProvider; import org.keycloak.events.admin.AdminEvent; +import org.keycloak.models.KeycloakSession; import io.micrometer.core.instrument.MeterRegistry; @@ -13,18 +17,23 @@ import io.micrometer.core.instrument.MeterRegistry; */ public class MicrometerEventListener implements EventListenerProvider, AutoCloseable { + private static final Logger log = Logger.getLogger(MicrometerEventListener.class); private final MeterRegistry registry; + private final KeycloakSession session; + private final boolean replace; - public MicrometerEventListener(MeterRegistry registry) { + public MicrometerEventListener(MeterRegistry registry, KeycloakSession session, boolean replaceId) { this.registry = registry; + this.session = session; + this.replace = replaceId; } @Override public void onEvent(Event event) { registry.counter("keycloak_event_user", - "realm", toBlank(event.getRealmId()), + "realm", toBlank(replace ? getRealmName(event.getRealmId()) : event.getRealmId()), "type", toBlank(event.getType()), - "client", toBlank(event.getClientId()), + "client", toBlank(replace ? getClientId(event.getRealmId(), event.getClientId()) : event.getClientId()), "error", toBlank(event.getError())) .increment(); } @@ -32,7 +41,7 @@ public class MicrometerEventListener implements EventListenerProvider, AutoClose @Override public void onEvent(AdminEvent event, boolean includeRepresentation) { registry.counter("keycloak_event_admin", - "realm", toBlank(event.getRealmId()), + "realm", toBlank(replace ? getRealmName(event.getRealmId()) : event.getRealmId()), "resource", toBlank(event.getResourceType()), "operation", toBlank(event.getOperationType()), "error", toBlank(event.getError())) @@ -45,4 +54,24 @@ public class MicrometerEventListener implements EventListenerProvider, AutoClose private String toBlank(Object value) { return value == null ? "" : value.toString(); } + + private String getRealmName(String realmId) { + var model = session.realms().getRealm(realmId); + if (model == null) { + log.warnv("Failed to resolve realm with id", realmId); + return realmId; + } + return model.getName(); + } + + private String getClientId(String realmId, String clientId) { + var model = Optional.ofNullable(session.realms().getRealm(realmId)) + .map(realm -> realm.getClientById(clientId)) + .orElse(null); + if (model == null) { + log.warnv("Failed to resolve client with id {} in realm {}", clientId, realmId); + return clientId; + } + return model.getClientId(); + } } diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java index f0dea73..8553df6 100644 --- a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java @@ -2,6 +2,7 @@ package io.kokuwa.keycloak.metrics; import javax.enterprise.inject.spi.CDI; +import org.jboss.logging.Logger; import org.keycloak.Config.Scope; import org.keycloak.events.EventListenerProvider; import org.keycloak.events.EventListenerProviderFactory; @@ -17,7 +18,9 @@ import io.micrometer.core.instrument.MeterRegistry; */ public class MicrometerEventListenerFactory implements EventListenerProviderFactory { + private static final Logger log = Logger.getLogger(MicrometerEventListener.class); private MeterRegistry registry; + private boolean replace; @Override public String getId() { @@ -25,7 +28,10 @@ public class MicrometerEventListenerFactory implements EventListenerProviderFact } @Override - public void init(Scope config) {} + public void init(Scope config) { + replace = "true".equals(System.getenv("KC_METRICS_EVENT_REPLACE_IDS")); + log.info(replace ? "Configured with model names." : "Configured with model ids."); + } @Override public void postInit(KeycloakSessionFactory factory) { @@ -34,7 +40,7 @@ public class MicrometerEventListenerFactory implements EventListenerProviderFact @Override public EventListenerProvider create(KeycloakSession session) { - return new MicrometerEventListener(registry); + return new MicrometerEventListener(registry, session, replace); } @Override diff --git a/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java b/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java index 67d545c..283f3a9 100644 --- a/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java +++ b/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java @@ -28,37 +28,42 @@ public class KeycloakIT { @Test void loginAndAttempts(KeycloakClient keycloak, Prometheus prometheus) { + var clientId1 = UUID.randomUUID().toString(); var realmName1 = UUID.randomUUID().toString(); var username1 = UUID.randomUUID().toString(); var password1 = UUID.randomUUID().toString(); + keycloak.createRealm(realmName1); + keycloak.createClient(realmName1, clientId1); + keycloak.createUser(realmName1, username1, password1); + + var clientId2 = UUID.randomUUID().toString(); var realmName2 = UUID.randomUUID().toString(); var username2 = UUID.randomUUID().toString(); var password2 = UUID.randomUUID().toString(); - var realmId1 = keycloak.createRealm(realmName1); - var realmId2 = keycloak.createRealm(realmName2); - keycloak.createUser(realmName1, username1, password1); + keycloak.createRealm(realmName2); + keycloak.createClient(realmName2, clientId2); keycloak.createUser(realmName2, username2, password2); prometheus.scrap(); var loginBefore = prometheus.userEvent(EventType.LOGIN); - var loginBefore1 = prometheus.userEvent(EventType.LOGIN, realmId1); - var loginBefore2 = prometheus.userEvent(EventType.LOGIN, realmId2); + var loginBefore1 = prometheus.userEvent(EventType.LOGIN, realmName1, clientId1); + var loginBefore2 = prometheus.userEvent(EventType.LOGIN, realmName2, clientId2); var loginErrorBefore = prometheus.userEvent(EventType.LOGIN_ERROR); - var loginErrorBefore1 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId1); - var loginErrorBefore2 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId2); + var loginErrorBefore1 = prometheus.userEvent(EventType.LOGIN_ERROR, realmName1, clientId1); + var loginErrorBefore2 = prometheus.userEvent(EventType.LOGIN_ERROR, realmName2, clientId2); - assertTrue(keycloak.login(realmName1, username1, password1)); - assertTrue(keycloak.login(realmName1, username1, password1)); - assertTrue(keycloak.login(realmName2, username2, password2)); - assertFalse(keycloak.login(realmName2, username2, "nope")); + assertTrue(keycloak.login(clientId1, realmName1, username1, password1)); + assertTrue(keycloak.login(clientId1, realmName1, username1, password1)); + assertTrue(keycloak.login(clientId2, realmName2, username2, password2)); + assertFalse(keycloak.login(clientId2, realmName2, username2, "nope")); prometheus.scrap(); var loginAfter = prometheus.userEvent(EventType.LOGIN); - var loginAfter1 = prometheus.userEvent(EventType.LOGIN, realmId1); - var loginAfter2 = prometheus.userEvent(EventType.LOGIN, realmId2); + var loginAfter1 = prometheus.userEvent(EventType.LOGIN, realmName1, clientId1); + var loginAfter2 = prometheus.userEvent(EventType.LOGIN, realmName2, clientId2); var loginErrorAfter = prometheus.userEvent(EventType.LOGIN_ERROR); - var loginErrorAfter1 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId1); - var loginErrorAfter2 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId2); + var loginErrorAfter1 = prometheus.userEvent(EventType.LOGIN_ERROR, realmName1, clientId1); + var loginErrorAfter2 = prometheus.userEvent(EventType.LOGIN_ERROR, realmName2, clientId2); assertAll("prometheus", () -> assertEquals(loginBefore + 3, loginAfter, "login success total"), diff --git a/src/test/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerTest.java b/src/test/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerTest.java index 29151d2..a195957 100644 --- a/src/test/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerTest.java +++ b/src/test/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerTest.java @@ -21,9 +21,12 @@ import org.keycloak.events.EventType; import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.ResourceType; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RealmProvider; import org.mockito.ArgumentCaptor; import org.mockito.Captor; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -38,8 +41,14 @@ import io.micrometer.core.instrument.MeterRegistry; @ExtendWith(MockitoExtension.class) public class MicrometerEventListenerTest { - @InjectMocks - MicrometerEventListener listener; + @Mock + KeycloakSession session; + @Mock + RealmProvider realmProvider; + @Mock + RealmModel realmModel; + @Mock + ClientModel clientModel; @Mock MeterRegistry registry; @Mock @@ -54,39 +63,91 @@ public class MicrometerEventListenerTest { when(registry.counter(metricCaptor.capture(), tagsCaptor.capture())).thenReturn(counter); } - @DisplayName("onEvent(Event)") + @DisplayName("onEvent(true)") @Nested class onEvent { - @DisplayName("without error") + @DisplayName("replace(true) - without error") @Test - void withoutError() { + void replaceWithoutError() { + + var realmId = UUID.randomUUID().toString(); + var realmName = UUID.randomUUID().toString(); + var clientId = UUID.randomUUID().toString(); + var clientName = UUID.randomUUID().toString(); + var type = EventType.LOGIN; + + when(session.realms()).thenReturn(realmProvider); + when(realmProvider.getRealm(realmId)).thenReturn(realmModel); + when(realmModel.getName()).thenReturn(realmName); + when(realmModel.getClientById(clientId)).thenReturn(clientModel); + when(clientModel.getClientId()).thenReturn(clientName); + + listener(true).onEvent(toEvent(realmId, clientId, type, null)); + assertEvent(realmName, clientName, type.toString(), ""); + } + + @DisplayName("replace(true) - with error") + @Test + void replaceWithError() { + + var realmId = UUID.randomUUID().toString(); + var realmName = UUID.randomUUID().toString(); + var clientId = UUID.randomUUID().toString(); + var clientName = UUID.randomUUID().toString(); + var type = EventType.LOGIN_ERROR; + var error = UUID.randomUUID().toString(); + + when(session.realms()).thenReturn(realmProvider); + when(realmProvider.getRealm(realmId)).thenReturn(realmModel); + when(realmModel.getName()).thenReturn(realmName); + when(realmModel.getClientById(clientId)).thenReturn(clientModel); + when(clientModel.getClientId()).thenReturn(clientName); + + listener(true).onEvent(toEvent(realmId, clientId, type, error)); + assertEvent(realmName, clientName, type.toString(), error); + } + + @DisplayName("replace(true) - all fields empty") + @Test + void replaceFieldsEmpty() { + + when(session.realms()).thenReturn(realmProvider); + when(realmProvider.getRealm(any())).thenReturn(null); + + listener(true).onEvent(toEvent(null, null, null, null)); + assertEvent("", "", "", ""); + } + + @DisplayName("replace(false) - without error") + @Test + void notReplaceWithoutError() { var realmId = UUID.randomUUID().toString(); var clientId = UUID.randomUUID().toString(); var type = EventType.LOGIN; - listener.onEvent(toEvent(realmId, clientId, type, null)); + listener(false).onEvent(toEvent(realmId, clientId, type, null)); assertEvent(realmId, clientId, type.toString(), ""); } - @DisplayName("with error") + @DisplayName("replace(false) - with error") @Test - void withError() { + void notReplaceWithError() { var realmId = UUID.randomUUID().toString(); var clientId = UUID.randomUUID().toString(); var type = EventType.LOGIN_ERROR; var error = UUID.randomUUID().toString(); - listener.onEvent(toEvent(realmId, clientId, type, error)); + listener(false).onEvent(toEvent(realmId, clientId, type, error)); assertEvent(realmId, clientId, type.toString(), error); } - @DisplayName("all fields empty") + @DisplayName("replace(false) - all fields empty") @Test - void fieldsEmpty() { - listener.onEvent(toEvent(null, null, null, null)); + void notReplaceFieldsEmpty() { + listener(false).onEvent(toEvent(null, null, null, null)); assertEvent("", "", "", ""); } @@ -112,35 +173,81 @@ public class MicrometerEventListenerTest { @Nested class onAdminEvent { - @DisplayName("without error") + @DisplayName("replace(true) - without error") @Test - void withoutError() { + void replaceWithoutError() { + + var realmId = UUID.randomUUID().toString(); + var realmName = UUID.randomUUID().toString(); + var resource = ResourceType.USER; + var operation = OperationType.CREATE; + + when(session.realms()).thenReturn(realmProvider); + when(realmProvider.getRealm(realmId)).thenReturn(realmModel); + when(realmModel.getName()).thenReturn(realmName); + + listener(true).onEvent(toAdminEvent(realmId, resource, operation, null), false); + assertAdminEvent(realmName, resource.toString(), operation.toString(), ""); + } + + @DisplayName("replace(true) - with error") + @Test + void replaceWithError() { + + var realmId = UUID.randomUUID().toString(); + var realmName = UUID.randomUUID().toString(); + var resource = ResourceType.USER; + var operation = OperationType.CREATE; + var error = UUID.randomUUID().toString(); + + when(session.realms()).thenReturn(realmProvider); + when(realmProvider.getRealm(realmId)).thenReturn(realmModel); + when(realmModel.getName()).thenReturn(realmName); + + listener(true).onEvent(toAdminEvent(realmId, resource, operation, error), false); + assertAdminEvent(realmName, resource.toString(), operation.toString(), error); + } + + @DisplayName("replace(true) - all fields empty") + @Test + void replaceFieldsEmpty() { + + when(session.realms()).thenReturn(realmProvider); + when(realmProvider.getRealm(any())).thenReturn(null); + + listener(true).onEvent(toAdminEvent(null, null, null, null), false); + assertAdminEvent("", "", "", ""); + } + + @DisplayName("replace(false) - without error") + @Test + void noReplaceWithoutError() { var realmId = UUID.randomUUID().toString(); var resource = ResourceType.USER; var operation = OperationType.CREATE; - listener.onEvent(toAdminEvent(realmId, resource, operation, null), false); + listener(false).onEvent(toAdminEvent(realmId, resource, operation, null), false); assertAdminEvent(realmId, resource.toString(), operation.toString(), ""); } - @DisplayName("with error") + @DisplayName("replace(false) - with error") @Test - void withError() { + void noReplaceWithError() { var realmId = UUID.randomUUID().toString(); var resource = ResourceType.USER; var operation = OperationType.CREATE; var error = UUID.randomUUID().toString(); - listener.onEvent(toAdminEvent(realmId, resource, operation, error), false); + listener(false).onEvent(toAdminEvent(realmId, resource, operation, error), false); assertAdminEvent(realmId, resource.toString(), operation.toString(), error); } - @DisplayName("all fields empty") + @DisplayName("replace(false) - all fields empty") @Test - void fieldsEmpty() { - listener.onEvent(toAdminEvent(null, null, null, null), false); + void noReplaceFieldsEmpty() { + listener(false).onEvent(toAdminEvent(null, null, null, null), false); assertAdminEvent("", "", "", ""); } @@ -162,6 +269,10 @@ public class MicrometerEventListenerTest { } } + private MicrometerEventListener listener(boolean replace) { + return new MicrometerEventListener(registry, session, replace); + } + private void assertCounter(String metric, Map tags) { verify(registry).counter(anyString(), any(String[].class)); verify(counter).increment(); diff --git a/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java index 6507440..a5cd952 100644 --- a/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java +++ b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java @@ -29,18 +29,20 @@ public class KeycloakClient { this.token = token; } - public String createRealm(String realmName) { - var client = new ClientRepresentation(); - client.setClientId("test"); - client.setPublicClient(true); - client.setDirectAccessGrantsEnabled(true); + public void createRealm(String realmName) { var realm = new RealmRepresentation(); realm.setEnabled(true); realm.setRealm(realmName); realm.setEventsListeners(List.of("metrics-listener")); - realm.setClients(List.of(client)); keycloak.realms().create(realm); - return keycloak.realms().realm(realmName).toRepresentation().getId(); + } + + public void createClient(String realmName, String clientId) { + var client = new ClientRepresentation(); + client.setClientId(clientId); + client.setPublicClient(true); + client.setDirectAccessGrantsEnabled(true); + keycloak.realms().realm(realmName).clients().create(client); } public void createUser(String realmName, String username, String password) { @@ -57,10 +59,10 @@ public class KeycloakClient { keycloak.realms().realm(realmName).users().create(user); } - public boolean login(String realmName, String username, String password) { + public boolean login(String clientId, String realmName, String username, String password) { try { token.grantToken(realmName, new MultivaluedHashMap<>(Map.of( - OAuth2Constants.CLIENT_ID, "test", + OAuth2Constants.CLIENT_ID, clientId, OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD, OAuth2Constants.USERNAME, username, OAuth2Constants.PASSWORD, password))); diff --git a/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java index 31ffe6c..892eceb 100644 --- a/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java +++ b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java @@ -60,6 +60,7 @@ public class KeycloakExtension implements BeforeAllCallback, ParameterResolver { .withEnv("KC_LOG_CONSOLE_COLOR", "true") .withEnv("KC_HEALTH_ENABLED", "true") .withEnv("KC_METRICS_ENABLED", "true") + .withEnv("KC_METRICS_EVENT_REPLACE_IDS", "true") .withCopyFileToContainer(MountableFile.forHostPath(jar), "/opt/keycloak/providers/metrics.jar") .withLogConsumer(out -> System.out.print(out.getUtf8String())) .withExposedPorts(8080) 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 d249edd..8fa1483 100644 --- a/src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java +++ b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java @@ -31,11 +31,12 @@ public class Prometheus { .sum(); } - public int userEvent(EventType type, String realmName) { + public int userEvent(EventType type, String realmName, String clientId) { 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)) + .filter(metric -> Objects.equals(metric.tags().get("client"), clientId)) .mapToInt(metric -> metric.value().intValue()) .sum(); }