From 932ca8435291f1a07925bad39a14defc366aea71 Mon Sep 17 00:00:00 2001 From: Stephan Schnabel Date: Fri, 3 Mar 2023 00:08:20 +0100 Subject: [PATCH] First draft of implementation Readme will follow --- .github/CODEOWNERS | 4 + .github/dependabot.yml | 17 + .github/workflows/ci.yaml | 70 ++++ .github/workflows/dependabot.yaml | 17 + .github/workflows/release.yaml | 34 ++ .markdownlint.yaml | 9 + .yamllint | 19 + LICENSE | 201 ++++++++++ README.md | 8 +- pom.xml | 377 ++++++++++++++++++ .../metrics/MicrometerEventListener.java | 32 ++ .../MicrometerEventListenerFactory.java | 42 ++ .../metrics/MicrometerEventListenerSpi.java | 32 ++ .../metrics/MicrometerEventRecorder.java | 102 +++++ ...ycloak.events.EventListenerProviderFactory | 1 + .../org.keycloak.events.EventListenerSpi | 1 + .../kokuwa/keycloak/metrics/KeycloakIT.java | 74 ++++ .../metrics/junit/KeycloakClient.java | 72 ++++ .../metrics/junit/KeycloakExtension.java | 95 +++++ .../metrics/prometheus/Prometheus.java | 81 ++++ .../metrics/prometheus/PrometheusClient.java | 19 + .../metrics/prometheus/PrometheusMetric.java | 13 + src/test/resources/test.properties | 3 + 23 files changed, 1322 insertions(+), 1 deletion(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/dependabot.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .markdownlint.yaml create mode 100644 .yamllint create mode 100644 LICENSE create mode 100644 pom.xml create mode 100644 src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java create mode 100644 src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java create mode 100644 src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerSpi.java create mode 100644 src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java create mode 100644 src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory create mode 100644 src/main/resources/META-INF/services/org.keycloak.events.EventListenerSpi create mode 100644 src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java create mode 100644 src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java create mode 100644 src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java create mode 100644 src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java create mode 100644 src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusClient.java create mode 100644 src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusMetric.java create mode 100644 src/test/resources/test.properties diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..164cbad --- /dev/null +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7a74c74 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: maven + directory: / + schedule: + interval: daily + allow: + - dependency-name: org.keycloak:keycloak-parent + - 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 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..0ab5ab5 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -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 + if: ${{ github.ref != 'refs/heads/main' }} + - run: mvn -B -ntp deploy -Dcheckstyle.skip + if: ${{ github.ref == 'refs/heads/main' }} + env: + SERVER_USERNAME: ${{ secrets.SONATYPE_NEXUS_USERNAME }} + SERVER_PASSWORD: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} diff --git a/.github/workflows/dependabot.yaml b/.github/workflows/dependabot.yaml new file mode 100644 index 0000000..ed63eca --- /dev/null +++ b/.github/workflows/dependabot.yaml @@ -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 }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..653e10d --- /dev/null +++ b/.github/workflows/release.yaml @@ -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 }} diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..5f08047 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,9 @@ +# Default state for all rules +default: true + +# MD009 - Trailing spaces +MD009: + strict: true + +# MD013 - Line length +MD013: false diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..8011808 --- /dev/null +++ b/.yamllint @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 6d33139..30c1cd6 100644 --- a/README.md +++ b/README.md @@ -1 +1,7 @@ -# keycloak-event-metrics +# Keycloak Event Metrics + +Provides metrics for Keycloak 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) +[![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) diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..5e41a2e --- /dev/null +++ b/pom.xml @@ -0,0 +1,377 @@ + + + 4.0.0 + + io.kokuwa.keycloak + keycloak-event-metrics + 0.0.1-SNAPSHOT + + Keycloak Metrics + Provides metrics for Keycloak events + https://github.com/kokuwaio/keycloak-event-metrics + 2023 + + Kokuwa.io + http://kokuwa.io + + + + Apache License 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + + + + + + stephanschnabel + Stephan Schnabel + https://github.com/sschnabe + stephan@grayc.de + GrayC GmbH + https://grayc.de + + + + + + + + + + UTF-8 + + 17 + 17 + true + true + true + true + + + + + + + + 3.2.1 + 3.2.0 + 3.11.0 + 3.5.0 + 3.1.0 + 3.0.1 + 3.1.0 + 3.3.0 + 1.0.0 + 3.0.0-M7 + 3.3.0 + 3.2.1 + 3.0.0-M9 + 1.2.0 + 1.6.13 + 10.8.0 + 0.5.6 + + + + 21.0.1 + 1.17.6 + + + + + + + + + org.keycloak + keycloak-parent + ${version.org.keycloak} + pom + import + + + + + org.testcontainers + testcontainers-bom + ${version.org.testcontainers} + pom + import + + + + + + + + + org.keycloak + keycloak-core + provided + + + org.keycloak + keycloak-server-spi + provided + + + org.keycloak + keycloak-server-spi-private + provided + + + org.keycloak + keycloak-quarkus-server + provided + + + org.keycloak + keycloak-admin-client + test + + + + + org.testcontainers + junit-jupiter + test + + + org.wildfly.client + wildfly-client-config + 1.0.1.Final + test + + + + + + + + ${project.basedir}/src/test/resources + true + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${version.org.apache.maven.plugins.checkstyle} + + checkstyle.xml + checkstyle-suppression.xml + true + + + + com.puppycrawl.tools + checkstyle + ${version.com.puppycrawl.tools.checkstyle} + + + io.kokuwa + maven-parent + ${version.io.kokuwa.checkstyle} + zip + checkstyle + + + + + org.apache.maven.plugins + maven-clean-plugin + ${version.org.apache.maven.plugins.clean} + + + org.apache.maven.plugins + maven-compiler-plugin + ${version.org.apache.maven.plugins.compiler} + + + org.apache.maven.plugins + maven-dependency-plugin + ${version.org.apache.maven.plugins.dependency} + + + org.apache.maven.plugins + maven-deploy-plugin + ${version.org.apache.maven.plugins.deploy} + + + org.apache.maven.plugins + maven-failsafe-plugin + ${version.org.apache.maven.plugins.surefire} + + true + + + + org.apache.maven.plugins + maven-gpg-plugin + ${version.org.apache.maven.plugins.gpg} + + + org.apache.maven.plugins + maven-install-plugin + ${version.org.apache.maven.plugins.install} + + + org.apache.maven.plugins + maven-jar-plugin + ${version.org.apache.maven.plugins.jar} + + + org.apache.maven.plugins + maven-javadoc-plugin + ${version.org.apache.maven.plugins.jar} + + + org.apache.maven.plugins + maven-release-plugin + ${version.org.apache.maven.plugins.release} + + @{project.version} + release + true + true + @{prefix} prepare release @{releaseLabel} [no ci] + + + + org.apache.maven.plugins + maven-source-plugin + ${version.org.apache.maven.plugins.source} + + + org.apache.maven.plugins + maven-resources-plugin + ${version.org.apache.maven.plugins.resources} + + UTF-8 + + + + org.apache.maven.plugins + maven-surefire-plugin + ${version.org.apache.maven.plugins.surefire} + + + org.codehaus.mojo + tidy-maven-plugin + ${version.org.codehaus.mojo.tidy} + + + org.sonatype.plugins + nexus-staging-maven-plugin + ${version.org.sonatype.plugins.nexus-staging} + + + + + + + + org.codehaus.mojo + tidy-maven-plugin + + + + check + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + + check + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + + + + + + + release + + + + + + org.apache.maven.plugins + maven-source-plugin + + + + jar-no-fork + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + jar + + + + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + + sign + + + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + true + + sonatype-nexus + https://oss.sonatype.org/ + true + + + + + + + + diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java new file mode 100644 index 0000000..d630739 --- /dev/null +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java @@ -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() {} +} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java new file mode 100644 index 0000000..16c35e5 --- /dev/null +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java @@ -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 "micrometer-event-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() {} +} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerSpi.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerSpi.java new file mode 100644 index 0000000..49ec789 --- /dev/null +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerSpi.java @@ -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 getProviderClass() { + return MicrometerEventListener.class; + } + + @Override + public Class getProviderFactoryClass() { + return MicrometerEventListenerFactory.class; + } +} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java new file mode 100644 index 0000000..3102a38 --- /dev/null +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java @@ -0,0 +1,102 @@ +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; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +/** + * Micrometer based recorder for events. + * + * @author Stephan Schnabel + */ +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 counters = new HashMap<>(); + private final MeterRegistry registry; + + MicrometerEventRecorder(MeterRegistry registry) { + this.registry = registry; + } + + void adminEvent(AdminEvent event) { + counter(ADMIN_EVENT_PREFIX + event.getOperationType().name(), + "realm", event.getRealmId(), + "resource", event.getResourceType() == null ? "" : event.getResourceType().name()); + } + + 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); + } + } + + private void counter(String counter, String... tags) { + counters.computeIfAbsent(counter + Arrays.toString(tags), string -> registry.counter(counter, tags)) + .increment(); + } +} diff --git a/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory new file mode 100644 index 0000000..a54f10d --- /dev/null +++ b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory @@ -0,0 +1 @@ +io.kokuwa.keycloak.metrics.MicrometerEventListenerFactory diff --git a/src/main/resources/META-INF/services/org.keycloak.events.EventListenerSpi b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerSpi new file mode 100644 index 0000000..d2b799f --- /dev/null +++ b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerSpi @@ -0,0 +1 @@ +io.kokuwa.keycloak.metrics.MicrometerEventListenerSpi diff --git a/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java b/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java new file mode 100644 index 0000000..0d57db0 --- /dev/null +++ b/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java @@ -0,0 +1,74 @@ +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 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 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); + + 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); + + 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")); + } +} diff --git a/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java new file mode 100644 index 0000000..3550814 --- /dev/null +++ b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java @@ -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("micrometer-event-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; + } + } +} diff --git a/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java new file mode 100644 index 0000000..31ffe6c --- /dev/null +++ b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java @@ -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; + } +} diff --git a/src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java new file mode 100644 index 0000000..fce4562 --- /dev/null +++ b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java @@ -0,0 +1,81 @@ +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; + +/** + * Client to access Prometheus metric values: + * + * @author Stephan Schnabel + */ +public class Prometheus { + + private final Set state = new HashSet<>(); + private final PrometheusClient client; + + 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 loginFailure(String realmName) { + return scrap("keycloak_failed_login_attempts_total", "realm", realmName).intValue(); + } + + 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.of(); + var value = Double.parseDouble(line.substring(line.lastIndexOf(" "))); + return new PrometheusMetric(name, tags, value); + }) + .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(); + } +} diff --git a/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusClient.java b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusClient.java new file mode 100644 index 0000000..2355f3f --- /dev/null +++ b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusClient.java @@ -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(); +} diff --git a/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusMetric.java b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusMetric.java new file mode 100644 index 0000000..a79f6c8 --- /dev/null +++ b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusMetric.java @@ -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 tags, Double value) {} diff --git a/src/test/resources/test.properties b/src/test/resources/test.properties new file mode 100644 index 0000000..d66d19f --- /dev/null +++ b/src/test/resources/test.properties @@ -0,0 +1,3 @@ +version=${version.org.keycloak} +timeout=PT5m +jar=${project.build.directory}/${project.build.finalName}.jar