Add additional metrics for user/client/session count (#31)
This commit is contained in:
parent
566f31ddc2
commit
37dcc07309
11 changed files with 312 additions and 6 deletions
|
@ -0,0 +1,10 @@
|
|||
package io.kokuwa.keycloak.metrics.stats;
|
||||
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
/**
|
||||
* Factory for Keycloak metrics.
|
||||
*
|
||||
* @author Stephan Schnabel
|
||||
*/
|
||||
public interface MetricsStatsFactory extends ProviderFactory<MetricsStatsTask> {}
|
|
@ -0,0 +1,69 @@
|
|||
package io.kokuwa.keycloak.metrics.stats;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.Config.Scope;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.timer.TimerProvider;
|
||||
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
|
||||
/**
|
||||
* Implementation of {@link MetricsStatsFactory}.
|
||||
*
|
||||
* @author Stephan Schnabel
|
||||
*/
|
||||
public class MetricsStatsFactoryImpl implements MetricsStatsFactory {
|
||||
|
||||
private static final Logger log = Logger.getLogger(MetricsStatsFactory.class);
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return "default";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Scope config) {}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
|
||||
if (!"true".equals(System.getenv().get("KC_METRICS_STATS_ENABLED"))) {
|
||||
log.infov("Keycloak stats not enabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
var intervalDuration = Optional
|
||||
.ofNullable(System.getenv("KC_METRICS_STATS_INTERVAL"))
|
||||
.map(Duration::parse)
|
||||
.orElse(Duration.ofSeconds(60));
|
||||
var infoThreshold = Optional
|
||||
.ofNullable(System.getenv("KC_METRICS_STATS_INFO_THRESHOLD"))
|
||||
.map(Duration::parse)
|
||||
.orElse(Duration.ofMillis(Double.valueOf(intervalDuration.toMillis() * 0.5).longValue()));
|
||||
var warnThreshold = Optional
|
||||
.ofNullable(System.getenv("KC_METRICS_STATS_WARN_THRESHOLD"))
|
||||
.map(Duration::parse)
|
||||
.orElse(Duration.ofMillis(Double.valueOf(intervalDuration.toMillis() * 0.75).longValue()));
|
||||
log.infov("Keycloak stats enabled with interval of {0} and info/warn after {1}/{2}.",
|
||||
intervalDuration, infoThreshold, warnThreshold);
|
||||
|
||||
var interval = intervalDuration.toMillis();
|
||||
var task = new MetricsStatsTask(Metrics.globalRegistry, intervalDuration, infoThreshold, warnThreshold);
|
||||
KeycloakModelUtils.runJobInTransaction(factory, session -> session
|
||||
.getProvider(TimerProvider.class)
|
||||
.schedule(() -> KeycloakModelUtils.runJobInTransaction(factory, task), interval, "metrics"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public MetricsStatsTask create(KeycloakSession session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package io.kokuwa.keycloak.metrics.stats;
|
||||
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.provider.Spi;
|
||||
|
||||
/**
|
||||
* SPI for Keycloak metrics.
|
||||
*
|
||||
* @author Stephan Schnabel
|
||||
*/
|
||||
public class MetricsStatsSpi implements Spi {
|
||||
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "metrics";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return MetricsStatsTask.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory<? extends Provider>> getProviderFactoryClass() {
|
||||
// this must be an interface, otherwise spi will be silenty ignored
|
||||
return MetricsStatsFactory.class;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
package io.kokuwa.keycloak.metrics.stats;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionTask;
|
||||
import org.keycloak.provider.Provider;
|
||||
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
|
||||
/**
|
||||
* Keycloak metrics.
|
||||
*
|
||||
* @author Stephan Schnabel
|
||||
*/
|
||||
public class MetricsStatsTask implements Provider, KeycloakSessionTask {
|
||||
|
||||
private static final Logger log = Logger.getLogger(MetricsStatsTask.class);
|
||||
private final Map<String, AtomicLong> values = new HashMap<>();
|
||||
private final MeterRegistry registry;
|
||||
private final Duration interval;
|
||||
private final Duration infoThreshold;
|
||||
private final Duration warnThreshold;
|
||||
|
||||
MetricsStatsTask(MeterRegistry registry, Duration interval, Duration infoThreshold, Duration warnThreshold) {
|
||||
this.registry = registry;
|
||||
this.interval = interval;
|
||||
this.infoThreshold = infoThreshold;
|
||||
this.warnThreshold = warnThreshold;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(KeycloakSession session) {
|
||||
log.tracev("Triggered metrics stats task.");
|
||||
var start = Instant.now();
|
||||
|
||||
try {
|
||||
scrape(session);
|
||||
} catch (Exception e) {
|
||||
if (e instanceof org.hibernate.exception.SQLGrammarException) {
|
||||
log.infov("Metrics status task skipped, database not ready");
|
||||
} else {
|
||||
log.errorv(e, "Failed to scrape stats.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var duration = Duration.between(start, Instant.now());
|
||||
if (duration.compareTo(interval) > 0) {
|
||||
log.errorv("Finished scrapping keycloak stats in {0}, consider to increase interval", duration);
|
||||
} else if (duration.compareTo(warnThreshold) > 0) {
|
||||
log.warnv("Finished scrapping keycloak stats in {0}, consider to increase interval", duration);
|
||||
} else if (duration.compareTo(infoThreshold) > 0) {
|
||||
log.infov("Finished scrapping keycloak stats in {0}", duration);
|
||||
} else {
|
||||
log.debugv("Finished scrapping keycloak stats in {0}", duration);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
|
||||
private void scrape(KeycloakSession session) {
|
||||
session.realms().getRealmsStream().forEach(realm -> {
|
||||
var tagRealm = Tag.of("realm", realm.getName());
|
||||
gauge("keycloak_users", Set.of(tagRealm), session.users().getUsersCount(realm));
|
||||
gauge("keycloak_clients", Set.of(tagRealm), session.clients().getClientsCount(realm));
|
||||
var sessions = session.sessions();
|
||||
var activeSessions = sessions.getActiveClientSessionStats(realm, false);
|
||||
realm.getClientsStream().forEach(client -> {
|
||||
var tags = Set.of(tagRealm, Tag.of("client", client.getClientId()));
|
||||
gauge("keycloak_offline_sessions", tags, sessions.getOfflineSessionsCount(realm, client));
|
||||
gauge("keycloak_active_user_sessions", tags, sessions.getActiveUserSessions(realm, client));
|
||||
gauge("keycloak_active_client_sessions", tags, activeSessions.getOrDefault(client.getId(), 0L));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void gauge(String name, Set<Tag> tags, long value) {
|
||||
values.computeIfAbsent(name + tags, s -> registry.gauge(name, tags, new AtomicLong())).set(value);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
io.kokuwa.keycloak.metrics.stats.MetricsStatsFactoryImpl
|
|
@ -0,0 +1 @@
|
|||
io.kokuwa.keycloak.metrics.stats.MetricsStatsSpi
|
|
@ -1,11 +1,14 @@
|
|||
package io.kokuwa.keycloak.metrics;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertAll;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
@ -73,4 +76,41 @@ public class KeycloakIT {
|
|||
() -> assertEquals(loginErrorBefore1 + 0, loginErrorAfter1, "login failure #1"),
|
||||
() -> assertEquals(loginErrorBefore2 + 1, loginErrorAfter2, "login failure #2"));
|
||||
}
|
||||
|
||||
@DisplayName("user count")
|
||||
@Test
|
||||
void userCount(KeycloakClient keycloak, Prometheus prometheus) {
|
||||
|
||||
var realmName1 = "userCount_1";
|
||||
var realmName2 = "userCount_2";
|
||||
var username = UUID.randomUUID().toString();
|
||||
|
||||
keycloak.createRealm(realmName1);
|
||||
keycloak.createRealm(realmName2);
|
||||
|
||||
await(() -> prometheus.userCount(realmName1) == 0, prometheus, "realm 1 not found");
|
||||
await(() -> prometheus.userCount(realmName2) == 0, prometheus, "realm 2 not found");
|
||||
|
||||
keycloak.createUser(realmName1, username, UUID.randomUUID().toString());
|
||||
keycloak.createUser(realmName1, UUID.randomUUID().toString(), UUID.randomUUID().toString());
|
||||
keycloak.createUser(realmName1, UUID.randomUUID().toString(), UUID.randomUUID().toString());
|
||||
keycloak.createUser(realmName2, UUID.randomUUID().toString(), UUID.randomUUID().toString());
|
||||
|
||||
await(() -> prometheus.userCount(realmName1) == 3, prometheus, "realm 1 shoud have 3 users");
|
||||
await(() -> prometheus.userCount(realmName2) == 1, prometheus, "realm 2 shoud have 1 users");
|
||||
|
||||
keycloak.deleteUser(realmName1, username);
|
||||
|
||||
await(() -> prometheus.userCount(realmName1) == 2, prometheus, "realm 1 shoud have 2 users after deletion");
|
||||
await(() -> prometheus.userCount(realmName2) == 1, prometheus, "realm 2 shoud have 1 users");
|
||||
}
|
||||
|
||||
void await(Supplier<Boolean> check, Prometheus prometheus, String message) {
|
||||
var end = Instant.now().plusSeconds(10);
|
||||
while (Instant.now().isBefore(end) && !check.get()) {
|
||||
assertDoesNotThrow(() -> Thread.sleep(1000));
|
||||
prometheus.scrap();
|
||||
}
|
||||
assertTrue(check.get(), message);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package io.kokuwa.keycloak.metrics.junit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
@ -45,7 +47,8 @@ public class KeycloakClient {
|
|||
client.setClientId(clientId);
|
||||
client.setPublicClient(true);
|
||||
client.setDirectAccessGrantsEnabled(true);
|
||||
keycloak.realms().realm(realmName).clients().create(client);
|
||||
var response = keycloak.realms().realm(realmName).clients().create(client);
|
||||
assertEquals(201, response.getStatus());
|
||||
}
|
||||
|
||||
public void createUser(String realmName, String username, String password) {
|
||||
|
@ -59,7 +62,15 @@ public class KeycloakClient {
|
|||
user.setEmailVerified(true);
|
||||
user.setUsername(username);
|
||||
user.setCredentials(List.of(credential));
|
||||
keycloak.realms().realm(realmName).users().create(user);
|
||||
var response = keycloak.realms().realm(realmName).users().create(user);
|
||||
assertEquals(201, response.getStatus());
|
||||
}
|
||||
|
||||
public void deleteUser(String realmName, String username) {
|
||||
keycloak.realms().realm(realmName).users()
|
||||
.searchByUsername(username, true).stream()
|
||||
.map(UserRepresentation::getId)
|
||||
.forEach(keycloak.realms().realm(realmName).users()::delete);
|
||||
}
|
||||
|
||||
public boolean login(String clientId, String realmName, String username, String password) {
|
||||
|
|
|
@ -55,8 +55,11 @@ public class KeycloakExtension implements BeforeAllCallback, ParameterResolver {
|
|||
.withEnv("KEYCLOAK_ADMIN", "admin")
|
||||
.withEnv("KEYCLOAK_ADMIN_PASSWORD", "password")
|
||||
.withEnv("KC_LOG_CONSOLE_COLOR", "true")
|
||||
.withEnv("KC_LOG_LEVEL", "io.kokuwa:trace")
|
||||
.withEnv("KC_HEALTH_ENABLED", "true")
|
||||
.withEnv("KC_METRICS_ENABLED", "true")
|
||||
.withEnv("KC_METRICS_STATS_ENABLED", "true")
|
||||
.withEnv("KC_METRICS_STATS_INTERVAL", "PT1s")
|
||||
.withCopyFileToContainer(MountableFile.forHostPath(jar), "/opt/keycloak/providers/metrics.jar")
|
||||
.withLogConsumer(out -> System.out.print(out.getUtf8String()))
|
||||
.withExposedPorts(8080)
|
||||
|
|
|
@ -41,6 +41,14 @@ public class Prometheus {
|
|||
.sum();
|
||||
}
|
||||
|
||||
public int userCount(String realm) {
|
||||
return state.stream()
|
||||
.filter(metric -> Objects.equals(metric.name(), "keycloak_users"))
|
||||
.filter(metric -> Objects.equals(metric.tags().get("realm"), realm))
|
||||
.mapToInt(metric -> metric.value().intValue())
|
||||
.sum();
|
||||
}
|
||||
|
||||
public void scrap() {
|
||||
state.clear();
|
||||
Stream.of(client.scrap().split("[\\r\\n]+"))
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue