diff --git a/.editorconfig b/.editorconfig
index 8628b45bd..aa77aa8ed 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -4,16 +4,27 @@ root = true
max_line_length = 100
indent_size = 2
-[{version-rules.xml,maven-wrapper.properties,checkstyle.xml,docker-compose.yaml,docker-compose.yml,Dockerfile,example_target_info.json,mise.toml,mvnm,mvnw.cmd,generate-protobuf.sh,super-linter.env,.gitleaksignore,*.json5}]
+[{version-rules.xml,maven-wrapper.properties,checkstyle.xml,docker-compose.yaml,docker-compose.yml,Dockerfile,example_target_info.json,mise.toml,mvnm,mvnw.cmd,generate-protobuf.sh,.gitleaksignore,prometheus.properties}]
max_line_length = 200
-[{grafana-dashboard-*.json,.editorconfig}]
+[{grafana-dashboard-*.json,.editorconfig,lychee.toml,renovate.json5,CODE_OF_CONDUCT.md}]
max_line_length = 300
[pom.xml]
-max_line_length = 110
+max_line_length = 210
[*.py]
# checked by black
indent_size = 4
max_line_length = 120
+
+[{.mise/tasks/build-release.sh,.github/workflows/*.yml}]
+max_line_length = 300
+
+[*.java]
+# Java line length is handled by google-java-format
+max_line_length = off
+
+[*.md]
+# Markdown line length is handled by rumdl
+max_line_length = off
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..be1c40c4a
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+mise.lock linguist-generated
+CHANGELOG.md linguist-generated
+**/src/main/generated/** linguist-generated
+docs/apidiffs/** linguist-generated
+docs/** linguist-documentation
diff --git a/.github/config/.release-please-manifest.json b/.github/config/.release-please-manifest.json
new file mode 100644
index 000000000..099626f4f
--- /dev/null
+++ b/.github/config/.release-please-manifest.json
@@ -0,0 +1,3 @@
+{
+ ".": "1.8.0"
+}
diff --git a/.github/config/.rumdl.toml b/.github/config/.rumdl.toml
new file mode 100644
index 000000000..066c5b40f
--- /dev/null
+++ b/.github/config/.rumdl.toml
@@ -0,0 +1,5 @@
+[MD013]
+enabled = true
+line-length = 120
+code-blocks = false
+tables = false
diff --git a/.github/config/.taplo.toml b/.github/config/.taplo.toml
new file mode 100644
index 000000000..91983407c
--- /dev/null
+++ b/.github/config/.taplo.toml
@@ -0,0 +1,3 @@
+[formatting]
+column_width = 120
+indent_string = " "
diff --git a/.github/config/.yamllint.yml b/.github/config/.yamllint.yml
new file mode 100644
index 000000000..483f3d4bf
--- /dev/null
+++ b/.github/config/.yamllint.yml
@@ -0,0 +1,8 @@
+extends: relaxed
+
+rules:
+ document-start: disable
+ # Line length is owned by the repo's root .editorconfig.
+ line-length: disable
+ # Keep indentation checks in yamllint so YAML structure issues still fail lint.
+ indentation: enable
diff --git a/.github/config/_typos.toml b/.github/config/_typos.toml
new file mode 100644
index 000000000..291218241
--- /dev/null
+++ b/.github/config/_typos.toml
@@ -0,0 +1,10 @@
+[default]
+extend-ignore-re = [
+ 're-use',
+ '\.atMost\(',
+ 'errorprone',
+ 'com\.google\.errorprone',
+ '\bNormalDistribution nd\b',
+ '\bnd\.inverseCumulativeProbability\(',
+ 'BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI0MDUxMDE0MzY1NloYDzIxMjQw',
+]
diff --git a/.github/config/flint.toml b/.github/config/flint.toml
new file mode 100644
index 000000000..45ec061d4
--- /dev/null
+++ b/.github/config/flint.toml
@@ -0,0 +1,5 @@
+[settings]
+exclude = ["docs/themes/**", "mvnw", "simpleclient-archive/**"]
+
+[checks.renovate-deps]
+exclude_managers = ["github-actions", "github-runners", "maven"]
diff --git a/.github/config/lychee.toml b/.github/config/lychee.toml
new file mode 100644
index 000000000..d9c1cffa5
--- /dev/null
+++ b/.github/config/lychee.toml
@@ -0,0 +1,30 @@
+# Lychee configuration file
+# See https://lychee.cli.rs/config/
+
+timeout = 30
+retry_wait_time = 10
+max_retries = 2
+max_concurrency = 4
+
+# Check link anchors
+include_fragments = "anchor-only"
+
+base_url = "https://prometheus.github.io"
+exclude_path = ["docs/themes", "CHANGELOG.md"]
+
+exclude = [
+ # excluding links to pull requests and issues is done for performance
+ "^https://github.com/prometheus/client_java/(issues|pull)/\\d+$",
+
+ # exclude localhost URLs as they require running services
+ "^http://localhost",
+ "^https://localhost",
+
+ '#',
+ 'CONTRIBUTING.md',
+ 'LICENSE',
+ 'MAINTAINERS.md',
+
+ # exclude private GitHub settings pages
+ "^https://github.com/prometheus/client_java/settings/",
+]
diff --git a/.github/config/release-please-config.json b/.github/config/release-please-config.json
new file mode 100644
index 000000000..b7986d08e
--- /dev/null
+++ b/.github/config/release-please-config.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
+ "group-pull-request-title-pattern": "chore${scope}: release${component} ${version}",
+ "pull-request-footer": "> [!IMPORTANT]\n> Close and reopen this PR to trigger CI checks.",
+ "packages": {
+ ".": {
+ "release-type": "maven"
+ }
+ }
+}
diff --git a/.github/release.yml b/.github/release.yml
deleted file mode 100644
index fb9b4d9d4..000000000
--- a/.github/release.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-# .github/release.yml
-
-changelog:
- categories:
- - title: ๐ Features
- labels:
- - "*"
- exclude:
- labels:
- - chore
- - dependencies
- - title: ๐งน Chore
- labels:
- - chore
- - title: ๐ Dependencies
- labels:
- - dependencies
diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json
new file mode 100644
index 000000000..292f6b0d8
--- /dev/null
+++ b/.github/renovate-tracked-deps.json
@@ -0,0 +1,200 @@
+{
+ "meta": {
+ "protoc": {
+ "packageName": "protocolbuffers/protobuf",
+ "datasource": "github-releases"
+ }
+ },
+ "files": {
+ ".github/renovate.json5": {
+ "renovate-config": [
+ "grafana/flint"
+ ]
+ },
+ ".github/workflows/acceptance-tests.yml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".github/workflows/api-diff.yml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".github/workflows/build.yml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".github/workflows/bump-api-diff-baseline.yml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".github/workflows/generate-protobuf.yml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".github/workflows/github-pages.yaml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".github/workflows/java-version-matrix-tests.yml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".github/workflows/jmx-exporter-compatibility.yml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".github/workflows/lint.yml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".github/workflows/micrometer-compatibility.yml": {
+ "regex": [
+ "micrometer-metrics/micrometer",
+ "mise",
+ "zeitlinger/micrometer"
+ ]
+ },
+ ".github/workflows/native-tests.yml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".github/workflows/nightly-benchmarks.yml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".github/workflows/pr-benchmarks.yml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".github/workflows/regenerate-api-diff-otel.yml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".github/workflows/release.yml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".github/workflows/test-release-build.yml": {
+ "regex": [
+ "mise"
+ ]
+ },
+ ".mise/envs/jmx-exporter/mise.toml": {
+ "mise": [
+ "java"
+ ]
+ },
+ ".mise/envs/micrometer/mise.toml": {
+ "mise": [
+ "java"
+ ]
+ },
+ ".mise/envs/native/mise.toml": {
+ "mise": [
+ "java"
+ ]
+ },
+ ".mvn/wrapper/maven-wrapper.properties": {
+ "maven-wrapper": [
+ "maven"
+ ]
+ },
+ "examples/example-custom-buckets/docker-compose.yaml": {
+ "docker-compose": [
+ "eclipse-temurin",
+ "grafana/grafana",
+ "prom/prometheus"
+ ]
+ },
+ "examples/example-exemplars-tail-sampling/docker-compose.yaml": {
+ "docker-compose": [
+ "grafana/grafana",
+ "grafana/k6",
+ "grafana/tempo",
+ "opentelemetry-java-agent",
+ "otel/opentelemetry-collector-contrib",
+ "prom/prometheus"
+ ]
+ },
+ "examples/example-exporter-opentelemetry/docker-compose.yaml": {
+ "docker-compose": [
+ "openjdk",
+ "otel/opentelemetry-collector-contrib",
+ "prom/prometheus"
+ ]
+ },
+ "examples/example-exporter-opentelemetry/oats-tests/agent/Dockerfile": {
+ "dockerfile": [
+ "eclipse-temurin"
+ ]
+ },
+ "examples/example-exporter-opentelemetry/oats-tests/http/Dockerfile": {
+ "dockerfile": [
+ "eclipse-temurin"
+ ]
+ },
+ "examples/example-native-histogram/docker-compose.yaml": {
+ "docker-compose": [
+ "eclipse-temurin",
+ "grafana/grafana",
+ "prom/prometheus"
+ ]
+ },
+ "mise.toml": {
+ "mise": [
+ "actionlint",
+ "aqua:grafana/flint",
+ "aqua:grafana/gcx",
+ "aqua:grafana/oats",
+ "aqua:jonwiggins/xmloxide",
+ "aqua:owenlamont/ryl",
+ "biome",
+ "checkstyle",
+ "editorconfig-checker",
+ "google-java-format",
+ "hugo",
+ "java",
+ "lychee",
+ "node",
+ "npm:renovate",
+ "protoc",
+ "ruff",
+ "rumdl",
+ "shellcheck",
+ "shfmt",
+ "taplo",
+ "typos",
+ "zizmor"
+ ],
+ "regex": [
+ "grafana/docker-otel-lgtm",
+ "micrometer-metrics/micrometer",
+ "prometheus/jmx_exporter"
+ ]
+ },
+ "mvnw": {
+ "maven-wrapper": [
+ "maven-wrapper"
+ ]
+ },
+ "mvnw.cmd": {
+ "maven-wrapper": [
+ "maven-wrapper"
+ ]
+ }
+ }
+}
diff --git a/.github/renovate.json5 b/.github/renovate.json5
index 67c8c137f..720659e04 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -1,6 +1,6 @@
{
$schema: "https://docs.renovatebot.com/renovate-schema.json",
- extends: ["config:best-practices", "config:recommended"],
+ extends: ["config:best-practices", "config:recommended", "github>grafana/flint#v0.22.10"],
platformCommit: "enabled",
automerge: true,
ignorePaths: [
@@ -12,20 +12,64 @@
labels: ["dependencies"],
packageRules: [
{
- matchPackageNames: ["jdx/mise"],
- groupName: "mise",
- schedule: ["before 4am on monday"],
+ matchFileNames: ["mise.toml"],
+ matchDepNames: ["java"],
+ groupName: "java temurin",
+ additionalBranchPrefix: "temurin-",
+ },
+ {
+ matchFileNames: [".mise/envs/native/mise.toml"],
+ matchDepNames: ["java"],
+ groupName: "java graalvm",
+ additionalBranchPrefix: "graalvm-",
+ },
+ {
+ description: "Compat tests pin an LTS JDK the upstream release supports; block major JDK bumps that would break those builds",
+ matchFileNames: [".mise/envs/jmx-exporter/mise.toml", ".mise/envs/micrometer/mise.toml"],
+ matchDepNames: ["java"],
+ matchUpdateTypes: ["major"],
+ enabled: false,
},
- ],
- customManagers: [
{
- customType: "regex",
- description: "update mise",
- managerFilePatterns: ["/(^|/)(workflow-templates|\\.(?:github|gitea|forgejo)/(?:workflows|actions))/.+\\.ya?ml$/", "/(^|/)action\\.ya?ml$/"],
- datasourceTemplate: "github-release-attachments",
- packageNameTemplate: "jdx/mise",
- depNameTemplate: "mise",
- matchStrings: ["jdx/mise-action.*\\n\\s*with:\\s*\\n\\s*version: [\"']?(?v[.\\d]+)[\"']?\\s*\\n\\s*sha256: [\"']?(?\\w+)[\"']?"],
+ matchPackageNames: ["io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha"],
+ ignoreUnstable: false,
},
+ {
+ enabled: false,
+ description: "Ignore internal project modules",
+ matchPackageNames: ["/^io\\.prometheus:(prometheus-metrics-bom|examples|example-.+|integration-tests|it-.+)$/"],
+ },
+ {
+ description: "Group protobuf-java and protoc together so generated code can be updated in one PR",
+ matchDepNames: ["com.google.protobuf:protobuf-java", "protoc"],
+ groupName: "protobuf",
+ separateMajorMinor: false,
+ },
+ ],
+ customManagers: [
+ {
+ "customType": "regex",
+ "description": "update _VERSION variables in mise.toml",
+ "managerFilePatterns": ["/^mise\\.toml$/"],
+ "matchStrings": [
+ "# renovate: datasource=(?[a-z-]+?)(?: depName=(?.+?))?(?: packageName=(?.+?))?(?: versioning=(?[a-z-]+?))?\\s.+?_VERSION\\s*=\\s*\"?(?[^@\"]+?)(?:@(?sha256:[a-f0-9]+))?\"?\\s"
+ ]
+ },
+ {
+ customType: "regex",
+ description: "track the latest Micrometer release for upstream compatibility",
+ managerFilePatterns: ["/^\\.github\\/workflows\\/micrometer-compatibility\\.yml$/"],
+ matchStrings: [
+ "# renovate: datasource=(?\\S+) depName=(?\\S+) packageName=(?\\S+)\\n\\s*ref:\\s*(?v[0-9][^\\s]*)",
+ ],
+ },
+ {
+ customType: "regex",
+ description: "pin the Micrometer typed-descriptor compatibility ref",
+ managerFilePatterns: ["/^\\.github\\/workflows\\/micrometer-compatibility\\.yml$/"],
+ matchStrings: [
+ "# renovate: datasource=(?\\S+) depName=(?\\S+) packageName=(?\\S+) currentValue=(?\\S+)\\n\\s*ref:\\s*(?[a-f0-9]{40})",
+ ],
+ },
],
}
diff --git a/.github/scripts/sync-api-diffs.sh b/.github/scripts/sync-api-diffs.sh
new file mode 100755
index 000000000..34fe18ff5
--- /dev/null
+++ b/.github/scripts/sync-api-diffs.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+# Refresh docs/apidiffs/current_vs_latest/ from the japicmp reports produced by
+# `mvn verify -P api-diff`.
+#
+# Each StableApi module gets one committed .txt describing how its
+# published API surface differs from the baseline release
+# ( in pom.xml). Committing these makes every API
+# change visible in the pull request diff. The CI check fails when the
+# regenerated files differ from what is committed.
+#
+# The layout and format match opentelemetry-java's docs/apidiffs: the
+# "Comparing ... .jar against ... .jar" header is kept, while the constant
+# ignore-missing-classes warning and the semantic-versioning suggestion are
+# dropped as noise.
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+out_dir="$repo_root/docs/apidiffs/current_vs_latest"
+
+shopt -s nullglob globstar
+
+reports=("$repo_root"/**/target/japicmp/api-diff.diff)
+if [ "${#reports[@]}" -eq 0 ]; then
+ echo "No japicmp reports found. Run 'mvn verify -P api-diff' first." >&2
+ exit 1
+fi
+
+rm -rf "$out_dir"
+mkdir -p "$out_dir"
+
+for report in "${reports[@]}"; do
+ # report path: //target/japicmp/api-diff.diff
+ module="$(basename "$(dirname "$(dirname "$(dirname "$report")")")")"
+ grep -vE '^(WARNING: You are using the option|Semantic versioning suggestion)' \
+ "$report" >"$out_dir/$module.txt"
+done
+
+echo "Wrote ${#reports[@]} API diff report(s) to docs/apidiffs/current_vs_latest/."
diff --git a/.github/super-linter.env b/.github/super-linter.env
deleted file mode 100644
index 62850b064..000000000
--- a/.github/super-linter.env
+++ /dev/null
@@ -1,41 +0,0 @@
-FILTER_REGEX_EXCLUDE=mvnw|src/main/generated/.*|docs/themes/.*|keystore.pkcs12|.*.java|prometheus-metrics-exporter-opentelemetry-shaded/pom.xml|CODE_OF_CONDUCT.md
-IGNORE_GITIGNORED_FILES=true
-JAVA_FILE_NAME=google_checks.xml
-# conflicts with prettier
-VALIDATE_BIOME_FORMAT=false
-# conflicts with prettier
-VALIDATE_BIOME_LINT=false
-# disable kubernetes linter - complains about resource limits, etc
-VALIDATE_CHECKOV=false
-VALIDATE_CSS=false
-VALIDATE_CSS_PRETTIER=false
-VALIDATE_DOCKERFILE_HADOLINT=false
-VALIDATE_GIT_COMMITLINT=false
-# done by maven
-VALIDATE_GOOGLE_JAVA_FORMAT=false
-# times out
-VALIDATE_GO_MODULES=false
-VALIDATE_HTML=false
-# done by checkstyle
-VALIDATE_JAVA=false
-# we have many duplicate code in our codebase for demo purposes
-VALIDATE_JSCPD=false
-VALIDATE_PYTHON_PYLINT=false
-# conflicts with black
-VALIDATE_PYTHON_RUFF_FORMAT=false
-# excluding simpleclient-archive doesn't seem to work
-VALIDATE_TRIVY=false
-
-FIX_ENV=true
-FIX_GITHUB_ACTIONS_ZIZMOR=true
-FIX_GO=true
-FIX_JAVASCRIPT_PRETTIER=true
-FIX_JSON=true
-FIX_JSONC=true
-FIX_JSONC_PRETTIER=true
-FIX_JSON_PRETTIER=true
-FIX_MARKDOWN=true
-FIX_MARKDOWN_PRETTIER=true
-FIX_PYTHON_BLACK=true
-FIX_SHELL_SHFMT=true
-FIX_YAML_PRETTIER=true
diff --git a/.github/workflows/acceptance-tests.yml b/.github/workflows/acceptance-tests.yml
index 735e4d30a..8c891d4bc 100644
--- a/.github/workflows/acceptance-tests.yml
+++ b/.github/workflows/acceptance-tests.yml
@@ -12,10 +12,10 @@ jobs:
- name: Check out
with:
persist-credentials: false
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- - uses: jdx/mise-action@be3be2260bc02bc3fbf94c5e2fed8b7964baf074 # v3.4.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5
with:
- version: v2025.11.3
- sha256: 2b01d2063ac9fb6988ed5bfa087bebfba4ae6300b0739231bb534924acd31306
+ version: v2026.8.6
+ sha256: 96f6f1f416d868b78addd22746eefc7f4bf7820c6a3afa392a9f653f708c1644
- name: Run acceptance tests
run: mise run acceptance-test
diff --git a/.github/workflows/api-diff.yml b/.github/workflows/api-diff.yml
new file mode 100644
index 000000000..892881fa9
--- /dev/null
+++ b/.github/workflows/api-diff.yml
@@ -0,0 +1,52 @@
+---
+name: API Diff
+
+on:
+ pull_request:
+ types:
+ - opened
+ - synchronize
+ - reopened
+ workflow_dispatch:
+ inputs:
+ baseline_version:
+ description: >-
+ Override the baseline version to compare against.
+ Defaults to in pom.xml.
+ required: false
+ default: ""
+
+permissions:
+ contents: read
+
+jobs:
+ api-diff:
+ if: ${{ !startsWith(github.head_ref, 'release-please--branches--') }}
+ runs-on: ubuntu-24.04
+ env:
+ # Empty unless overridden via workflow_dispatch; the api-diff task then
+ # falls back to in pom.xml.
+ API_DIFF_BASELINE_VERSION: ${{ inputs.baseline_version }}
+
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+ - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5
+ with:
+ version: v2026.8.6
+ sha256: 96f6f1f416d868b78addd22746eefc7f4bf7820c6a3afa392a9f653f708c1644
+ - name: Cache local Maven repository
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ - name: Run japicmp API diff
+ run: mise run api-diff
+ - name: Check docs/apidiffs is up to date
+ run: |
+ if ! git diff --exit-code -- docs/apidiffs; then
+ echo "::error::Published API surface changed but docs/apidiffs is stale."
+ echo "Run 'mise run api-diff' locally and commit the updated docs/apidiffs."
+ exit 1
+ fi
diff --git a/.github/workflows/approve-workflows.yml b/.github/workflows/approve-workflows.yml
new file mode 100644
index 000000000..f372012f2
--- /dev/null
+++ b/.github/workflows/approve-workflows.yml
@@ -0,0 +1,27 @@
+---
+###
+# This action is synced from https://github.com/prometheus/prometheus
+###
+name: Approve pending workflows
+
+on:
+ issue_comment:
+ types: [created]
+
+permissions: read-all
+
+jobs:
+ approve:
+ if: >-
+ github.event.issue.pull_request &&
+ github.event.comment.body == '/workflow-approve' &&
+ (github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community')
+ runs-on: ubuntu-latest
+ permissions:
+ actions: write
+ contents: read
+ pull-requests: write
+ steps:
+ - uses: prometheus/promci/approve_workflows@370e8c15dcec50043cbe66f2f34633d9efc0a190 # v0.9.0
+ with:
+ github_token: ${{ github.token }}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c7727e98b..2acb7ea55 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -9,19 +9,17 @@ jobs:
build:
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- - uses: jdx/mise-action@be3be2260bc02bc3fbf94c5e2fed8b7964baf074 # v3.4.0
+ - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5
with:
- version: v2025.11.3
- sha256: 2b01d2063ac9fb6988ed5bfa087bebfba4ae6300b0739231bb534924acd31306
+ version: v2026.8.6
+ sha256: 96f6f1f416d868b78addd22746eefc7f4bf7820c6a3afa392a9f653f708c1644
- name: Cache local Maven repository
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
- restore-keys: |
- ${{ runner.os }}-maven-
- name: Run the Maven verify phase
run: mise run ci
diff --git a/.github/workflows/bump-api-diff-baseline.yml b/.github/workflows/bump-api-diff-baseline.yml
new file mode 100644
index 000000000..38004405d
--- /dev/null
+++ b/.github/workflows/bump-api-diff-baseline.yml
@@ -0,0 +1,94 @@
+---
+name: Bump API Diff Baseline
+
+# After release.yml publishes to Maven Central, push a baseline bump and
+# regenerated docs/apidiffs onto release-please's open snapshot-bump PR branch
+# (release-please--branches--main). The user merges a single PR that bumps to
+# the next -SNAPSHOT *and* updates the api-diff baseline, so there is no window
+# where main has a stale baseline.
+#
+# If the snapshot PR branch no longer exists (already merged), this workflow
+# fails loudly โ re-run via workflow_dispatch after recreating it, or bump
+# manually.
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Released tag (e.g. v1.8.0)"
+ required: true
+
+permissions: {}
+
+env:
+ SNAPSHOT_BRANCH: release-please--branches--main
+
+jobs:
+ bump:
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write # push to the snapshot PR branch
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: ${{ env.SNAPSHOT_BRANCH }}
+ persist-credentials: true
+ - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5
+ with:
+ version: v2026.8.6
+ sha256: 96f6f1f416d868b78addd22746eefc7f4bf7820c6a3afa392a9f653f708c1644
+ - name: Cache local Maven repository
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ restore-keys: |
+ ${{ runner.os }}-maven-
+ - name: Compute versions and bump baseline
+ id: bump
+ env:
+ TAG: ${{ inputs.tag }}
+ run: |
+ NEW="${TAG#v}"
+ baseline() {
+ grep -oP '(?<=)[^<]+' "$1"
+ }
+ OLD=$(baseline pom.xml)
+ if [[ -z "$OLD" ]]; then
+ echo "::error::Could not read api.diff.baseline.version"
+ exit 1
+ fi
+ if [[ "$OLD" == "$NEW" ]]; then
+ echo "Baseline already $NEW; nothing to do"
+ echo "skip=true" >>"$GITHUB_OUTPUT"
+ exit 0
+ fi
+ sed -i -E \
+ "s|()[^<]+()|\1${NEW}\2|" \
+ pom.xml
+ if [[ -d docs/apidiffs/current_vs_latest ]]; then
+ cp -r docs/apidiffs/current_vs_latest "docs/apidiffs/${NEW}_vs_${OLD}"
+ fi
+ echo "old=${OLD}" >>"$GITHUB_OUTPUT"
+ echo "new=${NEW}" >>"$GITHUB_OUTPUT"
+ - name: Regenerate docs/apidiffs
+ if: steps.bump.outputs.skip != 'true'
+ run: mise run api-diff
+ - name: Push to snapshot PR branch
+ if: steps.bump.outputs.skip != 'true'
+ env:
+ NEW: ${{ steps.bump.outputs.new }}
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add pom.xml docs/apidiffs
+ OUTSIDE=$(git status --porcelain -- ':(exclude)pom.xml' ':(exclude)docs/apidiffs')
+ if [[ -n "$OUTSIDE" ]]; then
+ echo "::error::Unexpected changes outside pom.xml/docs/apidiffs:"
+ echo "$OUTSIDE"
+ exit 1
+ fi
+ git commit -m "chore: bump api-diff baseline to ${NEW}"
+ # Note: GITHUB_TOKEN pushes don't trigger CI re-runs on the PR.
+ # Close and reopen the snapshot PR to trigger CI after this commit.
+ git push origin "HEAD:${SNAPSHOT_BRANCH}"
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 000000000..b9ae9d3ce
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,61 @@
+---
+name: CodeQL Security Analysis
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ schedule:
+ - cron: "29 13 * * 2" # Weekly Tuesday 13:29 UTC
+
+permissions: {}
+
+jobs:
+ analyze:
+ name: Analyze Java
+ runs-on: ubuntu-24.04
+ permissions:
+ actions: read # required for github/codeql-action/init to get workflow details
+ contents: read
+ security-events: write # required for github/codeql-action/analyze to upload SARIF
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+
+ - name: Set up Java
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: temurin
+ java-version: "25"
+
+ - name: Cache Maven repository
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-codeql-${{ hashFiles('**/pom.xml') }}
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
+ with:
+ languages: java
+ tools: linked
+ queries: security-extended
+
+ # Do not use autobuild โ the multi-module Maven structure requires explicit
+ # build invocation so that CodeQL can trace the compilation correctly.
+ # Do not use mise-action here โ CodeQL needs to trace the raw Maven build.
+ - name: Build (CodeQL traces the build)
+ run: >
+ ./mvnw clean compile
+ -P '!default'
+ -DskipTests
+ -Dcoverage.skip=true
+ -Dcheckstyle.skip=true
+ -Djavadoc.skip=true
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
+ with:
+ category: /language:java
diff --git a/.github/workflows/detect-api-changes.yml b/.github/workflows/detect-api-changes.yml
new file mode 100644
index 000000000..f0de28c7a
--- /dev/null
+++ b/.github/workflows/detect-api-changes.yml
@@ -0,0 +1,117 @@
+---
+name: Detect API Changes
+
+on:
+ # pull_request_target is used so the workflow can update labels and comments
+ # without checking out or executing code from the PR branch.
+ # zizmor: ignore[dangerous-triggers] -- this workflow only reads PR metadata
+ # via the GitHub API and never checks out or executes code from the PR branch.
+ pull_request_target:
+ types:
+ - opened
+ - synchronize
+ - reopened
+ - ready_for_review
+
+permissions: {}
+
+jobs:
+ detect-api-changes:
+ if: github.repository == 'prometheus/client_java'
+ runs-on: ubuntu-24.04
+ permissions:
+ issues: write
+ pull-requests: write
+ steps:
+ - name: Check for API changes and update PR
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ REPO: ${{ github.repository }}
+ run: |
+ MARKER=""
+ BREAKING_LABEL="breaking-api-change"
+
+ api_files=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate \
+ --jq '.[] | select(.filename | startswith("docs/apidiffs/current_vs_latest/")) | .filename')
+
+ comment_id=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate \
+ --jq ".[] | select(.body | startswith(\"${MARKER}\")) | .id" | head -1)
+
+ if [[ -z "$api_files" ]]; then
+ echo "No API diff files changed."
+ gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "api-change" 2>/dev/null || true
+ gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$BREAKING_LABEL" 2>/dev/null || true
+ if [[ -n "$comment_id" ]]; then
+ gh api --method DELETE "repos/${REPO}/issues/comments/${comment_id}"
+ fi
+ exit 0
+ fi
+
+ echo "API diff files changed:"
+ echo "$api_files"
+
+ gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "api-change"
+
+ modules=$(echo "$api_files" \
+ | sed 's|docs/apidiffs/current_vs_latest/||' \
+ | sed 's|\.txt$||' \
+ | sort \
+ | sed 's/^/- /')
+
+ HEAD_SHA=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq .head.sha)
+ breaking_files=()
+ while IFS= read -r file; do
+ [[ -z "$file" ]] && continue
+ content=$(gh api \
+ -H "Accept: application/vnd.github.raw" \
+ "repos/${REPO}/contents/${file}?ref=${HEAD_SHA}")
+ if grep -Eq '^[[:space:]]*(\*\*\*!|---!|\+\+\+!)' <<<"$content"; then
+ breaking_files+=("$file")
+ fi
+ done <<<"$api_files"
+
+ if [[ ${#breaking_files[@]} -gt 0 ]]; then
+ gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "$BREAKING_LABEL"
+ breaking_modules=$(printf '%s\n' "${breaking_files[@]}" \
+ | sed 's|docs/apidiffs/current_vs_latest/||' \
+ | sed 's|\.txt$||' \
+ | sort \
+ | sed 's/^/- /')
+ body=$(cat </dev/null || true
+
+ body=$(cat < /tmp/protobuf-sources.patch
+ - name: Upload generated patch
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: protobuf-sources-patch
+ path: /tmp/protobuf-sources.patch
+ retention-days: 5
+
+ publish:
+ runs-on: ubuntu-24.04
+ needs: generate
+ permissions:
+ contents: write # push regenerated sources back to the renovate branch
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: ${{ github.ref }}
+ # zizmor: ignore[artipacked] -- needs credentials to push
+ persist-credentials: true
+ - name: Download generated patch
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: protobuf-sources-patch
+ path: /tmp/patch
+ - name: Commit and push generated sources
+ run: |
+ PATCH=/tmp/patch/protobuf-sources.patch
+ if [[ ! -s "$PATCH" ]]; then
+ echo "No generated changes to commit"
+ exit 0
+ fi
+ git apply --index "$PATCH"
+ if git diff --cached --quiet; then
+ echo "No generated changes to commit"
+ exit 0
+ fi
+ # Note: GITHUB_TOKEN pushes don't trigger CI re-runs.
+ # Close and reopen the PR to trigger CI after this commit.
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git commit -m "chore: regenerate protobuf sources"
+ git push
diff --git a/.github/workflows/github-pages.yaml b/.github/workflows/github-pages.yaml
index 0f3ad00aa..4878af3fd 100644
--- a/.github/workflows/github-pages.yaml
+++ b/.github/workflows/github-pages.yaml
@@ -32,25 +32,25 @@ jobs:
if: github.repository == 'prometheus/client_java'
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
fetch-tags: "true"
fetch-depth: 0
- - uses: jdx/mise-action@be3be2260bc02bc3fbf94c5e2fed8b7964baf074 # v3.4.0
+ - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5
with:
- version: v2025.11.3
- sha256: 2b01d2063ac9fb6988ed5bfa087bebfba4ae6300b0739231bb534924acd31306
+ version: v2026.8.6
+ sha256: 96f6f1f416d868b78addd22746eefc7f4bf7820c6a3afa392a9f653f708c1644
cache: "false"
- name: Setup Pages
id: pages
- uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
+ uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6
- name: Build GitHub Pages
run: mise run build-gh-pages
env:
BASE_URL: "${{ steps.pages.outputs.base_url }}/"
- name: Upload artifact
- uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
+ uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: ./docs/public
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
@@ -70,4 +70,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
+ uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5
diff --git a/.github/workflows/issue-management-stale-action.yml b/.github/workflows/issue-management-stale-action.yml
new file mode 100644
index 000000000..f0a294745
--- /dev/null
+++ b/.github/workflows/issue-management-stale-action.yml
@@ -0,0 +1,59 @@
+---
+name: Issue management - run stale action
+
+on:
+ schedule:
+ # hourly at minute 23
+ - cron: "23 6 * * *"
+ workflow_dispatch:
+
+permissions: {}
+
+concurrency:
+ group: "stale-issues-and-prs"
+ cancel-in-progress: false
+
+jobs:
+ stale:
+ permissions:
+ contents: read
+ actions: write # because actions/stale deletes its old cache before saving new one
+ issues: write # for actions/stale to close stale issues
+ pull-requests: write # for actions/stale to close stale PRs
+ runs-on: ubuntu-latest
+ steps:
+ # Handle stale PRs
+ # - After 120 days inactive: Adds "stale" label + warning comment
+ # - After 30 more days inactive: Closes
+ - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
+ with:
+ days-before-issue-stale: -1
+ days-before-issue-close: -1
+ days-before-pr-stale: 120
+ days-before-pr-close: 30
+ stale-pr-label: stale
+ stale-pr-message: >
+ This PR has been marked as stale due to 120 days of inactivity.
+ It will be automatically closed if there is no further activity over the next 30 days.
+ close-pr-message: >
+ This PR was automatically closed due to lack of activity after being marked stale. Feel
+ free to reopen if you would like to continue working on it.
+ operations-per-run: 1000
+
+ # Handle stale issues
+ # - After 360 days (12 months) inactive: Adds "stale" label + warning comment
+ # - After 30 more days inactive: Closes
+ - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
+ with:
+ days-before-issue-stale: 360
+ days-before-issue-close: 30
+ days-before-pr-stale: -1
+ days-before-pr-close: -1
+ stale-issue-label: stale
+ stale-issue-message: >
+ This issue has been marked as stale due to 12 months of inactivity.
+ It will be automatically closed if there is no further activity over the next 30 days.
+ close-issue-message: >
+ This issue was automatically closed due to lack of activity after being marked stale.
+ Feel free to reopen if you would like to continue working on it.
+ operations-per-run: 1000
diff --git a/.github/workflows/java-version-matrix-tests.yml b/.github/workflows/java-version-matrix-tests.yml
new file mode 100644
index 000000000..1fa501465
--- /dev/null
+++ b/.github/workflows/java-version-matrix-tests.yml
@@ -0,0 +1,79 @@
+---
+name: Integration Tests - Java Version Compatibility Matrix
+
+on:
+ pull_request:
+ paths:
+ - "integration-tests/**"
+ - "prometheus-metrics-core/**"
+ - "prometheus-metrics-exporter-*/**"
+ - "prometheus-metrics-exposition-*/**"
+ - ".github/workflows/java-version-matrix-tests.yml"
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions: {}
+
+jobs:
+ integration-tests:
+ name: Java ${{ matrix.java-version }}
+ runs-on: ubuntu-24.04
+ strategy:
+ fail-fast: false
+ matrix:
+ java-version: [8, 11, 17, 21, 25]
+ steps:
+ - name: Check out
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up mise
+ uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5
+ with:
+ version: v2026.8.6
+ sha256: 96f6f1f416d868b78addd22746eefc7f4bf7820c6a3afa392a9f653f708c1644
+
+ - name: Cache local Maven repository
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ - name: Build core library artifacts
+ run: mise exec -- ./mvnw install -DskipTests -Dcoverage.skip=true -Dcheckstyle.skip=true -Dwarnings=-nowarn -pl '!integration-tests'
+
+ - name: Install parent POMs
+ run: |
+ cd integration-tests
+ mise exec -- ../mvnw clean install -N
+ cd it-exporter
+ mise exec -- ../../mvnw install -N
+ - name: Rebuild sample apps targeting Java ${{ matrix.java-version }}
+ run: |
+ cd integration-tests
+ # Note: Jetty 12 and Tomcat 11 require Java 17+, so servlet samples are skipped for Java 8 & 11
+ if [[ "${{ matrix.java-version }}" == "11" || "${{ matrix.java-version }}" == "8" ]]; then
+ MODULES="it-common,it-exporter/it-exporter-httpserver-sample,it-exporter/it-exporter-no-protobuf,it-pushgateway"
+ else
+ MODULES="it-common,it-exporter/it-exporter-httpserver-sample,it-exporter/it-exporter-servlet-tomcat-sample,it-exporter/it-exporter-servlet-jetty-sample,it-exporter/it-exporter-no-protobuf,it-pushgateway"
+ fi
+ mise exec -- ../mvnw clean install -DskipTests -Dcoverage.skip=true -Dcheckstyle.skip=true -Dwarnings=-nowarn \
+ -Djava.version=${{ matrix.java-version }} \
+ -Dmaven.compiler.release=${{ matrix.java-version }} \
+ -pl $MODULES
+
+ - name: Run integration tests
+ env:
+ TEST_JAVA_VERSION: ${{ matrix.java-version }}
+ run: |
+ cd integration-tests
+ # Note: Servlet tests require Java 17+ (due to Jetty 12 and Tomcat 11)
+ if [[ "${{ matrix.java-version }}" == "11" || "${{ matrix.java-version }}" == "8" ]]; then
+ TEST_MODULES="it-exporter/it-no-protobuf-test,it-pushgateway"
+ else
+ TEST_MODULES="it-exporter/it-exporter-test,it-exporter/it-no-protobuf-test,it-pushgateway"
+ fi
+ mise exec -- ../mvnw verify -T 2C -Dcoverage.skip=true -Dcheckstyle.skip=true -Dwarnings=-nowarn \
+ -pl $TEST_MODULES
diff --git a/.github/workflows/jmx-exporter-compatibility.yml b/.github/workflows/jmx-exporter-compatibility.yml
new file mode 100644
index 000000000..5c300425c
--- /dev/null
+++ b/.github/workflows/jmx-exporter-compatibility.yml
@@ -0,0 +1,40 @@
+---
+name: JMX Exporter Compatibility
+
+on:
+ pull_request:
+ workflow_dispatch:
+ inputs:
+ repository:
+ description: JMX Exporter repository to test, in owner/name form
+ required: false
+ default: prometheus/jmx_exporter
+ ref:
+ description: JMX Exporter branch, tag, or commit to test
+ required: true
+
+permissions: {}
+
+jobs:
+ jmx-exporter-compatibility:
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+ - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5
+ with:
+ version: v2026.8.6
+ sha256: 96f6f1f416d868b78addd22746eefc7f4bf7820c6a3afa392a9f653f708c1644
+ working_directory: .mise/envs/jmx-exporter
+ - name: Cache local Maven repository
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ - name: Run JMX Exporter compatibility tests
+ working-directory: .mise/envs/jmx-exporter
+ env:
+ JMX_EXPORTER_REPOSITORY: ${{ inputs.repository || 'prometheus/jmx_exporter' }}
+ JMX_EXPORTER_REF: ${{ inputs.ref }}
+ run: mise compat-test
diff --git a/.github/workflows/lint-rest.yml b/.github/workflows/lint-rest.yml
deleted file mode 100644
index b6d4004a2..000000000
--- a/.github/workflows/lint-rest.yml
+++ /dev/null
@@ -1,21 +0,0 @@
----
-name: Lint What Super Linter Can't
-
-on: [pull_request]
-
-permissions: {}
-
-jobs:
- lint:
- runs-on: ubuntu-24.04
- steps:
- - name: Check out
- with:
- persist-credentials: false
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- - uses: jdx/mise-action@be3be2260bc02bc3fbf94c5e2fed8b7964baf074 # v3.4.0
- with:
- version: v2025.11.3
- sha256: 2b01d2063ac9fb6988ed5bfa087bebfba4ae6300b0739231bb534924acd31306
- - name: Lint
- run: mise run lint-rest
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 000000000..e2fb025fc
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,36 @@
+---
+name: Lint
+
+on:
+ pull_request:
+
+permissions: {}
+
+jobs:
+ lint:
+ runs-on: ubuntu-24.04
+
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ fetch-depth: 0 # needed for git diff --merge-base in lint:links
+
+ - name: Setup mise
+ uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5
+ with:
+ version: v2026.8.6
+ sha256: 96f6f1f416d868b78addd22746eefc7f4bf7820c6a3afa392a9f653f708c1644
+
+ - name: Lint
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ GITHUB_BASE_REF: ${{ github.base_ref }}
+ GITHUB_HEAD_REF: ${{ github.head_ref }}
+ PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
+ run: mise run lint
diff --git a/.github/workflows/micrometer-compatibility.yml b/.github/workflows/micrometer-compatibility.yml
new file mode 100644
index 000000000..b73552299
--- /dev/null
+++ b/.github/workflows/micrometer-compatibility.yml
@@ -0,0 +1,61 @@
+---
+name: Micrometer Compatibility
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+permissions: {}
+
+jobs:
+ compat-test:
+ name: ${{ matrix.name }}
+ runs-on: ubuntu-24.04
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: upstream
+ repository: micrometer-metrics/micrometer
+ # renovate: datasource=github-releases depName=micrometer-metrics/micrometer packageName=micrometer-metrics/micrometer
+ ref: v1.17.1
+ - name: typed-descriptor
+ # TODO: remove this temporary opt-in leg once Micrometer switches the
+ # Prometheus client integration to typed descriptors by default.
+ # Follow-up: https://github.com/prometheus/client_java/issues/2182
+ repository: zeitlinger/micrometer
+ # renovate: datasource=git-refs depName=zeitlinger/micrometer packageName=https://github.com/zeitlinger/micrometer currentValue=feat/prom-client-java-typed-family-descriptor
+ ref: f240f0978366378dae66ac005ea1d2ef65708ff3
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5
+ with:
+ version: v2026.8.6
+ sha256: 96f6f1f416d868b78addd22746eefc7f4bf7820c6a3afa392a9f653f708c1644
+ working_directory: .mise/envs/micrometer
+ - name: Cache local Maven repository
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ - name: Run Micrometer compatibility tests
+ working-directory: .mise/envs/micrometer
+ env:
+ MICROMETER_REPOSITORY: ${{ matrix.repository }}
+ MICROMETER_REF: ${{ matrix.ref }}
+ run: mise compat-test
+
+ micrometer-compatibility:
+ name: micrometer-compatibility
+ runs-on: ubuntu-24.04
+ needs: compat-test
+ if: always()
+ steps:
+ - name: Aggregate matrix results
+ run: |
+ if [[ "${{ needs.compat-test.result }}" != "success" ]]; then
+ echo "compat-test matrix failed: ${{ needs.compat-test.result }}"
+ exit 1
+ fi
diff --git a/.github/workflows/multi-version-test.yml b/.github/workflows/multi-version-test.yml
new file mode 100644
index 000000000..f90251766
--- /dev/null
+++ b/.github/workflows/multi-version-test.yml
@@ -0,0 +1,35 @@
+---
+name: Java-Version Compatibility Tests
+
+on: [pull_request]
+
+permissions: {}
+
+jobs:
+ compatibility-test:
+ name: Test on Java ${{ matrix.java }}
+ runs-on: ubuntu-24.04
+ strategy:
+ fail-fast: false
+ matrix:
+ java: [17, 21, 25]
+ steps:
+ - name: Check out
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+
+ - name: Set up Java ${{ matrix.java }}
+ id: setup-java
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
+ with:
+ distribution: "temurin"
+ java-version: ${{ matrix.java }}
+
+ - name: Cache local Maven repository
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-java${{ matrix.java }}-${{ hashFiles('**/pom.xml') }}
+ - name: Build and test on Java ${{ matrix.java }}
+ run: ./mvnw clean install -Dtest.java.version=${{ matrix.java }} -Dcheckstyle.skip=true -Dwarnings=-nowarn -Dcoverage.skip=true
diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml
index 1b2c51f0e..a099aaafc 100644
--- a/.github/workflows/native-tests.yml
+++ b/.github/workflows/native-tests.yml
@@ -12,12 +12,11 @@ jobs:
- name: Check out
with:
persist-credentials: false
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- - uses: jdx/mise-action@be3be2260bc02bc3fbf94c5e2fed8b7964baf074 # v3.4.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5
with:
- version: v2025.11.3
- sha256: 2b01d2063ac9fb6988ed5bfa087bebfba4ae6300b0739231bb534924acd31306
- env:
- MISE_ENV: native
+ version: v2026.8.6
+ sha256: 96f6f1f416d868b78addd22746eefc7f4bf7820c6a3afa392a9f653f708c1644
+ working_directory: .mise/envs/native
- name: Run native tests
- run: mise run test
+ run: mise //.mise/envs/native:native-test
diff --git a/.github/workflows/nightly-benchmarks.yml b/.github/workflows/nightly-benchmarks.yml
new file mode 100644
index 000000000..f7e1bb8cd
--- /dev/null
+++ b/.github/workflows/nightly-benchmarks.yml
@@ -0,0 +1,124 @@
+---
+name: Nightly Benchmarks
+
+on:
+ schedule:
+ # Run at 2 AM UTC every day
+ - cron: "0 2 * * *"
+ workflow_dispatch:
+ inputs:
+ jmh_args:
+ description: "Additional JMH arguments (e.g., '-f 1 -wi 1 -i 3' for quick run)"
+ required: false
+ default: ""
+
+permissions: {}
+
+concurrency:
+ group: "benchmarks"
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ benchmark:
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read # checkout only
+ steps:
+ - name: Checkout main branch
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ fetch-depth: 0
+
+ - name: Setup mise
+ uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5
+ with:
+ version: v2026.8.6
+ sha256: 96f6f1f416d868b78addd22746eefc7f4bf7820c6a3afa392a9f653f708c1644
+
+ - name: Cache local Maven repository
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ restore-keys: |
+ ${{ runner.os }}-maven-
+
+ - name: Run JMH benchmarks
+ run: mise run benchmark:ci-json
+ env:
+ JMH_ARGS: ${{ github.event.inputs.jmh_args }}
+
+ - name: Generate benchmark summary
+ run: |
+ mise run benchmark:generate-summary \
+ --input benchmark-results.json \
+ --output-dir benchmark-results \
+ --commit-sha "${{ github.sha }}"
+ env:
+ GITHUB_REPOSITORY: ${{ github.repository }}
+
+ - name: Upload benchmark results
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: benchmark-results
+ path: benchmark-results
+ retention-days: 5
+
+ publish:
+ runs-on: ubuntu-24.04
+ needs: benchmark
+ permissions:
+ contents: write # push generated results to the benchmarks branch
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ # zizmor: ignore[artipacked] -- needs credentials to push to benchmarks branch
+ persist-credentials: true
+ fetch-depth: 0
+
+ - name: Download benchmark results
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: benchmark-results
+ path: /tmp/benchmark-output
+
+ - name: Commit and push results to benchmarks branch
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ # Checkout or create benchmarks branch (use -- to disambiguate from benchmarks/ directory)
+ if git ls-remote --heads origin benchmarks | grep -q benchmarks; then
+ git fetch origin benchmarks
+ git switch benchmarks
+ # Preserve existing history
+ if [ -d history ]; then
+ cp -r history /tmp/benchmark-output/
+ fi
+ else
+ git switch --orphan benchmarks
+ fi
+
+ # Clean working directory
+ git rm -rf . 2>/dev/null || true
+ find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
+
+ # Copy only the benchmark results
+ cp -r /tmp/benchmark-output/* .
+
+ git add README.md results.json history/
+
+ DATE=$(date -u +"%Y-%m-%d")
+ COMMIT_SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
+
+ git commit \
+ -m "Benchmark results for ${DATE} (${COMMIT_SHORT})" \
+ -m "From commit ${{ github.sha }}" \
+ || echo "No changes to commit"
+
+ git push origin benchmarks --force-with-lease || git push origin benchmarks
diff --git a/.github/workflows/pr-benchmark-report.yml b/.github/workflows/pr-benchmark-report.yml
new file mode 100644
index 000000000..25e3d848e
--- /dev/null
+++ b/.github/workflows/pr-benchmark-report.yml
@@ -0,0 +1,123 @@
+---
+name: PR Benchmark Report
+
+on:
+ # zizmor: ignore[dangerous-triggers] -- this workflow never checks out or executes PR code;
+ # it only reads artifacts produced by the benchmark run and posts a PR comment.
+ workflow_run:
+ workflows:
+ - PR Benchmarks
+ types:
+ - completed
+
+permissions: {}
+
+jobs:
+ comment:
+ if: >
+ github.event.workflow_run.event == 'pull_request' &&
+ github.event.workflow_run.conclusion != 'cancelled'
+ runs-on: ubuntu-24.04
+ permissions:
+ actions: read
+ contents: read
+ pull-requests: write
+ steps:
+ - name: Comment on PR with benchmark results
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ RUN_ID: ${{ github.event.workflow_run.id }}
+ RUN_URL: ${{ github.event.workflow_run.html_url }}
+ CONCLUSION: ${{ github.event.workflow_run.conclusion }}
+ run: |
+ HEAD_SHA=$(gh api "repos/${REPO}/actions/runs/${RUN_ID}" --jq '.head_sha')
+ PR_NUMBER=$(gh api \
+ "repos/${REPO}/actions/runs/${RUN_ID}" \
+ --jq '.pull_requests[0].number // ""')
+
+ # workflow_run.pull_requests can be empty for forked PRs. Prefer the
+ # artifact name because the benchmark workflow had the PR number when it
+ # uploaded the artifacts, then fall back to the run head repository/branch.
+ if [[ -z "${PR_NUMBER}" ]]; then
+ PR_NUMBER=$(gh api "repos/${REPO}/actions/runs/${RUN_ID}/artifacts" \
+ --jq '.artifacts[].name
+ | select(startswith("pr-benchmark-") and endswith("-'"${HEAD_SHA}"'"))
+ | split("-")[4]' \
+ | sort -u | head -1)
+ fi
+ if [[ -z "${PR_NUMBER}" ]]; then
+ HEAD_OWNER=$(gh api \
+ "repos/${REPO}/actions/runs/${RUN_ID}" \
+ --jq '.head_repository.owner.login // ""')
+ HEAD_BRANCH=$(gh api \
+ "repos/${REPO}/actions/runs/${RUN_ID}" \
+ --jq '.head_branch // ""')
+ if [[ -n "${HEAD_OWNER}" && -n "${HEAD_BRANCH}" ]]; then
+ PR_NUMBER=$(gh api --method GET "repos/${REPO}/pulls" \
+ -f state=open \
+ -f head="${HEAD_OWNER}:${HEAD_BRANCH}" \
+ --jq '.[].number' \
+ | head -1)
+ fi
+ fi
+ if [[ -z "${PR_NUMBER}" ]]; then
+ PR_NUMBER=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/pulls" \
+ --jq '.[] | select(.state == "open") | .number' | head -1)
+ fi
+ if [[ -z "${PR_NUMBER}" ]]; then
+ echo "Could not determine PR number for run ${RUN_ID} (head ${HEAD_SHA})." >&2
+ exit 1
+ fi
+
+ COMMENT_ARTIFACT_NAME="pr-benchmark-comment-pr-${PR_NUMBER}-${HEAD_SHA}"
+ RESULTS_ARTIFACT_NAME="pr-benchmark-results-pr-${PR_NUMBER}-${HEAD_SHA}"
+ COMMENT_ARTIFACT_ID=$(gh api "repos/${REPO}/actions/runs/${RUN_ID}/artifacts" \
+ --jq '.artifacts[]
+ | select(.name == "'"${COMMENT_ARTIFACT_NAME}"'")
+ | .id' \
+ | head -1)
+
+ MARKER=""
+
+ if [[ -n "${COMMENT_ARTIFACT_ID}" ]]; then
+ gh api \
+ "repos/${REPO}/actions/artifacts/${COMMENT_ARTIFACT_ID}/zip" \
+ > /tmp/pr-benchmark-comment.zip
+ unzip -p /tmp/pr-benchmark-comment.zip pr-benchmark-comment.md \
+ > /tmp/pr-benchmark-comment.md
+ SUMMARY_BODY=$(cat /tmp/pr-benchmark-comment.md)
+ else
+ SUMMARY_BODY="_Benchmark summary artifact was not found; "
+ SUMMARY_BODY+="see the workflow run for details._"
+ fi
+
+ if [[ "${CONCLUSION}" == "success" ]]; then
+ STATUS_LINE="Benchmark run succeeded for \`${HEAD_SHA}\`."
+ else
+ STATUS_LINE="Benchmark run finished with conclusion \`${CONCLUSION}\` "
+ STATUS_LINE+="for \`${HEAD_SHA}\`."
+ fi
+
+ body=$(cat < /tmp/apidiffs.patch
+ - name: Upload generated patch
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: apidiffs-patch
+ path: /tmp/apidiffs.patch
+ retention-days: 5
+
+ publish:
+ runs-on: ubuntu-24.04
+ needs: generate
+ permissions:
+ contents: write # push regenerated apidiffs back to the renovate branch
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: ${{ github.ref }}
+ # zizmor: ignore[artipacked] -- needs credentials to push
+ persist-credentials: true
+ - name: Download generated patch
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: apidiffs-patch
+ path: /tmp/patch
+ - name: Commit and push regenerated apidiffs
+ run: |
+ PATCH=/tmp/patch/apidiffs.patch
+ if [[ ! -s "$PATCH" ]]; then
+ echo "No apidiff changes to commit"
+ exit 0
+ fi
+ git apply "$PATCH"
+ # Note: GITHUB_TOKEN pushes don't trigger CI re-runs.
+ # Close and reopen the PR to trigger CI after this commit.
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add docs/apidiffs
+ git commit -m "chore: regenerate api diff"
+ git push
diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
new file mode 100644
index 000000000..9e2b1cf62
--- /dev/null
+++ b/.github/workflows/release-please.yml
@@ -0,0 +1,35 @@
+---
+name: Release Please
+
+on:
+ push:
+ branches:
+ - main
+
+permissions: {}
+
+jobs:
+ release-please:
+ if: ${{ github.repository == 'prometheus/client_java' }}
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write # release-please creates release commits, tags, and the GitHub release
+ pull-requests: write # release-please opens/updates the release PR
+ issues: write # release-please comments on/labels issues included in the release
+ actions: write # required to trigger release.yml via `gh workflow run`
+ steps:
+ - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0
+ id: release-please
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ config-file: .github/config/release-please-config.json
+ manifest-file: .github/config/.release-please-manifest.json
+
+ - name: Trigger deploy to Maven Central
+ if: ${{ steps.release-please.outputs.releases_created == 'true' }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG_NAME: ${{ steps.release-please.outputs.tag_name }}
+ run: >
+ gh workflow run release.yml --repo "${GITHUB_REPOSITORY}"
+ -f "tag=${TAG_NAME}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 84492d0c2..6a8335014 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,41 +2,87 @@
name: Deploy to Maven Central
on:
- push:
- tags:
- - "v*.*.*"
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Release tag to deploy (e.g. v1.5.1)"
+ required: true
+
+permissions: {}
jobs:
deploy:
if: ${{ github.repository == 'prometheus/client_java' }}
+ environment: release
runs-on: ubuntu-24.04
- permissions: {}
+ env:
+ # Artifact releases pair a focused tool allowlist with cache: false.
+ MISE_ENABLE_TOOLS: java
+ permissions:
+ actions: write # required to trigger bump-api-diff-baseline.yml via `gh workflow run`
steps:
- - name: Debug gpg key
+ - name: Verify release secrets
env:
+ MAVEN_USERNAME: ${{ secrets.SONATYPE_MAVEN_REPOSITORY_USERNAME }}
+ MAVEN_CENTRAL_TOKEN: ${{ secrets.SONATYPE_MAVEN_REPOSITORY_PASSWORD }}
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }}
+ GPG_SIGNING_PASSPHRASE: ${{ secrets.GPG_SIGNING_PASSPHRASE }}
run: |
- echo "${#GPG_SIGNING_KEY}"
- echo "${GPG_SIGNING_KEY}" | gpg --batch --import-options import-show --import
+ fail() {
+ echo "::error::$1"
+ echo "::error::Fix: see '$2' in RELEASING.md"
+ exit 1
+ }
+
+ # Sonatype token
+ response=$(curl -s -u "${MAVEN_USERNAME}:${MAVEN_CENTRAL_TOKEN}" \
+ "https://central.sonatype.com/api/v1/publisher/status?id=test")
+ echo "Sonatype response: ${response}"
+ if echo "${response}" | grep -q "Invalid token"; then
+ fail "Sonatype Central token is invalid." \
+ "If the Sonatype Central Token is Invalid"
+ fi
+
+ # GPG key import
+ if ! echo "${GPG_SIGNING_KEY}" | gpg --batch --import 2>&1 | \
+ tee /tmp/gpg-import.log | grep -q "secret key imported\|secret keys read"; then
+ cat /tmp/gpg-import.log
+ fail "GPG_SIGNING_KEY did not import a secret key." \
+ "If the GPG Key Expired"
+ fi
+
+ # GPG passphrase
+ key_id=$(gpg --list-secret-keys --with-colons | \
+ awk -F: '/^sec:/ { print $5; exit }')
+ if [ -z "${key_id}" ]; then
+ fail "No secret key available after import." \
+ "If the GPG Key Expired"
+ fi
+ if ! echo "test" | gpg --batch --pinentry-mode loopback \
+ --passphrase "${GPG_SIGNING_PASSPHRASE}" \
+ -u "${key_id}" --clearsign >/dev/null 2>/tmp/gpg-sign.log; then
+ cat /tmp/gpg-sign.log
+ fail "GPG_SIGNING_PASSPHRASE does not match GPG_SIGNING_KEY." \
+ "If the GPG Key Expired"
+ fi
- name: Checkout Plugin Repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
+ ref: ${{ inputs.tag }}
persist-credentials: false
- - uses: jdx/mise-action@be3be2260bc02bc3fbf94c5e2fed8b7964baf074 # v3.4.0
+ - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5
with:
- version: v2025.11.3
- sha256: 2b01d2063ac9fb6988ed5bfa087bebfba4ae6300b0739231bb534924acd31306
+ version: v2026.8.6
+ sha256: 96f6f1f416d868b78addd22746eefc7f4bf7820c6a3afa392a9f653f708c1644
cache: false
- name: Build release version
run: mise run build-release
- env:
- TAG: ${{ github.ref_name }}
- name: Set up Apache Maven Central
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5
+ uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
with:
distribution: "temurin"
java-version: "21"
@@ -52,3 +98,11 @@ jobs:
MAVEN_USERNAME: ${{ secrets.SONATYPE_MAVEN_REPOSITORY_USERNAME }}
MAVEN_CENTRAL_TOKEN: ${{ secrets.SONATYPE_MAVEN_REPOSITORY_PASSWORD }}
MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_SIGNING_PASSPHRASE }}
+
+ - name: Bump api-diff baseline
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG: ${{ inputs.tag }}
+ run: >
+ gh workflow run bump-api-diff-baseline.yml
+ --repo "${GITHUB_REPOSITORY}" -f "tag=${TAG}"
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
new file mode 100644
index 000000000..290d3c9da
--- /dev/null
+++ b/.github/workflows/scorecard.yml
@@ -0,0 +1,46 @@
+---
+name: OSSF Scorecard
+
+on:
+ push:
+ branches: [main]
+ schedule:
+ - cron: "43 6 * * 5" # Weekly Friday 06:43 UTC
+ workflow_dispatch:
+
+permissions: {}
+
+jobs:
+ analysis:
+ name: Scorecard analysis
+ runs-on: ubuntu-24.04
+ # Prevents fork runs from failing due to missing write permissions or secrets.
+ if: ${{ github.repository == 'prometheus/client_java' }}
+ permissions:
+ contents: read
+ security-events: write # required to upload SARIF results
+ id-token: write # required by scorecard-action for OIDC token
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+
+ - name: Run OSSF Scorecard analysis
+ uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ publish_results: true
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: SARIF file
+ path: results.sarif
+ retention-days: 5
+
+ - name: Upload to code scanning
+ uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
+ with:
+ sarif_file: results.sarif
diff --git a/.github/workflows/super-linter.yml b/.github/workflows/super-linter.yml
deleted file mode 100644
index 1c7c56541..000000000
--- a/.github/workflows/super-linter.yml
+++ /dev/null
@@ -1,30 +0,0 @@
----
-name: Lint
-
-on: [pull_request]
-
-jobs:
- lint:
- runs-on: ubuntu-24.04
-
- permissions:
- contents: read
- packages: read
- # To report GitHub Actions status checks
- statuses: write
-
- steps:
- - name: Checkout code
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- with:
- persist-credentials: false
- fetch-depth: 0
-
- - name: Load super-linter configuration
- run: grep -v '^#' .github/super-linter.env | grep -v 'FIX_' >> "$GITHUB_ENV"
-
- - name: Super-linter
- uses: super-linter/super-linter@2bdd90ed3262e023ac84bf8fe35dc480721fc1f2 # v8.2.1
- env:
- # To report GitHub Actions status checks
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/test-release-build.yml b/.github/workflows/test-release-build.yml
index 8bb7a7fec..f5914a1aa 100644
--- a/.github/workflows/test-release-build.yml
+++ b/.github/workflows/test-release-build.yml
@@ -13,22 +13,23 @@ jobs:
build:
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- - uses: jdx/mise-action@be3be2260bc02bc3fbf94c5e2fed8b7964baf074 # v3.4.0
+ fetch-tags: "true"
+ fetch-depth: 0
+ - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5
with:
- version: v2025.11.3
- sha256: 2b01d2063ac9fb6988ed5bfa087bebfba4ae6300b0739231bb534924acd31306
+ version: v2026.8.6
+ sha256: 96f6f1f416d868b78addd22746eefc7f4bf7820c6a3afa392a9f653f708c1644
- name: Cache local Maven repository
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
- restore-keys: |
- ${{ runner.os }}-maven-
+ - name: Build GitHub Pages
+ run: mise run build-gh-pages
+ env:
+ BASE_URL: "/client_java"
- name: Build release version
run: mise run build-release
- env:
- # don't use the current snapshot version, to test a more realistic release
- TAG: ${{ github.run_number }}
diff --git a/.gitignore b/.gitignore
index b727017a9..b98fa5703 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,9 @@ dependency-reduced-pom.xml
**/.settings/
docs/public
.lycheecache
+
+benchmark-results/
+benchmark-results.json
+benchmark-output.log
+
+*.DS_Store
\ No newline at end of file
diff --git a/.gitleaksignore b/.gitleaksignore
deleted file mode 100644
index 605fefa97..000000000
--- a/.gitleaksignore
+++ /dev/null
@@ -1,2 +0,0 @@
-/tmp/lint/integration-tests/it-pushgateway/src/test/resources/pushgateway-ssl.yaml:private-key:36
-/github/workspace/integration-tests/it-pushgateway/src/test/resources/pushgateway-ssl.yaml:private-key:36
diff --git a/.mise/envs/jmx-exporter/mise.lock b/.mise/envs/jmx-exporter/mise.lock
new file mode 100644
index 000000000..3a07b15c3
--- /dev/null
+++ b/.mise/envs/jmx-exporter/mise.lock
@@ -0,0 +1,33 @@
+# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
+
+[[tools.java]]
+version = "temurin-21.0.11+10.0.LTS"
+backend = "core:java"
+
+[tools.java."platforms.linux-arm64"]
+checksum = "sha256:8d498ec88e1c1989fab95c6784240ab92d011e29c54d20a3f9c324b13476f9ad"
+url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_aarch64_linux_hotspot_21.0.11_10.tar.gz"
+
+[tools.java."platforms.linux-arm64-musl"]
+checksum = "sha256:c8d63598d1dc0a656033515ed258bd6db37506a05407d9f65cd23b95c21027b5"
+url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_aarch64_alpine-linux_hotspot_21.0.11_10.tar.gz"
+
+[tools.java."platforms.linux-x64"]
+checksum = "sha256:4b2220e232a97997b436ca6ab15cbf70171ecff52958a46159dfa5a8c44ca4de"
+url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_x64_linux_hotspot_21.0.11_10.tar.gz"
+
+[tools.java."platforms.linux-x64-musl"]
+checksum = "sha256:38bfdcef1e4b45de2ec222047ac79c76bea75d4d7406a310e26cfa236763f05f"
+url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_x64_alpine-linux_hotspot_21.0.11_10.tar.gz"
+
+[tools.java."platforms.macos-arm64"]
+checksum = "sha256:6ebcf221c9b41507b14c098e93c6ead6440b8d9bd154f8ec666c4c73abbdb201"
+url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_aarch64_mac_hotspot_21.0.11_10.tar.gz"
+
+[tools.java."platforms.macos-x64"]
+checksum = "sha256:34180eb03e6d207c388cce3da668f6cc7cd7508c185c24782fadac2c9c0e66f9"
+url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_x64_mac_hotspot_21.0.11_10.tar.gz"
+
+[tools.java."platforms.windows-x64"]
+checksum = "sha256:d3625e7cadf23787ea540229544b6e2ab494b3b54da1801879e583e1dfee0a64"
+url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_x64_windows_hotspot_21.0.11_10.zip"
diff --git a/.mise/envs/jmx-exporter/mise.toml b/.mise/envs/jmx-exporter/mise.toml
new file mode 100644
index 000000000..a04b4948c
--- /dev/null
+++ b/.mise/envs/jmx-exporter/mise.toml
@@ -0,0 +1,10 @@
+# The pinned JMX Exporter release does not build on the primary JDK (Java 25):
+# its spotless google-java-format predates JDK 25 support. Build it under an LTS
+# JDK the release supports instead.
+[tools]
+java = "temurin-21.0.11+10.0.LTS"
+
+[tasks.compat-test]
+description = "Run JMX Exporter compatibility tests under a supported JDK"
+dir = "../../.."
+run = "python3 .mise/tasks/jmx-exporter/test.py"
diff --git a/.mise/envs/micrometer/mise.lock b/.mise/envs/micrometer/mise.lock
new file mode 100644
index 000000000..3a07b15c3
--- /dev/null
+++ b/.mise/envs/micrometer/mise.lock
@@ -0,0 +1,33 @@
+# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
+
+[[tools.java]]
+version = "temurin-21.0.11+10.0.LTS"
+backend = "core:java"
+
+[tools.java."platforms.linux-arm64"]
+checksum = "sha256:8d498ec88e1c1989fab95c6784240ab92d011e29c54d20a3f9c324b13476f9ad"
+url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_aarch64_linux_hotspot_21.0.11_10.tar.gz"
+
+[tools.java."platforms.linux-arm64-musl"]
+checksum = "sha256:c8d63598d1dc0a656033515ed258bd6db37506a05407d9f65cd23b95c21027b5"
+url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_aarch64_alpine-linux_hotspot_21.0.11_10.tar.gz"
+
+[tools.java."platforms.linux-x64"]
+checksum = "sha256:4b2220e232a97997b436ca6ab15cbf70171ecff52958a46159dfa5a8c44ca4de"
+url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_x64_linux_hotspot_21.0.11_10.tar.gz"
+
+[tools.java."platforms.linux-x64-musl"]
+checksum = "sha256:38bfdcef1e4b45de2ec222047ac79c76bea75d4d7406a310e26cfa236763f05f"
+url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_x64_alpine-linux_hotspot_21.0.11_10.tar.gz"
+
+[tools.java."platforms.macos-arm64"]
+checksum = "sha256:6ebcf221c9b41507b14c098e93c6ead6440b8d9bd154f8ec666c4c73abbdb201"
+url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_aarch64_mac_hotspot_21.0.11_10.tar.gz"
+
+[tools.java."platforms.macos-x64"]
+checksum = "sha256:34180eb03e6d207c388cce3da668f6cc7cd7508c185c24782fadac2c9c0e66f9"
+url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_x64_mac_hotspot_21.0.11_10.tar.gz"
+
+[tools.java."platforms.windows-x64"]
+checksum = "sha256:d3625e7cadf23787ea540229544b6e2ab494b3b54da1801879e583e1dfee0a64"
+url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.11%2B10/OpenJDK21U-jdk_x64_windows_hotspot_21.0.11_10.zip"
diff --git a/.mise/envs/micrometer/mise.toml b/.mise/envs/micrometer/mise.toml
new file mode 100644
index 000000000..e5318c1d6
--- /dev/null
+++ b/.mise/envs/micrometer/mise.toml
@@ -0,0 +1,10 @@
+# The pinned Micrometer release does not build on the primary JDK (Java 25):
+# its bundled Kotlin compiler predates JDK 25 support. Build it under an LTS JDK
+# the release supports instead.
+[tools]
+java = "temurin-21.0.11+10.0.LTS"
+
+[tasks.compat-test]
+description = "Run Micrometer compatibility tests under a supported JDK"
+dir = "../../.."
+run = "python3 .mise/tasks/micrometer/test.py"
diff --git a/.mise/envs/native/mise.lock b/.mise/envs/native/mise.lock
new file mode 100644
index 000000000..87f54de47
--- /dev/null
+++ b/.mise/envs/native/mise.lock
@@ -0,0 +1,25 @@
+# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
+
+[[tools.java]]
+version = "oracle-graalvm-25.0.1"
+backend = "core:java"
+
+[tools.java."platforms.linux-arm64"]
+checksum = "sha256:edd77565c76570a6df5f38e53d54524102f2c1b1cf74eebd9f51e7fc19f667cd"
+url = "https://download.oracle.com/graalvm/25/archive/graalvm-jdk-25.0.1_linux-aarch64_bin.tar.gz"
+
+[tools.java."platforms.linux-x64"]
+checksum = "sha256:d4ab02ba1029e639f03374fdf91c242e1d0d49079880e1af1932ea7b7c431837"
+url = "https://download.oracle.com/graalvm/25/archive/graalvm-jdk-25.0.1_linux-x64_bin.tar.gz"
+
+[tools.java."platforms.macos-arm64"]
+checksum = "sha256:19dfd49ad112e6e6c2bdedc507c6859bf21294fa4c164f1be670d469c65b7993"
+url = "https://download.oracle.com/graalvm/25/archive/graalvm-jdk-25.0.1_macos-aarch64_bin.tar.gz"
+
+[tools.java."platforms.macos-x64"]
+checksum = "sha256:a762ca1d9a163e32790b9286f3af4c16369729ff27999d8dbab60d7be16cff2f"
+url = "https://download.oracle.com/graalvm/25/archive/graalvm-jdk-25.0.1_macos-x64_bin.tar.gz"
+
+[tools.java."platforms.windows-x64"]
+checksum = "sha256:5398592ecdf8f47f4b481896e1e63cbfff4cb8e0ba61538526e3b3795b1a85e5"
+url = "https://download.oracle.com/graalvm/25/archive/graalvm-jdk-25.0.1_windows-x64_bin.zip"
diff --git a/.mise/envs/native/mise.toml b/.mise/envs/native/mise.toml
new file mode 100644
index 000000000..b64367044
--- /dev/null
+++ b/.mise/envs/native/mise.toml
@@ -0,0 +1,10 @@
+[tools]
+java = "oracle-graalvm-25.0.1"
+
+[tasks.native-test]
+depends = "//:build"
+# Native Test uses Surefire, so explicitly select the Failsafe-named test class.
+run = '''
+cd "$MISE_MONOREPO_ROOT/integration-tests/it-spring-boot-smoke-test"
+"$MISE_MONOREPO_ROOT/mvnw" test -PnativeTest -Dtest=ApplicationIT
+'''
diff --git a/.mise/lib/jmx_exporter_compat.py b/.mise/lib/jmx_exporter_compat.py
new file mode 100755
index 000000000..dd364e064
--- /dev/null
+++ b/.mise/lib/jmx_exporter_compat.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+import os
+import re
+import subprocess
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+DEFAULT_JMX_EXPORTER_DIR = Path(
+ os.environ.get("JMX_EXPORTER_DIR", "/tmp/jmx-exporter-compat")
+)
+DEFAULT_JMX_EXPORTER_REPOSITORY = os.environ.get(
+ "JMX_EXPORTER_REPOSITORY", "prometheus/jmx_exporter"
+)
+DEFAULT_JMX_EXPORTER_REMOTE = os.environ.get("JMX_EXPORTER_REMOTE", "origin")
+DEFAULT_JMX_EXPORTER_REF = (
+ os.environ.get("JMX_EXPORTER_REF")
+ or os.environ.get("DEFAULT_JMX_EXPORTER_VERSION")
+ or "main"
+)
+DEFAULT_PROM_VERSION = os.environ.get("PROM_VERSION")
+
+# Quick test configuration: the integration_test_suite runs one matrix cell
+# (single Java + Prometheus distribution) instead of the full smoke-test matrix.
+# The distributions are read from the checked-out jmx_exporter's run-quick-test.sh
+# so they stay aligned with upstream rather than drifting from a hardcoded copy.
+QUICK_TEST_SCRIPT = "run-quick-test.sh"
+QUICK_TEST_IMAGE_VARS = ("JAVA_DOCKER_IMAGES", "PROMETHEUS_DOCKER_IMAGES")
+
+
+def run_cmd(
+ cmd: list[str],
+ cwd: Path | None = None,
+ env: dict[str, str] | None = None,
+) -> None:
+ subprocess.run(cmd, cwd=cwd, check=True, env=env)
+
+
+def jmx_exporter_repository_url(repository: str) -> str:
+ return f"https://github.com/{repository}.git"
+
+
+def check_clean_worktree(jmx_exporter_dir: Path) -> None:
+ result = subprocess.run(
+ ["git", "status", "--short"],
+ cwd=jmx_exporter_dir,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ if result.stdout.strip():
+ raise RuntimeError(
+ f"{jmx_exporter_dir} has uncommitted changes; use a clean clone or set "
+ "JMX_EXPORTER_DIR"
+ )
+
+
+def get_prom_version(root_dir: Path | None = None) -> str:
+ configured_version = DEFAULT_PROM_VERSION
+ if configured_version:
+ return configured_version
+ if root_dir is None:
+ root_dir = Path.cwd()
+ pom = ET.parse(root_dir / "pom.xml")
+ root = pom.getroot()
+ version = root.findtext("./{*}version")
+ if not version:
+ version = root.findtext("./{*}parent/{*}version")
+ if not version:
+ raise RuntimeError("could not determine Prometheus version from pom.xml")
+ return version
+
+
+def prepare_repo(
+ jmx_exporter_dir: Path = DEFAULT_JMX_EXPORTER_DIR,
+ repository: str = DEFAULT_JMX_EXPORTER_REPOSITORY,
+ remote: str = DEFAULT_JMX_EXPORTER_REMOTE,
+ ref: str = DEFAULT_JMX_EXPORTER_REF,
+) -> None:
+ repository_url = jmx_exporter_repository_url(repository)
+ if (jmx_exporter_dir / ".git").is_dir():
+ check_clean_worktree(jmx_exporter_dir)
+ run_cmd(
+ ["git", "remote", "set-url", remote, repository_url],
+ cwd=jmx_exporter_dir,
+ )
+ run_cmd(["git", "fetch", remote, ref], cwd=jmx_exporter_dir)
+ else:
+ run_cmd(["git", "clone", repository_url, str(jmx_exporter_dir)])
+ run_cmd(["git", "fetch", remote, ref], cwd=jmx_exporter_dir)
+ run_cmd(
+ ["git", "checkout", "-B", "jmx-exporter-compat", "FETCH_HEAD"],
+ cwd=jmx_exporter_dir,
+ )
+
+
+def install_local_artifacts(root_dir: Path | None = None) -> None:
+ if root_dir is None:
+ root_dir = Path.cwd()
+ run_cmd(
+ [
+ "./mvnw",
+ "install",
+ # Skip test compilation too (not just execution): downstream needs only
+ # our main artifacts, and our test sources target a newer release than
+ # the compatibility JDK supports.
+ "-Dmaven.test.skip=true",
+ # Deactivate the activeByDefault profiles that add test-only
+ # dependencies (incl. a test-jar). With maven.test.skip those are not
+ # built, so leaving the profile active breaks dependency resolution.
+ # Same approach the release task uses (-P 'release,!default').
+ "-P",
+ "!default",
+ "-Dcoverage.skip=true",
+ "-Dcheckstyle.skip=true",
+ "-Dwarnings=-nowarn",
+ ],
+ cwd=root_dir,
+ )
+
+
+def quick_test_images(
+ jmx_exporter_dir: Path = DEFAULT_JMX_EXPORTER_DIR,
+) -> dict[str, str]:
+ """Read the quick test docker image pins from the checked-out jmx_exporter's
+ run-quick-test.sh. An explicit env var overrides the script value."""
+ script_path = jmx_exporter_dir / QUICK_TEST_SCRIPT
+ script = script_path.read_text()
+ images: dict[str, str] = {}
+ for var in QUICK_TEST_IMAGE_VARS:
+ override = os.environ.get(var)
+ if override:
+ images[var] = override
+ continue
+ match = re.search(rf'^\s*export\s+{var}="([^"]+)"', script, re.MULTILINE)
+ if not match:
+ raise RuntimeError(f"could not find {var} in {script_path}")
+ images[var] = match.group(1)
+ return images
+
+
+def run_maven_test(
+ jmx_exporter_dir: Path = DEFAULT_JMX_EXPORTER_DIR,
+ prom_version: str | None = None,
+) -> None:
+ if prom_version is None:
+ prom_version = get_prom_version()
+ # Build the full reactor (including the integration_test_suite) the same way
+ # run-quick-test.sh does, but against our locally installed io.prometheus
+ # artifacts. The pinned distribution env vars keep this to the quick (single
+ # cell) matrix rather than the full smoke-test matrix.
+ env = {**os.environ, **quick_test_images(jmx_exporter_dir)}
+ cmd = [
+ "./mvnw",
+ "-B",
+ "clean",
+ "install",
+ f"-Dprometheus.metrics.version={prom_version}",
+ f"-Dparamixel.parallelism={os.cpu_count() or 1}",
+ "-Djacoco.skip=true",
+ ]
+ run_cmd(cmd, cwd=jmx_exporter_dir, env=env)
diff --git a/.mise/lib/micrometer_compat.py b/.mise/lib/micrometer_compat.py
new file mode 100755
index 000000000..b1c50d49f
--- /dev/null
+++ b/.mise/lib/micrometer_compat.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+import os
+import subprocess
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+DEFAULT_MICROMETER_DIR = Path(
+ os.environ.get("MICROMETER_DIR", "/tmp/micrometer-compat")
+)
+DEFAULT_MICROMETER_REPOSITORY = os.environ.get(
+ "MICROMETER_REPOSITORY", "micrometer-metrics/micrometer"
+)
+DEFAULT_MICROMETER_REMOTE = os.environ.get("MICROMETER_REMOTE", "origin")
+DEFAULT_MICROMETER_REF = (
+ os.environ.get("MICROMETER_REF")
+ or os.environ.get("DEFAULT_MICROMETER_VERSION")
+ or "main"
+)
+DEFAULT_INIT_SCRIPT = Path(
+ os.environ.get("MICROMETER_INIT_SCRIPT", "/tmp/micrometer-prom-local.init.gradle")
+)
+DEFAULT_PROM_VERSION = os.environ.get("PROM_VERSION")
+
+
+def run_cmd(cmd: list[str], cwd: Path | None = None) -> None:
+ subprocess.run(cmd, cwd=cwd, check=True)
+
+
+def micrometer_repository_url(repository: str) -> str:
+ return f"https://github.com/{repository}.git"
+
+
+def check_clean_worktree(micrometer_dir: Path) -> None:
+ result = subprocess.run(
+ ["git", "status", "--short"],
+ cwd=micrometer_dir,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ if result.stdout.strip():
+ raise RuntimeError(
+ f"{micrometer_dir} has uncommitted changes; use a clean clone or set MICROMETER_DIR"
+ )
+
+
+def get_prom_version(root_dir: Path | None = None) -> str:
+ configured_version = DEFAULT_PROM_VERSION
+ if configured_version:
+ return configured_version
+ if root_dir is None:
+ root_dir = Path.cwd()
+ pom = ET.parse(root_dir / "pom.xml")
+ root = pom.getroot()
+ version = root.findtext("./{*}version")
+ if not version:
+ version = root.findtext("./{*}parent/{*}version")
+ if not version:
+ raise RuntimeError("could not determine Prometheus version from pom.xml")
+ return version
+
+
+def write_init_script(
+ init_script: Path = DEFAULT_INIT_SCRIPT, prom_version: str | None = None
+) -> None:
+ if prom_version is None:
+ prom_version = get_prom_version()
+ init_script.write_text(
+ f"""allprojects {{
+ repositories {{
+ mavenLocal()
+ mavenCentral()
+ gradlePluginPortal()
+ }}
+ configurations.configureEach {{
+ resolutionStrategy.eachDependency {{ details ->
+ if (details.requested.group == 'io.prometheus') {{
+ details.useVersion('{prom_version}')
+ details.because(
+ 'Use local prom_client_java artifacts for downstream compatibility testing'
+ )
+ }}
+ }}
+ }}
+}}
+""",
+ encoding="utf-8",
+ )
+
+
+def prepare_repo(
+ micrometer_dir: Path = DEFAULT_MICROMETER_DIR,
+ repository: str = DEFAULT_MICROMETER_REPOSITORY,
+ remote: str = DEFAULT_MICROMETER_REMOTE,
+ ref: str = DEFAULT_MICROMETER_REF,
+) -> None:
+ repository_url = micrometer_repository_url(repository)
+ if (micrometer_dir / ".git").is_dir():
+ check_clean_worktree(micrometer_dir)
+ run_cmd(
+ ["git", "remote", "set-url", remote, repository_url], cwd=micrometer_dir
+ )
+ run_cmd(["git", "fetch", remote, ref], cwd=micrometer_dir)
+ else:
+ run_cmd(
+ [
+ "git",
+ "clone",
+ repository_url,
+ str(micrometer_dir),
+ ]
+ )
+ run_cmd(["git", "fetch", remote, ref], cwd=micrometer_dir)
+ run_cmd(
+ ["git", "checkout", "-B", "micrometer-compat", "FETCH_HEAD"],
+ cwd=micrometer_dir,
+ )
+
+
+def install_local_artifacts(root_dir: Path | None = None) -> None:
+ if root_dir is None:
+ root_dir = Path.cwd()
+ run_cmd(
+ [
+ "./mvnw",
+ "install",
+ # Skip test compilation too (not just execution): downstream needs only
+ # our main artifacts, and our test sources target a newer release than
+ # the compatibility JDK supports.
+ "-Dmaven.test.skip=true",
+ # Deactivate the activeByDefault profiles that add test-only
+ # dependencies (incl. a test-jar). With maven.test.skip those are not
+ # built, so leaving the profile active breaks dependency resolution.
+ # Same approach the release task uses (-P 'release,!default').
+ "-P",
+ "!default",
+ "-Dcoverage.skip=true",
+ "-Dcheckstyle.skip=true",
+ "-Dwarnings=-nowarn",
+ ],
+ cwd=root_dir,
+ )
+
+
+def run_gradle_test(
+ test_selector: str | None = None,
+ micrometer_dir: Path = DEFAULT_MICROMETER_DIR,
+ init_script: Path = DEFAULT_INIT_SCRIPT,
+) -> None:
+ cmd = [
+ "./gradlew",
+ "--no-daemon",
+ "-I",
+ str(init_script),
+ ":micrometer-registry-prometheus:test",
+ ]
+ if test_selector:
+ cmd.extend(["--tests", test_selector])
+ run_cmd(cmd, cwd=micrometer_dir)
diff --git a/.mise/tasks/build-release.sh b/.mise/tasks/build-release.sh
index a29b0d22f..620dca77a 100755
--- a/.mise/tasks/build-release.sh
+++ b/.mise/tasks/build-release.sh
@@ -1,12 +1,8 @@
#!/usr/bin/env bash
#MISE description="Build release package"
-#USAGE arg "" env="TAG" default="1.5.0-SNAPSHOT"
set -euo pipefail
-# shellcheck disable=SC2154 # is set by mise
-VERSION=${usage_tag#v}
-
-mise run set-version "$VERSION"
-mvn -B package -P 'release,!default' -Dmaven.test.skip=true
+mvn -B package -P 'release,!default,!examples-and-integration-tests' \
+ -Dmaven.test.skip=true -Dgpg.skip=true
diff --git a/.mise/tasks/generate_benchmark_summary.py b/.mise/tasks/generate_benchmark_summary.py
new file mode 100755
index 000000000..0da622bcb
--- /dev/null
+++ b/.mise/tasks/generate_benchmark_summary.py
@@ -0,0 +1,780 @@
+#!/usr/bin/env python3
+
+# [MISE] description="Generate markdown summary from JMH benchmark JSON results"
+# [MISE] alias="generate-benchmark-summary"
+
+"""
+Generate a markdown summary from JMH benchmark JSON results.
+
+Usage:
+ python3 .mise/tasks/generate_benchmark_summary.py \
+ [--input results.json] [--output-dir ./benchmark-results]
+
+This script:
+1. Reads JMH JSON output
+2. Generates a README.md with formatted tables
+3. Copies results to the output directory with historical naming
+"""
+
+import argparse
+import json
+import math
+import os
+import shutil
+import subprocess
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+
+PRACTICAL_CHANGE_THRESHOLD = 5.0
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(
+ description="Generate benchmark summary from JMH JSON"
+ )
+ parser.add_argument(
+ "--input",
+ default="benchmark-results.json",
+ help="Path to JMH JSON results file (default: benchmark-results.json)",
+ )
+ parser.add_argument(
+ "--output-dir",
+ default="benchmark-results",
+ help="Output directory for results (default: benchmark-results)",
+ )
+ parser.add_argument(
+ "--commit-sha",
+ default=None,
+ help="Git commit SHA (default: read from git or 'local')",
+ )
+ parser.add_argument(
+ "--baseline",
+ default=None,
+ help="Optional JMH JSON results file to compare against",
+ )
+ parser.add_argument(
+ "--baseline-sha",
+ default=None,
+ help="Baseline commit SHA (default: read from git or 'local')",
+ )
+ parser.add_argument(
+ "--baseline-repo",
+ default=None,
+ help="Baseline GitHub repository for commit links",
+ )
+ parser.add_argument(
+ "--comparison-note",
+ default=None,
+ help="Optional note to include in the benchmark comparison section",
+ )
+ parser.add_argument(
+ "--system-info",
+ default=None,
+ help="Optional JSON file with system info for the measured run",
+ )
+ parser.add_argument(
+ "--baseline-system-info",
+ default=None,
+ help="Optional JSON file with system info for the baseline run",
+ )
+ parser.add_argument(
+ "--write-system-info",
+ default=None,
+ help="Write current system info to this JSON file and exit",
+ )
+ return parser.parse_args()
+
+
+def get_system_info() -> dict[str, str]:
+ """Capture system hardware information."""
+ import multiprocessing
+ import platform
+
+ info = {}
+
+ try:
+ info["cpu_cores"] = str(multiprocessing.cpu_count())
+ except NotImplementedError:
+ pass
+
+ try:
+ with open("/proc/cpuinfo", "r") as f:
+ for line in f:
+ if line.startswith("model name"):
+ info["cpu_model"] = line.split(":")[1].strip()
+ break
+ except FileNotFoundError:
+ # macOS
+ try:
+ result = subprocess.run(
+ ["sysctl", "-n", "machdep.cpu.brand_string"],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=5,
+ )
+ if result.returncode == 0:
+ info["cpu_model"] = result.stdout.strip()
+ except (OSError, subprocess.SubprocessError):
+ pass
+
+ try:
+ with open("/proc/meminfo", "r") as f:
+ for line in f:
+ if line.startswith("MemTotal"):
+ kb = int(line.split()[1])
+ info["memory_gb"] = str(round(kb / 1024 / 1024))
+ break
+ except FileNotFoundError:
+ # macOS
+ try:
+ result = subprocess.run(
+ ["sysctl", "-n", "hw.memsize"],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=5,
+ )
+ if result.returncode == 0:
+ bytes_mem = int(result.stdout.strip())
+ info["memory_gb"] = str(round(bytes_mem / 1024 / 1024 / 1024))
+ except (OSError, ValueError, subprocess.SubprocessError):
+ pass
+
+ info["os"] = f"{platform.system()} {platform.release()}"
+
+ return info
+
+
+def read_system_info(path: str | None) -> dict[str, str]:
+ """Read system info from JSON, or capture it from the current host."""
+ if not path:
+ return get_system_info()
+ with open(path, "r") as f:
+ return json.load(f)
+
+
+def write_system_info(path: str) -> None:
+ """Write current system info to JSON."""
+ output_path = Path(path)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(output_path, "w") as f:
+ json.dump(get_system_info(), f, indent=2, sort_keys=True)
+ f.write("\n")
+
+
+def format_system_info(sysinfo: dict[str, str] | None) -> str:
+ """Format captured system info for markdown."""
+ if not sysinfo:
+ return "unknown"
+ parts = []
+ if sysinfo.get("cpu_model"):
+ parts.append(sysinfo["cpu_model"])
+ if sysinfo.get("cpu_cores"):
+ parts.append(f"{sysinfo['cpu_cores']} cores")
+ if sysinfo.get("memory_gb"):
+ parts.append(f"{sysinfo['memory_gb']} GB RAM")
+ if sysinfo.get("os"):
+ parts.append(sysinfo["os"])
+ return ", ".join(parts) if parts else "unknown"
+
+
+def get_commit_sha(provided_sha: str | None) -> str:
+ """Get commit SHA from argument, git, or return 'local'."""
+ if provided_sha:
+ return provided_sha
+
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ capture_output=True,
+ check=False,
+ text=True,
+ timeout=5,
+ )
+ if result.returncode == 0:
+ return result.stdout.strip()
+ except (OSError, subprocess.SubprocessError):
+ pass
+
+ return "local"
+
+
+def format_score(score) -> str:
+ """Format score with appropriate precision."""
+ if score is None:
+ return ""
+ try:
+ val = float(score)
+ if val >= 1_000_000:
+ return f"{val / 1_000_000:.2f}M"
+ elif val >= 1_000:
+ return f"{val / 1_000:.2f}K"
+ else:
+ return f"{val:.2f}"
+ except (ValueError, TypeError):
+ return str(score)
+
+
+def format_error(error) -> str:
+ """Format error value, handling NaN."""
+ try:
+ error_val = float(error)
+ if math.isnan(error_val):
+ return ""
+ elif error_val >= 1_000:
+ return f"ยฑ {error_val / 1_000:.2f}K"
+ else:
+ return f"ยฑ {error_val:.2f}"
+ except (ValueError, TypeError):
+ return ""
+
+
+def format_commit_link(commit_sha: str, repo: str) -> str:
+ """Format a commit SHA as a GitHub markdown link."""
+ commit_short = commit_sha[:7]
+ if commit_sha != "local":
+ return f"[`{commit_short}`](https://github.com/{repo}/commit/{commit_sha})"
+ return f"`{commit_short}` (local run)"
+
+
+def short_benchmark_name(name: str) -> str:
+ """Remove the common benchmark package prefix."""
+ return name.replace("io.prometheus.metrics.benchmarks.", "")
+
+
+def metric_score(result: dict) -> float | None:
+ """Extract a benchmark score as a finite float."""
+ try:
+ score = float(result.get("primaryMetric", {}).get("score"))
+ if math.isfinite(score):
+ return score
+ except (ValueError, TypeError):
+ pass
+ return None
+
+
+def score_interval(result: dict) -> tuple[float, float] | None:
+ """Extract the JMH confidence interval for a benchmark result."""
+ metric = result.get("primaryMetric", {})
+ confidence = metric.get("scoreConfidence")
+ if isinstance(confidence, list) and len(confidence) == 2:
+ try:
+ low = float(confidence[0])
+ high = float(confidence[1])
+ if math.isfinite(low) and math.isfinite(high):
+ return min(low, high), max(low, high)
+ except (ValueError, TypeError):
+ pass
+
+ score = metric_score(result)
+ if score is None:
+ return None
+ try:
+ error = float(metric.get("scoreError"))
+ if math.isfinite(error) and error >= 0:
+ return score - error, score + error
+ except (ValueError, TypeError):
+ pass
+ return None
+
+
+def lower_is_better(result: dict) -> bool:
+ """Return true for JMH modes where lower score is better."""
+ mode = str(result.get("mode", ""))
+ unit = str(result.get("primaryMetric", {}).get("scoreUnit", ""))
+ return mode in {"avgt", "sample", "ss"} or unit.endswith("/op")
+
+
+def normalize_jvm_args(value) -> list:
+ """Normalize optional JMH JVM argument fields for metadata comparison."""
+ if value is None:
+ return []
+ if isinstance(value, list):
+ return value
+ return [value]
+
+
+def benchmark_metadata(result: dict) -> dict:
+ """Return metadata that must match for a base/head comparison."""
+ primary_metric = result.get("primaryMetric", {})
+ return {
+ "jmhVersion": result.get("jmhVersion"),
+ "mode": result.get("mode"),
+ "vmName": result.get("vmName"),
+ "vmVersion": result.get("vmVersion"),
+ "jvmArgs": normalize_jvm_args(result.get("jvmArgs")),
+ "jvmArgsPrepend": normalize_jvm_args(result.get("jvmArgsPrepend")),
+ "jvmArgsAppend": normalize_jvm_args(result.get("jvmArgsAppend")),
+ "threads": result.get("threads"),
+ "forks": result.get("forks"),
+ "warmupIterations": result.get("warmupIterations"),
+ "warmupTime": result.get("warmupTime"),
+ "warmupBatchSize": result.get("warmupBatchSize"),
+ "measurementIterations": result.get("measurementIterations"),
+ "measurementTime": result.get("measurementTime"),
+ "measurementBatchSize": result.get("measurementBatchSize"),
+ "jdkVersion": result.get("jdkVersion"),
+ "scoreUnit": primary_metric.get("scoreUnit"),
+ "params": result.get("params", {}),
+ }
+
+
+def comparable_metadata(head: dict, baseline: dict) -> bool:
+ """Return whether two results describe the same benchmark configuration."""
+ return benchmark_metadata(head) == benchmark_metadata(baseline)
+
+
+def comparison_status(head: dict, baseline: dict) -> str:
+ """Classify a benchmark comparison using confidence intervals and a threshold."""
+ head_interval = score_interval(head)
+ baseline_interval = score_interval(baseline)
+ head_score = metric_score(head)
+ baseline_score = metric_score(baseline)
+ if (
+ not comparable_metadata(head, baseline)
+ or head_score is None
+ or baseline_score is None
+ ):
+ return "inconclusive"
+
+ change = performance_change(head, baseline)
+ if change is None or head_interval is None or baseline_interval is None:
+ return "inconclusive"
+
+ head_low, head_high = head_interval
+ baseline_low, baseline_high = baseline_interval
+ intervals_overlap = head_low <= baseline_high and baseline_low <= head_high
+ if intervals_overlap or abs(change) < PRACTICAL_CHANGE_THRESHOLD:
+ return "within noise"
+
+ return "meaningful improvement" if change > 0 else "meaningful regression"
+
+
+def performance_change(head: dict, baseline: dict) -> float | None:
+ """Return percent performance change, with positive meaning faster."""
+ head_score = metric_score(head)
+ baseline_score = metric_score(baseline)
+ if (
+ head_score is None
+ or baseline_score is None
+ or head_score == 0
+ or baseline_score == 0
+ ):
+ return None
+ if lower_is_better(head):
+ return (float(baseline_score) / head_score - 1) * 100
+ return (head_score / float(baseline_score) - 1) * 100
+
+
+def format_change(change: float | None) -> str:
+ """Format a percent performance change."""
+ if change is None:
+ return ""
+ return f"{change:+.1f}%"
+
+
+def metric_direction_note(results: list) -> str:
+ """Describe whether scores represent throughput or latency."""
+ directions = {
+ "latency" if lower_is_better(result) else "throughput" for result in results
+ }
+ if directions == {"throughput"}:
+ return (
+ "Throughput scores are higher-is-better; positive Head vs base deltas "
+ "indicate faster performance."
+ )
+ if directions == {"latency"}:
+ return (
+ "Latency scores are lower-is-better; positive Head vs base deltas "
+ "indicate faster performance."
+ )
+ return (
+ "Throughput scores are higher-is-better and latency scores are "
+ "lower-is-better; positive Head vs base deltas indicate faster performance."
+ )
+
+
+def generate_comparison_section(
+ results: list,
+ baseline_results: list,
+ commit_sha: str,
+ baseline_sha: str,
+ repo: str,
+ baseline_repo: str,
+ comparison_note: str | None = None,
+ system_info: dict[str, str] | None = None,
+ baseline_system_info: dict[str, str] | None = None,
+) -> list[str]:
+ """Generate a base-vs-head benchmark comparison section."""
+ by_name = {b.get("benchmark", ""): b for b in results if b.get("benchmark")}
+ baseline_by_name = {
+ b.get("benchmark", ""): b for b in baseline_results if b.get("benchmark")
+ }
+ common_names = sorted(set(by_name) & set(baseline_by_name))
+
+ md = []
+ md.append("## Comparison with base")
+ md.append("")
+ md.append(f"- **Head:** {format_commit_link(commit_sha, repo)}")
+ md.append(f"- **Base:** {format_commit_link(baseline_sha, baseline_repo)}")
+ md.append(f"- **Metric direction:** {metric_direction_note(results)}")
+ if comparison_note:
+ md.append(f"- **Note:** {comparison_note}")
+ if baseline_system_info:
+ md.append(f"- **Head runner:** {format_system_info(system_info)}")
+ md.append(f"- **Base runner:** {format_system_info(baseline_system_info)}")
+ md.append(
+ "- **Note:** base and head run in parallel jobs, so runner "
+ "hardware can differ and affect results."
+ )
+ md.append("")
+
+ if not common_names:
+ md.append("_No matching benchmark names were found in the base results._")
+ md.append("")
+ return md
+
+ md.append("| Benchmark | PR | Base | Head vs base | Regression verdict |")
+ md.append("|:----------|---:|-----:|-------:|:-------|")
+
+ for name in common_names:
+ head = by_name[name]
+ baseline = baseline_by_name[name]
+ head_score = metric_score(head)
+ baseline_score = metric_score(baseline)
+ md.append(
+ "| "
+ f"{short_benchmark_name(name)} | "
+ f"{format_score(head_score)} | "
+ f"{format_score(baseline_score)} | "
+ f"{format_change(performance_change(head, baseline))} | "
+ f"{comparison_status(head, baseline)} |"
+ )
+
+ missing_in_base = sorted(set(by_name) - set(baseline_by_name))
+ missing_in_head = sorted(set(baseline_by_name) - set(by_name))
+ if missing_in_base or missing_in_head:
+ md.append("")
+ if missing_in_base:
+ missing = ", ".join(short_benchmark_name(name) for name in missing_in_base)
+ md.append(
+ f"- Benchmarks only in PR results (listed separately below): {missing}"
+ )
+ if missing_in_head:
+ missing = ", ".join(short_benchmark_name(name) for name in missing_in_head)
+ md.append(f"- Benchmarks only in base results: {missing}")
+
+ md.append("")
+ return md
+
+
+def generate_markdown(
+ results: list,
+ commit_sha: str,
+ repo: str,
+ baseline_results: list | None = None,
+ baseline_sha: str | None = None,
+ baseline_repo: str | None = None,
+ comparison_note: str | None = None,
+ system_info: dict[str, str] | None = None,
+ baseline_system_info: dict[str, str] | None = None,
+) -> str:
+ """Generate markdown summary from JMH results."""
+ datetime_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ # Extract metadata from first result
+ first = results[0] if results else {}
+ jdk_version = first.get("jdkVersion", "unknown")
+ vm_name = first.get("vmName", "unknown")
+ threads = first.get("threads", "?")
+ forks = first.get("forks", "?")
+ warmup_iters = first.get("warmupIterations", "?")
+ measure_iters = first.get("measurementIterations", "?")
+
+ sysinfo = system_info or get_system_info()
+
+ md = []
+ md.append("# Prometheus Java Client Benchmarks")
+ md.append("")
+
+ md.append("## Run Information")
+ md.append("")
+ md.append(f"- **Date:** {datetime_str}")
+ md.append(f"- **Commit:** {format_commit_link(commit_sha, repo)}")
+ md.append(f"- **JDK:** {jdk_version} ({vm_name})")
+ bench_cfg = (
+ f"{forks} fork(s), {warmup_iters} warmup, "
+ f"{measure_iters} measurement, {threads} threads"
+ )
+ md.append(f"- **Benchmark config:** {bench_cfg}")
+
+ hw_parts = []
+ if sysinfo.get("cpu_model"):
+ hw_parts.append(sysinfo["cpu_model"])
+ if sysinfo.get("cpu_cores"):
+ hw_parts.append(f"{sysinfo['cpu_cores']} cores")
+ if sysinfo.get("memory_gb"):
+ hw_parts.append(f"{sysinfo['memory_gb']} GB RAM")
+ if hw_parts:
+ md.append(f"- **Hardware:** {', '.join(hw_parts)}")
+ if sysinfo.get("os"):
+ md.append(f"- **OS:** {sysinfo['os']}")
+
+ md.append("")
+
+ if baseline_results and baseline_sha and baseline_repo:
+ md.extend(
+ generate_comparison_section(
+ results,
+ baseline_results,
+ commit_sha,
+ baseline_sha,
+ repo,
+ baseline_repo,
+ comparison_note=comparison_note,
+ system_info=sysinfo,
+ baseline_system_info=baseline_system_info,
+ )
+ )
+
+ # A benchmark without a base counterpart cannot receive a regression verdict.
+ # Keep it out of the comparison-oriented head table and list it separately.
+ baseline_names = {
+ b.get("benchmark", "") for b in (baseline_results or []) if b.get("benchmark")
+ }
+ if baseline_results:
+ comparable_results = [
+ b for b in results if b.get("benchmark", "") in baseline_names
+ ]
+ head_only_results = [
+ b for b in results if b.get("benchmark", "") not in baseline_names
+ ]
+ else:
+ comparable_results = results
+ head_only_results = []
+
+ # Group by benchmark class
+ benchmarks_by_class: dict[str, list] = {}
+ for b in comparable_results:
+ name = b.get("benchmark", "")
+ parts = name.rsplit(".", 1)
+ if len(parts) == 2:
+ class_name, _method = parts
+ class_short = class_name.split(".")[-1]
+ else:
+ class_short = "Other"
+ benchmarks_by_class.setdefault(class_short, []).append(b)
+
+ md.append("## Results for PR head")
+ md.append("")
+
+ # Generate table for each class
+ for class_name in sorted(benchmarks_by_class.keys()):
+ benchmarks = benchmarks_by_class[class_name]
+ md.append(f"### {class_name}")
+ md.append("")
+
+ # Sort by score descending
+ sorted_benchmarks = sorted(
+ benchmarks,
+ key=lambda x: x.get("primaryMetric", {}).get("score", 0),
+ reverse=True,
+ )
+
+ md.append("| Benchmark | Score | Error | Units |")
+ md.append("|:----------|------:|------:|:------|")
+
+ for b in sorted_benchmarks:
+ name = b.get("benchmark", "").split(".")[-1]
+ score = b.get("primaryMetric", {}).get("score", 0)
+ error = b.get("primaryMetric", {}).get("scoreError", 0)
+ unit = b.get("primaryMetric", {}).get("scoreUnit", "ops/s")
+
+ score_fmt = format_score(score)
+ error_fmt = format_error(error)
+
+ md.append(f"| {name} | {score_fmt} | {error_fmt} | {unit} |")
+
+ md.append("")
+
+ if head_only_results:
+ md.append("## New benchmarks in PR head")
+ md.append("")
+ md.append(
+ "These benchmarks have no base counterpart; scores are descriptive only "
+ "and have no regression verdict."
+ )
+ md.append("")
+ md.append("| Benchmark | Score | Error | Units |")
+ md.append("|:----------|------:|------:|:------|")
+ for b in sorted(head_only_results, key=lambda x: x.get("benchmark", "")):
+ name = short_benchmark_name(b.get("benchmark", ""))
+ score = b.get("primaryMetric", {}).get("score", 0)
+ error = b.get("primaryMetric", {}).get("scoreError", 0)
+ unit = b.get("primaryMetric", {}).get("scoreUnit", "ops/s")
+ md.append(
+ f"| {name} | {format_score(score)} | {format_error(error)} | {unit} |"
+ )
+ md.append("")
+
+ md.append("### Raw Results")
+ md.append("")
+ md.append("```")
+ md.append(
+ f"{'Benchmark':<50} {'Mode':>6} {'Cnt':>4} {'Score':>14} {'Error':>12} Units"
+ )
+
+ for b in sorted(results, key=lambda x: x.get("benchmark", "")):
+ name = short_benchmark_name(b.get("benchmark", ""))
+ mode = b.get("mode", "thrpt")
+ cnt = b.get("measurementIterations", 0) * b.get("forks", 1)
+ score = b.get("primaryMetric", {}).get("score", 0)
+ error = b.get("primaryMetric", {}).get("scoreError", 0)
+ unit = b.get("primaryMetric", {}).get("scoreUnit", "ops/s")
+
+ try:
+ score_str = f"{float(score):.3f}"
+ except (ValueError, TypeError):
+ score_str = str(score)
+
+ try:
+ error_val = float(error)
+ if math.isnan(error_val):
+ error_str = ""
+ else:
+ error_str = f"ยฑ {error_val:.3f}"
+ except (ValueError, TypeError):
+ error_str = ""
+
+ md.append(
+ f"{name:<50} {mode:>6} {cnt:>4} {score_str:>14} {error_str:>12} {unit}"
+ )
+
+ md.append("```")
+ md.append("")
+
+ md.append("## Notes")
+ md.append("")
+ md.append(
+ "- **Score** = the JMH primary metric; "
+ "throughput is higher-is-better and latency is lower-is-better."
+ )
+ md.append("- **Error** = 99.9% confidence interval")
+ if baseline_results:
+ md.append(
+ "- **Regression verdict** requires comparable benchmark metadata, "
+ "non-overlapping JMH confidence intervals, and a change of at least "
+ f"{PRACTICAL_CHANGE_THRESHOLD:.0f}%; otherwise it is marked "
+ '"within noise" or "inconclusive".'
+ )
+ md.append(
+ "- Scores for different benchmark methods are not ranked against one another; "
+ "they may measure different workloads."
+ )
+ md.append("")
+
+ md.append("## Benchmark Descriptions")
+ md.append("")
+ md.append("| Benchmark | Description |")
+ md.append("|:----------|:------------|")
+ md.append(
+ "| **CounterBenchmark** | Counter increment performance: "
+ "Prometheus, OpenTelemetry, simpleclient, Codahale |"
+ )
+ md.append(
+ "| **HistogramBenchmark** | Histogram observation performance "
+ "(classic vs native/exponential) |"
+ )
+ md.append(
+ "| **TextFormatUtilBenchmark** | Metric exposition format writing speed |"
+ )
+ md.append("")
+ return "\n".join(md)
+
+
+def main():
+ args = parse_args()
+
+ if args.write_system_info:
+ write_system_info(args.write_system_info)
+ print(f"Wrote system info to: {args.write_system_info}")
+ return
+
+ input_path = Path(args.input)
+ if not input_path.exists():
+ print(f"Error: Input file not found: {input_path}")
+ sys.exit(1)
+
+ print(f"Reading results from: {input_path}")
+ with open(input_path, "r") as f:
+ results = json.load(f)
+
+ print(f"Found {len(results)} benchmark results")
+
+ commit_sha = get_commit_sha(args.commit_sha)
+ commit_short = commit_sha[:7]
+ repo = os.environ.get("GITHUB_REPOSITORY", "prometheus/client_java")
+ baseline_results = None
+ baseline_sha = None
+ baseline_repo = args.baseline_repo or os.environ.get("GITHUB_BASE_REPOSITORY", repo)
+ system_info = read_system_info(args.system_info)
+ baseline_system_info = None
+
+ baseline_path = Path(args.baseline) if args.baseline else None
+ if baseline_path:
+ if not baseline_path.exists():
+ print(f"Error: Baseline file not found: {baseline_path}")
+ sys.exit(1)
+ print(f"Reading baseline results from: {baseline_path}")
+ with open(baseline_path, "r") as f:
+ baseline_results = json.load(f)
+ baseline_sha = get_commit_sha(args.baseline_sha)
+ baseline_system_info = (
+ read_system_info(args.baseline_system_info)
+ if args.baseline_system_info
+ else None
+ )
+ print(f"Found {len(baseline_results)} baseline benchmark results")
+
+ output_dir = Path(args.output_dir)
+ output_dir.mkdir(parents=True, exist_ok=True)
+ history_dir = output_dir / "history"
+ history_dir.mkdir(parents=True, exist_ok=True)
+
+ results_json_path = output_dir / "results.json"
+ shutil.copy(input_path, results_json_path)
+ print(f"Copied results to: {results_json_path}")
+
+ if baseline_path:
+ baseline_json_path = output_dir / "baseline-results.json"
+ shutil.copy(baseline_path, baseline_json_path)
+ print(f"Copied baseline results to: {baseline_json_path}")
+
+ date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+ history_path = history_dir / f"{date_str}-{commit_short}.json"
+ shutil.copy(input_path, history_path)
+ print(f"Saved historical entry: {history_path}")
+
+ markdown = generate_markdown(
+ results,
+ commit_sha,
+ repo,
+ baseline_results=baseline_results,
+ baseline_sha=baseline_sha,
+ baseline_repo=baseline_repo,
+ comparison_note=args.comparison_note,
+ system_info=system_info,
+ baseline_system_info=baseline_system_info,
+ )
+ readme_path = output_dir / "README.md"
+ with open(readme_path, "w") as f:
+ f.write(markdown)
+ print(f"Generated summary: {readme_path}")
+
+ print(f"\nDone! Results are in: {output_dir}/")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.mise/tasks/jmx-exporter/prepare.py b/.mise/tasks/jmx-exporter/prepare.py
new file mode 100755
index 000000000..845b76661
--- /dev/null
+++ b/.mise/tasks/jmx-exporter/prepare.py
@@ -0,0 +1,20 @@
+#!/usr/bin/env python3
+
+# [MISE] description="Install local artifacts and check out a target JMX Exporter ref"
+# [MISE] alias="jmx-exporter:prepare"
+
+import sys
+
+sys.path.insert(0, ".mise/lib")
+
+
+def main() -> int:
+ from jmx_exporter_compat import install_local_artifacts, prepare_repo
+
+ install_local_artifacts()
+ prepare_repo()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.mise/tasks/jmx-exporter/test.py b/.mise/tasks/jmx-exporter/test.py
new file mode 100755
index 000000000..0f7694c19
--- /dev/null
+++ b/.mise/tasks/jmx-exporter/test.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python3
+
+# [MISE] description="Run JMX Exporter tests against a target ref"
+# [MISE] alias="jmx-exporter:test"
+
+import sys
+
+sys.path.insert(0, ".mise/lib")
+
+
+def main() -> int:
+ from jmx_exporter_compat import (
+ install_local_artifacts,
+ prepare_repo,
+ run_maven_test,
+ )
+
+ install_local_artifacts()
+ prepare_repo()
+ run_maven_test()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.mise/tasks/lint-bom.py b/.mise/tasks/lint/bom.py
similarity index 85%
rename from .mise/tasks/lint-bom.py
rename to .mise/tasks/lint/bom.py
index 76f996ef5..e4c70988c 100755
--- a/.mise/tasks/lint-bom.py
+++ b/.mise/tasks/lint/bom.py
@@ -7,9 +7,8 @@
import sys
from fnmatch import fnmatch
from pathlib import Path
-from typing import List
-ROOT = Path(__file__).resolve().parents[2] # repo root (.. from .mise/tasks)
+ROOT = Path(__file__).resolve().parents[3] # repo root (.. from .mise/tasks/lint)
IGNORE_DIRS = {"prometheus-metrics-parent"}
MODULE_PREFIX = "prometheus-metrics"
BOM_POM = ROOT / "prometheus-metrics-bom" / "pom.xml"
@@ -31,12 +30,11 @@ def first_artifact_id(pom_file: Path) -> str:
return matches[1].strip()
-def add_dir(dir_path: Path, want: List[str]):
+def add_dir(dir_path: Path, want: list[str]):
if not dir_path.is_dir():
raise FileNotFoundError(f"Directory {dir_path} does not exist.")
if any(dir_path.name == ig for ig in IGNORE_DIRS):
- print(f"Skipping {dir_path}")
return
pom = dir_path / "pom.xml"
@@ -47,12 +45,11 @@ def add_dir(dir_path: Path, want: List[str]):
if not artifact_id:
raise RuntimeError(f"No artifactId found in {pom}")
- print(f"Found artifactId '{artifact_id}' in {pom}")
want.append(artifact_id)
-def collect_want(root: Path) -> List[str]:
- want: List[str] = []
+def collect_want(root: Path) -> list[str]:
+ want: list[str] = []
# top-level prometheus-metrics*
for entry in sorted(root.iterdir()):
if entry.is_dir() and fnmatch(entry.name, f"{MODULE_PREFIX}*"):
@@ -70,14 +67,15 @@ def collect_want(root: Path) -> List[str]:
return want_unique
-def collect_have(bom_pom: Path) -> List[str]:
+def collect_have(bom_pom: Path) -> list[str]:
if not bom_pom.is_file():
raise FileNotFoundError(f"BOM file {bom_pom} does not exist.")
text = bom_pom.read_text(encoding="utf-8")
# find artifactId values that start with MODULE_PREFIX
matches = re.findall(
- r"\s*(%s[^<\s]*)\s*" % re.escape(MODULE_PREFIX), text
+ rf"\s*({re.escape(MODULE_PREFIX)}[^<\s]*)\s*",
+ text,
)
return sorted(matches)
@@ -108,10 +106,9 @@ def main() -> int:
sys.stdout.writelines(diff)
return 1
else:
- print("BOM file is up to date.")
return 0
- except Exception as e:
+ except (OSError, RuntimeError) as e:
print(e, file=sys.stderr)
return 1
diff --git a/.mise/tasks/micrometer/prepare.py b/.mise/tasks/micrometer/prepare.py
new file mode 100755
index 000000000..c4fbf2208
--- /dev/null
+++ b/.mise/tasks/micrometer/prepare.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python3
+
+# [MISE] description="Install local artifacts and check out a target Micrometer ref"
+# [MISE] alias="micrometer:prepare"
+
+import sys
+
+sys.path.insert(0, ".mise/lib")
+
+
+def main() -> int:
+ from micrometer_compat import (
+ install_local_artifacts,
+ prepare_repo,
+ write_init_script,
+ )
+
+ install_local_artifacts()
+ prepare_repo()
+ write_init_script()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.mise/tasks/micrometer/test.py b/.mise/tasks/micrometer/test.py
new file mode 100755
index 000000000..68fb3f661
--- /dev/null
+++ b/.mise/tasks/micrometer/test.py
@@ -0,0 +1,27 @@
+#!/usr/bin/env python3
+
+# [MISE] description="Run Micrometer Prometheus registry tests against a target Micrometer ref"
+# [MISE] alias="micrometer:test"
+
+import sys
+
+sys.path.insert(0, ".mise/lib")
+
+
+def main() -> int:
+ from micrometer_compat import (
+ install_local_artifacts,
+ prepare_repo,
+ run_gradle_test,
+ write_init_script,
+ )
+
+ install_local_artifacts()
+ prepare_repo()
+ write_init_script()
+ run_gradle_test()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.mise/tasks/set-release-version-github-pages.sh b/.mise/tasks/set-release-version-github-pages.sh
index 142d0eb82..2016373c8 100755
--- a/.mise/tasks/set-release-version-github-pages.sh
+++ b/.mise/tasks/set-release-version-github-pages.sh
@@ -1,9 +1,12 @@
#!/usr/bin/env bash
-#MISE description="Set release version in GitHub Pages quickstart.md"
+#MISE description="Set release version in all GitHub Pages docs"
set -euox pipefail
version=$(git tag -l | grep 'v' | sort | tail -1 | sed 's/v//')
-marker="\$version"
-sed -i "s/$marker/$version/g" docs/content/getting-started/quickstart.md
+otelVersion=$(grep -oP '\K[^<]+' pom.xml | sed 's/-alpha$//')
+
+find ./docs/content -name '*.md' \
+ -exec sed -i "s/\$version/$version/g" {} + \
+ -exec sed -i "s/\$otelVersion/$otelVersion/g" {} +
diff --git a/.mise/tasks/set-version.sh b/.mise/tasks/set-version.sh
deleted file mode 100755
index 2309976ce..000000000
--- a/.mise/tasks/set-version.sh
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/usr/bin/env bash
-
-#MISE description="Update version in all pom.xml files"
-#USAGE arg "" help="new version"
-
-set -euo pipefail
-
-# replace all occurrences '1.5.0-SNAPSHOT' with
-# '$usage_version' in all pom.xml files in the current directory and
-# subdirectories
-
-# shellcheck disable=SC2154 # is set by mise
-find . -name 'pom.xml' -exec \
- sed -i "s/1.5.0-SNAPSHOT<\/version>/$usage_version<\/version>/g" {} +
diff --git a/.mise/tasks/super-linter.sh b/.mise/tasks/super-linter.sh
deleted file mode 100755
index 54c2b434e..000000000
--- a/.mise/tasks/super-linter.sh
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/usr/bin/env bash
-
-#MISE description="Run GitHub Super Linter on the repository"
-
-set -euo pipefail
-
-docker pull ghcr.io/super-linter/super-linter:latest
-
-docker run --rm \
- -e RUN_LOCAL=true \
- -e DEFAULT_BRANCH=main \
- --env-file ".github/super-linter.env" \
- -v "$(pwd)":/tmp/lint \
- ghcr.io/super-linter/super-linter:latest
diff --git a/.mise/tasks/test_generate-benchmark-summary.py b/.mise/tasks/test_generate-benchmark-summary.py
new file mode 100644
index 000000000..9918d0e49
--- /dev/null
+++ b/.mise/tasks/test_generate-benchmark-summary.py
@@ -0,0 +1,156 @@
+import os
+import sys
+import unittest
+
+here = os.path.dirname(__file__)
+if here not in sys.path:
+ sys.path.insert(0, here)
+
+from generate_benchmark_summary import (
+ comparison_status,
+ generate_markdown,
+)
+
+
+def result(
+ name="io.prometheus.metrics.benchmarks.CounterBenchmark.prometheusInc",
+ score=100.0,
+ error=1.0,
+ threads=4,
+):
+ primary_metric = {
+ "score": score,
+ "scoreUnit": "ops/s",
+ }
+ if error is not None:
+ primary_metric.update(
+ {
+ "scoreError": error,
+ "scoreConfidence": [score - error, score + error],
+ }
+ )
+
+ return {
+ "benchmark": name,
+ "jmhVersion": "1.37",
+ "mode": "thrpt",
+ "threads": threads,
+ "forks": 3,
+ "warmupIterations": 3,
+ "warmupTime": "10 s",
+ "measurementIterations": 5,
+ "measurementTime": "10 s",
+ "jdkVersion": "25.0.3",
+ "primaryMetric": primary_metric,
+ }
+
+
+class TestBenchmarkComparison(unittest.TestCase):
+ def test_meaningful_improvement_requires_threshold_and_separation(self):
+ self.assertEqual(
+ comparison_status(result(score=106), result()), "meaningful improvement"
+ )
+
+ def test_meaningful_regression_requires_threshold_and_separation(self):
+ self.assertEqual(
+ comparison_status(result(score=94), result()), "meaningful regression"
+ )
+
+ def test_small_or_uncertain_change_is_within_noise(self):
+ self.assertEqual(
+ comparison_status(result(score=102, error=5), result(error=5)),
+ "within noise",
+ )
+
+ def test_mismatched_metadata_is_inconclusive(self):
+ self.assertEqual(comparison_status(result(threads=1), result()), "inconclusive")
+
+ def test_execution_metadata_mismatch_is_inconclusive(self):
+ for field, value in (
+ ("vmName", "different-vm"),
+ ("vmVersion", "different-vm-version"),
+ ("jvmArgs", ["-Xmx1g"]),
+ ("jvmArgsPrepend", ["-XX:+UseZGC"]),
+ ("jvmArgsAppend", ["-XX:-UseCompressedOops"]),
+ ("warmupBatchSize", 2),
+ ("measurementBatchSize", 2),
+ ):
+ head = result()
+ base = result()
+ head[field] = value
+ self.assertEqual(comparison_status(head, base), "inconclusive", field)
+
+ def test_latency_uses_lower_is_better_direction(self):
+ head = result(score=94, error=1)
+ base = result(score=100, error=1)
+ head["mode"] = base["mode"] = "avgt"
+ head["primaryMetric"]["scoreUnit"] = base["primaryMetric"]["scoreUnit"] = "ms"
+ self.assertEqual(comparison_status(head, base), "meaningful improvement")
+
+ def test_exact_practical_threshold_is_meaningful(self):
+ self.assertEqual(
+ comparison_status(result(score=105, error=0.1), result(error=0.1)),
+ "meaningful improvement",
+ )
+
+ def test_zero_or_non_finite_scores_are_inconclusive(self):
+ self.assertEqual(comparison_status(result(score=0), result()), "inconclusive")
+ self.assertEqual(comparison_status(result(), result(score=0)), "inconclusive")
+ self.assertEqual(
+ comparison_status(result(score=float("inf")), result()), "inconclusive"
+ )
+
+ def test_invalid_fallback_uncertainty_is_inconclusive(self):
+ for error in (float("inf"), float("-inf"), -1.0):
+ head = result(error=error)
+ head["primaryMetric"].pop("scoreConfidence")
+ self.assertEqual(comparison_status(head, result()), "inconclusive", error)
+
+ def test_missing_confidence_interval_is_inconclusive(self):
+ head = result(score=106, error=None)
+ self.assertEqual(comparison_status(head, result()), "inconclusive")
+
+
+class TestBenchmarkMarkdown(unittest.TestCase):
+ def test_head_only_benchmarks_are_separate_and_not_ranked(self):
+ base = [result()]
+ head_only = result(
+ name="io.prometheus.metrics.benchmarks.HistogramBenchmark.newProbe",
+ score=5.0,
+ )
+ markdown = generate_markdown(
+ base + [head_only],
+ "head",
+ "prometheus/client_java",
+ baseline_results=base,
+ baseline_sha="base",
+ baseline_repo="prometheus/client_java",
+ )
+
+ self.assertIn(
+ "| Benchmark | PR | Base | Head vs base | Regression verdict |", markdown
+ )
+ self.assertIn("## New benchmarks in PR head", markdown)
+ self.assertIn("no base counterpart", markdown)
+ self.assertIn("Throughput scores are higher-is-better", markdown)
+ self.assertNotIn("Within run", markdown)
+ self.assertNotIn("x slower", markdown)
+
+ def test_latency_note_is_mode_aware(self):
+ base = result()
+ head = result()
+ base["mode"] = head["mode"] = "avgt"
+ base["primaryMetric"]["scoreUnit"] = head["primaryMetric"]["scoreUnit"] = "ms"
+ markdown = generate_markdown(
+ [head],
+ "head",
+ "prometheus/client_java",
+ baseline_results=[base],
+ baseline_sha="base",
+ baseline_repo="prometheus/client_java",
+ )
+ self.assertIn("Latency scores are lower-is-better", markdown)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.mise/tasks/test_update-benchmarks.py b/.mise/tasks/test_update-benchmarks.py
index 3d80b4983..a42091ef1 100644
--- a/.mise/tasks/test_update-benchmarks.py
+++ b/.mise/tasks/test_update-benchmarks.py
@@ -66,7 +66,7 @@ def test_update_only_inserts_matching_class_lines(self):
updated = update_pre_blocks_under_module(self.module_path, self.table)
# All three files should be updated
self.assertEqual(
- set(os.path.basename(p) for p in updated),
+ {os.path.basename(p) for p in updated},
{
os.path.basename(self.files["CounterBenchmark"]),
os.path.basename(self.files["HistogramBenchmark"]),
diff --git a/.mise/tasks/update_benchmarks.py b/.mise/tasks/update_benchmarks.py
index 8a5819ae7..6f7f788dd 100755
--- a/.mise/tasks/update_benchmarks.py
+++ b/.mise/tasks/update_benchmarks.py
@@ -29,10 +29,9 @@
import shlex
import subprocess
import sys
-from typing import List, Optional
-def run_cmd(cmd: List[str], cwd: Optional[str] = None) -> str:
+def run_cmd(cmd: list[str], cwd: str | None = None) -> str:
"""Run a command, stream stdout/stderr to the console for progress, and return the full output.
This replaces the previous blocking subprocess.run approach so users can see build / JMH
@@ -47,13 +46,13 @@ def run_cmd(cmd: List[str], cwd: Optional[str] = None) -> str:
print(f"Command not found: {cmd[0]}")
raise
- output_lines: List[str] = []
+ output_lines: list[str] = []
try:
assert proc.stdout is not None
# Stream lines as they appear and capture them for returning
for line in proc.stdout:
# Print immediately so callers (and CI) can observe progress
- print(line, end="")
+ print(line, end="", flush=True)
output_lines.append(line)
proc.wait()
except KeyboardInterrupt:
@@ -74,7 +73,7 @@ def run_cmd(cmd: List[str], cwd: Optional[str] = None) -> str:
def build_benchmarks(mvnw: str, module: str) -> None:
print(f"Building Maven module '{module}' using {mvnw} (this may take a while)...")
- cmd = [mvnw, "-pl", module, "-am", "-DskipTests", "package"]
+ cmd = [mvnw, "-pl", module, "-am", "-DskipTests", "clean", "package"]
run_cmd(cmd)
print("Build completed.")
@@ -94,7 +93,7 @@ def find_benchmarks_jar(module: str) -> str:
return jar
-def run_jmh(jar: str, java_cmd: str, extra_args: Optional[str]) -> str:
+def run_jmh(jar: str, java_cmd: str, extra_args: str | None) -> str:
args = [java_cmd, "-jar", jar, "-rf", "text"]
if extra_args:
args += shlex.split(extra_args)
@@ -128,7 +127,7 @@ def extract_first_table(jmh_output: str) -> str:
return table
-def filter_table_for_class(table: str, class_name: str) -> Optional[str]:
+def filter_table_for_class(table: str, class_name: str) -> str | None:
"""
Return a table string that contains only the header and the lines belonging to `class_name`.
If no matching lines are found, return None.
@@ -157,16 +156,19 @@ def filter_table_for_class(table: str, class_name: str) -> Optional[str]:
return header + "\n" + "\n".join(matched)
-def update_pre_blocks_under_module(module: str, table: str) -> List[str]:
+def update_pre_blocks_under_module(module: str, table: str) -> list[str]:
# Find files under module and update any
...
block that contains 'thrpt'
updated_files = []
for path in glob.glob(os.path.join(module, "**"), recursive=True):
if os.path.isdir(path):
continue
+ content = None
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
- except Exception:
+ except (OSError, UnicodeError):
+ pass
+ if content is None:
continue
# quick filter
if "
" not in content or "thrpt" not in content:
@@ -188,10 +190,10 @@ def update_pre_blocks_under_module(module: str, table: str) -> List[str]:
# This will match patterns like: " *
...
" and capture the prefix (e.g. " * ")
pattern = re.compile(r"(?m)^(?P[ \t]*\*[ \t]*)
[\s\S]*?
")
- def repl(m: re.Match) -> str:
+ def repl(m: re.Match, replacement_table: str = filtered_table) -> str:
prefix = m.group("prefix")
# Build the new block with the same prefix on each line
- lines = filtered_table.splitlines()
+ lines = replacement_table.splitlines()
replaced = prefix + "
\n"
for ln in lines:
replaced += prefix + ln.rstrip() + "\n"
@@ -207,7 +209,7 @@ def repl(m: re.Match) -> str:
return updated_files
-def main(argv: List[str]):
+def main(argv: list[str]):
parser = argparse.ArgumentParser()
parser.add_argument("--mvnw", default="./mvnw", help="Path to maven wrapper")
parser.add_argument(
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
index 44f3cf2c1..e788e5e69 100644
--- a/.mvn/wrapper/maven-wrapper.properties
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -1,2 +1,2 @@
distributionType=only-script
-distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..2949d8089
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,163 @@
+# AGENTS.md
+
+This file provides guidance to AI coding agents when working
+with code in this repository.
+
+## Build Commands
+
+This project uses Maven with mise for task automation.
+The Maven wrapper (`./mvnw`) is used for all builds.
+
+```bash
+# Full CI build (clean + install + all checks)
+mise run ci
+
+# Quick compile without tests or checks (fastest)
+mise run compile
+
+# Run unit tests only (skips formatting and coverage)
+mise run test
+
+# Run all tests including integration tests
+mise run test-all
+
+# Run a single test class
+./mvnw test -Dtest=CounterTest \
+ -Dcoverage.skip=true
+
+# Run a single test method
+./mvnw test -Dtest=CounterTest#testIncrement \
+ -Dcoverage.skip=true
+
+# Run tests in a specific module
+./mvnw test -pl prometheus-metrics-core \
+ -Dcoverage.skip=true
+
+# Regenerate protobuf classes (after protobuf dep update)
+mise run generate
+```
+
+## Architecture
+
+The library follows a layered architecture where metrics
+flow from core types through a registry to exporters:
+
+```text
+prometheus-metrics-core (user-facing API)
+ โ
+ โผ collect()
+prometheus-metrics-model (immutable snapshots)
+ โ
+ โผ
+prometheus-metrics-exposition-formats
+ โ
+ โผ
+Exporters (httpserver, servlet, pushgateway, otel)
+```
+
+### Key Modules
+
+- **prometheus-metrics-core**: User-facing metric types
+ (Counter, Gauge, Histogram, Summary, Info, StateSet).
+ All metrics implement `Collector` with `collect()`.
+- **prometheus-metrics-model**: Internal read-only immutable
+ snapshot types returned by `collect()`.
+ Contains `PrometheusRegistry` for metric registration.
+- **prometheus-metrics-config**: Runtime configuration via
+ properties files or system properties.
+- **prometheus-metrics-exposition-formats**: Converts
+ snapshots to Prometheus exposition formats.
+- **prometheus-metrics-tracer**: Exemplar support with
+ OpenTelemetry tracing integration.
+- **prometheus-metrics-simpleclient-bridge**: Allows legacy
+ simpleclient 0.16.0 metrics to work with the new registry.
+
+### Instrumentation Modules
+
+Pre-built instrumentations:
+`prometheus-metrics-instrumentation-jvm`, `-caffeine`,
+`-guava`, `-dropwizard`, `-dropwizard5`.
+
+## Code Style
+
+- **Formatter**: Google Java Format (enforced via flint)
+- **Line length**: 100 characters
+ (enforced for ALL files including Markdown, Java, YAML)
+- **Indentation**: 2 spaces
+- **Static analysis**: `Error Prone` with NullAway
+ (`io.prometheus.metrics` package)
+- **Logger naming**: Logger fields must be named `logger`
+ (not `log`, `LOG`, or `LOGGER`)
+- **Assertions in tests**: Use static imports from AssertJ
+ (`import static ...Assertions.assertThat`)
+- **Empty catch blocks**: Use `ignored` as the variable name
+- **Markdown code blocks**: Always specify language
+ (e.g., ` ```java`, ` ```bash`, ` ```text`)
+
+## Pull Requests
+
+- PR titles must use semantic/conventional prefixes, for example
+ `feat: ...`, `fix: ...`, `docs: ...`, `chore: ...`, or
+ `test: ...`.
+- Do not prefix PR titles with `[codex]`.
+- Match the PR title type to the primary user-facing change.
+
+## Linting
+
+Run `mise run lint:fix` before committing changes.
+If output includes `fixed`, keep those changes.
+If output includes `partial` or `review`, address the remaining issues and
+run `mise run lint:fix` again.
+
+Example output:
+flint: fixed: gofmt โ commit before pushing | partial: cargo-clippy
+
+**CRITICAL**: These checks MUST be run before creating any
+commits. CI will fail if these checks fail.
+
+### Java Files
+
+- **ALWAYS** run `mise run build` after modifying Java files
+ to ensure:
+ - Code formatting via flint
+ - Static analysis (`Error Prone` with NullAway)
+ - Checkstyle validation
+ - Build succeeds (tests are skipped;
+ run `mise run test` or `mise run test-all` for tests)
+
+## API Design
+
+- For internal or SDK-facing classes, prefer static factories and builders
+ over adding new public constructors.
+- Keep constructors non-public unless they are intentionally part of the
+ stable API.
+
+## Testing
+
+- JUnit 5 (Jupiter) with `@Test` annotations
+- AssertJ for fluent assertions
+- Mockito for mocking
+- **Test visibility**: Test classes and test methods must be
+ package-protected (no `public` modifier)
+- Integration tests are in `integration-tests/` and run
+ during `verify` phase
+- Acceptance tests use OATs framework:
+ `mise run acceptance-test`
+
+## Documentation
+
+- Docs live under `docs/content/` and use `$version` as a
+ placeholder for the library version
+- When publishing GitHub Pages,
+ `mise run set-release-version-github-pages` replaces
+ `$version` with the latest Git tag across all
+ `docs/content/**/*.md` files
+ (the published site is not versioned)
+- Use `$version` for the Prometheus client version and
+ `$otelVersion-alpha` for the OTel instrumentation
+ version โ never hardcode them
+
+## Java Version
+
+Source compatibility: Java 8. Tests run on Java 25
+(configured in `mise.toml`).
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 000000000..81ba1f52a
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,144 @@
+# Changelog
+
+## [1.8.0](https://github.com/prometheus/client_java/compare/v1.7.0...v1.8.0) (2026-06-11)
+
+
+### Features
+
+* Add custom labels to exemplars ([#2191](https://github.com/prometheus/client_java/issues/2191)) ([fd1f3e8](https://github.com/prometheus/client_java/commit/fd1f3e85177ec4d4e4922f22f3aa79dc2dd7e17e))
+* add MetricMetadata.Builder, deprecate wide constructors ([#2202](https://github.com/prometheus/client_java/issues/2202)) ([adeef32](https://github.com/prometheus/client_java/commit/adeef32f303a9dfadee9b7702b255db193c9c533))
+
+
+### Bug Fixes
+
+* Avoid unnuecessary exemplar allocations ([#2209](https://github.com/prometheus/client_java/issues/2209)) ([0b6a91f](https://github.com/prometheus/client_java/commit/0b6a91f2bafe0fa15f6fe828f315103d8c20f9f9))
+* **deps:** update spring boot to v4.1.0 ([#2213](https://github.com/prometheus/client_java/issues/2213)) ([df25c08](https://github.com/prometheus/client_java/commit/df25c0821605b7edf7b87b9874a65d3d529592a5))
+
+
+### Documentation
+
+* cover typed family descriptors and @StableApi since v1.6.1 ([#2181](https://github.com/prometheus/client_java/issues/2181)) ([7ca9f99](https://github.com/prometheus/client_java/commit/7ca9f99b8f1731315d2cf8f68247fc94174a8b3b))
+
+## [1.7.0](https://github.com/prometheus/client_java/compare/v1.6.1...v1.7.0) (2026-06-03)
+
+
+### Features
+
+* Add StableApi marker and API diff check ([#2168](https://github.com/prometheus/client_java/issues/2168)) ([768fd3a](https://github.com/prometheus/client_java/commit/768fd3a7aab5f11f3558a35c0d6257b5a217a078))
+* add typed metric family descriptors ([#2114](https://github.com/prometheus/client_java/issues/2114)) ([9c3b097](https://github.com/prometheus/client_java/commit/9c3b097f6842ffc08fb3a2ed00217c73a6c2b191))
+* track api-diff baseline via Renovate and store diffs in docs/apidiffs ([#2174](https://github.com/prometheus/client_java/issues/2174)) ([3adb890](https://github.com/prometheus/client_java/commit/3adb89078df4bf3d7739886612d4cf051176a6f3))
+
+
+### Bug Fixes
+
+* **deps:** update dependency com.github.ben-manes.caffeine:caffeine to v3.2.4 ([#2088](https://github.com/prometheus/client_java/issues/2088)) ([144eb61](https://github.com/prometheus/client_java/commit/144eb61030d412afe83631b8f341d2cb1595ab1c))
+* **deps:** update dependency io.dropwizard.metrics:metrics-core to v4.2.39 ([#2139](https://github.com/prometheus/client_java/issues/2139)) ([5817d13](https://github.com/prometheus/client_java/commit/5817d1395dc348b6634ea169264fd13f4ad56e82))
+* **deps:** update dependency io.dropwizard.metrics5:metrics-core to v5.0.7 ([#2140](https://github.com/prometheus/client_java/issues/2140)) ([261c451](https://github.com/prometheus/client_java/commit/261c4510eefe156ad688e019b9239cfcfd39bd2b))
+* **deps:** update dependency io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha to v2.28.0-alpha ([#2126](https://github.com/prometheus/client_java/issues/2126)) ([b62b5d0](https://github.com/prometheus/client_java/commit/b62b5d0ab4b8d3a1335286bd3d36e8c9ac5aa269))
+* **deps:** update dependency io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha to v2.28.0-alpha ([#2127](https://github.com/prometheus/client_java/issues/2127)) ([e11ce3d](https://github.com/prometheus/client_java/commit/e11ce3de19daf5acd2f73ffb90c96689c172f3c3))
+* **deps:** update dependency io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha to v2.28.1-alpha ([#2132](https://github.com/prometheus/client_java/issues/2132)) ([b09be38](https://github.com/prometheus/client_java/commit/b09be3882f0ad95ff299db41d706a2e52faa7525))
+* **deps:** update dependency io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha to v2.28.1-alpha ([#2133](https://github.com/prometheus/client_java/issues/2133)) ([a241c16](https://github.com/prometheus/client_java/commit/a241c165927d3cbb91b97eedd52de9c9eff595d0))
+* **deps:** update dependency org.apache.tomcat.embed:tomcat-embed-core to v11.0.22 ([#2099](https://github.com/prometheus/client_java/issues/2099)) ([22125c5](https://github.com/prometheus/client_java/commit/22125c5f531467030793fc48cb2308ff14bbcaa7))
+* **deps:** update jetty monorepo to v12.1.10 ([#2169](https://github.com/prometheus/client_java/issues/2169)) ([ddd3991](https://github.com/prometheus/client_java/commit/ddd3991096d409a3e58ae2003ce13457a51b8876))
+* **deps:** update jetty monorepo to v12.1.9 ([#2102](https://github.com/prometheus/client_java/issues/2102)) ([04bee70](https://github.com/prometheus/client_java/commit/04bee70efff866f8c4966643926905c28a4eae3a))
+* **deps:** update protobuf ([#2129](https://github.com/prometheus/client_java/issues/2129)) ([320538a](https://github.com/prometheus/client_java/commit/320538a09efad128c6d80bcc3d6eecca394603db))
+* Reduce allocations for classic histogram buckets ([#2081](https://github.com/prometheus/client_java/issues/2081)) ([edd160a](https://github.com/prometheus/client_java/commit/edd160ab93254c80250d7cf58a1dcb399fef67a1))
+* restore legacy suffix compatibility ([#2100](https://github.com/prometheus/client_java/issues/2100)) ([b2ae70f](https://github.com/prometheus/client_java/commit/b2ae70ffd4ac0830fb567319beae9d1c3ad8bc2f))
+* restore reserved suffix stripping in `PrometheusNaming.sanitizeMetricName()` ([#2124](https://github.com/prometheus/client_java/issues/2124)) ([2d0f508](https://github.com/prometheus/client_java/commit/2d0f508efd2f5e009b6f09f6a9ccb451cf9f3b6f))
+
+
+### Performance Improvements
+
+* Refactored sorting to use optimized sort algorithms ([#2161](https://github.com/prometheus/client_java/issues/2161)) ([25b94fc](https://github.com/prometheus/client_java/commit/25b94fc16273659892af0132cedb71f57597adf7))
+
+
+### Documentation
+
+* clarify downstream adapter validation requirements ([#2101](https://github.com/prometheus/client_java/issues/2101)) ([ef8c75c](https://github.com/prometheus/client_java/commit/ef8c75cf352bddd0d3a2052c3f1b0c8b6103a6f4))
+* Document OM2 ([#2059](https://github.com/prometheus/client_java/issues/2059)) ([45d753c](https://github.com/prometheus/client_java/commit/45d753c418f005fbb17bf7caca3dc94655717687))
+* document PushGateway shading workaround ([#2106](https://github.com/prometheus/client_java/issues/2106)) ([8ca0eb8](https://github.com/prometheus/client_java/commit/8ca0eb8d79b800ad8d7a08f10762ed631f4f2a70))
+
+## [1.6.1](https://github.com/prometheus/client_java/compare/v1.6.0...v1.6.1) (2026-04-27)
+
+> Note: With the OM2 metric-name preservation fix in this release, OpenMetrics 2.0 can now be
+> tested. It is still in progress and not ready for general use yet.
+
+### Bug Fixes
+
+* Preserve original metric names in OM2 output ([#2058](https://github.com/prometheus/client_java/issues/2058)) ([59a7a6d](https://github.com/prometheus/client_java/commit/59a7a6d4d5a9eb31c33167764b11ba96d6625b74))
+
+
+### Documentation
+
+* clarify 1.6.0 release notes ([#2062](https://github.com/prometheus/client_java/issues/2062)) ([9e5d591](https://github.com/prometheus/client_java/commit/9e5d591f4c2e8e0d39ce5141ac14fff057b09c67))
+* Document semantic PR title guidance ([#2060](https://github.com/prometheus/client_java/issues/2060)) ([7277889](https://github.com/prometheus/client_java/commit/727788942cccbecfa57d75eee9fb3e942083a95e))
+
+## [1.6.0](https://github.com/prometheus/client_java/compare/v1.5.1...v1.6.0) (2026-04-25)
+
+> Note: OpenMetrics 2.0 support is still in progress and not ready for general use yet.
+>
+> As part of the OM2 work, metric-name suffix handling moved from metric creation time to scrape
+> time. A positive side effect is that metric names are now more flexible across the board, for
+> example names ending in suffixes like `_total` are accepted where they were previously rejected.
+> To keep the Prometheus and OM1 output unambiguous, the registry tracks claimed exposition names
+> and still rejects registrations that would collide at scrape time.
+>
+> Downstream adapter libraries that implement `MultiCollector` need their registration-time
+> metadata to match the metric families they emit at scrape time. When upgrading to 1.6.0+, adapter
+> registration metadata needs to stay aligned with emitted names, types, label names, and suffix
+> behavior under the new collision model.
+> See also: [Validation at registration only](docs/content/getting-started/registry.md#validation-at-registration-only)
+>
+> | Example | Before 1.6.0 | Since 1.6.0 | Reason |
+> | --- | --- | --- | --- |
+> | `Gauge("foo_total")` | Rejected | Allowed | Not breaking because this previously failed at registration, so no working setup changes behavior, and safe because `_total` suffix expansion applies to counters, not gauges. |
+> | `Counter("events_total")` | Rejected | Allowed | Not breaking because the OM1 output is still `events_total`; only the builder now accepts the name. |
+> | `Gauge("foo_total")` + `Histogram("foo")` | Rejected | Allowed | Not breaking because this combination used to be blocked even though the exposed names do not overlap. |
+> | `Gauge("events_total")` + `Counter("events")` | Rejected | Rejected | Not breaking because the ambiguous OM1 output would still expose two `events_total` series. |
+> | `Gauge("foo_count")` + `Histogram("foo")` | Allowed | Rejected | Intentionally breaking because the old behavior could expose a conflicting `foo_count` name at scrape time. |
+
+### Features
+
+* Relax metric name validation in Dropwizard5 ([#1985](https://github.com/prometheus/client_java/issues/1985)) ([deb782f](https://github.com/prometheus/client_java/commit/deb782f9fce60ffb1308a98b661c0a1ccb79a82b))
+
+
+### Bug Fixes
+
+* **deps:** update dependency com.google.guava:guava to v33.6.0-jre ([#2021](https://github.com/prometheus/client_java/issues/2021)) ([1382693](https://github.com/prometheus/client_java/commit/13826930b9c2f566040a6929090ef23c94e81796))
+* **deps:** update dependency commons-io:commons-io to v2.22.0 ([#2044](https://github.com/prometheus/client_java/issues/2044)) ([9e05c1d](https://github.com/prometheus/client_java/commit/9e05c1d56b7b0de17ba5aaaa300eb6433cc70824))
+* **deps:** update dependency io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha to v2.27.0-alpha ([#2022](https://github.com/prometheus/client_java/issues/2022)) ([30ac534](https://github.com/prometheus/client_java/commit/30ac534d860fb7c60a1e7835723a6cf0035ea7f7))
+* **deps:** update dependency io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha to v2.27.0-alpha ([#2023](https://github.com/prometheus/client_java/issues/2023)) ([2d51a32](https://github.com/prometheus/client_java/commit/2d51a3251f6943cf1b03ba9ea8778ff052f83ef9))
+* **deps:** update dependency io.prometheus:prometheus-metrics-bom to v1.5.1 ([#2004](https://github.com/prometheus/client_java/issues/2004)) ([650ce4b](https://github.com/prometheus/client_java/commit/650ce4b677f2ca65f5877e77260e403fa85533db))
+* **deps:** update dependency org.apache.tomcat.embed:tomcat-embed-core to v11.0.21 ([#2005](https://github.com/prometheus/client_java/issues/2005)) ([7a36df7](https://github.com/prometheus/client_java/commit/7a36df7151e55adafd5bb5a72af81fd7bf8f1133))
+* **deps:** update dependency org.springframework.boot:spring-boot-starter-parent to v4.0.5 ([#2006](https://github.com/prometheus/client_java/issues/2006)) ([0106c18](https://github.com/prometheus/client_java/commit/0106c18adffd6d3e829f73979917bdf7cc5f53dd))
+* **deps:** update dependency org.springframework.boot:spring-boot-starter-parent to v4.0.6 ([#2046](https://github.com/prometheus/client_java/issues/2046)) ([40a9db8](https://github.com/prometheus/client_java/commit/40a9db868805e36fbaa0f9ac3d02becb17104cd0))
+* **deps:** update jetty monorepo to v12.1.8 ([#2007](https://github.com/prometheus/client_java/issues/2007)) ([acab5b2](https://github.com/prometheus/client_java/commit/acab5b213e7661818470716158b3cfe67caae9da))
+* **deps:** update protobuf ([#2024](https://github.com/prometheus/client_java/issues/2024)) ([8e2214e](https://github.com/prometheus/client_java/commit/8e2214e0a3ac2fe8a9194d9519dcee10f6c9a694))
+* pass release tag as input to deploy workflow ([#1982](https://github.com/prometheus/client_java/issues/1982)) ([165c921](https://github.com/prometheus/client_java/commit/165c921c2508e073baa8f403b30e536ba9b43df9))
+* pin grafana/otel-lgtm to 0.7.2 in OATs acceptance test ([#1992](https://github.com/prometheus/client_java/issues/1992)) ([f17ad9a](https://github.com/prometheus/client_java/commit/f17ad9ad9be2ed0a8519db094f9d8fe9a8a83c48))
+* stabilize flaky timer and thread count tests ([#1973](https://github.com/prometheus/client_java/issues/1973)) ([ce5867b](https://github.com/prometheus/client_java/commit/ce5867b3e25e10c68a6face275732b994a80ec98))
+* trigger Maven deploy from release-please via workflow_dispatch ([#1981](https://github.com/prometheus/client_java/issues/1981)) ([698f956](https://github.com/prometheus/client_java/commit/698f9565e825cdb0f58d2782131cb152cc13894a))
+
+## [1.5.1](https://github.com/prometheus/client_java/compare/v1.5.0...v1.5.1) (2026-03-20)
+
+
+### Bug Fixes
+
+* **deps:** update dependency io.prometheus:prometheus-metrics-bom to v1.5.0 ([#1877](https://github.com/prometheus/client_java/issues/1877)) ([043fc57](https://github.com/prometheus/client_java/commit/043fc5742752fdc2f67f0219418030a190c53bde))
+* **deps:** update dependency org.springframework.boot:spring-boot-starter-parent to v4.0.3 ([#1900](https://github.com/prometheus/client_java/issues/1900)) ([0d800d0](https://github.com/prometheus/client_java/commit/0d800d0a91578e48f34909472c183174fdf1d83e))
+* **deps:** update jetty monorepo to v12.1.7 ([#1932](https://github.com/prometheus/client_java/issues/1932)) ([5bd3b79](https://github.com/prometheus/client_java/commit/5bd3b7932f454f3ed2cf55f26d6e1e1908d9ad16))
+* **deps:** update junit-framework monorepo to v6.0.3 ([#1880](https://github.com/prometheus/client_java/issues/1880)) ([05ad751](https://github.com/prometheus/client_java/commit/05ad751a40053f11eae90b9e6cbd741814ca71a7))
+* exclude standalone examples from `mise run format` ([#1931](https://github.com/prometheus/client_java/issues/1931)) ([537fb88](https://github.com/prometheus/client_java/commit/537fb88aae4048ab36041268f902afbbdce54a96))
+* fix release-please PR title pattern and permissions ([#1978](https://github.com/prometheus/client_java/issues/1978)) ([d737978](https://github.com/prometheus/client_java/commit/d7379780f1351a1521c8d93d0544bffce49d02a6))
+* Handle empty datapoints in otel exporter ([#1898](https://github.com/prometheus/client_java/issues/1898)) ([59c8552](https://github.com/prometheus/client_java/commit/59c8552f3d67c06d82344383b45e07fea8ed88b9))
+* inline set-version logic in build-release.sh ([#1884](https://github.com/prometheus/client_java/issues/1884)) ([c050435](https://github.com/prometheus/client_java/commit/c050435a4153046c72d158991c4c8e064dfb24ec))
+* reduce lychee retries to avoid compounding GitHub 429s ([#1940](https://github.com/prometheus/client_java/issues/1940)) ([cc17d6e](https://github.com/prometheus/client_java/commit/cc17d6e4346c9d51e054010fddc75cf8935cbc7d))
+* remove version manipulation from build-release.sh ([#1886](https://github.com/prometheus/client_java/issues/1886)) ([93e2b6d](https://github.com/prometheus/client_java/commit/93e2b6da48abc03ba7d96ffff5020ab73c1ee8c1))
+* trigger Maven deploy on release-please published events ([#1966](https://github.com/prometheus/client_java/issues/1966)) ([643d0e7](https://github.com/prometheus/client_java/commit/643d0e70c274e2a55024611c73a53545a65e94a0))
+* use /tree/ instead of /blob/ for directory URL ([#1944](https://github.com/prometheus/client_java/issues/1944)) ([b81332e](https://github.com/prometheus/client_java/commit/b81332e3a09e465f956f118a2403e64b83771ae5))
+* use maven release type for release-please ([#1967](https://github.com/prometheus/client_java/issues/1967)) ([ff3bd2d](https://github.com/prometheus/client_java/commit/ff3bd2d329acdb6761044846559c353a526c0384))
+
+
+### Documentation
+
+* document DCO sign-off requirement for contributions ([#1937](https://github.com/prometheus/client_java/issues/1937)) ([0860e77](https://github.com/prometheus/client_java/commit/0860e7742b08ab019d129ea24c348191c3f9e0da))
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..6b5e23414
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,3 @@
+
+
+@AGENTS.md
diff --git a/CODEOWNERS b/CODEOWNERS
new file mode 100644
index 000000000..1f70593ce
--- /dev/null
+++ b/CODEOWNERS
@@ -0,0 +1,4 @@
+# https://help.github.com/articles/about-codeowners/
+# https://git-scm.com/docs/gitignore#_pattern_format
+
+* @fstab @dhoard @zeitlinger @jaydeluca
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 50d60bfcc..1b42bfaf4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -12,12 +12,73 @@ Prometheus uses GitHub to manage reviews of pull requests.
This will avoid unnecessary work and surely give you and us a good deal
of inspiration.
+## Pull Request Titles
+
+Use a [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary)-style
+title for pull requests:
+
+```text
+[optional scope]:
+```
+
+For example: `fix(metrics): handle empty scrapes`. Common types include `feat`, `fix`,
+`docs`, `test`, `refactor`, `perf`, `build`, `ci`, `chore`, and `revert`.
+
+## Signing Off Commits
+
+Every commit must include a `Signed-off-by` line, as required by the
+[Developer Certificate of Origin (DCO)](https://developercertificate.org/).
+
+Sign off each commit by passing `--signoff` (or `-s`) to `git commit`:
+
+```bash
+git commit --signoff -m "Your commit message"
+```
+
+To sign off only the most recent commit, use `--amend`:
+
+```bash
+git commit --amend --signoff --no-edit
+```
+
+To sign off multiple commits, rebase (replace `N` with the number of commits):
+
+```bash
+git rebase --signoff HEAD~N
+```
+
+Then force-push the branch:
+
+```bash
+git push --force-with-lease
+```
+
## Formatting
-This repository uses [Google Java Format](https://github.com/google/google-java-format) to format
-the code.
+This repository uses flint to run formatting and lint checks.
+
+Run all the linters:
+
+`mise run lint`
+
+To autofix linting issues:
+
+`mise run lint:fix`
+
+### Pre-commit Hook (Optional)
-Run `./mvnw spotless:apply` to format the code (only changed files) before committing.
+Run `mise run setup:pre-commit-hook` to install a git pre-commit hook
+that auto-lints changed files before each commit.
+This requires native lint tools,
+which you can install with `mise run setup:native-lint-tools`.
+These are optional but catch formatting and lint issues before CI.
+
+## API Design
+
+For internal or SDK-facing classes, prefer static factories and builders over
+adding new public constructors. Constructors are difficult to evolve
+compatibly, so keep them non-public unless they are intentionally part of the
+stable API.
## Running Tests
@@ -29,21 +90,18 @@ If you're getting errors when running tests:
### Running native tests
```shell
-mise --env native test
+mise --cd .mise/envs/native run native-test
```
### Avoid failures while running tests
-- Use `-Dspotless.check.skip=true` to skip the formatting check during development.
- Use `-Dcoverage.skip=true` to skip the coverage check during development.
-- Use `-Dcheckstyle.skip=true` to skip the checkstyle check during development.
- Use `-Dwarnings=-nowarn` to skip the warnings during development.
Combine all with
```shell
-./mvnw install -DskipTests -Dspotless.check.skip=true -Dcoverage.skip=true \
- -Dcheckstyle.skip=true -Dwarnings=-nowarn
+./mvnw install -DskipTests -Dcoverage.skip=true -Dwarnings=-nowarn
```
or simply
@@ -52,12 +110,34 @@ or simply
mise run compile
```
+## Version Numbers in Examples
+
+Example `pom.xml` files (under `examples/`) should reference the latest
+**released** version, not a SNAPSHOT. After each release, Renovate
+updates these versions automatically.
+
+Only use a SNAPSHOT version in an example when it demonstrates a new
+feature that has not been released yet.
+
## Updating the Protobuf Java Classes
+The generated protobuf `Metrics.java` lives in a versioned package
+(e.g., `...generated.com_google_protobuf_4_33_5`) that changes with each
+protobuf release. A stable extending class at
+`...generated/Metrics.java` reexports all types so that consumer code
+only imports from the version-free package. On protobuf upgrades only
+the `extends` clause in the stable class changes.
+
In the failing PR from renovate, run:
```shell
mise run generate
```
-Add the new `Metrics.java` to Git and commit it.
+The script will:
+
+1. Re-generate the protobuf sources with the new version.
+2. Update the versioned package name in all Java files
+ (including the stable `Metrics.java` extends clause).
+
+Add the updated files to Git and commit them.
diff --git a/MAINTAINERS.md b/MAINTAINERS.md
index cf0a885eb..a247eb14a 100644
--- a/MAINTAINERS.md
+++ b/MAINTAINERS.md
@@ -2,5 +2,9 @@
- Fabian Stรคber @fstab
- Doug Hoard @dhoard
-- Tom Wilkie @tomwilkie
- Gregor Zeitlinger @zeitlinger
+- Jay DeLuca @jaydeluca
+
+## Emeritus
+
+- Tom Wilkie @tomwilkie
diff --git a/README.md b/README.md
index a6b611db2..daef2ac3d 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,15 @@
# Prometheus Java Metrics Library
-[](https://github.com/prometheus/client_java/actions/workflows/build.yml) # editorconfig-checker-disable-line
+
+
+[![Build][build-badge]][build-workflow]
+
+
+
+
+
+[build-badge]: https://github.com/prometheus/client_java/actions/workflows/build.yml/badge.svg
+[build-workflow]: https://github.com/prometheus/client_java/actions/workflows/build.yml
## Documentation
diff --git a/RELEASING.md b/RELEASING.md
index 601034d01..c5ae0ed4b 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -1,39 +1,79 @@
# Releasing Instructions for Prometheus Java Client
+Releases are automated via
+[release-please](https://github.com/googleapis/release-please).
+
+## How It Works
+
+1. Commits to `main` using
+ [Conventional Commits](https://www.conventionalcommits.org/) are
+ tracked by release-please.
+2. Release-please maintains a release PR that accumulates changes and
+ updates the changelog.
+3. When the release PR is merged, release-please creates a GitHub
+ release and a `vX.Y.Z` tag.
+4. The tag triggers the existing `release.yml` workflow, which deploys
+ to Maven Central.
+5. After tagging, release-please opens a follow-up PR to bump the
+ SNAPSHOT version in all `pom.xml` files.
+
+## Patch Release (default)
+
+Simply merge the release PR โ release-please bumps the patch version
+by default (e.g. `1.5.0` -> `1.5.1`).
+
+## Minor or Major Release
+
+Add a `release-as: X.Y.0` footer to any commit on `main`:
+
+```text
+feat: add new feature
+
+release-as: 1.6.0
+```
+
+Alternatively, edit the release PR title to
+`chore(main): release 1.6.0`.
+
## Before the Release
-If there have been significant changes since the last release, update the
-benchmarks before creating a new release:
+If there have been significant changes since the last release, update
+the benchmarks before merging the release PR:
```shell
mise run update-benchmarks
```
-## Create a Release
+## If the Sonatype Central Token is Invalid
-1. Go to
-2. Click on "Choose a tag", enter the tag name (e.g. `v0.1.0`), and click "Create a new tag".
-3. Click on "Generate release notes" to auto-generate the release notes based on the commits since
- the last release.
-4. Click on "Publish release".
+The release workflow verifies the token before deploy. If it fails:
-## Major or minor release
+1. Sign in at and open
+ View Account -> Generate User Token.
+2. Copy the `username` and `password` values from the snippet.
+3. Update the secrets:
+ -
+ -
+4. Verify locally:
-After the release is created, do a text replace everywhere in the repository to update the
-snapshot version in the `pom.xml` files (and some other files) to the next version.
-For example, if the last release was `1.4.0`, the next snapshot version should be `1.5.0-SNAPSHOT`.
+ ```shell
+ curl -i -u "$USER:$PASS" \
+ "https://central.sonatype.com/api/v1/publisher/status?id=test"
+ ```
-Replace `1.4.0-SNAPSHOT` with `1.5.0-SNAPSHOT` in all following files.
+ `{"error":{"message":"Invalid token"}}` means the token is still
+ wrong. Any other response (including 404 for the test id) means the
+ token works.
-## If the GPG key expired
+## If the GPG Key Expired
1. Generate a new key:
-2. Distribute the
- key:
-3. use `gpg --armor --export-secret-keys YOUR_ID` to
- export ([docs](https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#gpg))
-4. Update the
- passphrase:
-5. Update the GPG
- key:
+2. Distribute the key:
+
+3. Use `gpg --armor --export-secret-keys YOUR_ID` to export
+ ([docs](https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#gpg))
+4. Update the passphrase:
+
+5. Update the GPG key:
+
diff --git a/SECURITY.md b/SECURITY.md
index fed02d85c..5e6f976db 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -3,4 +3,4 @@
The Prometheus security policy, including how to report vulnerabilities, can be
found here:
-
+[https://prometheus.io/docs/operating/security/](https://prometheus.io/docs/operating/security/)
diff --git a/benchmarks/README.md b/benchmarks/README.md
index 3bba56422..3859eddea 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -10,6 +10,17 @@ Run benchmarks and update the results in the Javadoc of the benchmark classes:
mise run update-benchmarks
```
+### Different benchmark configurations
+
+The full benchmark suite takes approximately 2 hours with JMH defaults.
+For faster iterations, use these preset configurations:
+
+| Command | Duration | Use Case |
+| ----------------------------- | -------- | ---------------------------------------- |
+| `mise run benchmark:quick` | ~10 min | Quick smoke test during development |
+| `mise run benchmark:standard` | ~60 min | CI/nightly runs with good accuracy |
+| `mise run benchmark:full` | ~2 hours | Full JMH defaults for release validation |
+
### Running benchmarks manually
```shell
@@ -22,13 +33,33 @@ Run only one specific benchmark:
java -jar ./benchmarks/target/benchmarks.jar CounterBenchmark
```
+### Custom JMH arguments
+
+You can pass custom JMH arguments:
+
+```shell
+# Quick run: 1 fork, 1 warmup iteration, 3 measurement iterations
+mise run update-benchmarks -- --jmh-args "-f 1 -wi 1 -i 3"
+
+# Standard CI: 3 forks, 3 warmup iterations, 5 measurement iterations
+mise run update-benchmarks -- --jmh-args "-f 3 -wi 3 -i 5"
+```
+
+JMH parameter reference:
+
+- `-f N`: Number of forks (JVM restarts)
+- `-wi N`: Number of warmup iterations
+- `-i N`: Number of measurement iterations
+- `-w Ns`: Warmup iteration time (default: 10s)
+- `-r Ns`: Measurement iteration time (default: 10s)
+
## Results
See Javadoc of the benchmark classes:
-- [CounterBenchmark](https://github.com/prometheus/client_java/blob/main/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/CounterBenchmark.java)
-- [HistogramBenchmark](https://github.com/prometheus/client_java/blob/main/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/HistogramBenchmark.java)
-- [TextFormatUtilBenchmark](https://github.com/prometheus/client_java/blob/main/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/TextFormatUtilBenchmark.java)
+- [CounterBenchmark](https://github.com/prometheus/client_java/blob/main/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/CounterBenchmark.java)
+- [HistogramBenchmark](https://github.com/prometheus/client_java/blob/main/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/HistogramBenchmark.java)
+- [TextFormatUtilBenchmark](https://github.com/prometheus/client_java/blob/main/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/TextFormatUtilBenchmark.java)
## What Prometheus Java client optimizes for
diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml
index b2fad5766..6d8c9c5c4 100644
--- a/benchmarks/pom.xml
+++ b/benchmarks/pom.xml
@@ -1,14 +1,11 @@
-
+4.0.0io.prometheusclient_java
- 1.5.0-SNAPSHOT
+ 1.8.1-SNAPSHOTbenchmarks
@@ -23,6 +20,7 @@
0.16.03.0.2true
+ true
@@ -85,8 +83,8 @@
1.81.8
-
-
+
+
-parameters
@@ -110,8 +108,7 @@
benchmarks
-
+ io.prometheus.metrics.benchmarks.BenchmarkRunner
diff --git a/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/CounterBenchmark.java b/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/CounterBenchmark.java
index f5d0a1a0f..d8b75e437 100644
--- a/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/CounterBenchmark.java
+++ b/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/CounterBenchmark.java
@@ -22,16 +22,16 @@
*
*
*
* Prometheus counters are faster than counters of other libraries. For example, incrementing a
diff --git a/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/HistogramBenchmark.java b/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/HistogramBenchmark.java
index 9ada40f35..41b7097db 100644
--- a/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/HistogramBenchmark.java
+++ b/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/HistogramBenchmark.java
@@ -21,11 +21,11 @@
*
*
Use this on public or protected types to publish the type and its members. Use it on
+ * individual constructors, methods, and fields when only part of a public type is stable.
+ */
+@Documented
+@Retention(CLASS)
+@Target({TYPE, CONSTRUCTOR, METHOD, FIELD})
+public @interface StableApi {}
diff --git a/prometheus-metrics-bom/pom.xml b/prometheus-metrics-bom/pom.xml
index a13398228..c2c9935f7 100644
--- a/prometheus-metrics-bom/pom.xml
+++ b/prometheus-metrics-bom/pom.xml
@@ -1,12 +1,11 @@
-
+4.0.0io.prometheusclient_java_parent
- 1.5.0-SNAPSHOT
+ 1.8.1-SNAPSHOT../prometheus-metrics-parent/pom.xml
@@ -18,12 +17,13 @@
Bill of Materials for the Prometheus Metrics library
-
- true
-
-
+
+ io.prometheus
+ prometheus-metrics-annotations
+ ${project.version}
+ io.prometheusprometheus-metrics-config
@@ -119,6 +119,12 @@
prometheus-metrics-model${project.version}
+
+ io.prometheus
+ prometheus-metrics-otel-support
+ ${project.version}
+ pom
+ io.prometheusprometheus-metrics-simpleclient-bridge
diff --git a/prometheus-metrics-config/pom.xml b/prometheus-metrics-config/pom.xml
index a07c83f0b..681f4a557 100644
--- a/prometheus-metrics-config/pom.xml
+++ b/prometheus-metrics-config/pom.xml
@@ -1,12 +1,11 @@
-
+4.0.0io.prometheusclient_java
- 1.5.0-SNAPSHOT
+ 1.8.1-SNAPSHOTprometheus-metrics-config
@@ -21,4 +20,19 @@
io.prometheus.metrics.config
+
+
+ io.prometheus
+ prometheus-metrics-annotations
+ ${project.version}
+ true
+
+
+ org.junit-pioneer
+ junit-pioneer
+ ${junit-pioneer.version}
+ test
+
+
+
diff --git a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/EscapingScheme.java b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/EscapingScheme.java
index 1cd037bf3..1d6bd37cf 100644
--- a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/EscapingScheme.java
+++ b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/EscapingScheme.java
@@ -1,7 +1,9 @@
package io.prometheus.metrics.config;
+import io.prometheus.metrics.annotations.StableApi;
import javax.annotation.Nullable;
+@StableApi
public enum EscapingScheme {
/** NO_ESCAPING indicates that a name will not be escaped. */
ALLOW_UTF8("allow-utf-8"),
diff --git a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ExemplarsProperties.java b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ExemplarsProperties.java
index 20933bfdc..93190ef19 100644
--- a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ExemplarsProperties.java
+++ b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ExemplarsProperties.java
@@ -1,15 +1,16 @@
package io.prometheus.metrics.config;
-import java.util.Map;
+import io.prometheus.metrics.annotations.StableApi;
import javax.annotation.Nullable;
/** Properties starting with io.prometheus.exemplars */
+@StableApi
public class ExemplarsProperties {
private static final String PREFIX = "io.prometheus.exemplars";
- private static final String MIN_RETENTION_PERIOD_SECONDS = "minRetentionPeriodSeconds";
- private static final String MAX_RETENTION_PERIOD_SECONDS = "maxRetentionPeriodSeconds";
- private static final String SAMPLE_INTERVAL_MILLISECONDS = "sampleIntervalMilliseconds";
+ private static final String MIN_RETENTION_PERIOD_SECONDS = "min_retention_period_seconds";
+ private static final String MAX_RETENTION_PERIOD_SECONDS = "max_retention_period_seconds";
+ private static final String SAMPLE_INTERVAL_MILLISECONDS = "sample_interval_milliseconds";
@Nullable private final Integer minRetentionPeriodSeconds;
@Nullable private final Integer maxRetentionPeriodSeconds;
@@ -57,17 +58,17 @@ public Integer getSampleIntervalMilliseconds() {
}
/**
- * Note that this will remove entries from {@code properties}. This is because we want to know if
- * there are unused properties remaining after all properties have been loaded.
+ * Note that this will remove entries from {@code propertySource}. This is because we want to know
+ * if there are unused properties remaining after all properties have been loaded.
*/
- static ExemplarsProperties load(Map