Skip to content

Commit

Permalink
Simplify metrics and add documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
sschnabe committed Mar 3, 2023
1 parent c99bad7 commit 31620d8
Show file tree
Hide file tree
Showing 7 changed files with 129 additions and 137 deletions.
9 changes: 6 additions & 3 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
71 changes: 69 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
```
5 changes: 3 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -210,6 +210,7 @@
<version>${version.org.apache.maven.plugins.surefire}</version>
<configuration>
<failIfNoTests>true</failIfNoTests>
<redirectTestOutputToFile>${maven.test.redirectTestOutputToFile}</redirectTestOutputToFile>
</configuration>
</plugin>
<plugin>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
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;
Expand All @@ -18,22 +17,6 @@
*/
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;

Expand All @@ -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();
}
}
48 changes: 20 additions & 28 deletions src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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;
Expand All @@ -34,41 +35,32 @@ void loginAndAttempts(KeycloakClient keycloak, Prometheus prometheus) {
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));
assertTrue(keycloak.login(realmName2, username2, password2));
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"));
}
}
50 changes: 15 additions & 35 deletions src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.keycloak.events.EventType;

/**
* Client to access Prometheus metric values:
*
Expand All @@ -21,28 +23,21 @@ public Prometheus(PrometheusClient client) {
this.client = client;
}

public int loginAttempts() {
return scrap("keycloak_login_attempts_total").intValue();
}

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 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 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() {
Expand All @@ -63,19 +58,4 @@ public void scrap() {
})
.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();
}
}

0 comments on commit 31620d8

Please sign in to comment.