diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index adfef7f3e..3903cf4ed 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -36,6 +36,11 @@ "mise" ] }, + ".github/workflows/micrometer-compatibility.yml": { + "regex": [ + "mise" + ] + }, ".github/workflows/lint.yml": { "regex": [ "mise" @@ -125,6 +130,7 @@ "hugo", "java", "lychee", + "maven", "node", "npm:renovate", "protoc", diff --git a/.github/workflows/micrometer-compatibility.yml b/.github/workflows/micrometer-compatibility.yml new file mode 100644 index 000000000..e4399291c --- /dev/null +++ b/.github/workflows/micrometer-compatibility.yml @@ -0,0 +1,43 @@ +--- +name: Micrometer Opt-In Compatibility + +on: + pull_request: + workflow_dispatch: + inputs: + micrometer-repository: + description: Micrometer repository to test, in owner/name form + required: false + default: zeitlinger/micrometer + micrometer-ref: + description: Micrometer branch, tag, or commit to test + required: false + default: feat/prometheus-client-opt-in + +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.5.5 + sha256: 3aaab5c05a8a94a93b42b4f581779bbd5c44ddb251e7f3639fc671ec5c6aab8a + - 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 100755 index 000000000..88b0853a0 --- /dev/null +++ b/.mise/lib/micrometer_compat.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os +import subprocess +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Optional + + +DEFAULT_MICROMETER_DIR = Path( + os.environ.get("MICROMETER_DIR", "/tmp/micrometer-compat") +) +DEFAULT_MICROMETER_REPOSITORY = os.environ.get( + "MICROMETER_REPOSITORY", "zeitlinger/micrometer" +) +DEFAULT_MICROMETER_REMOTE = os.environ.get("MICROMETER_REMOTE", "origin") +DEFAULT_MICROMETER_REF = os.environ.get( + "MICROMETER_REF", "feat/prometheus-client-opt-in" +) +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 f46c0143c..05242c60f 100644 --- a/mise.toml +++ b/mise.toml @@ -2,6 +2,7 @@ "go:github.com/grafana/oats" = "0.6.1" hugo = "0.161.1" java = "temurin-25.0.3+9.0.LTS" +maven = "3.9.15" node = "24.15.0" protoc = "34.1" diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/CounterSnapshot.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/CounterSnapshot.java index 72a83a879..e5831168b 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/CounterSnapshot.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/CounterSnapshot.java @@ -179,6 +179,14 @@ public Builder dataPoint(CounterDataPointSnapshot dataPoint) { return this; } + @Override + protected MetricMetadata buildMetadata() { + if (name == null) { + throw new IllegalArgumentException("Missing required field: name is null"); + } + return MetricMetadataSupport.counterMetadata(name, help, unit); + } + @Override public CounterSnapshot build() { return new CounterSnapshot(buildMetadata(), dataPoints); diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/InfoSnapshot.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/InfoSnapshot.java index ca6cf70a0..20f9038b1 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/InfoSnapshot.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/InfoSnapshot.java @@ -118,6 +118,14 @@ public Builder unit(@Nullable Unit unit) { throw new IllegalArgumentException("Info metric cannot have a unit."); } + @Override + protected MetricMetadata buildMetadata() { + if (name == null) { + throw new IllegalArgumentException("Missing required field: name is null"); + } + return MetricMetadataSupport.infoMetadata(name, help); + } + @Override public InfoSnapshot build() { return new InfoSnapshot(buildMetadata(), dataPoints); diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricFamilyDescriptor.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricFamilyDescriptor.java new file mode 100644 index 000000000..ae20a3ca3 --- /dev/null +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricFamilyDescriptor.java @@ -0,0 +1,257 @@ +package io.prometheus.metrics.model.snapshots; + +import io.prometheus.metrics.model.registry.MetricType; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import javax.annotation.Nullable; + +/** Registration-time descriptor for a metric family. */ +public final class MetricFamilyDescriptor { + + private final MetricType type; + private final MetricMetadata metadata; + private final Set labelNames; + + private MetricFamilyDescriptor( + MetricType type, MetricMetadata metadata, Collection labelNames) { + this.type = type; + this.metadata = metadata; + this.labelNames = Collections.unmodifiableSet(new LinkedHashSet<>(labelNames)); + } + + public static Builder of(MetricType type, String name) { + switch (type) { + case COUNTER: + return counter(name); + case GAUGE: + return gauge(name); + case HISTOGRAM: + return histogram(name); + case SUMMARY: + return summary(name); + case INFO: + return info(name); + case STATESET: + return stateSet(name); + case UNKNOWN: + default: + return unknown(name); + } + } + + public static CounterBuilder counter(String name) { + return new CounterBuilder().name(name); + } + + public static GaugeBuilder gauge(String name) { + return new GaugeBuilder().name(name); + } + + public static HistogramBuilder histogram(String name) { + return new HistogramBuilder().name(name); + } + + public static SummaryBuilder summary(String name) { + return new SummaryBuilder().name(name); + } + + public static InfoBuilder info(String name) { + return new InfoBuilder().name(name); + } + + public static StateSetBuilder stateSet(String name) { + return new StateSetBuilder().name(name); + } + + public static UnknownBuilder unknown(String name) { + return new UnknownBuilder().name(name); + } + + public MetricType getType() { + return type; + } + + public MetricMetadata getMetadata() { + return metadata; + } + + public Set getLabelNames() { + return labelNames; + } + + public String getPrometheusName() { + return metadata.getPrometheusName(); + } + + public abstract static class Builder> { + + @Nullable protected String name; + @Nullable protected String help; + @Nullable protected Unit unit; + protected final Set labelNames = new LinkedHashSet<>(); + + public T name(String name) { + this.name = name; + return self(); + } + + public T help(@Nullable String help) { + this.help = help; + return self(); + } + + public T unit(@Nullable Unit unit) { + this.unit = unit; + return self(); + } + + public T labelName(String labelName) { + this.labelNames.add(labelName); + return self(); + } + + public T labelNames(String... labelNames) { + Collections.addAll(this.labelNames, labelNames); + return self(); + } + + public T labelNames(Collection labelNames) { + this.labelNames.addAll(labelNames); + return self(); + } + + public MetricFamilyDescriptor build() { + return new MetricFamilyDescriptor(getType(), buildMetadata(), labelNames); + } + + protected MetricMetadata buildMetadata() { + if (name == null) { + throw new IllegalArgumentException("Missing required field: name is null"); + } + return MetricMetadataSupport.metricMetadata(name, help, unit); + } + + protected abstract MetricType getType(); + + protected abstract T self(); + } + + public static final class CounterBuilder extends Builder { + + @Override + protected MetricMetadata buildMetadata() { + if (name == null) { + throw new IllegalArgumentException("Missing required field: name is null"); + } + return MetricMetadataSupport.counterMetadata(name, help, unit); + } + + @Override + protected MetricType getType() { + return MetricType.COUNTER; + } + + @Override + protected CounterBuilder self() { + return this; + } + } + + public static final class GaugeBuilder extends Builder { + + @Override + protected MetricType getType() { + return MetricType.GAUGE; + } + + @Override + protected GaugeBuilder self() { + return this; + } + } + + public static final class HistogramBuilder extends Builder { + + @Override + protected MetricType getType() { + return MetricType.HISTOGRAM; + } + + @Override + protected HistogramBuilder self() { + return this; + } + } + + public static final class SummaryBuilder extends Builder { + + @Override + protected MetricType getType() { + return MetricType.SUMMARY; + } + + @Override + protected SummaryBuilder self() { + return this; + } + } + + public static final class InfoBuilder extends Builder { + + @Override + public InfoBuilder unit(@Nullable Unit unit) { + throw new IllegalArgumentException("Info metric cannot have a unit."); + } + + @Override + protected MetricMetadata buildMetadata() { + if (name == null) { + throw new IllegalArgumentException("Missing required field: name is null"); + } + return MetricMetadataSupport.infoMetadata(name, help); + } + + @Override + protected MetricType getType() { + return MetricType.INFO; + } + + @Override + protected InfoBuilder self() { + return this; + } + } + + public static final class StateSetBuilder extends Builder { + + @Override + public StateSetBuilder unit(@Nullable Unit unit) { + throw new IllegalArgumentException("State set metric cannot have a unit."); + } + + @Override + protected MetricType getType() { + return MetricType.STATESET; + } + + @Override + protected StateSetBuilder self() { + return this; + } + } + + public static final class UnknownBuilder extends Builder { + + @Override + protected MetricType getType() { + return MetricType.UNKNOWN; + } + + @Override + protected UnknownBuilder self() { + return this; + } + } +} diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadataSupport.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadataSupport.java new file mode 100644 index 000000000..7fa0df6f0 --- /dev/null +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadataSupport.java @@ -0,0 +1,52 @@ +package io.prometheus.metrics.model.snapshots; + +import javax.annotation.Nullable; + +final class MetricMetadataSupport { + + private MetricMetadataSupport() {} + + static MetricMetadata metricMetadata(String name, @Nullable String help, @Nullable Unit unit) { + return new MetricMetadata(name, help, unit); + } + + static MetricMetadata counterMetadata(String name, @Nullable String help, @Nullable Unit unit) { + return typedMetadata(name, help, unit, "_total", ".total"); + } + + static MetricMetadata infoMetadata(String name, @Nullable String help) { + return typedMetadata(name, help, null, "_info", ".info"); + } + + private static MetricMetadata typedMetadata( + String originalName, + @Nullable String help, + @Nullable Unit unit, + String suffix, + String dotSuffix) { + String baseName = stripSuffix(originalName, suffix, dotSuffix); + return new MetricMetadata( + appendUnitIfMissing(baseName, unit), + appendUnitIfMissing(originalName, unit), + originalName, + help, + unit); + } + + private static String appendUnitIfMissing(String name, @Nullable Unit unit) { + if (unit != null && !name.endsWith("_" + unit) && !name.endsWith("." + unit)) { + return name + "_" + unit; + } + return name; + } + + private static String stripSuffix(String name, String suffix, String dotSuffix) { + if (name.endsWith(suffix)) { + return name.substring(0, name.length() - suffix.length()); + } + if (name.endsWith(dotSuffix)) { + return name.substring(0, name.length() - dotSuffix.length()); + } + return name; + } +} diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshot.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshot.java index 4dac2e30e..a5b776ec2 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshot.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshot.java @@ -55,9 +55,9 @@ private static void validateLabels( public abstract static class Builder> { - @Nullable private String name; - @Nullable private String help; - @Nullable private Unit unit; + @Nullable protected String name; + @Nullable protected String help; + @Nullable protected Unit unit; /** * The name is required. If the name is missing or invalid, {@code build()} will throw an {@link @@ -85,7 +85,7 @@ protected MetricMetadata buildMetadata() { if (name == null) { throw new IllegalArgumentException("Missing required field: name is null"); } - return new MetricMetadata(name, help, unit); + return MetricMetadataSupport.metricMetadata(name, help, unit); } protected abstract T self(); diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/CounterSnapshotTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/CounterSnapshotTest.java index 16a324323..a9d9ba000 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/CounterSnapshotTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/CounterSnapshotTest.java @@ -75,6 +75,7 @@ void testMinimalGoodCase() { .dataPoint(CounterDataPointSnapshot.builder().value(1.0).build()) .build(); SnapshotTestUtil.assertMetadata(snapshot, "events", null, null); + SnapshotTestUtil.assertDerivedMetadata(snapshot, "events", "events", "events"); assertThat(snapshot.getDataPoints()).hasSize(1); CounterDataPointSnapshot data = snapshot.getDataPoints().get(0); assertThat((Iterable) data.getLabels()).isEmpty(); @@ -93,7 +94,18 @@ void testEmptyCounter() { @Test void testTotalSuffixPresent() { CounterSnapshot snapshot = CounterSnapshot.builder().name("test_total").build(); - assertThat(snapshot.getMetadata().getPrometheusName()).isEqualTo("test_total"); + assertThat(snapshot.getMetadata().getPrometheusName()).isEqualTo("test"); + SnapshotTestUtil.assertDerivedMetadata(snapshot, "test", "test_total", "test_total"); + } + + @Test + void testCounterUnitDerivedFromTypedBuilder() { + CounterSnapshot snapshot = + CounterSnapshot.builder().name("test_total").unit(Unit.SECONDS).build(); + + SnapshotTestUtil.assertMetadata(snapshot, "test_seconds", null, "seconds"); + SnapshotTestUtil.assertDerivedMetadata( + snapshot, "test_seconds", "test_total_seconds", "test_total"); } @Test diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/GaugeSnapshotTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/GaugeSnapshotTest.java index 7bd965913..6a68ebd88 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/GaugeSnapshotTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/GaugeSnapshotTest.java @@ -87,14 +87,16 @@ void testEmptyGauge() { @Test void testTotalSuffixPresent() { - CounterSnapshot snapshot = CounterSnapshot.builder().name("test_total").build(); + GaugeSnapshot snapshot = GaugeSnapshot.builder().name("test_total").build(); assertThat(snapshot.getMetadata().getPrometheusName()).isEqualTo("test_total"); + SnapshotTestUtil.assertDerivedMetadata(snapshot, "test_total", "test_total", "test_total"); } @Test void testTotalSuffixPresentDot() { - CounterSnapshot snapshot = CounterSnapshot.builder().name("test.total").build(); + GaugeSnapshot snapshot = GaugeSnapshot.builder().name("test.total").build(); assertThat(snapshot.getMetadata().getPrometheusName()).isEqualTo("test_total"); + SnapshotTestUtil.assertDerivedMetadata(snapshot, "test.total", "test.total", "test.total"); } @Test diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/InfoSnapshotTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/InfoSnapshotTest.java index 20353ea3a..065041bc7 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/InfoSnapshotTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/InfoSnapshotTest.java @@ -19,7 +19,7 @@ void testCompleteGoodCase() { .labels(Labels.of("instance_id", "127.0.0.1:9100", "service_name", "gateway")) .build()) .build(); - assertThat(snapshot.getMetadata().getName()).isEqualTo("target"); + SnapshotTestUtil.assertDerivedMetadata(snapshot, "target", "target", "target"); assertThat(snapshot.getMetadata().getHelp()).isEqualTo("Target info"); assertThat(snapshot.getMetadata().hasUnit()).isFalse(); assertThat(snapshot.getDataPoints().size()).isOne(); @@ -62,12 +62,14 @@ void testDataImmutable() { @Test void testNameMayIncludeSuffix() { InfoSnapshot snapshot = InfoSnapshot.builder().name("jvm_info").build(); - assertThat(snapshot.getMetadata().getPrometheusName()).isEqualTo("jvm_info"); + assertThat(snapshot.getMetadata().getPrometheusName()).isEqualTo("jvm"); + SnapshotTestUtil.assertDerivedMetadata(snapshot, "jvm", "jvm_info", "jvm_info"); } @Test void testNameMayIncludeSuffixDot() { InfoSnapshot snapshot = InfoSnapshot.builder().name("jvm.info").build(); - assertThat(snapshot.getMetadata().getPrometheusName()).isEqualTo("jvm_info"); + assertThat(snapshot.getMetadata().getPrometheusName()).isEqualTo("jvm"); + SnapshotTestUtil.assertDerivedMetadata(snapshot, "jvm", "jvm.info", "jvm.info"); } } diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/MetricFamilyDescriptorTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/MetricFamilyDescriptorTest.java new file mode 100644 index 000000000..b909e4ceb --- /dev/null +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/MetricFamilyDescriptorTest.java @@ -0,0 +1,135 @@ +package io.prometheus.metrics.model.snapshots; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.prometheus.metrics.model.registry.MetricType; +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +class MetricFamilyDescriptorTest { + + @Test + void counterDescriptorDerivesMetadata() { + MetricFamilyDescriptor descriptor = + MetricFamilyDescriptor.counter("events_total") + .help("help") + .unit(Unit.SECONDS) + .labelNames(Arrays.asList("method", "status")) + .build(); + + assertThat(descriptor.getType()).isEqualTo(MetricType.COUNTER); + assertThat(descriptor.getPrometheusName()).isEqualTo("events_seconds"); + assertThat(descriptor.getLabelNames()).containsExactly("method", "status"); + assertThat(descriptor.getMetadata().getName()).isEqualTo("events_seconds"); + assertThat(descriptor.getMetadata().getExpositionBaseName()).isEqualTo("events_total_seconds"); + assertThat(descriptor.getMetadata().getOriginalName()).isEqualTo("events_total"); + } + + @Test + void infoDescriptorDerivesMetadata() { + MetricFamilyDescriptor descriptor = + MetricFamilyDescriptor.info("jvm_info").help("JVM info").labelName("vendor").build(); + + assertThat(descriptor.getType()).isEqualTo(MetricType.INFO); + assertThat(descriptor.getPrometheusName()).isEqualTo("jvm"); + assertThat(descriptor.getLabelNames()).containsExactly("vendor"); + assertThat(descriptor.getMetadata().getName()).isEqualTo("jvm"); + assertThat(descriptor.getMetadata().getExpositionBaseName()).isEqualTo("jvm_info"); + assertThat(descriptor.getMetadata().getOriginalName()).isEqualTo("jvm_info"); + } + + @Test + void gaugeDescriptorKeepsLiteralName() { + MetricFamilyDescriptor descriptor = MetricFamilyDescriptor.gauge("test_total").build(); + + assertThat(descriptor.getType()).isEqualTo(MetricType.GAUGE); + assertThat(descriptor.getPrometheusName()).isEqualTo("test_total"); + assertThat(descriptor.getMetadata().getExpositionBaseName()).isEqualTo("test_total"); + assertThat(descriptor.getMetadata().getOriginalName()).isEqualTo("test_total"); + } + + @Test + void histogramDescriptorKeepsLiteralName() { + MetricFamilyDescriptor descriptor = + MetricFamilyDescriptor.histogram("request_duration_seconds") + .help("Request duration") + .labelName("method") + .build(); + + assertThat(descriptor.getType()).isEqualTo(MetricType.HISTOGRAM); + assertThat(descriptor.getPrometheusName()).isEqualTo("request_duration_seconds"); + assertThat(descriptor.getLabelNames()).containsExactly("method"); + } + + @Test + void summaryDescriptorKeepsLiteralName() { + MetricFamilyDescriptor descriptor = + MetricFamilyDescriptor.summary("request_size_bytes") + .help("Request size") + .labelName("method") + .build(); + + assertThat(descriptor.getType()).isEqualTo(MetricType.SUMMARY); + assertThat(descriptor.getPrometheusName()).isEqualTo("request_size_bytes"); + assertThat(descriptor.getLabelNames()).containsExactly("method"); + } + + @Test + void stateSetDescriptorKeepsLiteralName() { + MetricFamilyDescriptor descriptor = + MetricFamilyDescriptor.stateSet("feature_flags").help("Flags").labelName("service").build(); + + assertThat(descriptor.getType()).isEqualTo(MetricType.STATESET); + assertThat(descriptor.getPrometheusName()).isEqualTo("feature_flags"); + assertThat(descriptor.getLabelNames()).containsExactly("service"); + } + + @Test + void unknownDescriptorKeepsLiteralName() { + MetricFamilyDescriptor descriptor = + MetricFamilyDescriptor.unknown("vendor_metric").help("Vendor metric").build(); + + assertThat(descriptor.getType()).isEqualTo(MetricType.UNKNOWN); + assertThat(descriptor.getPrometheusName()).isEqualTo("vendor_metric"); + } + + @Test + void genericFactoryUsesTypedBuilderSemanticsForAllKinds() { + MetricFamilyDescriptor counter = + MetricFamilyDescriptor.of(MetricType.COUNTER, "http_requests_total").build(); + MetricFamilyDescriptor gauge = + MetricFamilyDescriptor.of(MetricType.GAUGE, "queue_depth").build(); + MetricFamilyDescriptor histogram = + MetricFamilyDescriptor.of(MetricType.HISTOGRAM, "request_duration_seconds").build(); + MetricFamilyDescriptor summary = + MetricFamilyDescriptor.of(MetricType.SUMMARY, "request_size_bytes").build(); + MetricFamilyDescriptor info = MetricFamilyDescriptor.of(MetricType.INFO, "build_info").build(); + MetricFamilyDescriptor stateSet = + MetricFamilyDescriptor.of(MetricType.STATESET, "feature_flags").build(); + MetricFamilyDescriptor unknown = + MetricFamilyDescriptor.of(MetricType.UNKNOWN, "vendor_metric").build(); + + assertThat(counter.getPrometheusName()).isEqualTo("http_requests"); + assertThat(gauge.getPrometheusName()).isEqualTo("queue_depth"); + assertThat(histogram.getPrometheusName()).isEqualTo("request_duration_seconds"); + assertThat(summary.getPrometheusName()).isEqualTo("request_size_bytes"); + assertThat(info.getPrometheusName()).isEqualTo("build"); + assertThat(stateSet.getPrometheusName()).isEqualTo("feature_flags"); + assertThat(unknown.getPrometheusName()).isEqualTo("vendor_metric"); + } + + @Test + void infoDescriptorRejectsUnit() { + assertThatThrownBy(() -> MetricFamilyDescriptor.info("jvm_info").unit(Unit.SECONDS)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Info metric cannot have a unit."); + } + + @Test + void stateSetDescriptorRejectsUnit() { + assertThatThrownBy(() -> MetricFamilyDescriptor.stateSet("feature_flags").unit(Unit.SECONDS)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("State set metric cannot have a unit."); + } +} diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/SnapshotTestUtil.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/SnapshotTestUtil.java index 8a8a7f93b..de75eeaeb 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/SnapshotTestUtil.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/SnapshotTestUtil.java @@ -14,4 +14,11 @@ public static void assertMetadata( assertThat(snapshot.getMetadata().getUnit()).isNull(); } } + + public static void assertDerivedMetadata( + MetricSnapshot snapshot, String name, String expositionBaseName, String originalName) { + assertThat(snapshot.getMetadata().getName()).isEqualTo(name); + assertThat(snapshot.getMetadata().getExpositionBaseName()).isEqualTo(expositionBaseName); + assertThat(snapshot.getMetadata().getOriginalName()).isEqualTo(originalName); + } }