diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index b29b53789..d101f7abf 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -34,6 +34,11 @@ "mise" ] }, + ".github/workflows/micrometer-compatibility.yml": { + "regex": [ + "mise" + ] + }, ".github/workflows/native-tests.yml": { "regex": [ "mise" diff --git a/.github/workflows/micrometer-compatibility.yml b/.github/workflows/micrometer-compatibility.yml new file mode 100644 index 000000000..e044d5b2a --- /dev/null +++ b/.github/workflows/micrometer-compatibility.yml @@ -0,0 +1,43 @@ +--- +name: Micrometer Compatibility + +on: + pull_request: + workflow_dispatch: + inputs: + micrometer-repository: + description: Micrometer repository to test, in owner/name form + required: false + default: micrometer-metrics/micrometer + micrometer-ref: + description: Micrometer branch, tag, or commit to test + required: false + default: main + +permissions: {} + +jobs: + micrometer-compatibility: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 + with: + version: v2026.4.23 + sha256: 4a650daf1c6db2bb9c32a4d4f6d2389051906f85792d97b04ad10b9f6e212372 + - name: Cache local Maven repository + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + - name: Run Micrometer compatibility tests + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + export MICROMETER_REPOSITORY="${{ github.event.inputs.micrometer-repository }}" + export MICROMETER_REF="${{ github.event.inputs.micrometer-ref }}" + fi + mise run micrometer:test diff --git a/.mise/lib/micrometer_compat.py b/.mise/lib/micrometer_compat.py new file mode 100644 index 000000000..74bbb69a2 --- /dev/null +++ b/.mise/lib/micrometer_compat.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from typing import Optional +import xml.etree.ElementTree as ET + + +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", "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: Optional[Path] = 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 = Path.cwd()) -> str: + configured_version = DEFAULT_PROM_VERSION + if configured_version: + return configured_version + 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: Optional[str] = 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", "codex-micrometer-compat", "FETCH_HEAD"], + cwd=micrometer_dir, + ) + + +def install_local_artifacts(root_dir: Path = Path.cwd()) -> None: + run_cmd( + [ + "./mvnw", + "install", + "-DskipTests", + "-Dcoverage.skip=true", + "-Dcheckstyle.skip=true", + "-Dwarnings=-nowarn", + ], + cwd=root_dir, + ) + + +def run_gradle_test( + test_selector: Optional[str] = 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/micrometer/prepare.py b/.mise/tasks/micrometer/prepare.py new file mode 100755 index 000000000..ba13acf69 --- /dev/null +++ b/.mise/tasks/micrometer/prepare.py @@ -0,0 +1,26 @@ +#!/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-class.py b/.mise/tasks/micrometer/test-class.py new file mode 100755 index 000000000..89ae436de --- /dev/null +++ b/.mise/tasks/micrometer/test-class.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 + +# [MISE] description="Run Micrometer PrometheusMeterRegistryTest against a target Micrometer ref" +# [MISE] alias="micrometer:test-class" + +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("io.micrometer.prometheusmetrics.PrometheusMeterRegistryTest") + 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..40316f569 --- /dev/null +++ b/.mise/tasks/micrometer/test.py @@ -0,0 +1,28 @@ +#!/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.toml b/mise.toml index a6ac6c257..b55b8776e 100644 --- a/mise.toml +++ b/mise.toml @@ -24,6 +24,8 @@ taplo = "0.10.0" [env] FLINT_CONFIG_DIR = ".github/config" +MICROMETER_REPOSITORY = "zeitlinger/micrometer" +MICROMETER_REF = "bea3c3badfaa12f302a5dbbe4ad68cd60ec9d419" # renovate: datasource=github-releases depName=grafana/docker-otel-lgtm LGTM_VERSION = "0.25.0" diff --git a/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java b/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java index 5e58275ca..6dd0bbd31 100644 --- a/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java +++ b/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java @@ -21,6 +21,7 @@ import io.prometheus.metrics.model.snapshots.MetricSnapshot; import io.prometheus.metrics.model.snapshots.MetricSnapshots; import io.prometheus.metrics.model.snapshots.NativeHistogramBuckets; +import io.prometheus.metrics.model.snapshots.PrometheusNaming; import io.prometheus.metrics.model.snapshots.Quantiles; import io.prometheus.metrics.model.snapshots.SnapshotEscaper; import io.prometheus.metrics.model.snapshots.StateSetSnapshot; @@ -82,7 +83,7 @@ public Metrics.MetricFamily convert(MetricSnapshot snapshot, EscapingScheme sche builder.addMetric(convert(data, scheme)); } setMetadataUnlessEmpty( - builder, snapshot.getMetadata(), null, Metrics.MetricType.GAUGE, scheme); + builder, snapshot.getMetadata(), null, Metrics.MetricType.GAUGE, scheme, true); } else if (snapshot instanceof HistogramSnapshot) { HistogramSnapshot histogram = (HistogramSnapshot) snapshot; for (HistogramSnapshot.HistogramDataPointSnapshot data : histogram.getDataPoints()) { @@ -290,25 +291,53 @@ private void setMetadataUnlessEmpty( @Nullable String nameSuffix, Metrics.MetricType type, EscapingScheme scheme) { + setMetadataUnlessEmpty(builder, metadata, nameSuffix, type, scheme, false); + } + + private void setMetadataUnlessEmpty( + Metrics.MetricFamily.Builder builder, + MetricMetadata metadata, + @Nullable String nameSuffix, + Metrics.MetricType type, + EscapingScheme scheme, + boolean normalizeLegacyGaugeName) { if (builder.getMetricCount() == 0) { return; } - if (nameSuffix == null) { - builder.setName(SnapshotEscaper.getMetadataName(metadata, scheme)); - } else { - String expositionBaseName = SnapshotEscaper.getExpositionBaseMetadataName(metadata, scheme); - if (expositionBaseName.endsWith(nameSuffix)) { - builder.setName(expositionBaseName); - } else { - builder.setName(SnapshotEscaper.getMetadataName(metadata, scheme) + nameSuffix); - } - } + builder.setName( + resolveMetricFamilyName(metadata, nameSuffix, scheme, normalizeLegacyGaugeName)); if (metadata.getHelp() != null) { builder.setHelp(metadata.getHelp()); } builder.setType(type); } + private String resolveMetricFamilyName( + MetricMetadata metadata, + @Nullable String nameSuffix, + EscapingScheme scheme, + boolean normalizeLegacyGaugeName) { + if (normalizeLegacyGaugeName) { + String originalName = metadata.getOriginalName(); + if (originalName.endsWith(".created")) { + return PrometheusNaming.escapeName( + originalName.substring(0, originalName.length() - ".created".length()), scheme); + } + if (originalName.endsWith(".total")) { + return PrometheusNaming.escapeName( + originalName.substring(0, originalName.length() - ".total".length()), scheme); + } + } + if (nameSuffix == null) { + return SnapshotEscaper.getMetadataName(metadata, scheme); + } + String expositionBaseName = SnapshotEscaper.getExpositionBaseMetadataName(metadata, scheme); + if (expositionBaseName.endsWith(nameSuffix)) { + return expositionBaseName; + } + return SnapshotEscaper.getMetadataName(metadata, scheme) + nameSuffix; + } + private long getNativeCount(HistogramSnapshot.HistogramDataPointSnapshot data) { if (data.hasCount()) { return data.getCount(); diff --git a/prometheus-metrics-exposition-formats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesProtobufTest.java b/prometheus-metrics-exposition-formats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesProtobufTest.java index c5fc7bf34..ba0b653c0 100644 --- a/prometheus-metrics-exposition-formats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesProtobufTest.java +++ b/prometheus-metrics-exposition-formats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesProtobufTest.java @@ -239,6 +239,28 @@ void testDifferentMetrics_producesSeparateMetricFamilies() throws IOException { assertThat(gaugeFamily.getMetric(0).getGauge().getValue()).isEqualTo(50.0); } + @Test + void testLegacyGaugeNameWithDotTotal_usesBaseName() throws IOException { + MetricSnapshots snapshots = + MetricSnapshots.of( + GaugeSnapshot.builder() + .name("legacy.total") + .dataPoint(GaugeSnapshot.GaugeDataPointSnapshot.builder().value(7).build()) + .build()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + PrometheusProtobufWriterImpl writer = new PrometheusProtobufWriterImpl(); + writer.write(out, snapshots, EscapingScheme.UNDERSCORE_ESCAPING); + + List metricFamilies = parseProtobufOutput(out); + + assertThat(metricFamilies).hasSize(1); + Metrics.MetricFamily family = metricFamilies.get(0); + assertThat(family.getName()).isEqualTo("legacy"); + assertThat(family.getType()).isEqualTo(Metrics.MetricType.GAUGE); + assertThat(family.getMetricCount()).isEqualTo(1); + assertThat(family.getMetric(0).getGauge().getValue()).isEqualTo(7.0); + } + private static MetricSnapshots getMetricSnapshots() { PrometheusRegistry registry = new PrometheusRegistry(); diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusTextFormatWriter.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusTextFormatWriter.java index b40dcfdf2..af4300c94 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusTextFormatWriter.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusTextFormatWriter.java @@ -192,9 +192,10 @@ private void writeCounter(Writer writer, CounterSnapshot snapshot, EscapingSchem private void writeGauge(Writer writer, GaugeSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); - writeMetadata(writer, "", "gauge", metadata, scheme); + String gaugeName = resolveLegacyGaugeName(metadata, scheme); + writeMetadataWithFullName(writer, gaugeName, "gauge", metadata); for (GaugeSnapshot.GaugeDataPointSnapshot data : snapshot.getDataPoints()) { - writeNameAndLabels(writer, getMetadataName(metadata, scheme), null, data.getLabels(), scheme); + writeNameAndLabels(writer, gaugeName, null, data.getLabels(), scheme); writeDouble(writer, data.getValue()); writeScrapeTimestampAndNewline(writer, data); } @@ -475,6 +476,19 @@ private static String resolveBaseName(String fullName, String suffix) { return fullName; } + private static String resolveLegacyGaugeName(MetricMetadata metadata, EscapingScheme scheme) { + String originalName = metadata.getOriginalName(); + if (originalName.endsWith(".created")) { + return PrometheusNaming.escapeName( + originalName.substring(0, originalName.length() - ".created".length()), scheme); + } + if (originalName.endsWith(".total")) { + return PrometheusNaming.escapeName( + originalName.substring(0, originalName.length() - ".total".length()), scheme); + } + return getMetadataName(metadata, scheme); + } + private void writeEscapedHelp(Writer writer, String s) throws IOException { for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); diff --git a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java index 454b3ef7b..3deeb6b37 100644 --- a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java +++ b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java @@ -666,6 +666,63 @@ void testGaugeWithDots() throws IOException { assertPrometheusProtobuf(prometheusProtobuf, gauge); } + @Test + void testGaugeReservedSuffixCompatibilityOutsideOpenMetrics() throws IOException { + GaugeSnapshot createdGauge = + GaugeSnapshot.builder() + .name("test3.created") + .dataPoint(GaugeDataPointSnapshot.builder().value(3).build()) + .build(); + assertOpenMetricsText( + """ + # TYPE U__test3_2e_created gauge + U__test3_2e_created 3.0 + # EOF + """, + createdGauge); + assertPrometheusText( + """ + # TYPE test3 gauge + test3 3.0 + """, + createdGauge); + assertPrometheusTextWithoutCreated( + """ + # TYPE test3 gauge + test3 3.0 + """, + createdGauge); + assertPrometheusProtobuf( + "name: \"test3\" type: GAUGE metric { gauge { value: 3.0 } }", createdGauge); + + GaugeSnapshot totalGauge = + GaugeSnapshot.builder() + .name("test6.total") + .dataPoint(GaugeDataPointSnapshot.builder().value(6).build()) + .build(); + assertOpenMetricsText( + """ + # TYPE U__test6_2e_total gauge + U__test6_2e_total 6.0 + # EOF + """, + totalGauge); + assertPrometheusText( + """ + # TYPE test6 gauge + test6 6.0 + """, + totalGauge); + assertPrometheusTextWithoutCreated( + """ + # TYPE test6 gauge + test6 6.0 + """, + totalGauge); + assertPrometheusProtobuf( + "name: \"test6\" type: GAUGE metric { gauge { value: 6.0 } }", totalGauge); + } + @Test void testGaugeUTF8() throws IOException { String prometheusText =