First draft of implementation #1
23 changed files with 1320 additions and 1 deletions
4
.github/CODEOWNERS
vendored
Normal file
4
.github/CODEOWNERS
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
# https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax
|
||||
* @sschnabe @rpahli @fabian-schlegel @jschwarze @wistefan @monotek
|
||||
.github/workflows/* @kokuwaio-bot
|
||||
pom.xml @kokuwaio-bot
|
20
.github/dependabot.yml
vendored
Normal file
20
.github/dependabot.yml
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: maven
|
||||
directory: /
|
||||
schedule:
|
||||
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:
|
||||
interval: monthly
|
||||
day: monday
|
||||
# github parses time without quotes to int
|
||||
# yamllint disable-line rule:quoted-strings
|
||||
time: "09:00"
|
||||
timezone: Europe/Berlin
|
70
.github/workflows/ci.yaml
vendored
Normal file
70
.github/workflows/ci.yaml
vendored
Normal file
|
@ -0,0 +1,70 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request: {}
|
||||
|
||||
jobs:
|
||||
|
||||
yaml:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ibiqlik/action-yamllint@v3
|
||||
with:
|
||||
format: colored
|
||||
strict: true
|
||||
|
||||
markdown:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: avto-dev/markdown-lint@v1
|
||||
with:
|
||||
args: /github/workspace
|
||||
|
||||
javadoc:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: maven
|
||||
- run: mvn -B -ntp dependency:go-offline
|
||||
- run: mvn -B -ntp javadoc:javadoc-no-fork -Ddoclint=all
|
||||
|
||||
checkstyle:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: maven
|
||||
- run: mvn -B -ntp dependency:go-offline
|
||||
- run: mvn -B -ntp checkstyle:check
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: maven
|
||||
server-id: sonatype-nexus
|
||||
server-username: SERVER_USERNAME
|
||||
server-password: SERVER_PASSWORD
|
||||
- run: mvn -B -ntp dependency:go-offline
|
||||
- run: mvn -B -ntp verify -Dcheckstyle.skip -Dmaven.test.redirectTestOutputToFile=false
|
||||
if: ${{ github.ref != 'refs/heads/main' }}
|
||||
- run: mvn -B -ntp deploy -Dcheckstyle.skip -Dmaven.test.redirectTestOutputToFile=false
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
SERVER_USERNAME: ${{ secrets.SONATYPE_NEXUS_USERNAME }}
|
||||
SERVER_PASSWORD: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}
|
17
.github/workflows/dependabot.yaml
vendored
Normal file
17
.github/workflows/dependabot.yaml
vendored
Normal file
|
@ -0,0 +1,17 @@
|
|||
name: Dependabot
|
||||
|
||||
on: pull_request_target
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
steps:
|
||||
- run: gh pr review --approve "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GITHUB_TOKEN: ${{ secrets.GIT_ACTION_TOKEN }}
|
||||
- run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GITHUB_TOKEN: ${{ secrets.GIT_ACTION_TOKEN }}
|
34
.github/workflows/release.yaml
vendored
Normal file
34
.github/workflows/release.yaml
vendored
Normal file
|
@ -0,0 +1,34 @@
|
|||
name: Release
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
token: ${{ secrets.GIT_ACTION_TOKEN }}
|
||||
- uses: crazy-max/ghaction-import-gpg@v5
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||
git_user_signingkey: true
|
||||
git_commit_gpgsign: true
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: maven
|
||||
server-id: sonatype-nexus
|
||||
server-username: SERVER_USERNAME
|
||||
server-password: SERVER_PASSWORD
|
||||
gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
gpg-passphrase: GPG_PASSPHRASE
|
||||
- run: mvn -B -ntp dependency:go-offline
|
||||
- run: mvn -B -ntp release:prepare
|
||||
- run: mvn -B -ntp release:perform
|
||||
env:
|
||||
SERVER_USERNAME: ${{ secrets.SONATYPE_NEXUS_USERNAME }}
|
||||
SERVER_PASSWORD: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}
|
||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
9
.markdownlint.yaml
Normal file
9
.markdownlint.yaml
Normal file
|
@ -0,0 +1,9 @@
|
|||
# Default state for all rules
|
||||
default: true
|
||||
|
||||
# MD009 - Trailing spaces
|
||||
MD009:
|
||||
strict: true
|
||||
|
||||
# MD013 - Line length
|
||||
MD013: false
|
19
.yamllint
Normal file
19
.yamllint
Normal file
|
@ -0,0 +1,19 @@
|
|||
extends: default
|
||||
|
||||
## see https://yamllint.readthedocs.io/en/stable/rules.html
|
||||
rules:
|
||||
|
||||
# no need for document start
|
||||
document-start: disable
|
||||
|
||||
# line length is not important
|
||||
line-length: disable
|
||||
|
||||
# force double quotes everywhere
|
||||
quoted-strings:
|
||||
quote-type: double
|
||||
required: only-when-needed
|
||||
|
||||
# allow everything on keys
|
||||
truthy:
|
||||
check-keys: false
|
201
LICENSE
Normal file
201
LICENSE
Normal file
|
@ -0,0 +1,201 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
75
README.md
75
README.md
|
@ -1 +1,74 @@
|
|||
# keycloak-event-metrics
|
||||
# Keycloak Event Metrics
|
||||
|
||||
Provides metrics for Keycloak user/admin events.
|
||||
|
||||
[](http://www.apache.org/licenses/)
|
||||
[](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
|
||||
```
|
||||
|
|
384
pom.xml
Normal file
384
pom.xml
Normal file
|
@ -0,0 +1,384 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>io.kokuwa.keycloak</groupId>
|
||||
<artifactId>keycloak-event-metrics</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
|
||||
<name>Keycloak Metrics</name>
|
||||
<description>Provides metrics for Keycloak user/admin events</description>
|
||||
<url>https://github.com/kokuwaio/keycloak-event-metrics</url>
|
||||
<inceptionYear>2023</inceptionYear>
|
||||
<organization>
|
||||
<name>Kokuwa.io</name>
|
||||
<url>http://kokuwa.io</url>
|
||||
</organization>
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||
</license>
|
||||
</licenses>
|
||||
|
||||
<developers>
|
||||
<developer>
|
||||
<id>stephanschnabel</id>
|
||||
<name>Stephan Schnabel</name>
|
||||
<url>https://github.com/sschnabe</url>
|
||||
<email>stephan@grayc.de</email>
|
||||
<organization>GrayC GmbH</organization>
|
||||
<organizationUrl>https://grayc.de</organizationUrl>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- =============================== Build =============================== -->
|
||||
<!-- ===================================================================== -->
|
||||
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<maven.compiler.showDeprecation>true</maven.compiler.showDeprecation>
|
||||
<maven.compiler.showWarnings>true</maven.compiler.showWarnings>
|
||||
<maven.compiler.failOnWarning>true</maven.compiler.failOnWarning>
|
||||
<maven.test.redirectTestOutputToFile>true</maven.test.redirectTestOutputToFile>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- ============================== Libaries ============================= -->
|
||||
<!-- ===================================================================== -->
|
||||
|
||||
<!-- plugins -->
|
||||
|
||||
<version.org.apache.maven.plugins.checkstyle>3.2.1</version.org.apache.maven.plugins.checkstyle>
|
||||
<version.org.apache.maven.plugins.clean>3.2.0</version.org.apache.maven.plugins.clean>
|
||||
<version.org.apache.maven.plugins.compiler>3.11.0</version.org.apache.maven.plugins.compiler>
|
||||
<version.org.apache.maven.plugins.dependency>3.5.0</version.org.apache.maven.plugins.dependency>
|
||||
<version.org.apache.maven.plugins.deploy>3.1.0</version.org.apache.maven.plugins.deploy>
|
||||
<version.org.apache.maven.plugins.gpg>3.0.1</version.org.apache.maven.plugins.gpg>
|
||||
<version.org.apache.maven.plugins.install>3.1.0</version.org.apache.maven.plugins.install>
|
||||
<version.org.apache.maven.plugins.jar>3.3.0</version.org.apache.maven.plugins.jar>
|
||||
<version.org.apache.maven.plugins.javadoc>1.0.0</version.org.apache.maven.plugins.javadoc>
|
||||
<version.org.apache.maven.plugins.release>3.0.0-M7</version.org.apache.maven.plugins.release>
|
||||
<version.org.apache.maven.plugins.resources>3.3.0</version.org.apache.maven.plugins.resources>
|
||||
<version.org.apache.maven.plugins.source>3.2.1</version.org.apache.maven.plugins.source>
|
||||
<version.org.apache.maven.plugins.surefire>3.0.0-M9</version.org.apache.maven.plugins.surefire>
|
||||
<version.org.codehaus.mojo.tidy>1.2.0</version.org.codehaus.mojo.tidy>
|
||||
<version.org.sonatype.plugins.nexus-staging>1.6.13</version.org.sonatype.plugins.nexus-staging>
|
||||
<version.com.puppycrawl.tools.checkstyle>10.8.0</version.com.puppycrawl.tools.checkstyle>
|
||||
<version.io.kokuwa.checkstyle>0.5.6</version.io.kokuwa.checkstyle>
|
||||
|
||||
<!-- dependencies -->
|
||||
|
||||
<version.org.keycloak>21.0.1</version.org.keycloak>
|
||||
<version.org.testcontainers>1.17.6</version.org.testcontainers>
|
||||
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
<!-- keycloak -->
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-parent</artifactId>
|
||||
<version>${version.org.keycloak}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- test -->
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-bom</artifactId>
|
||||
<version>${version.org.testcontainers}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
<!-- keycloak -->
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-core</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi-private</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-quarkus-server</artifactId>
|
||||
<scope>provided</scope>
|
||||
<exclusions>
|
||||
<exclusion><!-- references ancient `commons-io` -->
|
||||
<groupId>com.openshift</groupId>
|
||||
<artifactId>openshift-restclient-java</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-admin-client</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- test -->
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency><!-- is missing and necessary for `keycloak-admin-client` -->
|
||||
<groupId>org.wildfly.client</groupId>
|
||||
<artifactId>wildfly-client-config</artifactId>
|
||||
<version>1.0.1.Final</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<testResources>
|
||||
<testResource>
|
||||
<directory>${project.basedir}/src/test/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
</testResource>
|
||||
</testResources>
|
||||
<pluginManagement>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>${version.org.apache.maven.plugins.checkstyle}</version>
|
||||
<configuration>
|
||||
<configLocation>checkstyle.xml</configLocation>
|
||||
<suppressionsLocation>checkstyle-suppression.xml</suppressionsLocation>
|
||||
<includeTestSourceDirectory>true</includeTestSourceDirectory>
|
||||
</configuration>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>${version.com.puppycrawl.tools.checkstyle}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.kokuwa</groupId>
|
||||
<artifactId>maven-parent</artifactId>
|
||||
<version>${version.io.kokuwa.checkstyle}</version>
|
||||
<type>zip</type>
|
||||
<classifier>checkstyle</classifier>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-clean-plugin</artifactId>
|
||||
<version>${version.org.apache.maven.plugins.clean}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>${version.org.apache.maven.plugins.compiler}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-dependency-plugin</artifactId>
|
||||
<version>${version.org.apache.maven.plugins.dependency}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-deploy-plugin</artifactId>
|
||||
<version>${version.org.apache.maven.plugins.deploy}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>${version.org.apache.maven.plugins.surefire}</version>
|
||||
<configuration>
|
||||
<failIfNoTests>true</failIfNoTests>
|
||||
<redirectTestOutputToFile>${maven.test.redirectTestOutputToFile}</redirectTestOutputToFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-gpg-plugin</artifactId>
|
||||
<version>${version.org.apache.maven.plugins.gpg}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-install-plugin</artifactId>
|
||||
<version>${version.org.apache.maven.plugins.install}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>${version.org.apache.maven.plugins.jar}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-javadoc-plugin</artifactId>
|
||||
<version>${version.org.apache.maven.plugins.jar}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-release-plugin</artifactId>
|
||||
<version>${version.org.apache.maven.plugins.release}</version>
|
||||
<configuration>
|
||||
<tagNameFormat>@{project.version}</tagNameFormat>
|
||||
<releaseProfiles>release</releaseProfiles>
|
||||
<localCheckout>true</localCheckout>
|
||||
<signTag>true</signTag>
|
||||
<scmReleaseCommitComment>@{prefix} prepare release @{releaseLabel} [no ci]</scmReleaseCommitComment>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-source-plugin</artifactId>
|
||||
<version>${version.org.apache.maven.plugins.source}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-resources-plugin</artifactId>
|
||||
<version>${version.org.apache.maven.plugins.resources}</version>
|
||||
<configuration>
|
||||
<propertiesEncoding>UTF-8</propertiesEncoding>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>${version.org.apache.maven.plugins.surefire}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>tidy-maven-plugin</artifactId>
|
||||
<version>${version.org.codehaus.mojo.tidy}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.sonatype.plugins</groupId>
|
||||
<artifactId>nexus-staging-maven-plugin</artifactId>
|
||||
<version>${version.org.sonatype.plugins.nexus-staging}</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
<plugins>
|
||||
|
||||
<!-- fail if any pom is dirty -->
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>tidy-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- fail if checkstyle reports problems -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- run tests -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>integration-test</goal>
|
||||
<goal>verify</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>release</id>
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
<!-- add source for downstream projects -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-source-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>jar-no-fork</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- add javadoc for downstream projects -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-javadoc-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>jar</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- sign documents before upload -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-gpg-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>sign</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- autoclose sonatype nexus repo -->
|
||||
<plugin>
|
||||
<groupId>org.sonatype.plugins</groupId>
|
||||
<artifactId>nexus-staging-maven-plugin</artifactId>
|
||||
<extensions>true</extensions>
|
||||
<configuration>
|
||||
<serverId>sonatype-nexus</serverId>
|
||||
<nexusUrl>https://oss.sonatype.org/</nexusUrl>
|
||||
<autoReleaseAfterClose>true</autoReleaseAfterClose>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
|
@ -0,0 +1,32 @@
|
|||
package io.kokuwa.keycloak.metrics;
|
||||
|
||||
import org.keycloak.events.Event;
|
||||
import org.keycloak.events.EventListenerProvider;
|
||||
import org.keycloak.events.admin.AdminEvent;
|
||||
|
||||
/**
|
||||
* Listener for {@link Event} and {@link AdminEvent}.
|
||||
*
|
||||
* @author Stephan Schnabel
|
||||
*/
|
||||
public class MicrometerEventListener implements EventListenerProvider, AutoCloseable {
|
||||
|
||||
private final MicrometerEventRecorder recorder;
|
||||
|
||||
MicrometerEventListener(MicrometerEventRecorder recorder) {
|
||||
this.recorder = recorder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEvent(Event event) {
|
||||
recorder.userEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEvent(AdminEvent event, boolean includeRepresentation) {
|
||||
recorder.adminEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package io.kokuwa.keycloak.metrics;
|
||||
|
||||
import javax.enterprise.inject.spi.CDI;
|
||||
|
||||
import org.keycloak.Config.Scope;
|
||||
import org.keycloak.events.EventListenerProvider;
|
||||
import org.keycloak.events.EventListenerProviderFactory;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
|
||||
/**
|
||||
* Factory for {@link MicrometerEventListener}, uses {@link MeterRegistry} from CDI.
|
||||
*
|
||||
* @author Stephan Schnabel
|
||||
*/
|
||||
public class MicrometerEventListenerFactory implements EventListenerProviderFactory {
|
||||
|
||||
private MicrometerEventRecorder recorder;
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return "metrics-listener";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Scope config) {}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
recorder = new MicrometerEventRecorder(CDI.current().select(MeterRegistry.class).get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventListenerProvider create(KeycloakSession session) {
|
||||
return new MicrometerEventListener(recorder);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package io.kokuwa.keycloak.metrics;
|
||||
|
||||
import org.keycloak.events.EventListenerSpi;
|
||||
import org.keycloak.provider.Provider;
|
||||
|
||||
/**
|
||||
* Factory for {@link MicrometerEventListener}.
|
||||
*
|
||||
* @author Stephan Schnabel
|
||||
*/
|
||||
public class MicrometerEventListenerSpi extends EventListenerSpi {
|
||||
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Micrometer Metrics Provider";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return MicrometerEventListener.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<MicrometerEventListenerFactory> getProviderFactoryClass() {
|
||||
return MicrometerEventListenerFactory.class;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
package io.kokuwa.keycloak.metrics;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.keycloak.events.Event;
|
||||
import org.keycloak.events.admin.AdminEvent;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
|
||||
/**
|
||||
* Micrometer based recorder for events.
|
||||
*
|
||||
* @author Stephan Schnabel
|
||||
*/
|
||||
public class MicrometerEventRecorder {
|
||||
|
||||
private final Map<String, Counter> counters = new HashMap<>();
|
||||
private final MeterRegistry registry;
|
||||
|
||||
MicrometerEventRecorder(MeterRegistry registry) {
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
void adminEvent(AdminEvent event) {
|
||||
counter("keycloak_event_admin",
|
||||
"realm", toBlankIfNull(event.getRealmId()),
|
||||
"resource", toBlankIfNull(event.getResourceType()),
|
||||
"operation", toBlankIfNull(event.getOperationType()),
|
||||
"error", toBlankIfNull(event.getError()));
|
||||
}
|
||||
|
||||
void userEvent(Event event) {
|
||||
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();
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
io.kokuwa.keycloak.metrics.MicrometerEventListenerFactory
|
||||
![]() This could be autogenerated by the com.google.auto.service.AutoService; annotation at class-level This could be autogenerated by the com.google.auto.service.AutoService; annotation at class-level
![]() yes, i know. but i don't like to add an additional dependency to generate one file. yes, i know. but i don't like to add an additional dependency to generate one file.
|
|
@ -0,0 +1 @@
|
|||
io.kokuwa.keycloak.metrics.MicrometerEventListenerSpi
|
66
src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java
Normal file
66
src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java
Normal file
|
@ -0,0 +1,66 @@
|
|||
package io.kokuwa.keycloak.metrics;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertAll;
|
||||
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.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;
|
||||
import io.kokuwa.keycloak.metrics.prometheus.Prometheus;
|
||||
|
||||
@ExtendWith(KeycloakExtension.class)
|
||||
public class KeycloakIT {
|
||||
|
||||
@DisplayName("login and attempts")
|
||||
@Test
|
||||
void loginAndAttempts(KeycloakClient keycloak, Prometheus prometheus) {
|
||||
|
||||
var realmName1 = UUID.randomUUID().toString();
|
||||
var username1 = UUID.randomUUID().toString();
|
||||
var password1 = 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.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 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 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("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"));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
package io.kokuwa.keycloak.metrics.junit;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.ws.rs.NotAuthorizedException;
|
||||
import javax.ws.rs.core.MultivaluedHashMap;
|
||||
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.admin.client.Keycloak;
|
||||
import org.keycloak.admin.client.token.TokenService;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
import org.keycloak.representations.idm.CredentialRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
|
||||
/**
|
||||
* Client for keycloak.
|
||||
*
|
||||
* @author Stephan Schnabel
|
||||
*/
|
||||
public class KeycloakClient {
|
||||
|
||||
private final Keycloak keycloak;
|
||||
private final TokenService token;
|
||||
|
||||
KeycloakClient(Keycloak keycloak, TokenService token) {
|
||||
this.keycloak = keycloak;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public String createRealm(String realmName) {
|
||||
var client = new ClientRepresentation();
|
||||
client.setClientId("test");
|
||||
client.setPublicClient(true);
|
||||
client.setDirectAccessGrantsEnabled(true);
|
||||
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 createUser(String realmName, String username, String password) {
|
||||
var credential = new CredentialRepresentation();
|
||||
credential.setType(CredentialRepresentation.PASSWORD);
|
||||
credential.setValue(password);
|
||||
credential.setTemporary(false);
|
||||
var user = new UserRepresentation();
|
||||
user.setEnabled(true);
|
||||
user.setEmail(username + "@example.org");
|
||||
user.setEmailVerified(true);
|
||||
user.setUsername(username);
|
||||
user.setCredentials(List.of(credential));
|
||||
keycloak.realms().realm(realmName).users().create(user);
|
||||
}
|
||||
|
||||
public boolean login(String realmName, String username, String password) {
|
||||
try {
|
||||
token.grantToken(realmName, new MultivaluedHashMap<>(Map.of(
|
||||
OAuth2Constants.CLIENT_ID, "test",
|
||||
OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD,
|
||||
OAuth2Constants.USERNAME, username,
|
||||
OAuth2Constants.PASSWORD, password)));
|
||||
return true;
|
||||
} catch (NotAuthorizedException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
package io.kokuwa.keycloak.metrics.junit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.ws.rs.client.ClientBuilder;
|
||||
|
||||
import org.junit.jupiter.api.extension.BeforeAllCallback;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import org.junit.jupiter.api.extension.ParameterContext;
|
||||
import org.junit.jupiter.api.extension.ParameterResolver;
|
||||
import org.keycloak.admin.client.Keycloak;
|
||||
import org.keycloak.admin.client.token.TokenService;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.containers.wait.strategy.Wait;
|
||||
import org.testcontainers.utility.MountableFile;
|
||||
|
||||
import io.kokuwa.keycloak.metrics.prometheus.Prometheus;
|
||||
import io.kokuwa.keycloak.metrics.prometheus.PrometheusClient;
|
||||
|
||||
/**
|
||||
* JUnit extension to start keycloak.
|
||||
*
|
||||
* @author Stephan Schnabel
|
||||
*/
|
||||
public class KeycloakExtension implements BeforeAllCallback, ParameterResolver {
|
||||
|
||||
private static KeycloakClient client;
|
||||
private static Prometheus prometheus;
|
||||
|
||||
@Override
|
||||
public void beforeAll(ExtensionContext context) throws Exception {
|
||||
|
||||
if (client != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// get test properties
|
||||
|
||||
var properties = new Properties();
|
||||
try {
|
||||
properties.load(getClass().getResourceAsStream("/test.properties"));
|
||||
} catch (IOException e) {
|
||||
throw new Exception("Failed to read properties", e);
|
||||
}
|
||||
var version = properties.getProperty("version");
|
||||
var jar = properties.getProperty("jar");
|
||||
var timeout = properties.getProperty("timeout");
|
||||
|
||||
// create and start container
|
||||
|
||||
@SuppressWarnings("resource")
|
||||
var container = new GenericContainer<>("quay.io/keycloak/keycloak:" + version)
|
||||
.withEnv("KEYCLOAK_ADMIN", "admin")
|
||||
.withEnv("KEYCLOAK_ADMIN_PASSWORD", "password")
|
||||
.withEnv("KC_LOG_CONSOLE_COLOR", "true")
|
||||
.withEnv("KC_HEALTH_ENABLED", "true")
|
||||
.withEnv("KC_METRICS_ENABLED", "true")
|
||||
.withCopyFileToContainer(MountableFile.forHostPath(jar), "/opt/keycloak/providers/metrics.jar")
|
||||
.withLogConsumer(out -> System.out.print(out.getUtf8String()))
|
||||
.withExposedPorts(8080)
|
||||
.withStartupTimeout(Duration.parse(timeout))
|
||||
.waitingFor(Wait.forHttp("/health").forPort(8080))
|
||||
.withCommand("start-dev");
|
||||
try {
|
||||
container.start();
|
||||
} catch (RuntimeException e) {
|
||||
throw new Exception("Failed to start keycloak", e);
|
||||
}
|
||||
|
||||
// create client for keycloak container
|
||||
|
||||
var url = "http://" + container.getHost() + ":" + container.getMappedPort(8080);
|
||||
var keycloak = Keycloak.getInstance(url, "master", "admin", "password", "admin-cli");
|
||||
assertEquals(version, keycloak.serverInfo().getInfo().getSystemInfo().getVersion(), "version invalid");
|
||||
var target = ClientBuilder.newClient().target(url);
|
||||
var token = Keycloak.getClientProvider().targetProxy(target, TokenService.class);
|
||||
prometheus = new Prometheus(Keycloak.getClientProvider().targetProxy(target, PrometheusClient.class));
|
||||
client = new KeycloakClient(keycloak, token);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
|
||||
return Set.of(KeycloakClient.class, Prometheus.class).contains(parameterContext.getParameter().getType());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
|
||||
return parameterContext.getParameter().getType().equals(KeycloakClient.class) ? client : prometheus;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
package io.kokuwa.keycloak.metrics.prometheus;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
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:
|
||||
*
|
||||
* @author Stephan Schnabel
|
||||
*/
|
||||
public class Prometheus {
|
||||
|
||||
private final Set<PrometheusMetric> state = new HashSet<>();
|
||||
private final PrometheusClient client;
|
||||
|
||||
public Prometheus(PrometheusClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
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 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() {
|
||||
state.clear();
|
||||
Stream.of(client.scrap().split("[\\r\\n]+"))
|
||||
.filter(line -> !line.startsWith("#"))
|
||||
.filter(line -> line.startsWith("keycloak"))
|
||||
.map(line -> {
|
||||
var name = line.substring(0, line.contains("{") ? line.indexOf("{") : line.lastIndexOf(" "));
|
||||
var tags = line.contains("{")
|
||||
? Stream.of(line.substring(line.indexOf("{") + 1, line.indexOf("}")).split(","))
|
||||
.map(tag -> tag.split("="))
|
||||
.filter(tag -> tag.length >= 2)
|
||||
.collect(Collectors.toMap(tag -> tag[0], tag -> tag[1].replace("\"", "")))
|
||||
: Map.<String, String>of();
|
||||
var value = Double.parseDouble(line.substring(line.lastIndexOf(" ")));
|
||||
return new PrometheusMetric(name, tags, value);
|
||||
})
|
||||
.forEach(state::add);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package io.kokuwa.keycloak.metrics.prometheus;
|
||||
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
|
||||
/**
|
||||
* JAX-RS client for prometheus endpoint.
|
||||
*
|
||||
* @author Stephan Schnabel
|
||||
*/
|
||||
public interface PrometheusClient {
|
||||
|
||||
@GET
|
||||
@Path("/metrics")
|
||||
@Consumes(MediaType.TEXT_PLAIN)
|
||||
String scrap();
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package io.kokuwa.keycloak.metrics.prometheus;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Represents a parsed Prometheus line.
|
||||
*
|
||||
* @author Stephan Schnabel
|
||||
* @param name Metric name
|
||||
* @param tags Tags for this metriv value
|
||||
* @param value Metric value
|
||||
*/
|
||||
public record PrometheusMetric(String name, Map<String, String> tags, Double value) {}
|
3
src/test/resources/test.properties
Normal file
3
src/test/resources/test.properties
Normal file
|
@ -0,0 +1,3 @@
|
|||
version=${version.org.keycloak}
|
||||
timeout=PT5m
|
||||
jar=${project.build.directory}/${project.build.finalName}.jar
|
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.