First draft of implementation #1
7 changed files with 129 additions and 137 deletions
9
.github/dependabot.yml
vendored
9
.github/dependabot.yml
vendored
|
@ -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:
|
||||
|
|
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
|
@ -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 }}
|
||||
|
|
71
README.md
71
README.md
|
@ -1,7 +1,74 @@
|
|||
# Keycloak Event Metrics
|
||||
|
||||
Provides metrics for Keycloak events.
|
||||
Provides metrics for Keycloak user/admin events.
|
||||
|
||||
[](http://www.apache.org/licenses/)
|
||||
[](https://search.maven.org/search?q=g:%22io.kokuwa.keycloak%22%20AND%20a:%22keycloak-event-metrics%22)
|
||||
[](https://central.sonatype.com/search?namespace=io.kokuwa.keycloak&q=keycloak-event-metrics)
|
||||
[](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
|
||||
![]() I know, this was only an example. I know, this was only an example.
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
docker build . --tag keycloak:metrics
|
||||
docker run --rm -p8080 keycloak:metrics start-dev
|
||||
```
|
||||
|
|
5
pom.xml
5
pom.xml
|
@ -4,10 +4,10 @@
|
|||
|
||||
<groupId>io.kokuwa.keycloak</groupId>
|
||||
<artifactId>keycloak-event-metrics</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
|
||||
<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>
|
||||
<inceptionYear>2023</inceptionYear>
|
||||
<organization>
|
||||
|
@ -210,6 +210,7 @@
|
|||
<version>${version.org.apache.maven.plugins.surefire}</version>
|
||||
<configuration>
|
||||
<failIfNoTests>true</failIfNoTests>
|
||||
<redirectTestOutputToFile>${maven.test.redirectTestOutputToFile}</redirectTestOutputToFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
|
|
|
@ -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<String, Counter> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue
If put under /opt/keycloak/providers , you dont need to rebuild, its enough to just start. That allows distribution of the provider through init containers.