Simplify metrics and add documentation

This commit is contained in:
Stephan Schnabel 2023-03-03 09:42:03 +01:00
parent c99bad79d2
commit 31620d8136
Signed by: stephan.schnabel
GPG key ID: E07AF5BA239FE543
7 changed files with 129 additions and 137 deletions

View file

@ -3,9 +3,12 @@ updates:
- package-ecosystem: maven - package-ecosystem: maven
directory: / directory: /
schedule: schedule:
interval: daily interval: monthly
allow: day: monday
- dependency-name: org.keycloak:keycloak-parent # github parses time without quotes to int
# yamllint disable-line rule:quoted-strings
time: "09:00"
timezone: Europe/Berlin
- package-ecosystem: github-actions - package-ecosystem: github-actions
directory: / directory: /
schedule: schedule:

View file

@ -61,9 +61,9 @@ jobs:
server-username: SERVER_USERNAME server-username: SERVER_USERNAME
server-password: SERVER_PASSWORD server-password: SERVER_PASSWORD
- run: mvn -B -ntp dependency:go-offline - 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' }} 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' }} if: ${{ github.ref == 'refs/heads/main' }}
env: env:
SERVER_USERNAME: ${{ secrets.SONATYPE_NEXUS_USERNAME }} SERVER_USERNAME: ${{ secrets.SONATYPE_NEXUS_USERNAME }}

View file

@ -1,7 +1,74 @@
# Keycloak Event Metrics # 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/) [![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) [![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
```

View file

@ -4,10 +4,10 @@
<groupId>io.kokuwa.keycloak</groupId> <groupId>io.kokuwa.keycloak</groupId>
<artifactId>keycloak-event-metrics</artifactId> <artifactId>keycloak-event-metrics</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.1.0-SNAPSHOT</version>
<name>Keycloak Metrics</name> <name>Keycloak Metrics</name>
<description>Provides metrics for Keycloak events</description> <description>Provides metrics for Keycloak user/admin events</description>
<url>https://github.com/kokuwaio/keycloak-event-metrics</url> <url>https://github.com/kokuwaio/keycloak-event-metrics</url>
<inceptionYear>2023</inceptionYear> <inceptionYear>2023</inceptionYear>
<organization> <organization>
@ -210,6 +210,7 @@
<version>${version.org.apache.maven.plugins.surefire}</version> <version>${version.org.apache.maven.plugins.surefire}</version>
<configuration> <configuration>
<failIfNoTests>true</failIfNoTests> <failIfNoTests>true</failIfNoTests>
<redirectTestOutputToFile>${maven.test.redirectTestOutputToFile}</redirectTestOutputToFile>
</configuration> </configuration>
</plugin> </plugin>
<plugin> <plugin>

View file

@ -3,7 +3,6 @@ package io.kokuwa.keycloak.metrics;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import org.keycloak.events.Event; import org.keycloak.events.Event;
import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.admin.AdminEvent;
@ -18,22 +17,6 @@ import io.micrometer.core.instrument.MeterRegistry;
*/ */
public class MicrometerEventRecorder { 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<String, Counter> counters = new HashMap<>(); private final Map<String, Counter> counters = new HashMap<>();
private final MeterRegistry registry; private final MeterRegistry registry;
@ -42,61 +25,27 @@ public class MicrometerEventRecorder {
} }
void adminEvent(AdminEvent event) { void adminEvent(AdminEvent event) {
counter(ADMIN_EVENT_PREFIX + event.getOperationType().name(), counter("keycloak_event_admin",
"realm", event.getRealmId(), "realm", toBlankIfNull(event.getRealmId()),
"resource", event.getResourceType() == null ? "" : event.getResourceType().name()); "resource", toBlankIfNull(event.getResourceType()),
"operation", toBlankIfNull(event.getOperationType()),
"error", toBlankIfNull(event.getError()));
} }
void userEvent(Event event) { void userEvent(Event event) {
counter("keycloak_event_user",
var tags = new String[] { "realm", toBlankIfNull(event.getRealmId()),
"provider", Optional "type", toBlankIfNull(event.getType()),
.ofNullable(event.getDetails()).orElseGet(Map::of) "client", toBlankIfNull(event.getClientId()),
.getOrDefault("identity_provider", "keycloak"), "error", toBlankIfNull(event.getError()));
"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);
}
} }
private void counter(String counter, String... tags) { private void counter(String counter, String... tags) {
counters.computeIfAbsent(counter + Arrays.toString(tags), string -> registry.counter(counter, tags)) counters.computeIfAbsent(counter + Arrays.toString(tags), string -> registry.counter(counter, tags))
.increment(); .increment();
} }
private String toBlankIfNull(Object value) {
return value == null ? "" : value.toString();
}
} }

View file

@ -10,6 +10,7 @@ import java.util.UUID;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; 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.KeycloakClient;
import io.kokuwa.keycloak.metrics.junit.KeycloakExtension; import io.kokuwa.keycloak.metrics.junit.KeycloakExtension;
@ -34,15 +35,12 @@ public class KeycloakIT {
keycloak.createUser(realmName2, username2, password2); keycloak.createUser(realmName2, username2, password2);
prometheus.scrap(); prometheus.scrap();
var loginAttemptsBefore = prometheus.loginAttempts(); var loginBefore = prometheus.userEvent(EventType.LOGIN);
var loginAttemptsBefore1 = prometheus.loginAttempts(realmId1); var loginBefore1 = prometheus.userEvent(EventType.LOGIN, realmId1);
var loginAttemptsBefore2 = prometheus.loginAttempts(realmId2); var loginBefore2 = prometheus.userEvent(EventType.LOGIN, realmId2);
var loginSuccessBefore = prometheus.loginSuccess(); var loginErrorBefore = prometheus.userEvent(EventType.LOGIN_ERROR);
var loginSuccessBefore1 = prometheus.loginSuccess(realmId1); var loginErrorBefore1 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId1);
var loginSuccessBefore2 = prometheus.loginSuccess(realmId2); var loginErrorBefore2 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId2);
var loginFailureBefore = prometheus.loginFailure();
var loginFailureBefore1 = prometheus.loginFailure(realmId1);
var loginFailureBefore2 = prometheus.loginFailure(realmId2);
assertTrue(keycloak.login(realmName1, username1, password1)); assertTrue(keycloak.login(realmName1, username1, password1));
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")); assertFalse(keycloak.login(realmName2, username2, "nope"));
prometheus.scrap(); prometheus.scrap();
var loginAttemptsAfter = prometheus.loginAttempts(); var loginAfter = prometheus.userEvent(EventType.LOGIN);
var loginAttemptsAfter1 = prometheus.loginAttempts(realmId1); var loginAfter1 = prometheus.userEvent(EventType.LOGIN, realmId1);
var loginAttemptsAfter2 = prometheus.loginAttempts(realmId2); var loginAfter2 = prometheus.userEvent(EventType.LOGIN, realmId2);
var loginSuccessAfter = prometheus.loginSuccess(); var loginErrorAfter = prometheus.userEvent(EventType.LOGIN_ERROR);
var loginSuccessAfter1 = prometheus.loginSuccess(realmId1); var loginErrorAfter1 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId1);
var loginSuccessAfter2 = prometheus.loginSuccess(realmId2); var loginErrorAfter2 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId2);
var loginFailureAfter = prometheus.loginFailure();
var loginFailureAfter1 = prometheus.loginFailure(realmId1);
var loginFailureAfter2 = prometheus.loginFailure(realmId2);
assertAll("promethus", assertAll("prometheus",
() -> assertEquals(loginAttemptsBefore + 4, loginAttemptsAfter, "login attempts total"), () -> assertEquals(loginBefore + 3, loginAfter, "login success total"),
() -> assertEquals(loginAttemptsBefore1 + 2, loginAttemptsAfter1, "login attempts #1"), () -> assertEquals(loginBefore1 + 2, loginAfter1, "login success #1"),
() -> assertEquals(loginAttemptsBefore2 + 2, loginAttemptsAfter2, "login attempts #2"), () -> assertEquals(loginBefore2 + 1, loginAfter2, "login success #2"),
() -> assertEquals(loginSuccessBefore + 3, loginSuccessAfter, "login success total"), () -> assertEquals(loginErrorBefore + 1, loginErrorAfter, "login failure total"),
() -> assertEquals(loginSuccessBefore1 + 2, loginSuccessAfter1, "login success #1"), () -> assertEquals(loginErrorBefore1 + 0, loginErrorAfter1, "login failure #1"),
() -> assertEquals(loginSuccessBefore2 + 1, loginSuccessAfter2, "login success #2"), () -> assertEquals(loginErrorBefore2 + 1, loginErrorAfter2, "login failure #2"));
() -> assertEquals(loginFailureBefore + 1, loginFailureAfter, "login failure total"),
() -> assertEquals(loginFailureBefore1 + 0, loginFailureAfter1, "login failure #1"),
() -> assertEquals(loginFailureBefore2 + 1, loginFailureAfter2, "login failure #2"));
} }
} }

View file

@ -7,6 +7,8 @@ import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.keycloak.events.EventType;
/** /**
* Client to access Prometheus metric values: * Client to access Prometheus metric values:
* *
@ -21,28 +23,21 @@ public class Prometheus {
this.client = client; this.client = client;
} }
public int loginAttempts() { public int userEvent(EventType type) {
return scrap("keycloak_login_attempts_total").intValue(); 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) { public int userEvent(EventType type, String realmName) {
return scrap("keycloak_login_attempts_total", "realm", realmName).intValue(); return state.stream()
} .filter(metric -> Objects.equals(metric.name(), "keycloak_event_user_total"))
.filter(metric -> Objects.equals(metric.tags().get("type"), type.toString()))
public int loginSuccess() { .filter(metric -> Objects.equals(metric.tags().get("realm"), realmName))
return scrap("keycloak_logins_total").intValue(); .mapToInt(metric -> metric.value().intValue())
} .sum();
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 void scrap() { public void scrap() {
@ -63,19 +58,4 @@ public class Prometheus {
}) })
.forEach(state::add); .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();
}
} }