From 22c24ade4e2234407f94bc6f19d766373c917d47 Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Mon, 8 Dec 2025 09:22:07 -0500 Subject: [PATCH 01/12] fix formatting Signed-off-by: Jay DeLuca --- .../pom.xml | 58 ++ .../DuplicateMetricsSample.java | 93 +++ .../it/exporter/test/DuplicateMetricsIT.java | 198 +++++ integration-tests/it-exporter/pom.xml | 1 + .../metrics/core/metrics/Counter.java | 6 + .../metrics/core/metrics/Gauge.java | 6 + .../metrics/core/metrics/Histogram.java | 6 + .../prometheus/metrics/core/metrics/Info.java | 6 + .../metrics/core/metrics/StateSet.java | 6 + .../metrics/core/metrics/Summary.java | 6 + .../PrometheusProtobufWriterImpl.java | 7 +- .../DuplicateNamesProtobufTest.java | 298 +++++++ .../OpenMetricsTextFormatWriter.java | 6 +- .../PrometheusTextFormatWriter.java | 9 +- .../expositionformats/TextFormatUtil.java | 96 +++ .../DuplicateNamesExpositionTest.java | 237 ++++++ .../metrics/model/registry/Collector.java | 19 + .../model/registry/MetricIdentifier.java | 57 ++ .../metrics/model/registry/MetricType.java | 30 + .../model/registry/MultiCollector.java | 19 + .../model/registry/PrometheusRegistry.java | 154 +++- .../model/snapshots/MetricSnapshots.java | 34 +- .../registry/PrometheusRegistryTest.java | 785 +++++++++++++++++- 23 files changed, 2065 insertions(+), 72 deletions(-) create mode 100644 integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml create mode 100644 integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/src/main/java/io/prometheus/metrics/it/exporter/duplicatemetrics/DuplicateMetricsSample.java create mode 100644 integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/DuplicateMetricsIT.java create mode 100644 prometheus-metrics-exposition-formats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesProtobufTest.java create mode 100644 prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesExpositionTest.java create mode 100644 prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricIdentifier.java create mode 100644 prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricType.java diff --git a/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml b/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml new file mode 100644 index 000000000..a405ecb56 --- /dev/null +++ b/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + + io.prometheus + it-exporter + 1.5.0-SNAPSHOT + + + it-exporter-duplicate-metrics-sample + + Integration Tests - Duplicate Metrics Sample + + HTTPServer Sample demonstrating duplicate metric names with different label sets + + + + + io.prometheus + prometheus-metrics-exporter-httpserver + ${project.version} + + + io.prometheus + prometheus-metrics-core + ${project.version} + + + + + exporter-duplicate-metrics-sample + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + + + io.prometheus.metrics.it.exporter.duplicatemetrics.DuplicateMetricsSample + + + + + + + + + + diff --git a/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/src/main/java/io/prometheus/metrics/it/exporter/duplicatemetrics/DuplicateMetricsSample.java b/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/src/main/java/io/prometheus/metrics/it/exporter/duplicatemetrics/DuplicateMetricsSample.java new file mode 100644 index 000000000..9fe9416fd --- /dev/null +++ b/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/src/main/java/io/prometheus/metrics/it/exporter/duplicatemetrics/DuplicateMetricsSample.java @@ -0,0 +1,93 @@ +package io.prometheus.metrics.it.exporter.duplicatemetrics; + +import io.prometheus.metrics.core.metrics.Counter; +import io.prometheus.metrics.core.metrics.Gauge; +import io.prometheus.metrics.exporter.httpserver.HTTPServer; +import io.prometheus.metrics.model.snapshots.Unit; +import java.io.IOException; + +/** + * Integration test sample demonstrating metrics with duplicate names but different label sets. This + * validates that the duplicate metrics feature works end-to-end. + */ +public class DuplicateMetricsSample { + + public static void main(String[] args) throws IOException, InterruptedException { + if (args.length < 1 || args.length > 2) { + System.err.println("Usage: java -jar duplicate-metrics-sample.jar [mode]"); + System.err.println("Where mode is optional (ignored for this sample)."); + System.exit(1); + } + + int port = parsePortOrExit(args[0]); + run(port); + } + + private static void run(int port) throws IOException, InterruptedException { + // Register multiple counters with the same Prometheus name "http_requests_total" + // but different label sets + Counter requestsSuccess = + Counter.builder() + .name("http_requests_total") + .help("Total HTTP requests by status") + .labelNames("status", "method") + .register(); + requestsSuccess.labelValues("success", "GET").inc(150); + requestsSuccess.labelValues("success", "POST").inc(45); + + Counter requestsError = + Counter.builder() + .name("http_requests_total") + .help("Total HTTP requests by status") + .labelNames("status", "endpoint") + .register(); + requestsError.labelValues("error", "/api").inc(5); + requestsError.labelValues("error", "/health").inc(2); + + // Register multiple gauges with the same Prometheus name "active_connections" + // but different label sets + Gauge connectionsByRegion = + Gauge.builder() + .name("active_connections") + .help("Active connections") + .labelNames("region", "protocol") + .register(); + connectionsByRegion.labelValues("us-east", "http").set(42); + connectionsByRegion.labelValues("us-west", "http").set(38); + connectionsByRegion.labelValues("eu-west", "https").set(55); + + Gauge connectionsByPool = + Gauge.builder() + .name("active_connections") + .help("Active connections") + .labelNames("pool", "type") + .register(); + connectionsByPool.labelValues("primary", "read").set(30); + connectionsByPool.labelValues("replica", "write").set(10); + + // Also add a regular metric without duplicates for reference + Counter uniqueMetric = + Counter.builder() + .name("unique_metric_total") + .help("A unique metric for reference") + .unit(Unit.BYTES) + .register(); + uniqueMetric.inc(1024); + + HTTPServer server = HTTPServer.builder().port(port).buildAndStart(); + + System.out.println( + "DuplicateMetricsSample listening on http://localhost:" + server.getPort() + "/metrics"); + Thread.currentThread().join(); // wait forever + } + + private static int parsePortOrExit(String port) { + try { + return Integer.parseInt(port); + } catch (NumberFormatException e) { + System.err.println("\"" + port + "\": Invalid port number."); + System.exit(1); + } + return 0; // this won't happen + } +} diff --git a/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/DuplicateMetricsIT.java b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/DuplicateMetricsIT.java new file mode 100644 index 000000000..367a01133 --- /dev/null +++ b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/DuplicateMetricsIT.java @@ -0,0 +1,198 @@ +package io.prometheus.metrics.it.exporter.test; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.prometheus.client.it.common.ExporterTest; +import io.prometheus.metrics.expositionformats.generated.com_google_protobuf_4_33_2.Metrics; +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Integration test for duplicate metric names with different label sets. + * + *

This test validates that: + * + *

    + *
  • Multiple metrics with the same Prometheus name but different labels can be registered + *
  • All exposition formats (text, OpenMetrics, protobuf) correctly merge and expose them + *
  • The merged output is valid and scrapeable by Prometheus + *
+ */ +class DuplicateMetricsIT extends ExporterTest { + + public DuplicateMetricsIT() throws IOException, URISyntaxException { + super("exporter-duplicate-metrics-sample"); + } + + @Test + void testDuplicateMetricsInPrometheusTextFormat() throws IOException { + start(); + Response response = scrape("GET", ""); + assertThat(response.status).isEqualTo(200); + assertContentType( + "text/plain; version=0.0.4; charset=utf-8", response.getHeader("Content-Type")); + + String body = response.stringBody(); + + assertThat(body).contains("# TYPE http_requests_total counter"); + assertThat(body).contains("# HELP http_requests_total Total HTTP requests by status"); + + // Verify all data points from both collectors are present + assertThat(body) + .contains("http_requests_total{method=\"GET\",status=\"success\"} 150.0") + .contains("http_requests_total{method=\"POST\",status=\"success\"} 45.0") + .contains("http_requests_total{endpoint=\"/api\",status=\"error\"} 5.0") + .contains("http_requests_total{endpoint=\"/health\",status=\"error\"} 2.0"); + + assertThat(body).contains("# TYPE active_connections gauge"); + assertThat(body).contains("# HELP active_connections Active connections"); + + assertThat(body) + .contains("active_connections{protocol=\"http\",region=\"us-east\"} 42.0") + .contains("active_connections{protocol=\"http\",region=\"us-west\"} 38.0") + .contains("active_connections{protocol=\"https\",region=\"eu-west\"} 55.0") + .contains("active_connections{pool=\"primary\",type=\"read\"} 30.0") + .contains("active_connections{pool=\"replica\",type=\"write\"} 10.0"); + + assertThat(body).contains("unique_metric_bytes_total 1024.0"); + } + + @Test + void testDuplicateMetricsInOpenMetricsTextFormat() throws IOException { + start(); + Response response = + scrape("GET", "", "Accept", "application/openmetrics-text; version=1.0.0; charset=utf-8"); + assertThat(response.status).isEqualTo(200); + assertContentType( + "application/openmetrics-text; version=1.0.0; charset=utf-8", + response.getHeader("Content-Type")); + + String body = response.stringBody(); + + // Verify http_requests_total is properly merged + assertThat(body).contains("# TYPE http_requests counter"); + assertThat(body) + .contains("http_requests_total{method=\"GET\",status=\"success\"} 150.0") + .contains("http_requests_total{method=\"POST\",status=\"success\"} 45.0") + .contains("http_requests_total{endpoint=\"/api\",status=\"error\"} 5.0") + .contains("http_requests_total{endpoint=\"/health\",status=\"error\"} 2.0"); + + // Verify active_connections is properly merged + assertThat(body).contains("# TYPE active_connections gauge"); + assertThat(body) + .contains("active_connections{protocol=\"http\",region=\"us-east\"} 42.0") + .contains("active_connections{protocol=\"http\",region=\"us-west\"} 38.0") + .contains("active_connections{protocol=\"https\",region=\"eu-west\"} 55.0") + .contains("active_connections{pool=\"primary\",type=\"read\"} 30.0") + .contains("active_connections{pool=\"replica\",type=\"write\"} 10.0"); + + // OpenMetrics format should have UNIT for unique_metric_bytes (base name without _total) + assertThat(body) + .contains("unique_metric_bytes_total 1024.0") + .contains("# UNIT unique_metric_bytes bytes"); + + assertThat(body).endsWith("# EOF\n"); + } + + @Test + void testDuplicateMetricsInPrometheusProtobufFormat() throws IOException { + start(); + Response response = + scrape( + "GET", + "", + "Accept", + "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily;" + + " encoding=delimited"); + assertThat(response.status).isEqualTo(200); + assertContentType( + "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily;" + + " encoding=delimited", + response.getHeader("Content-Type")); + + List metrics = response.protoBody(); + + // Should have exactly 3 metric families (active_connections, http_requests_total, + // unique_metric_bytes_total) + assertThat(metrics).hasSize(3); + + // Metrics are sorted by name + assertThat(metrics.get(0).getName()).isEqualTo("active_connections"); + assertThat(metrics.get(1).getName()).isEqualTo("http_requests_total"); + assertThat(metrics.get(2).getName()).isEqualTo("unique_metric_bytes_total"); + + // Verify active_connections has all 5 data points merged + Metrics.MetricFamily activeConnections = metrics.get(0); + assertThat(activeConnections.getType()).isEqualTo(Metrics.MetricType.GAUGE); + assertThat(activeConnections.getHelp()).isEqualTo("Active connections"); + assertThat(activeConnections.getMetricList()).hasSize(5); + + // Verify http_requests_total has all 4 data points merged + Metrics.MetricFamily httpRequests = metrics.get(1); + assertThat(httpRequests.getType()).isEqualTo(Metrics.MetricType.COUNTER); + assertThat(httpRequests.getHelp()).isEqualTo("Total HTTP requests by status"); + assertThat(httpRequests.getMetricList()).hasSize(4); + + // Verify each data point has the expected labels + boolean foundSuccessGet = false; + boolean foundSuccessPost = false; + boolean foundErrorApi = false; + boolean foundErrorHealth = false; + + for (Metrics.Metric metric : httpRequests.getMetricList()) { + List labels = metric.getLabelList(); + if (hasLabel(labels, "status", "success") && hasLabel(labels, "method", "GET")) { + assertThat(metric.getCounter().getValue()).isEqualTo(150.0); + foundSuccessGet = true; + } else if (hasLabel(labels, "status", "success") && hasLabel(labels, "method", "POST")) { + assertThat(metric.getCounter().getValue()).isEqualTo(45.0); + foundSuccessPost = true; + } else if (hasLabel(labels, "status", "error") && hasLabel(labels, "endpoint", "/api")) { + assertThat(metric.getCounter().getValue()).isEqualTo(5.0); + foundErrorApi = true; + } else if (hasLabel(labels, "status", "error") && hasLabel(labels, "endpoint", "/health")) { + assertThat(metric.getCounter().getValue()).isEqualTo(2.0); + foundErrorHealth = true; + } + } + + assertThat(foundSuccessGet).isTrue(); + assertThat(foundSuccessPost).isTrue(); + assertThat(foundErrorApi).isTrue(); + assertThat(foundErrorHealth).isTrue(); + + // Verify unique metric + Metrics.MetricFamily uniqueMetric = metrics.get(2); + assertThat(uniqueMetric.getType()).isEqualTo(Metrics.MetricType.COUNTER); + assertThat(uniqueMetric.getMetricList()).hasSize(1); + assertThat(uniqueMetric.getMetric(0).getCounter().getValue()).isEqualTo(1024.0); + } + + @Test + void testDuplicateMetricsWithNameFilter() throws IOException { + start(); + // Only scrape http_requests_total + Response response = scrape("GET", nameParam()); + assertThat(response.status).isEqualTo(200); + + String body = response.stringBody(); + + assertThat(body) + .contains("http_requests_total{method=\"GET\",status=\"success\"} 150.0") + .contains("http_requests_total{endpoint=\"/api\",status=\"error\"} 5.0"); + + // Should NOT contain active_connections or unique_metric_total + assertThat(body).doesNotContain("active_connections").doesNotContain("unique_metric_total"); + } + + private boolean hasLabel(List labels, String name, String value) { + return labels.stream() + .anyMatch(label -> label.getName().equals(name) && label.getValue().equals(value)); + } + + private String nameParam() { + return "name[]=" + "http_requests_total"; + } +} diff --git a/integration-tests/it-exporter/pom.xml b/integration-tests/it-exporter/pom.xml index a442b9086..c4a29fe74 100644 --- a/integration-tests/it-exporter/pom.xml +++ b/integration-tests/it-exporter/pom.xml @@ -21,6 +21,7 @@ it-exporter-servlet-tomcat-sample it-exporter-servlet-jetty-sample it-exporter-httpserver-sample + it-exporter-duplicate-metrics-sample it-exporter-no-protobuf it-exporter-test it-no-protobuf-test diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Counter.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Counter.java index a2bac20d2..c5f2f1cff 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Counter.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Counter.java @@ -5,6 +5,7 @@ import io.prometheus.metrics.core.datapoints.CounterDataPoint; import io.prometheus.metrics.core.exemplars.ExemplarSampler; import io.prometheus.metrics.core.exemplars.ExemplarSamplerConfig; +import io.prometheus.metrics.model.registry.MetricType; import io.prometheus.metrics.model.snapshots.CounterSnapshot; import io.prometheus.metrics.model.snapshots.Exemplar; import io.prometheus.metrics.model.snapshots.Labels; @@ -92,6 +93,11 @@ protected CounterSnapshot collect(List labels, List metricDat return new CounterSnapshot(getMetadata(), data); } + @Override + public MetricType getMetricType() { + return MetricType.COUNTER; + } + @Override protected DataPoint newDataPoint() { if (exemplarSamplerConfig != null) { diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Gauge.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Gauge.java index 5850a1cfe..8b1f31409 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Gauge.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Gauge.java @@ -5,6 +5,7 @@ import io.prometheus.metrics.core.datapoints.GaugeDataPoint; import io.prometheus.metrics.core.exemplars.ExemplarSampler; import io.prometheus.metrics.core.exemplars.ExemplarSamplerConfig; +import io.prometheus.metrics.model.registry.MetricType; import io.prometheus.metrics.model.snapshots.Exemplar; import io.prometheus.metrics.model.snapshots.GaugeSnapshot; import io.prometheus.metrics.model.snapshots.Labels; @@ -94,6 +95,11 @@ protected GaugeSnapshot collect(List labels, List metricData) return new GaugeSnapshot(getMetadata(), dataPointSnapshots); } + @Override + public MetricType getMetricType() { + return MetricType.GAUGE; + } + @Override protected DataPoint newDataPoint() { if (exemplarSamplerConfig != null) { diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Histogram.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Histogram.java index 85f6225d3..a2cfd79f3 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Histogram.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Histogram.java @@ -7,6 +7,7 @@ import io.prometheus.metrics.core.exemplars.ExemplarSampler; import io.prometheus.metrics.core.exemplars.ExemplarSamplerConfig; import io.prometheus.metrics.core.util.Scheduler; +import io.prometheus.metrics.model.registry.MetricType; import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets; import io.prometheus.metrics.model.snapshots.Exemplars; import io.prometheus.metrics.model.snapshots.HistogramSnapshot; @@ -649,6 +650,11 @@ protected HistogramSnapshot collect(List labels, List metricD return new HistogramSnapshot(getMetadata(), data); } + @Override + public MetricType getMetricType() { + return MetricType.HISTOGRAM; + } + @Override protected DataPoint newDataPoint() { return new DataPoint(); diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Info.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Info.java index d7aa6be70..011f0bb73 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Info.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Info.java @@ -1,6 +1,7 @@ package io.prometheus.metrics.core.metrics; import io.prometheus.metrics.config.PrometheusProperties; +import io.prometheus.metrics.model.registry.MetricType; import io.prometheus.metrics.model.snapshots.InfoSnapshot; import io.prometheus.metrics.model.snapshots.Labels; import io.prometheus.metrics.model.snapshots.Unit; @@ -105,6 +106,11 @@ public InfoSnapshot collect() { return new InfoSnapshot(getMetadata(), data); } + @Override + public MetricType getMetricType() { + return MetricType.INFO; + } + public static Builder builder() { return new Builder(PrometheusProperties.get()); } diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/StateSet.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/StateSet.java index 4dbaf8ad5..740183f31 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/StateSet.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/StateSet.java @@ -4,6 +4,7 @@ import io.prometheus.metrics.config.PrometheusProperties; import io.prometheus.metrics.core.datapoints.StateSetDataPoint; +import io.prometheus.metrics.model.registry.MetricType; import io.prometheus.metrics.model.snapshots.Labels; import io.prometheus.metrics.model.snapshots.StateSetSnapshot; import java.util.ArrayList; @@ -84,6 +85,11 @@ protected StateSetSnapshot collect(List labels, List metricDa return new StateSetSnapshot(getMetadata(), data); } + @Override + public MetricType getMetricType() { + return MetricType.STATESET; + } + @Override public void setTrue(String state) { getNoLabels().setTrue(state); diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Summary.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Summary.java index 7d964dbb6..bde60771f 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Summary.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Summary.java @@ -7,6 +7,7 @@ import io.prometheus.metrics.core.datapoints.DistributionDataPoint; import io.prometheus.metrics.core.exemplars.ExemplarSampler; import io.prometheus.metrics.core.exemplars.ExemplarSamplerConfig; +import io.prometheus.metrics.model.registry.MetricType; import io.prometheus.metrics.model.snapshots.Exemplars; import io.prometheus.metrics.model.snapshots.Labels; import io.prometheus.metrics.model.snapshots.Quantile; @@ -118,6 +119,11 @@ protected SummarySnapshot collect(List labels, List metricDat return new SummarySnapshot(getMetadata(), data); } + @Override + public MetricType getMetricType() { + return MetricType.SUMMARY; + } + @Override protected DataPoint newDataPoint() { return new DataPoint(); 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 13f7f34b9..76be1d57f 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 @@ -6,6 +6,7 @@ import com.google.protobuf.TextFormat; import io.prometheus.metrics.config.EscapingScheme; import io.prometheus.metrics.expositionformats.ExpositionFormatWriter; +import io.prometheus.metrics.expositionformats.TextFormatUtil; import io.prometheus.metrics.expositionformats.generated.com_google_protobuf_4_33_2.Metrics; import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets; import io.prometheus.metrics.model.snapshots.CounterSnapshot; @@ -43,8 +44,9 @@ public String getContentType() { @Override public String toDebugString(MetricSnapshots metricSnapshots, EscapingScheme escapingScheme) { + MetricSnapshots merged = TextFormatUtil.mergeDuplicates(metricSnapshots); StringBuilder stringBuilder = new StringBuilder(); - for (MetricSnapshot s : metricSnapshots) { + for (MetricSnapshot s : merged) { MetricSnapshot snapshot = SnapshotEscaper.escapeMetricSnapshot(s, escapingScheme); if (!snapshot.getDataPoints().isEmpty()) { stringBuilder.append(TextFormat.printer().printToString(convert(snapshot, escapingScheme))); @@ -57,7 +59,8 @@ public String toDebugString(MetricSnapshots metricSnapshots, EscapingScheme esca public void write( OutputStream out, MetricSnapshots metricSnapshots, EscapingScheme escapingScheme) throws IOException { - for (MetricSnapshot s : metricSnapshots) { + MetricSnapshots merged = TextFormatUtil.mergeDuplicates(metricSnapshots); + for (MetricSnapshot s : merged) { MetricSnapshot snapshot = SnapshotEscaper.escapeMetricSnapshot(s, escapingScheme); if (!snapshot.getDataPoints().isEmpty()) { convert(snapshot, escapingScheme).writeDelimitedTo(out); 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 new file mode 100644 index 000000000..a6a1a33c4 --- /dev/null +++ b/prometheus-metrics-exposition-formats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesProtobufTest.java @@ -0,0 +1,298 @@ +package io.prometheus.metrics.expositionformats; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.prometheus.metrics.config.EscapingScheme; +import io.prometheus.metrics.expositionformats.generated.com_google_protobuf_4_33_2.Metrics; +import io.prometheus.metrics.expositionformats.internal.PrometheusProtobufWriterImpl; +import io.prometheus.metrics.model.registry.Collector; +import io.prometheus.metrics.model.registry.PrometheusRegistry; +import io.prometheus.metrics.model.snapshots.CounterSnapshot; +import io.prometheus.metrics.model.snapshots.GaugeSnapshot; +import io.prometheus.metrics.model.snapshots.Labels; +import io.prometheus.metrics.model.snapshots.MetricSnapshot; +import io.prometheus.metrics.model.snapshots.MetricSnapshots; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class DuplicateNamesProtobufTest { + + private static PrometheusRegistry getPrometheusRegistry() { + PrometheusRegistry registry = new PrometheusRegistry(); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + .value(100) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels( + Labels.of("uri", "/hello", "outcome", "FAILURE", "error", "TIMEOUT")) + .value(10) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }); + return registry; + } + + @Test + void testDuplicateNames_differentLabels_producesSingleMetricFamily() throws IOException { + PrometheusRegistry registry = getPrometheusRegistry(); + + MetricSnapshots snapshots = registry.scrape(); + 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("api_responses_total"); + assertThat(family.getHelp()).isEqualTo("API responses"); + assertThat(family.getType()).isEqualTo(Metrics.MetricType.COUNTER); + assertThat(family.getMetricCount()).isEqualTo(2); + + Metrics.Metric successMetric = + family.getMetricList().stream() + .filter( + m -> + m.getLabelList().stream() + .anyMatch( + l -> l.getName().equals("outcome") && l.getValue().equals("SUCCESS"))) + .findFirst() + .orElseThrow(() -> new AssertionError("SUCCESS metric not found")); + assertThat(successMetric.getCounter().getValue()).isEqualTo(100.0); + + Metrics.Metric failureMetric = + family.getMetricList().stream() + .filter( + m -> + m.getLabelList().stream() + .anyMatch( + l -> + l.getName().equals("outcome") && l.getValue().equals("FAILURE")) + && m.getLabelList().stream() + .anyMatch( + l -> l.getName().equals("error") && l.getValue().equals("TIMEOUT"))) + .findFirst() + .orElseThrow(() -> new AssertionError("FAILURE metric not found")); + assertThat(failureMetric.getCounter().getValue()).isEqualTo(10.0); + } + + @Test + void testDuplicateNames_multipleDataPoints_producesSingleMetricFamily() throws IOException { + PrometheusRegistry registry = new PrometheusRegistry(); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + .value(100) + .build()) + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/world", "outcome", "SUCCESS")) + .value(200) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels( + Labels.of("uri", "/hello", "outcome", "FAILURE", "error", "TIMEOUT")) + .value(10) + .build()) + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels( + Labels.of("uri", "/world", "outcome", "FAILURE", "error", "NOT_FOUND")) + .value(5) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }); + + MetricSnapshots snapshots = registry.scrape(); + 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("api_responses_total"); + assertThat(family.getMetricCount()).isEqualTo(4); + + long successCount = + family.getMetricList().stream() + .filter( + m -> + m.getLabelList().stream() + .anyMatch( + l -> l.getName().equals("outcome") && l.getValue().equals("SUCCESS"))) + .count(); + + long failureCount = + family.getMetricList().stream() + .filter( + m -> + m.getLabelList().stream() + .anyMatch( + l -> l.getName().equals("outcome") && l.getValue().equals("FAILURE"))) + .count(); + + assertThat(successCount).isEqualTo(2); + assertThat(failureCount).isEqualTo(2); + } + + @Test + void testDifferentMetrics_producesSeparateMetricFamilies() throws IOException { + MetricSnapshots snapshots = getMetricSnapshots(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + PrometheusProtobufWriterImpl writer = new PrometheusProtobufWriterImpl(); + writer.write(out, snapshots, EscapingScheme.UNDERSCORE_ESCAPING); + + List metricFamilies = parseProtobufOutput(out); + + assertThat(metricFamilies).hasSize(2); + + Metrics.MetricFamily counterFamily = null; + Metrics.MetricFamily gaugeFamily = null; + for (Metrics.MetricFamily family : metricFamilies) { + if (family.getName().equals("http_requests_total")) { + counterFamily = family; + } else if (family.getName().equals("active_sessions")) { + gaugeFamily = family; + } + } + + assertThat(counterFamily).isNotNull(); + assertThat(counterFamily.getType()).isEqualTo(Metrics.MetricType.COUNTER); + assertThat(counterFamily.getMetricCount()).isEqualTo(1); + assertThat(counterFamily.getMetric(0).getCounter().getValue()).isEqualTo(100.0); + + assertThat(gaugeFamily).isNotNull(); + assertThat(gaugeFamily.getType()).isEqualTo(Metrics.MetricType.GAUGE); + assertThat(gaugeFamily.getMetricCount()).isEqualTo(1); + assertThat(gaugeFamily.getMetric(0).getGauge().getValue()).isEqualTo(50.0); + } + + private static MetricSnapshots getMetricSnapshots() { + PrometheusRegistry registry = new PrometheusRegistry(); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("http_requests") + .help("HTTP Request counter") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("method", "GET")) + .value(100) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "http_requests_total"; + } + }); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return GaugeSnapshot.builder() + .name("active_sessions") + .help("Active sessions gauge") + .dataPoint( + GaugeSnapshot.GaugeDataPointSnapshot.builder() + .labels(Labels.of("method", "POST")) + .value(50) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "active_sessions"; + } + }); + + return registry.scrape(); + } + + private List parseProtobufOutput(ByteArrayOutputStream out) + throws IOException { + List metricFamilies = new ArrayList<>(); + ByteArrayInputStream in = new ByteArrayInputStream(out.toByteArray()); + while (in.available() > 0) { + metricFamilies.add(Metrics.MetricFamily.parseDelimitedFrom(in)); + } + return metricFamilies; + } +} diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetricsTextFormatWriter.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetricsTextFormatWriter.java index 1ba1c627d..73f883a82 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetricsTextFormatWriter.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetricsTextFormatWriter.java @@ -1,5 +1,6 @@ package io.prometheus.metrics.expositionformats; +import static io.prometheus.metrics.expositionformats.TextFormatUtil.mergeDuplicates; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeDouble; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeEscapedString; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeLabels; @@ -112,8 +113,11 @@ public String getContentType() { @Override public void write(OutputStream out, MetricSnapshots metricSnapshots, EscapingScheme scheme) throws IOException { + // Merge duplicate metric names to ensure single HELP/TYPE per metric family + MetricSnapshots merged = mergeDuplicates(metricSnapshots); + Writer writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8)); - for (MetricSnapshot s : metricSnapshots) { + for (MetricSnapshot s : merged) { MetricSnapshot snapshot = SnapshotEscaper.escapeMetricSnapshot(s, scheme); if (!snapshot.getDataPoints().isEmpty()) { if (snapshot instanceof CounterSnapshot) { 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 73d33504e..dde4427fd 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 @@ -1,5 +1,6 @@ package io.prometheus.metrics.expositionformats; +import static io.prometheus.metrics.expositionformats.TextFormatUtil.mergeDuplicates; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeDouble; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeEscapedString; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeLabels; @@ -114,8 +115,12 @@ public void write(OutputStream out, MetricSnapshots metricSnapshots, EscapingSch // See https://prometheus.io/docs/instrumenting/exposition_formats/ // "unknown", "gauge", "counter", "stateset", "info", "histogram", "gaugehistogram", and // "summary". + + // Merge duplicate metric names to ensure single HELP/TYPE per metric family + MetricSnapshots merged = mergeDuplicates(metricSnapshots); + Writer writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8)); - for (MetricSnapshot s : metricSnapshots) { + for (MetricSnapshot s : merged) { MetricSnapshot snapshot = escapeMetricSnapshot(s, scheme); if (!snapshot.getDataPoints().isEmpty()) { if (snapshot instanceof CounterSnapshot) { @@ -136,7 +141,7 @@ public void write(OutputStream out, MetricSnapshots metricSnapshots, EscapingSch } } if (writeCreatedTimestamps) { - for (MetricSnapshot s : metricSnapshots) { + for (MetricSnapshot s : merged) { MetricSnapshot snapshot = escapeMetricSnapshot(s, scheme); if (!snapshot.getDataPoints().isEmpty()) { if (snapshot instanceof CounterSnapshot) { diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/TextFormatUtil.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/TextFormatUtil.java index fb9d3f313..298e9f937 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/TextFormatUtil.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/TextFormatUtil.java @@ -1,11 +1,26 @@ package io.prometheus.metrics.expositionformats; import io.prometheus.metrics.config.EscapingScheme; +import io.prometheus.metrics.model.snapshots.CounterSnapshot; +import io.prometheus.metrics.model.snapshots.DataPointSnapshot; +import io.prometheus.metrics.model.snapshots.GaugeSnapshot; +import io.prometheus.metrics.model.snapshots.HistogramSnapshot; +import io.prometheus.metrics.model.snapshots.InfoSnapshot; import io.prometheus.metrics.model.snapshots.Labels; +import io.prometheus.metrics.model.snapshots.MetricSnapshot; +import io.prometheus.metrics.model.snapshots.MetricSnapshots; import io.prometheus.metrics.model.snapshots.PrometheusNaming; import io.prometheus.metrics.model.snapshots.SnapshotEscaper; +import io.prometheus.metrics.model.snapshots.StateSetSnapshot; +import io.prometheus.metrics.model.snapshots.SummarySnapshot; +import io.prometheus.metrics.model.snapshots.UnknownSnapshot; import java.io.IOException; import java.io.Writer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import javax.annotation.Nullable; public class TextFormatUtil { @@ -155,4 +170,85 @@ static void writeName(Writer writer, String name, NameType nameType) throws IOEx writeEscapedString(writer, name); writer.write('"'); } + + /** + * Merges snapshots with duplicate Prometheus names by combining their data points. This ensures + * only one HELP/TYPE declaration per metric family. + */ + public static MetricSnapshots mergeDuplicates(MetricSnapshots metricSnapshots) { + Map> grouped = new LinkedHashMap<>(); + + // Group snapshots by Prometheus name + for (MetricSnapshot snapshot : metricSnapshots) { + String prometheusName = snapshot.getMetadata().getPrometheusName(); + grouped.computeIfAbsent(prometheusName, k -> new ArrayList<>()).add(snapshot); + } + + // Merge groups with multiple snapshots + MetricSnapshots.Builder builder = MetricSnapshots.builder(); + for (List group : grouped.values()) { + if (group.size() == 1) { + builder.metricSnapshot(group.get(0)); + } else { + // Merge multiple snapshots with same name + MetricSnapshot merged = mergeSnapshots(group); + builder.metricSnapshot(merged); + } + } + + return builder.build(); + } + + /** + * Merges multiple snapshots of the same type into a single snapshot with combined data points. + */ + @SuppressWarnings("unchecked") + private static MetricSnapshot mergeSnapshots(List snapshots) { + if (snapshots.isEmpty()) { + throw new IllegalArgumentException("Cannot merge empty list of snapshots"); + } + + MetricSnapshot first = snapshots.get(0); + if (snapshots.size() == 1) { + return first; + } + + List allDataPoints = new ArrayList<>(); + for (MetricSnapshot snapshot : snapshots) { + allDataPoints.addAll(snapshot.getDataPoints()); + } + + // Create merged snapshot based on type + if (first instanceof CounterSnapshot) { + return new CounterSnapshot( + first.getMetadata(), + (Collection) (Object) allDataPoints); + } else if (first instanceof GaugeSnapshot) { + return new GaugeSnapshot( + first.getMetadata(), + (Collection) (Object) allDataPoints); + } else if (first instanceof HistogramSnapshot) { + return new HistogramSnapshot( + first.getMetadata(), + (Collection) (Object) allDataPoints); + } else if (first instanceof SummarySnapshot) { + return new SummarySnapshot( + first.getMetadata(), + (Collection) (Object) allDataPoints); + } else if (first instanceof InfoSnapshot) { + return new InfoSnapshot( + first.getMetadata(), + (Collection) (Object) allDataPoints); + } else if (first instanceof StateSetSnapshot) { + return new StateSetSnapshot( + first.getMetadata(), + (Collection) (Object) allDataPoints); + } else if (first instanceof UnknownSnapshot) { + return new UnknownSnapshot( + first.getMetadata(), + (Collection) (Object) allDataPoints); + } else { + throw new IllegalArgumentException("Unknown snapshot type: " + first.getClass().getName()); + } + } } diff --git a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesExpositionTest.java b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesExpositionTest.java new file mode 100644 index 000000000..ff2c025f7 --- /dev/null +++ b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesExpositionTest.java @@ -0,0 +1,237 @@ +package io.prometheus.metrics.expositionformats; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.prometheus.metrics.model.registry.Collector; +import io.prometheus.metrics.model.registry.PrometheusRegistry; +import io.prometheus.metrics.model.snapshots.CounterSnapshot; +import io.prometheus.metrics.model.snapshots.Labels; +import io.prometheus.metrics.model.snapshots.MetricSnapshot; +import io.prometheus.metrics.model.snapshots.MetricSnapshots; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class DuplicateNamesExpositionTest { + + private static PrometheusRegistry getPrometheusRegistry() { + PrometheusRegistry registry = new PrometheusRegistry(); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + .value(100) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels( + Labels.of("uri", "/hello", "outcome", "FAILURE", "error", "TIMEOUT")) + .value(10) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }); + return registry; + } + + @Test + void testDuplicateNames_differentLabels_producesValidOutput() throws IOException { + PrometheusRegistry registry = getPrometheusRegistry(); + + MetricSnapshots snapshots = registry.scrape(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + PrometheusTextFormatWriter writer = PrometheusTextFormatWriter.create(); + writer.write(out, snapshots); + String output = out.toString(UTF_8); + + String expected = + """ + # HELP api_responses_total API responses + # TYPE api_responses_total counter + api_responses_total{error="TIMEOUT",outcome="FAILURE",uri="/hello"} 10.0 + api_responses_total{outcome="SUCCESS",uri="/hello"} 100.0 + """; + + assertThat(output).isEqualTo(expected); + } + + @Test + void testDuplicateNames_multipleDataPoints_producesValidOutput() throws IOException { + PrometheusRegistry registry = new PrometheusRegistry(); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + .value(100) + .build()) + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/world", "outcome", "SUCCESS")) + .value(200) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels( + Labels.of("uri", "/hello", "outcome", "FAILURE", "error", "TIMEOUT")) + .value(10) + .build()) + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels( + Labels.of("uri", "/world", "outcome", "FAILURE", "error", "NOT_FOUND")) + .value(5) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }); + + MetricSnapshots snapshots = registry.scrape(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + PrometheusTextFormatWriter writer = PrometheusTextFormatWriter.create(); + writer.write(out, snapshots); + String output = out.toString(UTF_8); + + String expected = + """ + # HELP api_responses_total API responses + # TYPE api_responses_total counter + api_responses_total{error="NOT_FOUND",outcome="FAILURE",uri="/world"} 5.0 + api_responses_total{error="TIMEOUT",outcome="FAILURE",uri="/hello"} 10.0 + api_responses_total{outcome="SUCCESS",uri="/hello"} 100.0 + api_responses_total{outcome="SUCCESS",uri="/world"} 200.0 + """; + assertThat(output).isEqualTo(expected); + } + + @Test + void testOpenMetricsFormat_withDuplicateNames() throws IOException { + PrometheusRegistry registry = getPrometheusRegistry(); + + MetricSnapshots snapshots = registry.scrape(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(false, false); + writer.write(out, snapshots); + String output = out.toString(UTF_8); + + String expected = + """ + # TYPE api_responses counter + # HELP api_responses API responses + api_responses_total{error="TIMEOUT",outcome="FAILURE",uri="/hello"} 10.0 + api_responses_total{outcome="SUCCESS",uri="/hello"} 100.0 + # EOF + """; + assertThat(output).isEqualTo(expected); + } + + @Test + void testDuplicateNames_sameLabels_throwsException() { + PrometheusRegistry registry = new PrometheusRegistry(); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + .value(100) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }); + + // Registration should throw exception due to duplicate time series (same name + same labels) + assertThatThrownBy( + () -> + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + .value(50) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + })) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Duplicate labels detected") + .hasMessageContaining("api_responses"); + } +} diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java index b7154ae70..ef31190f5 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java @@ -78,4 +78,23 @@ default MetricSnapshot collect( default String getPrometheusName() { return null; } + + /** + * Returns the type of metric this collector produces. + * + *

This is used during registration to validate that all collectors with the same Prometheus + * name have the same metric type. If two collectors return the same non-null Prometheus name but + * different metric types, registration will fail with an exception. + * + *

Returning {@code null} means the type is unknown, and type validation will be skipped for + * this collector. + * + *

All metrics in {@code prometheus-metrics-core} override this to return their specific type. + * + * @return the metric type, or {@code null} if unknown + */ + @Nullable + default MetricType getMetricType() { + return null; + } } diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricIdentifier.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricIdentifier.java new file mode 100644 index 000000000..a62e6c439 --- /dev/null +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricIdentifier.java @@ -0,0 +1,57 @@ +package io.prometheus.metrics.model.registry; + +import javax.annotation.Nullable; + +/** + * Identifies a registered metric by its Prometheus name and type. + * + *

Used internally by PrometheusRegistry to track registered metrics and validate that collectors + * with the same Prometheus name have consistent types. + */ +class MetricIdentifier { + private final String prometheusName; + @Nullable private final MetricType type; + + MetricIdentifier(String prometheusName, @Nullable MetricType type) { + this.prometheusName = prometheusName; + this.type = type; + } + + public String getPrometheusName() { + return prometheusName; + } + + @Nullable + public MetricType getType() { + return type; + } + + /** + * Checks if this identifier is compatible with another collector. + * + *

Two identifiers are compatible if: + * + *

    + *
  • They have different Prometheus names, OR + *
  • At least one has a null type (unknown), OR + *
  • They have the same type + *
+ * + * @param other the other identifier to check compatibility with + * @return true if compatible, false if there's a type conflict + */ + public boolean isCompatibleWith(MetricIdentifier other) { + // Different names are always compatible + if (!prometheusName.equals(other.prometheusName)) { + return true; + } + + // If either type is null (unknown), skip validation + if (type == null || other.type == null) { + return true; + } + + // Same name requires same type + return type.equals(other.type); + } +} diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricType.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricType.java new file mode 100644 index 000000000..45106e2fc --- /dev/null +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricType.java @@ -0,0 +1,30 @@ +package io.prometheus.metrics.model.registry; + +/** + * Represents the type of a Prometheus metric. + * + *

This enum is used for early validation when registering collectors with duplicate Prometheus + * names. All collectors with the same Prometheus name must have the same metric type. + */ +public enum MetricType { + /** Counter metric type - monotonically increasing value. */ + COUNTER, + + /** Gauge metric type - value that can go up or down. */ + GAUGE, + + /** Histogram metric type - samples observations and counts them in buckets. */ + HISTOGRAM, + + /** Summary metric type - samples observations and calculates quantiles. */ + SUMMARY, + + /** Info metric type - key-value pairs providing information about the entity. */ + INFO, + + /** StateSet metric type - represents a set of boolean states. */ + STATESET, + + /** Unknown metric type - for custom or legacy collectors. */ + UNKNOWN +} diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java index d1051958d..fe90e87ff 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java @@ -70,4 +70,23 @@ default MetricSnapshots collect( default List getPrometheusNames() { return Collections.emptyList(); } + + /** + * Returns the metric type for a given Prometheus name produced by this MultiCollector. + * + *

This is used during registration to validate that all collectors with the same Prometheus + * name have the same metric type. If this method returns {@code null} for a given name, type + * validation will be skipped for that metric. + * + *

If your collector returns metrics with constant types that do not change at runtime, it is a + * good idea to override this method to enable early type validation at registration time instead + * of at scrape time. + * + * @param prometheusName the Prometheus name to get the type for + * @return the metric type, or {@code null} if unknown + */ + @Nullable + default MetricType getMetricType(String prometheusName) { + return null; + } } diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java index 7db568d95..6b2df933f 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java @@ -1,11 +1,9 @@ package io.prometheus.metrics.model.registry; -import static io.prometheus.metrics.model.snapshots.PrometheusNaming.prometheusName; - +import io.prometheus.metrics.model.snapshots.DataPointSnapshot; import io.prometheus.metrics.model.snapshots.MetricSnapshot; import io.prometheus.metrics.model.snapshots.MetricSnapshots; import java.util.List; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Predicate; @@ -15,52 +13,148 @@ public class PrometheusRegistry { public static final PrometheusRegistry defaultRegistry = new PrometheusRegistry(); - private final Set prometheusNames = ConcurrentHashMap.newKeySet(); private final List collectors = new CopyOnWriteArrayList<>(); private final List multiCollectors = new CopyOnWriteArrayList<>(); + private final ConcurrentHashMap registeredMetrics = + new ConcurrentHashMap<>(); public void register(Collector collector) { - String prometheusName = collector.getPrometheusName(); - if (prometheusName != null) { - if (!prometheusNames.add(prometheusName)) { - throw new IllegalStateException( - "Can't register " - + prometheusName - + " because a metric with that name is already registered."); - } - } + validateTypeConsistency(collector); collectors.add(collector); + cacheMetricIdentifier(collector); } public void register(MultiCollector collector) { - for (String prometheusName : collector.getPrometheusNames()) { - if (!prometheusNames.add(prometheusName)) { - throw new IllegalStateException( - "Can't register " + prometheusName + " because that name is already registered."); + validateTypeConsistency(collector); + multiCollectors.add(collector); + } + + /** + * Validates that the new collector's type is consistent with any existing collectors that have + * the same Prometheus name, and that there are no duplicate label sets. + */ + private void validateTypeConsistency(Collector newCollector) { + String newName = newCollector.getPrometheusName(); + MetricType newType = newCollector.getMetricType(); + + if (newName == null) { + return; + } + + // Validate type consistency if type is provided + if (newType != null) { + MetricIdentifier newIdentifier = new MetricIdentifier(newName, newType); + MetricIdentifier existing = registeredMetrics.get(newName); + if (existing != null && !newIdentifier.isCompatibleWith(existing)) { + throw new IllegalArgumentException( + "Collector with Prometheus name '" + + newName + + "' is already registered with type " + + existing.getType() + + ", but you are trying to register a new collector with type " + + newType + + ". All collectors with the same Prometheus name must have the same type."); + } + } + + // Validate no duplicate labels by collecting and comparing snapshots + MetricSnapshot newSnapshot = newCollector.collect(); + if (newSnapshot != null) { + for (Collector existingCollector : collectors) { + if (newName.equals(existingCollector.getPrometheusName())) { + MetricSnapshot existingSnapshot = existingCollector.collect(); + if (existingSnapshot != null) { + validateNoDuplicateLabels(newSnapshot, existingSnapshot); + } + } } } - multiCollectors.add(collector); + } + + /** + * Validates type consistency for MultiCollector. + * + *

Validates each Prometheus name returned by the MultiCollector. If the MultiCollector + * provides type information via {@link MultiCollector#getMetricType(String)}, validation happens + * at registration time. + */ + private void validateTypeConsistency(MultiCollector newCollector) { + List names = newCollector.getPrometheusNames(); + + for (String name : names) { + MetricType type = newCollector.getMetricType(name); + + if (type == null) { + continue; + } + + MetricIdentifier newIdentifier = new MetricIdentifier(name, type); + + MetricIdentifier existing = registeredMetrics.get(name); + if (existing != null && !newIdentifier.isCompatibleWith(existing)) { + throw new IllegalArgumentException( + "MultiCollector contains a metric with Prometheus name '" + + name + + "' and type " + + type + + ", but a collector with the same name and type " + + existing.getType() + + " is already registered. All collectors with the same Prometheus name must have" + + " the same type."); + } + + registeredMetrics.putIfAbsent(name, newIdentifier); + } + } + + /** + * Validates that two snapshots with the same Prometheus name don't have overlapping label sets. + */ + private void validateNoDuplicateLabels(MetricSnapshot snapshot1, MetricSnapshot snapshot2) { + String metricName = snapshot1.getMetadata().getName(); + + for (DataPointSnapshot dp1 : snapshot1.getDataPoints()) { + for (DataPointSnapshot dp2 : snapshot2.getDataPoints()) { + if (dp1.getLabels().equals(dp2.getLabels())) { + throw new IllegalArgumentException( + "Duplicate labels detected for metric '" + + metricName + + "' with labels " + + dp1.getLabels() + + ". Each time series (metric name + label set) must be unique."); + } + } + } + } + + /** + * Caches the metric identifier for lookup during future registrations. + * + *

Only caches if the collector provides both a Prometheus name and type. + */ + private void cacheMetricIdentifier(Collector collector) { + String name = collector.getPrometheusName(); + MetricType type = collector.getMetricType(); + + if (name != null && type != null) { + registeredMetrics.putIfAbsent(name, new MetricIdentifier(name, type)); + } } public void unregister(Collector collector) { collectors.remove(collector); - String prometheusName = collector.getPrometheusName(); - if (prometheusName != null) { - prometheusNames.remove(collector.getPrometheusName()); - } + // Note: We don't remove from cache because another collector with the same name might exist + // The cache will be cleaned up when clear() is called } public void unregister(MultiCollector collector) { multiCollectors.remove(collector); - for (String prometheusName : collector.getPrometheusNames()) { - prometheusNames.remove(prometheusName(prometheusName)); - } } public void clear() { collectors.clear(); multiCollectors.clear(); - prometheusNames.clear(); + registeredMetrics.clear(); } public MetricSnapshots scrape() { @@ -73,10 +167,6 @@ public MetricSnapshots scrape(@Nullable PrometheusScrapeRequest scrapeRequest) { MetricSnapshot snapshot = scrapeRequest == null ? collector.collect() : collector.collect(scrapeRequest); if (snapshot != null) { - if (result.containsMetricName(snapshot.getMetadata().getName())) { - throw new IllegalStateException( - snapshot.getMetadata().getPrometheusName() + ": duplicate metric name."); - } result.metricSnapshot(snapshot); } } @@ -84,10 +174,6 @@ public MetricSnapshots scrape(@Nullable PrometheusScrapeRequest scrapeRequest) { MetricSnapshots snapshots = scrapeRequest == null ? collector.collect() : collector.collect(scrapeRequest); for (MetricSnapshot snapshot : snapshots) { - if (result.containsMetricName(snapshot.getMetadata().getName())) { - throw new IllegalStateException( - snapshot.getMetadata().getPrometheusName() + ": duplicate metric name."); - } result.metricSnapshot(snapshot); } } diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshots.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshots.java index ecee897e4..cbe39bba2 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshots.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshots.java @@ -7,9 +7,11 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Stream; @@ -28,24 +30,34 @@ public MetricSnapshots(MetricSnapshot... snapshots) { * #builder()}. * * @param snapshots the constructor creates a sorted copy of snapshots. - * @throws IllegalArgumentException if snapshots contains duplicate metric names. To avoid - * duplicate metric names use {@link #builder()} and check {@link - * Builder#containsMetricName(String)} before calling {@link - * Builder#metricSnapshot(MetricSnapshot)}. + * @throws IllegalArgumentException if snapshots with the same Prometheus name have conflicting + * types */ public MetricSnapshots(Collection snapshots) { + validateTypeConsistency(snapshots); List list = new ArrayList<>(snapshots); list.sort(comparing(s -> s.getMetadata().getPrometheusName())); - for (int i = 0; i < snapshots.size() - 1; i++) { - if (list.get(i) - .getMetadata() - .getPrometheusName() - .equals(list.get(i + 1).getMetadata().getPrometheusName())) { + this.snapshots = unmodifiableList(list); + } + + /** Validates that all snapshots with the same Prometheus name have the same type. */ + private static void validateTypeConsistency(Collection snapshots) { + Map> typesByName = new HashMap<>(); + for (MetricSnapshot snapshot : snapshots) { + String prometheusName = snapshot.getMetadata().getPrometheusName(); + Class existingType = typesByName.get(prometheusName); + if (existingType != null && !existingType.equals(snapshot.getClass())) { throw new IllegalArgumentException( - list.get(i).getMetadata().getPrometheusName() + ": duplicate metric name"); + "Conflicting metric types for Prometheus name '" + + prometheusName + + "': " + + existingType.getSimpleName() + + " vs " + + snapshot.getClass().getSimpleName() + + ". All metrics with the same Prometheus name must have the same type."); } + typesByName.put(prometheusName, snapshot.getClass()); } - this.snapshots = unmodifiableList(list); } public static MetricSnapshots of(MetricSnapshot... snapshots) { diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java index 9e87f1fc9..88d89da0a 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java @@ -1,21 +1,22 @@ package io.prometheus.metrics.model.registry; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import io.prometheus.metrics.model.snapshots.CounterSnapshot; import io.prometheus.metrics.model.snapshots.GaugeSnapshot; +import io.prometheus.metrics.model.snapshots.HistogramSnapshot; +import io.prometheus.metrics.model.snapshots.Labels; import io.prometheus.metrics.model.snapshots.MetricSnapshot; import io.prometheus.metrics.model.snapshots.MetricSnapshots; -import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.Test; class PrometheusRegistryTest { - Collector noName = () -> GaugeSnapshot.builder().name("no_name_gauge").build(); - Collector counterA1 = new Collector() { @Override @@ -77,30 +78,110 @@ public MetricSnapshots collect() { @Override public List getPrometheusNames() { - return Arrays.asList(gaugeA.getPrometheusName(), counterB.getPrometheusName()); + return asList(gaugeA.getPrometheusName(), counterB.getPrometheusName()); } }; @Test - public void registerNoName() { + public void register_duplicateName_IsAllowed() { PrometheusRegistry registry = new PrometheusRegistry(); - // If the collector does not have a name at registration time, there is no conflict during - // registration. - registry.register(noName); - registry.register(noName); - // However, at scrape time the collector has to provide a metric name, and then we'll get a - // duplicate name error. - assertThatCode(registry::scrape) - .hasMessageContaining("duplicate") - .hasMessageContaining("no_name_gauge"); + registry.register(counterA1); + registry.register(counterA2); + + MetricSnapshots snapshots = registry.scrape(); + assertThat(snapshots.size()).isEqualTo(2); } @Test - public void registerDuplicateName() { + public void register_duplicateName_differentTypeIsNotAllowed() { + Collector counter = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder().name("my_metric").build(); + } + + @Override + public String getPrometheusName() { + return "my_metric"; + } + + @Override + public MetricType getMetricType() { + return MetricType.COUNTER; + } + }; + + Collector gauge = + new Collector() { + @Override + public MetricSnapshot collect() { + return GaugeSnapshot.builder().name("my_metric").build(); + } + + @Override + public String getPrometheusName() { + return "my_metric"; + } + + @Override + public MetricType getMetricType() { + return MetricType.GAUGE; + } + }; + PrometheusRegistry registry = new PrometheusRegistry(); - registry.register(counterA1); - assertThatExceptionOfType(IllegalStateException.class) - .isThrownBy(() -> registry.register(counterA2)); + registry.register(counter); + + assertThatThrownBy(() -> registry.register(gauge)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Collector with Prometheus name 'my_metric' is already registered with type COUNTER, but you are trying to register a new collector with type GAUGE.") + .hasMessageContaining( + "All collectors with the same Prometheus name must have the same type."); + } + + @Test + public void register_duplicateName_sameLabelsNotAllowed() { + Collector counter1 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("my_metric") + .dataPoint(CounterSnapshot.CounterDataPointSnapshot.builder().value(1).build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "my_metric_total"; + } + }; + + Collector counter2 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("my_metric") + .dataPoint(CounterSnapshot.CounterDataPointSnapshot.builder().value(2).build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "my_metric_total"; + } + }; + + PrometheusRegistry registry = new PrometheusRegistry(); + registry.register(counter1); + + assertThatThrownBy(() -> registry.register(counter2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Duplicate labels detected for metric 'my_metric'") + .hasMessageContaining("Each time series (metric name + label set) must be unique."); } @Test @@ -122,11 +203,13 @@ public void registerOk() { } @Test - public void registerDuplicateMultiCollector() { + public void registerDuplicateMultiCollectorIsAllowed() { PrometheusRegistry registry = new PrometheusRegistry(); registry.register(multiCollector); - assertThatExceptionOfType(IllegalStateException.class) - .isThrownBy(() -> registry.register(multiCollector)); + registry.register(multiCollector); + + MetricSnapshots snapshots = registry.scrape(); + assertThat(snapshots.size()).isEqualTo(4); } @Test @@ -152,4 +235,662 @@ public void clearOk() { registry.clear(); assertThat(registry.scrape().size()).isZero(); } + + @Test + public void duplicateRegistration_multiCollector() { + PrometheusRegistry registry = new PrometheusRegistry(); + registry.register(multiCollector); + assertThatCode(() -> registry.register(multiCollector)).doesNotThrowAnyException(); + } + + @Test + public void duplicateRegistration_mixed() { + // Test mixing regular collectors and multi-collectors with same names + PrometheusRegistry registry = new PrometheusRegistry(); + registry.register(counterA1); + registry.register(counterA2); + registry.register(counterB); + + // Should have 3 collectors registered (2 with same name, 1 different) + MetricSnapshots snapshots = registry.scrape(); + assertThat(snapshots.size()).isEqualTo(3); + } + + @Test + public void allowDuplicateRegistration_scrapeSucceeds() { + PrometheusRegistry registry = new PrometheusRegistry(); + + registry.register(counterA1); + registry.register(counterA2); + + MetricSnapshots snapshots = registry.scrape(); + assertThat(snapshots.size()).isEqualTo(2); + + String firstName = snapshots.get(0).getMetadata().getPrometheusName(); + String secondName = snapshots.get(1).getMetadata().getPrometheusName(); + assertThat(firstName).isEqualTo("counter_a"); + assertThat(secondName).isEqualTo("counter_a"); + } + + @Test + void testDuplicateNames_sameLabels_throwsException() { + PrometheusRegistry registry = new PrometheusRegistry(); + + Collector counter1 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + .value(100) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }; + + Collector counter2 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + .value(50) // Different value! + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }; + + registry.register(counter1); + + // Registration should throw exception due to duplicate time series (same name + same labels) + assertThatThrownBy(() -> registry.register(counter2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Duplicate labels detected") + .hasMessageContaining("api_responses"); + } + + @Test + void testDuplicateNames_multipleDataPoints_scrapesSuccessfully() { + PrometheusRegistry registry = new PrometheusRegistry(); + + Collector counter1 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + .value(100) + .build()) + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/world", "outcome", "SUCCESS")) + .value(200) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }; + + Collector counter2 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels( + Labels.of("uri", "/hello", "outcome", "FAILURE", "error", "TIMEOUT")) + .value(10) + .build()) + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels( + Labels.of("uri", "/world", "outcome", "FAILURE", "error", "NOT_FOUND")) + .value(5) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }; + + registry.register(counter1); + registry.register(counter2); + + MetricSnapshots snapshots = registry.scrape(); + assertThat(snapshots.size()).isEqualTo(2); + + assertThat(snapshots.get(0).getDataPoints()).hasSize(2); + assertThat(snapshots.get(1).getDataPoints()).hasSize(2); + + int totalDataPoints = snapshots.stream().mapToInt(s -> s.getDataPoints().size()).sum(); + assertThat(totalDataPoints).isEqualTo(4); + } + + @Test + void testDuplicateNames_mixedMetricTypes_scrapesSuccessfully() { + PrometheusRegistry registry = new PrometheusRegistry(); + + Collector counter = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("requests") + .help("Request counter") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("method", "GET")) + .value(100) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "requests_total"; + } + }; + + Collector gauge = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("requests") + .help("Request gauge") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("method", "POST")) + .value(50) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "requests"; // Note: Gauge doesn't add _total suffix + } + }; + + // Register both - different prometheus names so shouldn't conflict + registry.register(counter); + registry.register(gauge); + + MetricSnapshots snapshots = registry.scrape(); + assertThat(snapshots.size()).isEqualTo(2); + } + + @Test + void testDuplicateNames_samePrometheusNameDifferentTypes_throwsException() { + PrometheusRegistry registry = new PrometheusRegistry(); + + Collector counter = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_metrics") + .help("API metrics counter") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("method", "GET")) + .value(100) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_metrics"; + } + + @Override + public MetricType getMetricType() { + return MetricType.COUNTER; + } + }; + + Collector gauge = + new Collector() { + @Override + public MetricSnapshot collect() { + return GaugeSnapshot.builder() + .name("api_metrics") + .help("API metrics gauge") + .dataPoint( + GaugeSnapshot.GaugeDataPointSnapshot.builder() + .labels(Labels.of("method", "POST")) + .value(50) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_metrics"; // Same Prometheus name as counter + } + + @Override + public MetricType getMetricType() { + return MetricType.GAUGE; + } + }; + + registry.register(counter); + + // Registration should throw exception due to conflicting metric types + assertThatThrownBy(() -> registry.register(gauge)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("api_metrics") + .hasMessageContaining("COUNTER") + .hasMessageContaining("GAUGE"); + } + + @Test + void testUnregister_withDuplicateNames() { + PrometheusRegistry registry = new PrometheusRegistry(); + + Collector counter1 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder().name("test").build(); + } + + @Override + public String getPrometheusName() { + return "test_total"; + } + }; + + Collector counter2 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder().name("test").build(); + } + + @Override + public String getPrometheusName() { + return "test_total"; + } + }; + + registry.register(counter1); + registry.register(counter2); + assertThat(registry.scrape().size()).isEqualTo(2); + + registry.unregister(counter1); + assertThat(registry.scrape().size()).isEqualTo(1); + + registry.unregister(counter2); + assertThat(registry.scrape().size()).isEqualTo(0); + } + + @Test + void testClear_withDuplicateNames() { + PrometheusRegistry registry = new PrometheusRegistry(); + + Collector counter1 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder().name("test").build(); + } + + @Override + public String getPrometheusName() { + return "test_total"; + } + }; + + Collector counter2 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder().name("test").build(); + } + + @Override + public String getPrometheusName() { + return "test_total"; + } + }; + + registry.register(counter1); + registry.register(counter2); + assertThat(registry.scrape().size()).isEqualTo(2); + + registry.clear(); + assertThat(registry.scrape().size()).isEqualTo(0); + } + + @Test + void testMultiCollector_withTypeInfo_validatesAtRegistration() { + PrometheusRegistry registry = new PrometheusRegistry(); + + // MultiCollector that declares its types + MultiCollector multiCollector = + new MultiCollector() { + @Override + public MetricSnapshots collect() { + return new MetricSnapshots( + CounterSnapshot.builder().name("api_counter").build(), + GaugeSnapshot.builder().name("api_gauge").build()); + } + + @Override + public List getPrometheusNames() { + return asList("api_counter_total", "api_gauge"); + } + + @Override + public MetricType getMetricType(String prometheusName) { + switch (prometheusName) { + case "api_counter_total": + return MetricType.COUNTER; + case "api_gauge": + return MetricType.GAUGE; + default: + return null; + } + } + }; + + assertThatCode(() -> registry.register(multiCollector)).doesNotThrowAnyException(); + + Collector conflictingCounter = + new Collector() { + @Override + public MetricSnapshot collect() { + return GaugeSnapshot.builder().name("api_counter").build(); + } + + @Override + public String getPrometheusName() { + return "api_counter_total"; + } + + @Override + public MetricType getMetricType() { + return MetricType.GAUGE; + } + }; + + assertThatThrownBy(() -> registry.register(conflictingCounter)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("api_counter_total") + .hasMessageContaining("COUNTER") + .hasMessageContaining("GAUGE"); + } + + @Test + void testMultiCollector_withoutTypeInfo_skipsValidation() { + PrometheusRegistry registry = new PrometheusRegistry(); + + // MultiCollector without type info (returns null) + MultiCollector multiCollector = + new MultiCollector() { + @Override + public MetricSnapshots collect() { + return new MetricSnapshots( + CounterSnapshot.builder().name("my_metric").build(), + GaugeSnapshot.builder().name("other_metric").build()); + } + + @Override + public List getPrometheusNames() { + return asList("my_metric_total", "other_metric"); + } + // getMetricType() returns null by default + }; + + // Should register successfully (no validation) + assertThatCode(() -> registry.register(multiCollector)).doesNotThrowAnyException(); + + // Another collector with same name should also succeed (no validation) + Collector collector = + new Collector() { + @Override + public MetricSnapshot collect() { + return GaugeSnapshot.builder().name("my_metric").build(); + } + + @Override + public String getPrometheusName() { + return "my_metric_total"; + } + // getMetricType() returns null by default + }; + + assertThatCode(() -> registry.register(collector)).doesNotThrowAnyException(); + } + + @Test + void testTypeValidation_cacheIsClearedOnClear() { + PrometheusRegistry registry = new PrometheusRegistry(); + + Collector counter = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder().name("my_metric").build(); + } + + @Override + public String getPrometheusName() { + return "my_metric_total"; + } + + @Override + public MetricType getMetricType() { + return MetricType.COUNTER; + } + }; + + registry.register(counter); + + // Clear should remove from cache + registry.clear(); + + // Now we should be able to register a Gauge with the same name + Collector gauge = + new Collector() { + @Override + public MetricSnapshot collect() { + return GaugeSnapshot.builder().name("my_metric").build(); + } + + @Override + public String getPrometheusName() { + return "my_metric_total"; + } + + @Override + public MetricType getMetricType() { + return MetricType.GAUGE; + } + }; + + assertThatCode(() -> registry.register(gauge)).doesNotThrowAnyException(); + } + + @Test + void testMultiCollector_conflictBetweenItsOwnMetrics_detectedAtRegistration() { + PrometheusRegistry registry = new PrometheusRegistry(); + + Collector counter = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder().name("shared").build(); + } + + @Override + public String getPrometheusName() { + return "shared_metric"; + } + + @Override + public MetricType getMetricType() { + return MetricType.COUNTER; + } + }; + + registry.register(counter); + + MultiCollector conflictingMulti = + new MultiCollector() { + @Override + public MetricSnapshots collect() { + return new MetricSnapshots( + GaugeSnapshot.builder().name("shared").build(), + HistogramSnapshot.builder().name("other").build()); + } + + @Override + public List getPrometheusNames() { + return asList("shared_metric", "other_histogram"); + } + + @Override + public MetricType getMetricType(String prometheusName) { + switch (prometheusName) { + case "shared_metric": + return MetricType.GAUGE; // Conflict! + case "other_histogram": + return MetricType.HISTOGRAM; + default: + return null; + } + } + }; + + assertThatThrownBy(() -> registry.register(conflictingMulti)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("shared_metric") + .hasMessageContaining("COUNTER") + .hasMessageContaining("GAUGE"); + } + + @Test + void testMultiCollector_withEmptyNames_noValidation() { + PrometheusRegistry registry = new PrometheusRegistry(); + + // MultiCollector with empty getPrometheusNames() + MultiCollector emptyNamesCollector = + new MultiCollector() { + @Override + public MetricSnapshots collect() { + return new MetricSnapshots(CounterSnapshot.builder().name("dynamic").build()); + } + + @Override + public List getPrometheusNames() { + return emptyList(); // Unknown names + } + + @Override + public MetricType getMetricType(String prometheusName) { + return MetricType.COUNTER; + } + }; + + assertThatCode(() -> registry.register(emptyNamesCollector)).doesNotThrowAnyException(); + } + + @Test + void testTypeValidation_sameTypeAllowed() { + PrometheusRegistry registry = new PrometheusRegistry(); + + Collector counter1 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("requests") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("path", "/api")) + .value(100) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "requests_total"; + } + + @Override + public MetricType getMetricType() { + return MetricType.COUNTER; + } + }; + + registry.register(counter1); + + // same name but different labels - should succeed + Collector counter2 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("requests") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("path", "/web")) + .value(50) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "requests_total"; + } + + @Override + public MetricType getMetricType() { + return MetricType.COUNTER; + } + }; + + assertThatCode(() -> registry.register(counter2)).doesNotThrowAnyException(); + + MetricSnapshots snapshots = registry.scrape(); + assertThat(snapshots.size()).isEqualTo(2); + } } From 7c9c45ae7ad59345b2a2f0fb0d94f73ff851194f Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Tue, 9 Dec 2025 09:08:40 -0500 Subject: [PATCH 02/12] remove unneeded extra line Signed-off-by: Jay DeLuca --- .../it-exporter/it-exporter-duplicate-metrics-sample/pom.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml b/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml index a405ecb56..7b132da00 100644 --- a/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml +++ b/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml @@ -55,4 +55,3 @@ - From b1e342c441222e3ae5db7852a821beb0b1092190 Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Tue, 9 Dec 2025 09:21:47 -0500 Subject: [PATCH 03/12] more lint errors Signed-off-by: Jay DeLuca --- .../it-exporter-duplicate-metrics-sample/pom.xml | 4 +++- .../io/prometheus/metrics/it/springboot/Application.java | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml b/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml index 7b132da00..35900a8f2 100644 --- a/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml +++ b/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml @@ -45,7 +45,9 @@ - io.prometheus.metrics.it.exporter.duplicatemetrics.DuplicateMetricsSample + + io.prometheus.metrics.it.exporter.duplicatemetrics.DuplicateMetricsSample + diff --git a/integration-tests/it-spring-boot-smoke-test/src/main/java/io/prometheus/metrics/it/springboot/Application.java b/integration-tests/it-spring-boot-smoke-test/src/main/java/io/prometheus/metrics/it/springboot/Application.java index 955ea4033..a17bd1266 100644 --- a/integration-tests/it-spring-boot-smoke-test/src/main/java/io/prometheus/metrics/it/springboot/Application.java +++ b/integration-tests/it-spring-boot-smoke-test/src/main/java/io/prometheus/metrics/it/springboot/Application.java @@ -4,9 +4,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class Application { +public final class Application { - public static void main(String[] args) { + private Application() { + // Utility class + } + + public static void main(final String[] args) { SpringApplication.run(Application.class, args); } } From 4e4379b40d6c6a99b841a7a35f4b059484c21bdf Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 9 Dec 2025 18:53:42 +0100 Subject: [PATCH 04/12] fix native test (#1729) Signed-off-by: Gregor Zeitlinger Signed-off-by: Jay DeLuca --- .github/workflows/native-tests.yml | 6 +++--- .mise/envs/native/mise.toml | 8 ++++++++ CONTRIBUTING.md | 2 +- .../it-spring-boot-smoke-test/pom.xml | 15 +++++++++++++++ mise.native.toml | 8 -------- 5 files changed, 27 insertions(+), 12 deletions(-) create mode 100644 .mise/envs/native/mise.toml delete mode 100644 mise.native.toml diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index 0a9b6b940..1450009fa 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -17,7 +17,7 @@ jobs: with: version: v2025.12.0 sha256: 5b7b9992950b65569b4f6f1c78b05abceea2506f15b750a4b9781f33c6cc1ae4 - env: - MISE_ENV: native + working_directory: .mise/envs/native - name: Run native tests - run: mise run test + working-directory: .mise/envs/native + run: mise native-test diff --git a/.mise/envs/native/mise.toml b/.mise/envs/native/mise.toml new file mode 100644 index 000000000..0cb0f1cef --- /dev/null +++ b/.mise/envs/native/mise.toml @@ -0,0 +1,8 @@ +[tools] +java = "graalvm-community-25.0.1" + +[tasks.native-test] +depends = "build" +run = "../../mvnw test -PnativeTest" +dir = "../../../integration-tests/it-spring-boot-smoke-test" + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 50d60bfcc..5be7b073d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,7 @@ 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 diff --git a/integration-tests/it-spring-boot-smoke-test/pom.xml b/integration-tests/it-spring-boot-smoke-test/pom.xml index 747756671..a5b17b39c 100644 --- a/integration-tests/it-spring-boot-smoke-test/pom.xml +++ b/integration-tests/it-spring-boot-smoke-test/pom.xml @@ -136,6 +136,21 @@ --initialize-at-build-time=org.junit.platform.suite.engine.SuiteTestDescriptor$LifecycleMethods + + --initialize-at-build-time=org.junit.platform.commons.logging.LoggerFactory$DelegatingLogger + + + --initialize-at-build-time=org.junit.jupiter.engine.execution.ConditionEvaluator + + + --initialize-at-build-time=org.junit.jupiter.engine.execution.InterceptingExecutableInvoker + + + --initialize-at-build-time=org.junit.jupiter.api.extension.ConditionEvaluationResult + + + --initialize-at-build-time=org.junit.jupiter.engine.execution.InvocationInterceptorChain + diff --git a/mise.native.toml b/mise.native.toml deleted file mode 100644 index 67ad3940f..000000000 --- a/mise.native.toml +++ /dev/null @@ -1,8 +0,0 @@ -[tools] -java = "graalvm-community-24.0.1" - -[tasks.test] -depends = "build" -run = "../../mvnw test -PnativeTest" -dir = "integration-tests/it-spring-boot-smoke-test" - From 959aac71cb74b9987627e828dbae03616db3eb71 Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Tue, 9 Dec 2025 16:03:44 -0500 Subject: [PATCH 05/12] move label validation to scrape time Signed-off-by: Jay DeLuca --- .../DuplicateNamesExpositionTest.java | 46 ++++++++--------- .../model/registry/PrometheusRegistry.java | 35 +------------ .../model/snapshots/MetricSnapshots.java | 51 ++++++++++++++++++- .../registry/PrometheusRegistryTest.java | 50 ++---------------- 4 files changed, 78 insertions(+), 104 deletions(-) diff --git a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesExpositionTest.java b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesExpositionTest.java index ff2c025f7..eeb949c3a 100644 --- a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesExpositionTest.java +++ b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesExpositionTest.java @@ -207,29 +207,29 @@ public String getPrometheusName() { } }); - // Registration should throw exception due to duplicate time series (same name + same labels) - assertThatThrownBy( - () -> - registry.register( - new Collector() { - @Override - public MetricSnapshot collect() { - return CounterSnapshot.builder() - .name("api_responses") - .help("API responses") - .dataPoint( - CounterSnapshot.CounterDataPointSnapshot.builder() - .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) - .value(50) - .build()) - .build(); - } - - @Override - public String getPrometheusName() { - return "api_responses_total"; - } - })) + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + .value(50) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }); + + // Scrape should throw exception due to duplicate time series (same name + same labels) + assertThatThrownBy(() -> registry.scrape()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Duplicate labels detected") .hasMessageContaining("api_responses"); diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java index 6b2df933f..a9e1e3916 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java @@ -1,6 +1,5 @@ package io.prometheus.metrics.model.registry; -import io.prometheus.metrics.model.snapshots.DataPointSnapshot; import io.prometheus.metrics.model.snapshots.MetricSnapshot; import io.prometheus.metrics.model.snapshots.MetricSnapshots; import java.util.List; @@ -31,7 +30,7 @@ public void register(MultiCollector collector) { /** * Validates that the new collector's type is consistent with any existing collectors that have - * the same Prometheus name, and that there are no duplicate label sets. + * the same Prometheus name. */ private void validateTypeConsistency(Collector newCollector) { String newName = newCollector.getPrometheusName(); @@ -56,19 +55,6 @@ private void validateTypeConsistency(Collector newCollector) { + ". All collectors with the same Prometheus name must have the same type."); } } - - // Validate no duplicate labels by collecting and comparing snapshots - MetricSnapshot newSnapshot = newCollector.collect(); - if (newSnapshot != null) { - for (Collector existingCollector : collectors) { - if (newName.equals(existingCollector.getPrometheusName())) { - MetricSnapshot existingSnapshot = existingCollector.collect(); - if (existingSnapshot != null) { - validateNoDuplicateLabels(newSnapshot, existingSnapshot); - } - } - } - } } /** @@ -107,25 +93,6 @@ private void validateTypeConsistency(MultiCollector newCollector) { } } - /** - * Validates that two snapshots with the same Prometheus name don't have overlapping label sets. - */ - private void validateNoDuplicateLabels(MetricSnapshot snapshot1, MetricSnapshot snapshot2) { - String metricName = snapshot1.getMetadata().getName(); - - for (DataPointSnapshot dp1 : snapshot1.getDataPoints()) { - for (DataPointSnapshot dp2 : snapshot2.getDataPoints()) { - if (dp1.getLabels().equals(dp2.getLabels())) { - throw new IllegalArgumentException( - "Duplicate labels detected for metric '" - + metricName - + "' with labels " - + dp1.getLabels() - + ". Each time series (metric name + label set) must be unique."); - } - } - } - } /** * Caches the metric identifier for lookup during future registrations. diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshots.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshots.java index cbe39bba2..5cae6fe09 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshots.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshots.java @@ -31,10 +31,11 @@ public MetricSnapshots(MetricSnapshot... snapshots) { * * @param snapshots the constructor creates a sorted copy of snapshots. * @throws IllegalArgumentException if snapshots with the same Prometheus name have conflicting - * types + * types or have duplicate label sets */ public MetricSnapshots(Collection snapshots) { validateTypeConsistency(snapshots); + validateNoDuplicateLabelsAcrossSnapshots(snapshots); List list = new ArrayList<>(snapshots); list.sort(comparing(s -> s.getMetadata().getPrometheusName())); this.snapshots = unmodifiableList(list); @@ -60,6 +61,54 @@ private static void validateTypeConsistency(Collection snapshots } } + /** + * Validates that snapshots with the same Prometheus name don't have overlapping label sets. + * + *

This validation ensures that when multiple collectors with the same metric name are + * registered, each time series (metric name + label set) is unique. Validation happens at scrape + * time rather than registration time for efficiency. + */ + private static void validateNoDuplicateLabelsAcrossSnapshots( + Collection snapshots) { + // Group snapshots by Prometheus name + Map> snapshotsByName = new HashMap<>(); + for (MetricSnapshot snapshot : snapshots) { + String prometheusName = snapshot.getMetadata().getPrometheusName(); + snapshotsByName.computeIfAbsent(prometheusName, k -> new ArrayList<>()).add(snapshot); + } + + // For each group with multiple snapshots, check for duplicate labels + for (Map.Entry> entry : snapshotsByName.entrySet()) { + List group = entry.getValue(); + if (group.size() > 1) { + validateNoDuplicateLabelsInGroup(group); + } + } + } + + /** + * Validates that a group of snapshots with the same Prometheus name don't have duplicate label + * sets. + */ + private static void validateNoDuplicateLabelsInGroup(List snapshots) { + String metricName = snapshots.get(0).getMetadata().getName(); + Set seenLabels = new HashSet<>(); + + for (MetricSnapshot snapshot : snapshots) { + for (DataPointSnapshot dataPoint : snapshot.getDataPoints()) { + Labels labels = dataPoint.getLabels(); + if (!seenLabels.add(labels)) { + throw new IllegalArgumentException( + "Duplicate labels detected for metric '" + + metricName + + "' with labels " + + labels + + ". Each time series (metric name + label set) must be unique."); + } + } + } + } + public static MetricSnapshots of(MetricSnapshot... snapshots) { return new MetricSnapshots(snapshots); } diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java index 88d89da0a..db3412e5a 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java @@ -141,49 +141,6 @@ public MetricType getMetricType() { "All collectors with the same Prometheus name must have the same type."); } - @Test - public void register_duplicateName_sameLabelsNotAllowed() { - Collector counter1 = - new Collector() { - @Override - public MetricSnapshot collect() { - return CounterSnapshot.builder() - .name("my_metric") - .dataPoint(CounterSnapshot.CounterDataPointSnapshot.builder().value(1).build()) - .build(); - } - - @Override - public String getPrometheusName() { - return "my_metric_total"; - } - }; - - Collector counter2 = - new Collector() { - @Override - public MetricSnapshot collect() { - return CounterSnapshot.builder() - .name("my_metric") - .dataPoint(CounterSnapshot.CounterDataPointSnapshot.builder().value(2).build()) - .build(); - } - - @Override - public String getPrometheusName() { - return "my_metric_total"; - } - }; - - PrometheusRegistry registry = new PrometheusRegistry(); - registry.register(counter1); - - assertThatThrownBy(() -> registry.register(counter2)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Duplicate labels detected for metric 'my_metric'") - .hasMessageContaining("Each time series (metric name + label set) must be unique."); - } - @Test public void registerOk() { PrometheusRegistry registry = new PrometheusRegistry(); @@ -307,7 +264,7 @@ public MetricSnapshot collect() { .dataPoint( CounterSnapshot.CounterDataPointSnapshot.builder() .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) - .value(50) // Different value! + .value(50) .build()) .build(); } @@ -319,9 +276,10 @@ public String getPrometheusName() { }; registry.register(counter1); + registry.register(counter2); - // Registration should throw exception due to duplicate time series (same name + same labels) - assertThatThrownBy(() -> registry.register(counter2)) + // Scrape should throw exception due to duplicate time series (same name + same labels) + assertThatThrownBy(registry::scrape) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Duplicate labels detected") .hasMessageContaining("api_responses"); From 130ac5987f2d5f50bc22d77c0abc884905cdb3f8 Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Wed, 10 Dec 2025 15:17:18 -0500 Subject: [PATCH 06/12] start on code review updates Signed-off-by: Jay DeLuca --- .../metrics/model/registry/Collector.java | 4 +--- .../metrics/model/registry/MetricType.java | 9 +-------- .../metrics/model/registry/MultiCollector.java | 5 +---- .../model/registry/PrometheusRegistry.java | 15 +++++++++++++++ 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java index ef31190f5..f3004816f 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java @@ -94,7 +94,5 @@ default String getPrometheusName() { * @return the metric type, or {@code null} if unknown */ @Nullable - default MetricType getMetricType() { - return null; - } + MetricType getMetricType(); } diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricType.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricType.java index 45106e2fc..11d053c21 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricType.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricType.java @@ -1,30 +1,23 @@ package io.prometheus.metrics.model.registry; /** - * Represents the type of a Prometheus metric. + * Represents the type of Prometheus metric. * *

This enum is used for early validation when registering collectors with duplicate Prometheus * names. All collectors with the same Prometheus name must have the same metric type. */ public enum MetricType { - /** Counter metric type - monotonically increasing value. */ COUNTER, - /** Gauge metric type - value that can go up or down. */ GAUGE, - /** Histogram metric type - samples observations and counts them in buckets. */ HISTOGRAM, - /** Summary metric type - samples observations and calculates quantiles. */ SUMMARY, - /** Info metric type - key-value pairs providing information about the entity. */ INFO, - /** StateSet metric type - represents a set of boolean states. */ STATESET, - /** Unknown metric type - for custom or legacy collectors. */ UNKNOWN } diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java index fe90e87ff..908404779 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java @@ -8,7 +8,6 @@ import javax.annotation.Nullable; /** Like {@link Collector}, but collecting multiple Snapshots at once. */ -@FunctionalInterface public interface MultiCollector { /** Called when the Prometheus server scrapes metrics. */ @@ -86,7 +85,5 @@ default List getPrometheusNames() { * @return the metric type, or {@code null} if unknown */ @Nullable - default MetricType getMetricType(String prometheusName) { - return null; - } + MetricType getMetricType(String prometheusName); } diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java index a9e1e3916..3be9164e6 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java @@ -6,6 +6,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Predicate; +import java.util.stream.Collectors; import javax.annotation.Nullable; public class PrometheusRegistry { @@ -26,6 +27,7 @@ public void register(Collector collector) { public void register(MultiCollector collector) { validateTypeConsistency(collector); multiCollectors.add(collector); + cacheMetricIdentifier(collector); } /** @@ -108,6 +110,19 @@ private void cacheMetricIdentifier(Collector collector) { } } + /** + * Caches the metric identifier for lookup during future registrations. + */ + private void cacheMetricIdentifier(MultiCollector collector) { + for (String name : collector.getPrometheusNames()) { + MetricType type = collector.getMetricType(name); + + if (name != null) { + registeredMetrics.putIfAbsent(name, new MetricIdentifier(name, type)); + } + } + } + public void unregister(Collector collector) { collectors.remove(collector); // Note: We don't remove from cache because another collector with the same name might exist From 338ee562cbd204d3f887405491d2b231ebab13da Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Wed, 10 Dec 2025 15:36:29 -0500 Subject: [PATCH 07/12] remove unneeded code since moved validation Signed-off-by: Jay DeLuca --- .../metrics/core/metrics/Counter.java | 6 - .../metrics/core/metrics/Gauge.java | 6 - .../metrics/core/metrics/Histogram.java | 6 - .../prometheus/metrics/core/metrics/Info.java | 6 - .../metrics/core/metrics/StateSet.java | 6 - .../metrics/core/metrics/Summary.java | 6 - .../metrics/model/registry/Collector.java | 17 - .../model/registry/MetricIdentifier.java | 57 --- .../metrics/model/registry/MetricType.java | 23 -- .../model/registry/MultiCollector.java | 18 +- .../model/registry/PrometheusRegistry.java | 104 ------ .../registry/PrometheusRegistryTest.java | 341 +----------------- 12 files changed, 15 insertions(+), 581 deletions(-) delete mode 100644 prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricIdentifier.java delete mode 100644 prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricType.java diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Counter.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Counter.java index c5f2f1cff..a2bac20d2 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Counter.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Counter.java @@ -5,7 +5,6 @@ import io.prometheus.metrics.core.datapoints.CounterDataPoint; import io.prometheus.metrics.core.exemplars.ExemplarSampler; import io.prometheus.metrics.core.exemplars.ExemplarSamplerConfig; -import io.prometheus.metrics.model.registry.MetricType; import io.prometheus.metrics.model.snapshots.CounterSnapshot; import io.prometheus.metrics.model.snapshots.Exemplar; import io.prometheus.metrics.model.snapshots.Labels; @@ -93,11 +92,6 @@ protected CounterSnapshot collect(List labels, List metricDat return new CounterSnapshot(getMetadata(), data); } - @Override - public MetricType getMetricType() { - return MetricType.COUNTER; - } - @Override protected DataPoint newDataPoint() { if (exemplarSamplerConfig != null) { diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Gauge.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Gauge.java index 8b1f31409..5850a1cfe 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Gauge.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Gauge.java @@ -5,7 +5,6 @@ import io.prometheus.metrics.core.datapoints.GaugeDataPoint; import io.prometheus.metrics.core.exemplars.ExemplarSampler; import io.prometheus.metrics.core.exemplars.ExemplarSamplerConfig; -import io.prometheus.metrics.model.registry.MetricType; import io.prometheus.metrics.model.snapshots.Exemplar; import io.prometheus.metrics.model.snapshots.GaugeSnapshot; import io.prometheus.metrics.model.snapshots.Labels; @@ -95,11 +94,6 @@ protected GaugeSnapshot collect(List labels, List metricData) return new GaugeSnapshot(getMetadata(), dataPointSnapshots); } - @Override - public MetricType getMetricType() { - return MetricType.GAUGE; - } - @Override protected DataPoint newDataPoint() { if (exemplarSamplerConfig != null) { diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Histogram.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Histogram.java index a2cfd79f3..85f6225d3 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Histogram.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Histogram.java @@ -7,7 +7,6 @@ import io.prometheus.metrics.core.exemplars.ExemplarSampler; import io.prometheus.metrics.core.exemplars.ExemplarSamplerConfig; import io.prometheus.metrics.core.util.Scheduler; -import io.prometheus.metrics.model.registry.MetricType; import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets; import io.prometheus.metrics.model.snapshots.Exemplars; import io.prometheus.metrics.model.snapshots.HistogramSnapshot; @@ -650,11 +649,6 @@ protected HistogramSnapshot collect(List labels, List metricD return new HistogramSnapshot(getMetadata(), data); } - @Override - public MetricType getMetricType() { - return MetricType.HISTOGRAM; - } - @Override protected DataPoint newDataPoint() { return new DataPoint(); diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Info.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Info.java index 011f0bb73..d7aa6be70 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Info.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Info.java @@ -1,7 +1,6 @@ package io.prometheus.metrics.core.metrics; import io.prometheus.metrics.config.PrometheusProperties; -import io.prometheus.metrics.model.registry.MetricType; import io.prometheus.metrics.model.snapshots.InfoSnapshot; import io.prometheus.metrics.model.snapshots.Labels; import io.prometheus.metrics.model.snapshots.Unit; @@ -106,11 +105,6 @@ public InfoSnapshot collect() { return new InfoSnapshot(getMetadata(), data); } - @Override - public MetricType getMetricType() { - return MetricType.INFO; - } - public static Builder builder() { return new Builder(PrometheusProperties.get()); } diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/StateSet.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/StateSet.java index 740183f31..4dbaf8ad5 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/StateSet.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/StateSet.java @@ -4,7 +4,6 @@ import io.prometheus.metrics.config.PrometheusProperties; import io.prometheus.metrics.core.datapoints.StateSetDataPoint; -import io.prometheus.metrics.model.registry.MetricType; import io.prometheus.metrics.model.snapshots.Labels; import io.prometheus.metrics.model.snapshots.StateSetSnapshot; import java.util.ArrayList; @@ -85,11 +84,6 @@ protected StateSetSnapshot collect(List labels, List metricDa return new StateSetSnapshot(getMetadata(), data); } - @Override - public MetricType getMetricType() { - return MetricType.STATESET; - } - @Override public void setTrue(String state) { getNoLabels().setTrue(state); diff --git a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Summary.java b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Summary.java index bde60771f..7d964dbb6 100644 --- a/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Summary.java +++ b/prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Summary.java @@ -7,7 +7,6 @@ import io.prometheus.metrics.core.datapoints.DistributionDataPoint; import io.prometheus.metrics.core.exemplars.ExemplarSampler; import io.prometheus.metrics.core.exemplars.ExemplarSamplerConfig; -import io.prometheus.metrics.model.registry.MetricType; import io.prometheus.metrics.model.snapshots.Exemplars; import io.prometheus.metrics.model.snapshots.Labels; import io.prometheus.metrics.model.snapshots.Quantile; @@ -119,11 +118,6 @@ protected SummarySnapshot collect(List labels, List metricDat return new SummarySnapshot(getMetadata(), data); } - @Override - public MetricType getMetricType() { - return MetricType.SUMMARY; - } - @Override protected DataPoint newDataPoint() { return new DataPoint(); diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java index f3004816f..b7154ae70 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java @@ -78,21 +78,4 @@ default MetricSnapshot collect( default String getPrometheusName() { return null; } - - /** - * Returns the type of metric this collector produces. - * - *

This is used during registration to validate that all collectors with the same Prometheus - * name have the same metric type. If two collectors return the same non-null Prometheus name but - * different metric types, registration will fail with an exception. - * - *

Returning {@code null} means the type is unknown, and type validation will be skipped for - * this collector. - * - *

All metrics in {@code prometheus-metrics-core} override this to return their specific type. - * - * @return the metric type, or {@code null} if unknown - */ - @Nullable - MetricType getMetricType(); } diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricIdentifier.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricIdentifier.java deleted file mode 100644 index a62e6c439..000000000 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricIdentifier.java +++ /dev/null @@ -1,57 +0,0 @@ -package io.prometheus.metrics.model.registry; - -import javax.annotation.Nullable; - -/** - * Identifies a registered metric by its Prometheus name and type. - * - *

Used internally by PrometheusRegistry to track registered metrics and validate that collectors - * with the same Prometheus name have consistent types. - */ -class MetricIdentifier { - private final String prometheusName; - @Nullable private final MetricType type; - - MetricIdentifier(String prometheusName, @Nullable MetricType type) { - this.prometheusName = prometheusName; - this.type = type; - } - - public String getPrometheusName() { - return prometheusName; - } - - @Nullable - public MetricType getType() { - return type; - } - - /** - * Checks if this identifier is compatible with another collector. - * - *

Two identifiers are compatible if: - * - *

    - *
  • They have different Prometheus names, OR - *
  • At least one has a null type (unknown), OR - *
  • They have the same type - *
- * - * @param other the other identifier to check compatibility with - * @return true if compatible, false if there's a type conflict - */ - public boolean isCompatibleWith(MetricIdentifier other) { - // Different names are always compatible - if (!prometheusName.equals(other.prometheusName)) { - return true; - } - - // If either type is null (unknown), skip validation - if (type == null || other.type == null) { - return true; - } - - // Same name requires same type - return type.equals(other.type); - } -} diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricType.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricType.java deleted file mode 100644 index 11d053c21..000000000 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricType.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.prometheus.metrics.model.registry; - -/** - * Represents the type of Prometheus metric. - * - *

This enum is used for early validation when registering collectors with duplicate Prometheus - * names. All collectors with the same Prometheus name must have the same metric type. - */ -public enum MetricType { - COUNTER, - - GAUGE, - - HISTOGRAM, - - SUMMARY, - - INFO, - - STATESET, - - UNKNOWN -} diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java index 908404779..d1051958d 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java @@ -8,6 +8,7 @@ import javax.annotation.Nullable; /** Like {@link Collector}, but collecting multiple Snapshots at once. */ +@FunctionalInterface public interface MultiCollector { /** Called when the Prometheus server scrapes metrics. */ @@ -69,21 +70,4 @@ default MetricSnapshots collect( default List getPrometheusNames() { return Collections.emptyList(); } - - /** - * Returns the metric type for a given Prometheus name produced by this MultiCollector. - * - *

This is used during registration to validate that all collectors with the same Prometheus - * name have the same metric type. If this method returns {@code null} for a given name, type - * validation will be skipped for that metric. - * - *

If your collector returns metrics with constant types that do not change at runtime, it is a - * good idea to override this method to enable early type validation at registration time instead - * of at scrape time. - * - * @param prometheusName the Prometheus name to get the type for - * @return the metric type, or {@code null} if unknown - */ - @Nullable - MetricType getMetricType(String prometheusName); } diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java index 3be9164e6..3575d05af 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/PrometheusRegistry.java @@ -3,10 +3,8 @@ import io.prometheus.metrics.model.snapshots.MetricSnapshot; import io.prometheus.metrics.model.snapshots.MetricSnapshots; import java.util.List; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Predicate; -import java.util.stream.Collectors; import javax.annotation.Nullable; public class PrometheusRegistry { @@ -15,118 +13,17 @@ public class PrometheusRegistry { private final List collectors = new CopyOnWriteArrayList<>(); private final List multiCollectors = new CopyOnWriteArrayList<>(); - private final ConcurrentHashMap registeredMetrics = - new ConcurrentHashMap<>(); public void register(Collector collector) { - validateTypeConsistency(collector); collectors.add(collector); - cacheMetricIdentifier(collector); } public void register(MultiCollector collector) { - validateTypeConsistency(collector); multiCollectors.add(collector); - cacheMetricIdentifier(collector); - } - - /** - * Validates that the new collector's type is consistent with any existing collectors that have - * the same Prometheus name. - */ - private void validateTypeConsistency(Collector newCollector) { - String newName = newCollector.getPrometheusName(); - MetricType newType = newCollector.getMetricType(); - - if (newName == null) { - return; - } - - // Validate type consistency if type is provided - if (newType != null) { - MetricIdentifier newIdentifier = new MetricIdentifier(newName, newType); - MetricIdentifier existing = registeredMetrics.get(newName); - if (existing != null && !newIdentifier.isCompatibleWith(existing)) { - throw new IllegalArgumentException( - "Collector with Prometheus name '" - + newName - + "' is already registered with type " - + existing.getType() - + ", but you are trying to register a new collector with type " - + newType - + ". All collectors with the same Prometheus name must have the same type."); - } - } - } - - /** - * Validates type consistency for MultiCollector. - * - *

Validates each Prometheus name returned by the MultiCollector. If the MultiCollector - * provides type information via {@link MultiCollector#getMetricType(String)}, validation happens - * at registration time. - */ - private void validateTypeConsistency(MultiCollector newCollector) { - List names = newCollector.getPrometheusNames(); - - for (String name : names) { - MetricType type = newCollector.getMetricType(name); - - if (type == null) { - continue; - } - - MetricIdentifier newIdentifier = new MetricIdentifier(name, type); - - MetricIdentifier existing = registeredMetrics.get(name); - if (existing != null && !newIdentifier.isCompatibleWith(existing)) { - throw new IllegalArgumentException( - "MultiCollector contains a metric with Prometheus name '" - + name - + "' and type " - + type - + ", but a collector with the same name and type " - + existing.getType() - + " is already registered. All collectors with the same Prometheus name must have" - + " the same type."); - } - - registeredMetrics.putIfAbsent(name, newIdentifier); - } - } - - - /** - * Caches the metric identifier for lookup during future registrations. - * - *

Only caches if the collector provides both a Prometheus name and type. - */ - private void cacheMetricIdentifier(Collector collector) { - String name = collector.getPrometheusName(); - MetricType type = collector.getMetricType(); - - if (name != null && type != null) { - registeredMetrics.putIfAbsent(name, new MetricIdentifier(name, type)); - } - } - - /** - * Caches the metric identifier for lookup during future registrations. - */ - private void cacheMetricIdentifier(MultiCollector collector) { - for (String name : collector.getPrometheusNames()) { - MetricType type = collector.getMetricType(name); - - if (name != null) { - registeredMetrics.putIfAbsent(name, new MetricIdentifier(name, type)); - } - } } public void unregister(Collector collector) { collectors.remove(collector); - // Note: We don't remove from cache because another collector with the same name might exist - // The cache will be cleaned up when clear() is called } public void unregister(MultiCollector collector) { @@ -136,7 +33,6 @@ public void unregister(MultiCollector collector) { public void clear() { collectors.clear(); multiCollectors.clear(); - registeredMetrics.clear(); } public MetricSnapshots scrape() { diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java index db3412e5a..41228b831 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java @@ -93,7 +93,7 @@ public void register_duplicateName_IsAllowed() { } @Test - public void register_duplicateName_differentTypeIsNotAllowed() { + public void register_duplicateName_differentType_failsAtScrapeTime() { Collector counter = new Collector() { @Override @@ -105,11 +105,6 @@ public MetricSnapshot collect() { public String getPrometheusName() { return "my_metric"; } - - @Override - public MetricType getMetricType() { - return MetricType.COUNTER; - } }; Collector gauge = @@ -123,22 +118,19 @@ public MetricSnapshot collect() { public String getPrometheusName() { return "my_metric"; } - - @Override - public MetricType getMetricType() { - return MetricType.GAUGE; - } }; PrometheusRegistry registry = new PrometheusRegistry(); registry.register(counter); + registry.register(gauge); // Registration succeeds - assertThatThrownBy(() -> registry.register(gauge)) + // But scrape fails due to type conflict + assertThatThrownBy(registry::scrape) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining( - "Collector with Prometheus name 'my_metric' is already registered with type COUNTER, but you are trying to register a new collector with type GAUGE.") - .hasMessageContaining( - "All collectors with the same Prometheus name must have the same type."); + .hasMessageContaining("Conflicting metric types") + .hasMessageContaining("my_metric") + .hasMessageContaining("CounterSnapshot") + .hasMessageContaining("GaugeSnapshot"); } @Test @@ -411,7 +403,7 @@ public String getPrometheusName() { } @Test - void testDuplicateNames_samePrometheusNameDifferentTypes_throwsException() { + void testDuplicateNames_samePrometheusNameDifferentTypes_throwsExceptionAtScrape() { PrometheusRegistry registry = new PrometheusRegistry(); Collector counter = @@ -433,11 +425,6 @@ public MetricSnapshot collect() { public String getPrometheusName() { return "api_metrics"; } - - @Override - public MetricType getMetricType() { - return MetricType.COUNTER; - } }; Collector gauge = @@ -459,21 +446,17 @@ public MetricSnapshot collect() { public String getPrometheusName() { return "api_metrics"; // Same Prometheus name as counter } - - @Override - public MetricType getMetricType() { - return MetricType.GAUGE; - } }; registry.register(counter); + registry.register(gauge); // Registration succeeds - // Registration should throw exception due to conflicting metric types - assertThatThrownBy(() -> registry.register(gauge)) + // Scrape should throw exception due to conflicting metric types + assertThatThrownBy(registry::scrape) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("api_metrics") - .hasMessageContaining("COUNTER") - .hasMessageContaining("GAUGE"); + .hasMessageContaining("CounterSnapshot") + .hasMessageContaining("GaugeSnapshot"); } @Test @@ -555,300 +538,4 @@ public String getPrometheusName() { assertThat(registry.scrape().size()).isEqualTo(0); } - @Test - void testMultiCollector_withTypeInfo_validatesAtRegistration() { - PrometheusRegistry registry = new PrometheusRegistry(); - - // MultiCollector that declares its types - MultiCollector multiCollector = - new MultiCollector() { - @Override - public MetricSnapshots collect() { - return new MetricSnapshots( - CounterSnapshot.builder().name("api_counter").build(), - GaugeSnapshot.builder().name("api_gauge").build()); - } - - @Override - public List getPrometheusNames() { - return asList("api_counter_total", "api_gauge"); - } - - @Override - public MetricType getMetricType(String prometheusName) { - switch (prometheusName) { - case "api_counter_total": - return MetricType.COUNTER; - case "api_gauge": - return MetricType.GAUGE; - default: - return null; - } - } - }; - - assertThatCode(() -> registry.register(multiCollector)).doesNotThrowAnyException(); - - Collector conflictingCounter = - new Collector() { - @Override - public MetricSnapshot collect() { - return GaugeSnapshot.builder().name("api_counter").build(); - } - - @Override - public String getPrometheusName() { - return "api_counter_total"; - } - - @Override - public MetricType getMetricType() { - return MetricType.GAUGE; - } - }; - - assertThatThrownBy(() -> registry.register(conflictingCounter)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("api_counter_total") - .hasMessageContaining("COUNTER") - .hasMessageContaining("GAUGE"); - } - - @Test - void testMultiCollector_withoutTypeInfo_skipsValidation() { - PrometheusRegistry registry = new PrometheusRegistry(); - - // MultiCollector without type info (returns null) - MultiCollector multiCollector = - new MultiCollector() { - @Override - public MetricSnapshots collect() { - return new MetricSnapshots( - CounterSnapshot.builder().name("my_metric").build(), - GaugeSnapshot.builder().name("other_metric").build()); - } - - @Override - public List getPrometheusNames() { - return asList("my_metric_total", "other_metric"); - } - // getMetricType() returns null by default - }; - - // Should register successfully (no validation) - assertThatCode(() -> registry.register(multiCollector)).doesNotThrowAnyException(); - - // Another collector with same name should also succeed (no validation) - Collector collector = - new Collector() { - @Override - public MetricSnapshot collect() { - return GaugeSnapshot.builder().name("my_metric").build(); - } - - @Override - public String getPrometheusName() { - return "my_metric_total"; - } - // getMetricType() returns null by default - }; - - assertThatCode(() -> registry.register(collector)).doesNotThrowAnyException(); - } - - @Test - void testTypeValidation_cacheIsClearedOnClear() { - PrometheusRegistry registry = new PrometheusRegistry(); - - Collector counter = - new Collector() { - @Override - public MetricSnapshot collect() { - return CounterSnapshot.builder().name("my_metric").build(); - } - - @Override - public String getPrometheusName() { - return "my_metric_total"; - } - - @Override - public MetricType getMetricType() { - return MetricType.COUNTER; - } - }; - - registry.register(counter); - - // Clear should remove from cache - registry.clear(); - - // Now we should be able to register a Gauge with the same name - Collector gauge = - new Collector() { - @Override - public MetricSnapshot collect() { - return GaugeSnapshot.builder().name("my_metric").build(); - } - - @Override - public String getPrometheusName() { - return "my_metric_total"; - } - - @Override - public MetricType getMetricType() { - return MetricType.GAUGE; - } - }; - - assertThatCode(() -> registry.register(gauge)).doesNotThrowAnyException(); - } - - @Test - void testMultiCollector_conflictBetweenItsOwnMetrics_detectedAtRegistration() { - PrometheusRegistry registry = new PrometheusRegistry(); - - Collector counter = - new Collector() { - @Override - public MetricSnapshot collect() { - return CounterSnapshot.builder().name("shared").build(); - } - - @Override - public String getPrometheusName() { - return "shared_metric"; - } - - @Override - public MetricType getMetricType() { - return MetricType.COUNTER; - } - }; - - registry.register(counter); - - MultiCollector conflictingMulti = - new MultiCollector() { - @Override - public MetricSnapshots collect() { - return new MetricSnapshots( - GaugeSnapshot.builder().name("shared").build(), - HistogramSnapshot.builder().name("other").build()); - } - - @Override - public List getPrometheusNames() { - return asList("shared_metric", "other_histogram"); - } - - @Override - public MetricType getMetricType(String prometheusName) { - switch (prometheusName) { - case "shared_metric": - return MetricType.GAUGE; // Conflict! - case "other_histogram": - return MetricType.HISTOGRAM; - default: - return null; - } - } - }; - - assertThatThrownBy(() -> registry.register(conflictingMulti)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("shared_metric") - .hasMessageContaining("COUNTER") - .hasMessageContaining("GAUGE"); - } - - @Test - void testMultiCollector_withEmptyNames_noValidation() { - PrometheusRegistry registry = new PrometheusRegistry(); - - // MultiCollector with empty getPrometheusNames() - MultiCollector emptyNamesCollector = - new MultiCollector() { - @Override - public MetricSnapshots collect() { - return new MetricSnapshots(CounterSnapshot.builder().name("dynamic").build()); - } - - @Override - public List getPrometheusNames() { - return emptyList(); // Unknown names - } - - @Override - public MetricType getMetricType(String prometheusName) { - return MetricType.COUNTER; - } - }; - - assertThatCode(() -> registry.register(emptyNamesCollector)).doesNotThrowAnyException(); - } - - @Test - void testTypeValidation_sameTypeAllowed() { - PrometheusRegistry registry = new PrometheusRegistry(); - - Collector counter1 = - new Collector() { - @Override - public MetricSnapshot collect() { - return CounterSnapshot.builder() - .name("requests") - .dataPoint( - CounterSnapshot.CounterDataPointSnapshot.builder() - .labels(Labels.of("path", "/api")) - .value(100) - .build()) - .build(); - } - - @Override - public String getPrometheusName() { - return "requests_total"; - } - - @Override - public MetricType getMetricType() { - return MetricType.COUNTER; - } - }; - - registry.register(counter1); - - // same name but different labels - should succeed - Collector counter2 = - new Collector() { - @Override - public MetricSnapshot collect() { - return CounterSnapshot.builder() - .name("requests") - .dataPoint( - CounterSnapshot.CounterDataPointSnapshot.builder() - .labels(Labels.of("path", "/web")) - .value(50) - .build()) - .build(); - } - - @Override - public String getPrometheusName() { - return "requests_total"; - } - - @Override - public MetricType getMetricType() { - return MetricType.COUNTER; - } - }; - - assertThatCode(() -> registry.register(counter2)).doesNotThrowAnyException(); - - MetricSnapshots snapshots = registry.scrape(); - assertThat(snapshots.size()).isEqualTo(2); - } } From 758707be96667c51860eb1d68b9aab231a593493 Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Wed, 10 Dec 2025 15:54:26 -0500 Subject: [PATCH 08/12] cleanup and new test Signed-off-by: Jay DeLuca --- .../metrics/model/registry/Collector.java | 1 - .../model/registry/MultiCollector.java | 1 - .../registry/PrometheusRegistryTest.java | 172 +++++++++++++++++- 3 files changed, 170 insertions(+), 4 deletions(-) diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java index b7154ae70..5b1f1da66 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/Collector.java @@ -60,7 +60,6 @@ default MetricSnapshot collect( * This is called in two places: * *

    - *
  1. During registration to check if a metric with that name already exists. *
  2. During scrape to check if this collector can be skipped because a name filter is present * and the metric name is excluded. *
diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java index d1051958d..a3ae8a740 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java @@ -55,7 +55,6 @@ default MetricSnapshots collect( * This is called in two places: * *
    - *
  1. During registration to check if a metric with that name already exists. *
  2. During scrape to check if the collector can be skipped because a name filter is present * and all names are excluded. *
diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java index 41228b831..662f14f44 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/PrometheusRegistryTest.java @@ -1,14 +1,13 @@ package io.prometheus.metrics.model.registry; import static java.util.Arrays.asList; -import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import io.prometheus.metrics.model.snapshots.CounterSnapshot; import io.prometheus.metrics.model.snapshots.GaugeSnapshot; -import io.prometheus.metrics.model.snapshots.HistogramSnapshot; import io.prometheus.metrics.model.snapshots.Labels; import io.prometheus.metrics.model.snapshots.MetricSnapshot; import io.prometheus.metrics.model.snapshots.MetricSnapshots; @@ -538,4 +537,173 @@ public String getPrometheusName() { assertThat(registry.scrape().size()).isEqualTo(0); } + @Test + void testPartialUnregister_scrapeStillWorks() { + PrometheusRegistry registry = new PrometheusRegistry(); + + Collector counter1 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_requests") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("endpoint", "/api/v1")) + .value(100) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_requests_total"; + } + }; + + Collector counter2 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_requests") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("endpoint", "/api/v2")) + .value(200) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_requests_total"; + } + }; + + Collector counter3 = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_requests") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("endpoint", "/api/v3")) + .value(300) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_requests_total"; + } + }; + + registry.register(counter1); + registry.register(counter2); + registry.register(counter3); + assertThat(registry.scrape().size()).isEqualTo(3); + + registry.unregister(counter2); + + MetricSnapshots snapshots = registry.scrape(); + assertThat(snapshots.size()).isEqualTo(2); + + int totalDataPoints = snapshots.stream().mapToInt(s -> s.getDataPoints().size()).sum(); + assertThat(totalDataPoints).isEqualTo(2); + } + + @Test + void testCollectorAndMultiCollectorNameOverlap_sameType() { + PrometheusRegistry registry = new PrometheusRegistry(); + + Collector singleCounter = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("shared_metric") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("source", "collector")) + .value(100) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "shared_metric_total"; + } + }; + + MultiCollector multi = + new MultiCollector() { + @Override + public MetricSnapshots collect() { + return new MetricSnapshots( + CounterSnapshot.builder() + .name("shared_metric") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("source", "multicollector")) + .value(200) + .build()) + .build(), + GaugeSnapshot.builder().name("other_metric").build()); + } + + @Override + public List getPrometheusNames() { + return asList("shared_metric_total", "other_metric"); + } + }; + + registry.register(singleCounter); + registry.register(multi); + + MetricSnapshots snapshots = registry.scrape(); + assertThat(snapshots.size()).isEqualTo(3); + } + + @Test + void testCollectorAndMultiCollectorNameOverlap_differentType() { + PrometheusRegistry registry = new PrometheusRegistry(); + + Collector counter = + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder().name("conflict").build(); + } + + @Override + public String getPrometheusName() { + return "conflict_metric"; + } + }; + + MultiCollector multi = + new MultiCollector() { + @Override + public MetricSnapshots collect() { + return new MetricSnapshots(GaugeSnapshot.builder().name("conflict").build()); + } + + @Override + public List getPrometheusNames() { + return singletonList("conflict_metric"); + } + }; + + registry.register(counter); + registry.register(multi); + + assertThatThrownBy(registry::scrape) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Conflicting metric types") + .hasMessageContaining("conflict"); + } } From 2e8011d0bd1ce15eace6de85133fbf4699867dd4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:21:52 +0000 Subject: [PATCH 09/12] chore(deps): update dependency org.mockito:mockito-core to v5.21.0 (#1731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [org.mockito:mockito-core](https://redirect.github.com/mockito/mockito) | `5.20.0` -> `5.21.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/org.mockito:mockito-core/5.21.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.mockito:mockito-core/5.20.0/5.21.0?slim=true) | --- > [!WARNING] > Some dependencies could not be looked up. Check the Dependency Dashboard for more information. --- ### Release Notes
mockito/mockito (org.mockito:mockito-core) ### [`v5.21.0`](https://redirect.github.com/mockito/mockito/releases/tag/v5.21.0) *Changelog generated by [Shipkit Changelog Gradle Plugin](https://redirect.github.com/shipkit/shipkit-changelog)* ##### 5.21.0 - 2025-12-09 - [17 commit(s)](https://redirect.github.com/mockito/mockito/compare/v5.20.0...v5.21.0) by Giulio Longfils, Joshua Selbo, Woongi9, Zylox, dependabot\[bot] - Bump graalvm/setup-graalvm from 1.4.3 to 1.4.4 [(#​3768)](https://redirect.github.com/mockito/mockito/pull/3768) - Bump graalvm/setup-graalvm from 1.4.2 to 1.4.3 [(#​3767)](https://redirect.github.com/mockito/mockito/pull/3767) - Bump actions/checkout from 5 to 6 [(#​3765)](https://redirect.github.com/mockito/mockito/pull/3765) - Adds output of matchers to potential mismatch; Fixes [#​2468](https://redirect.github.com/mockito/mockito/issues/2468) [(#​3760)](https://redirect.github.com/mockito/mockito/pull/3760) - Forbid mocking WeakReference with inline mock maker [(#​3759)](https://redirect.github.com/mockito/mockito/pull/3759) - StackOverflowError when mocking WeakReference [(#​3758)](https://redirect.github.com/mockito/mockito/issues/3758) - Bump actions/upload-artifact from 4 to 5 [(#​3756)](https://redirect.github.com/mockito/mockito/pull/3756) - Bump graalvm/setup-graalvm from 1.4.1 to 1.4.2 [(#​3755)](https://redirect.github.com/mockito/mockito/pull/3755) - Support primitives in GenericArrayReturnType. [(#​3753)](https://redirect.github.com/mockito/mockito/pull/3753) - ClassNotFoundException when stubbing array of primitive type on Android [(#​3752)](https://redirect.github.com/mockito/mockito/issues/3752) - Bump graalvm/setup-graalvm from 1.4.0 to 1.4.1 [(#​3744)](https://redirect.github.com/mockito/mockito/pull/3744) - Bump gradle/actions from 4 to 5 [(#​3743)](https://redirect.github.com/mockito/mockito/pull/3743) - Bump org.graalvm.buildtools.native from 0.11.0 to 0.11.1 [(#​3738)](https://redirect.github.com/mockito/mockito/pull/3738) - Bump com.diffplug.spotless:spotless-plugin-gradle from 7.2.1 to 8.0.0 [(#​3735)](https://redirect.github.com/mockito/mockito/pull/3735) - Bump graalvm/setup-graalvm from 1.3.7 to 1.4.0 [(#​3734)](https://redirect.github.com/mockito/mockito/pull/3734) - Bump org.assertj:assertj-core from 3.27.5 to 3.27.6 [(#​3733)](https://redirect.github.com/mockito/mockito/pull/3733) - Bump errorprone from 2.41.0 to 2.42.0 [(#​3732)](https://redirect.github.com/mockito/mockito/pull/3732) - Feat: automatically detect class to mock in mockStatic and mockConstruction [(#​3731)](https://redirect.github.com/mockito/mockito/pull/3731) - Return completed futures for unstubbed Future/CompletionStage in ReturnsEmptyValues [(#​3727)](https://redirect.github.com/mockito/mockito/pull/3727) - automatically detect class to mock [(#​2779)](https://redirect.github.com/mockito/mockito/pull/2779) - Incorrect "has following stubbing(s) with different arguments" message when using Argument Matchers [(#​2468)](https://redirect.github.com/mockito/mockito/issues/2468)
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/prometheus/client_java). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: Jay DeLuca --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 50f35c3fe..7eb626e2d 100644 --- a/pom.xml +++ b/pom.xml @@ -381,7 +381,7 @@ org.mockito mockito-core - 5.20.0 + 5.21.0 test From 8427b0b361f7249c113f809eb1a19c68343057a0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 02:54:18 +0000 Subject: [PATCH 10/12] chore(deps): update eclipse-temurin:25.0.1_8-jre docker digest to f6b0925 (#1732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | eclipse-temurin | final | digest | `73fe03b` -> `f6b0925` | | eclipse-temurin | | digest | `73fe03b` -> `f6b0925` | --- > [!WARNING] > Some dependencies could not be looked up. Check the Dependency Dashboard for more information. --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/prometheus/client_java). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: Jay DeLuca --- .../example-exporter-opentelemetry/oats-tests/agent/Dockerfile | 2 +- .../example-exporter-opentelemetry/oats-tests/http/Dockerfile | 2 +- examples/example-native-histogram/docker-compose.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/example-exporter-opentelemetry/oats-tests/agent/Dockerfile b/examples/example-exporter-opentelemetry/oats-tests/agent/Dockerfile index 038d93261..861890709 100644 --- a/examples/example-exporter-opentelemetry/oats-tests/agent/Dockerfile +++ b/examples/example-exporter-opentelemetry/oats-tests/agent/Dockerfile @@ -1,4 +1,4 @@ -FROM eclipse-temurin:25.0.1_8-jre@sha256:73fe03b88c2a48ec41f9a86428c65ea5913229488ca218cd6a4f55f8cb0ece1f +FROM eclipse-temurin:25.0.1_8-jre@sha256:f6b092537e68d9836e86f676344e94102f2be325bbc652133cd9ef85b27d3ea9 COPY target/example-exporter-opentelemetry.jar ./app.jar # check that the resource attributes from the agent are used, epsecially the service.instance.id should be the same diff --git a/examples/example-exporter-opentelemetry/oats-tests/http/Dockerfile b/examples/example-exporter-opentelemetry/oats-tests/http/Dockerfile index 86c75f67c..ea011f6c3 100644 --- a/examples/example-exporter-opentelemetry/oats-tests/http/Dockerfile +++ b/examples/example-exporter-opentelemetry/oats-tests/http/Dockerfile @@ -1,4 +1,4 @@ -FROM eclipse-temurin:25.0.1_8-jre@sha256:73fe03b88c2a48ec41f9a86428c65ea5913229488ca218cd6a4f55f8cb0ece1f +FROM eclipse-temurin:25.0.1_8-jre@sha256:f6b092537e68d9836e86f676344e94102f2be325bbc652133cd9ef85b27d3ea9 COPY target/example-exporter-opentelemetry.jar ./app.jar diff --git a/examples/example-native-histogram/docker-compose.yaml b/examples/example-native-histogram/docker-compose.yaml index c2ab43a33..ec3fa7895 100644 --- a/examples/example-native-histogram/docker-compose.yaml +++ b/examples/example-native-histogram/docker-compose.yaml @@ -1,7 +1,7 @@ version: "3" services: example-application: - image: eclipse-temurin:25.0.1_8-jre@sha256:73fe03b88c2a48ec41f9a86428c65ea5913229488ca218cd6a4f55f8cb0ece1f + image: eclipse-temurin:25.0.1_8-jre@sha256:f6b092537e68d9836e86f676344e94102f2be325bbc652133cd9ef85b27d3ea9 network_mode: host volumes: - ./target/example-native-histogram.jar:/example-native-histogram.jar From 27f6115b6dbf56cd8343ac05ed4504b03b2813f3 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 10 Dec 2025 12:15:04 +0100 Subject: [PATCH 11/12] use graalvm version that renovate can update (#1733) Signed-off-by: Gregor Zeitlinger Signed-off-by: Jay DeLuca --- .mise/envs/native/mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mise/envs/native/mise.toml b/.mise/envs/native/mise.toml index 0cb0f1cef..de9c06a68 100644 --- a/.mise/envs/native/mise.toml +++ b/.mise/envs/native/mise.toml @@ -1,5 +1,5 @@ [tools] -java = "graalvm-community-25.0.1" +java = "oracle-graalvm-25.0.1" [tasks.native-test] depends = "build" From f56241e48d7f3f5146ef527a41005e4fd2f16b5b Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Thu, 11 Dec 2025 16:08:03 -0500 Subject: [PATCH 12/12] updates and cleanups Signed-off-by: Jay DeLuca --- .../DuplicateMetricsSample.java | 8 +- .../it/exporter/test/DuplicateMetricsIT.java | 92 +++++++++---------- .../metrics/it/springboot/Application.java | 8 +- .../DuplicateNamesProtobufTest.java | 12 ++- .../expositionformats/TextFormatUtil.java | 78 +++++++++------- .../expositionformats/TextFormatUtilTest.java | 92 +++++++++++++++++++ .../model/snapshots/MetricSnapshots.java | 80 +++++++++------- 7 files changed, 237 insertions(+), 133 deletions(-) diff --git a/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/src/main/java/io/prometheus/metrics/it/exporter/duplicatemetrics/DuplicateMetricsSample.java b/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/src/main/java/io/prometheus/metrics/it/exporter/duplicatemetrics/DuplicateMetricsSample.java index 9fe9416fd..4c548b6e3 100644 --- a/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/src/main/java/io/prometheus/metrics/it/exporter/duplicatemetrics/DuplicateMetricsSample.java +++ b/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/src/main/java/io/prometheus/metrics/it/exporter/duplicatemetrics/DuplicateMetricsSample.java @@ -6,16 +6,12 @@ import io.prometheus.metrics.model.snapshots.Unit; import java.io.IOException; -/** - * Integration test sample demonstrating metrics with duplicate names but different label sets. This - * validates that the duplicate metrics feature works end-to-end. - */ +/** Integration test sample demonstrating metrics with duplicate names but different label sets. */ public class DuplicateMetricsSample { public static void main(String[] args) throws IOException, InterruptedException { if (args.length < 1 || args.length > 2) { - System.err.println("Usage: java -jar duplicate-metrics-sample.jar [mode]"); - System.err.println("Where mode is optional (ignored for this sample)."); + System.err.println("Usage: java -jar duplicate-metrics-sample.jar "); System.exit(1); } diff --git a/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/DuplicateMetricsIT.java b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/DuplicateMetricsIT.java index 367a01133..a1dbaa35f 100644 --- a/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/DuplicateMetricsIT.java +++ b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/DuplicateMetricsIT.java @@ -34,29 +34,27 @@ void testDuplicateMetricsInPrometheusTextFormat() throws IOException { assertContentType( "text/plain; version=0.0.4; charset=utf-8", response.getHeader("Content-Type")); - String body = response.stringBody(); - - assertThat(body).contains("# TYPE http_requests_total counter"); - assertThat(body).contains("# HELP http_requests_total Total HTTP requests by status"); - - // Verify all data points from both collectors are present - assertThat(body) - .contains("http_requests_total{method=\"GET\",status=\"success\"} 150.0") - .contains("http_requests_total{method=\"POST\",status=\"success\"} 45.0") - .contains("http_requests_total{endpoint=\"/api\",status=\"error\"} 5.0") - .contains("http_requests_total{endpoint=\"/health\",status=\"error\"} 2.0"); - - assertThat(body).contains("# TYPE active_connections gauge"); - assertThat(body).contains("# HELP active_connections Active connections"); - - assertThat(body) - .contains("active_connections{protocol=\"http\",region=\"us-east\"} 42.0") - .contains("active_connections{protocol=\"http\",region=\"us-west\"} 38.0") - .contains("active_connections{protocol=\"https\",region=\"eu-west\"} 55.0") - .contains("active_connections{pool=\"primary\",type=\"read\"} 30.0") - .contains("active_connections{pool=\"replica\",type=\"write\"} 10.0"); - - assertThat(body).contains("unique_metric_bytes_total 1024.0"); + String expected = + """ + # HELP active_connections Active connections + # TYPE active_connections gauge + active_connections{pool="primary",type="read"} 30.0 + active_connections{pool="replica",type="write"} 10.0 + active_connections{protocol="http",region="us-east"} 42.0 + active_connections{protocol="http",region="us-west"} 38.0 + active_connections{protocol="https",region="eu-west"} 55.0 + # HELP http_requests_total Total HTTP requests by status + # TYPE http_requests_total counter + http_requests_total{endpoint="/api",status="error"} 5.0 + http_requests_total{endpoint="/health",status="error"} 2.0 + http_requests_total{method="GET",status="success"} 150.0 + http_requests_total{method="POST",status="success"} 45.0 + # HELP unique_metric_bytes_total A unique metric for reference + # TYPE unique_metric_bytes_total counter + unique_metric_bytes_total 1024.0 + """; + + assertThat(response.stringBody()).isEqualTo(expected); } @Test @@ -69,31 +67,30 @@ void testDuplicateMetricsInOpenMetricsTextFormat() throws IOException { "application/openmetrics-text; version=1.0.0; charset=utf-8", response.getHeader("Content-Type")); - String body = response.stringBody(); - - // Verify http_requests_total is properly merged - assertThat(body).contains("# TYPE http_requests counter"); - assertThat(body) - .contains("http_requests_total{method=\"GET\",status=\"success\"} 150.0") - .contains("http_requests_total{method=\"POST\",status=\"success\"} 45.0") - .contains("http_requests_total{endpoint=\"/api\",status=\"error\"} 5.0") - .contains("http_requests_total{endpoint=\"/health\",status=\"error\"} 2.0"); - - // Verify active_connections is properly merged - assertThat(body).contains("# TYPE active_connections gauge"); - assertThat(body) - .contains("active_connections{protocol=\"http\",region=\"us-east\"} 42.0") - .contains("active_connections{protocol=\"http\",region=\"us-west\"} 38.0") - .contains("active_connections{protocol=\"https\",region=\"eu-west\"} 55.0") - .contains("active_connections{pool=\"primary\",type=\"read\"} 30.0") - .contains("active_connections{pool=\"replica\",type=\"write\"} 10.0"); - // OpenMetrics format should have UNIT for unique_metric_bytes (base name without _total) - assertThat(body) - .contains("unique_metric_bytes_total 1024.0") - .contains("# UNIT unique_metric_bytes bytes"); - - assertThat(body).endsWith("# EOF\n"); + String expected = + """ + # TYPE active_connections gauge + # HELP active_connections Active connections + active_connections{pool="primary",type="read"} 30.0 + active_connections{pool="replica",type="write"} 10.0 + active_connections{protocol="http",region="us-east"} 42.0 + active_connections{protocol="http",region="us-west"} 38.0 + active_connections{protocol="https",region="eu-west"} 55.0 + # TYPE http_requests counter + # HELP http_requests Total HTTP requests by status + http_requests_total{endpoint="/api",status="error"} 5.0 + http_requests_total{endpoint="/health",status="error"} 2.0 + http_requests_total{method="GET",status="success"} 150.0 + http_requests_total{method="POST",status="success"} 45.0 + # TYPE unique_metric_bytes counter + # UNIT unique_metric_bytes bytes + # HELP unique_metric_bytes A unique metric for reference + unique_metric_bytes_total 1024.0 + # EOF + """; + + assertThat(response.stringBody()).isEqualTo(expected); } @Test @@ -163,7 +160,6 @@ void testDuplicateMetricsInPrometheusProtobufFormat() throws IOException { assertThat(foundErrorApi).isTrue(); assertThat(foundErrorHealth).isTrue(); - // Verify unique metric Metrics.MetricFamily uniqueMetric = metrics.get(2); assertThat(uniqueMetric.getType()).isEqualTo(Metrics.MetricType.COUNTER); assertThat(uniqueMetric.getMetricList()).hasSize(1); diff --git a/integration-tests/it-spring-boot-smoke-test/src/main/java/io/prometheus/metrics/it/springboot/Application.java b/integration-tests/it-spring-boot-smoke-test/src/main/java/io/prometheus/metrics/it/springboot/Application.java index a17bd1266..955ea4033 100644 --- a/integration-tests/it-spring-boot-smoke-test/src/main/java/io/prometheus/metrics/it/springboot/Application.java +++ b/integration-tests/it-spring-boot-smoke-test/src/main/java/io/prometheus/metrics/it/springboot/Application.java @@ -4,13 +4,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public final class Application { +public class Application { - private Application() { - // Utility class - } - - public static void main(final String[] args) { + public static void main(String[] args) { SpringApplication.run(Application.class, args); } } 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 a6a1a33c4..cb4aac1da 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 @@ -271,7 +271,7 @@ public MetricSnapshot collect() { .help("Active sessions gauge") .dataPoint( GaugeSnapshot.GaugeDataPointSnapshot.builder() - .labels(Labels.of("method", "POST")) + .labels(Labels.of("region", "us-east-1")) .value(50) .build()) .build(); @@ -286,12 +286,14 @@ public String getPrometheusName() { return registry.scrape(); } - private List parseProtobufOutput(ByteArrayOutputStream out) + private static List parseProtobufOutput(ByteArrayOutputStream out) throws IOException { List metricFamilies = new ArrayList<>(); - ByteArrayInputStream in = new ByteArrayInputStream(out.toByteArray()); - while (in.available() > 0) { - metricFamilies.add(Metrics.MetricFamily.parseDelimitedFrom(in)); + try (ByteArrayInputStream in = new ByteArrayInputStream(out.toByteArray())) { + Metrics.MetricFamily family; + while ((family = Metrics.MetricFamily.parseDelimitedFrom(in)) != null) { + metricFamilies.add(family); + } } return metricFamilies; } diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/TextFormatUtil.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/TextFormatUtil.java index 298e9f937..d54de3d57 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/TextFormatUtil.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/TextFormatUtil.java @@ -23,7 +23,41 @@ import java.util.Map; import javax.annotation.Nullable; +/** + * Utility methods for writing Prometheus text exposition formats. + * + *

This class provides low-level formatting utilities used by both Prometheus text format and + * OpenMetrics format writers. It handles escaping, label formatting, timestamp conversion, and + * merging of duplicate metric names. + */ public class TextFormatUtil { + /** + * Merges snapshots with duplicate Prometheus names by combining their data points. This ensures + * only one HELP/TYPE declaration per metric family. + */ + public static MetricSnapshots mergeDuplicates(MetricSnapshots metricSnapshots) { + Map> grouped = new LinkedHashMap<>(); + + // Group snapshots by Prometheus name + for (MetricSnapshot snapshot : metricSnapshots) { + String prometheusName = snapshot.getMetadata().getPrometheusName(); + grouped.computeIfAbsent(prometheusName, k -> new ArrayList<>()).add(snapshot); + } + + // Merge groups with multiple snapshots + MetricSnapshots.Builder builder = MetricSnapshots.builder(); + for (List group : grouped.values()) { + if (group.size() == 1) { + builder.metricSnapshot(group.get(0)); + } else { + // Merge multiple snapshots with same name + MetricSnapshot merged = mergeSnapshots(group); + builder.metricSnapshot(merged); + } + } + + return builder.build(); + } static void writeLong(Writer writer, long value) throws IOException { writer.append(Long.toString(value)); @@ -171,46 +205,22 @@ static void writeName(Writer writer, String name, NameType nameType) throws IOEx writer.write('"'); } - /** - * Merges snapshots with duplicate Prometheus names by combining their data points. This ensures - * only one HELP/TYPE declaration per metric family. - */ - public static MetricSnapshots mergeDuplicates(MetricSnapshots metricSnapshots) { - Map> grouped = new LinkedHashMap<>(); - - // Group snapshots by Prometheus name - for (MetricSnapshot snapshot : metricSnapshots) { - String prometheusName = snapshot.getMetadata().getPrometheusName(); - grouped.computeIfAbsent(prometheusName, k -> new ArrayList<>()).add(snapshot); - } - - // Merge groups with multiple snapshots - MetricSnapshots.Builder builder = MetricSnapshots.builder(); - for (List group : grouped.values()) { - if (group.size() == 1) { - builder.metricSnapshot(group.get(0)); - } else { - // Merge multiple snapshots with same name - MetricSnapshot merged = mergeSnapshots(group); - builder.metricSnapshot(merged); - } - } - - return builder.build(); - } - /** * Merges multiple snapshots of the same type into a single snapshot with combined data points. */ @SuppressWarnings("unchecked") private static MetricSnapshot mergeSnapshots(List snapshots) { - if (snapshots.isEmpty()) { - throw new IllegalArgumentException("Cannot merge empty list of snapshots"); - } - MetricSnapshot first = snapshots.get(0); - if (snapshots.size() == 1) { - return first; + + // Validate all snapshots are the same type + for (MetricSnapshot snapshot : snapshots) { + if (snapshot.getClass() != first.getClass()) { + throw new IllegalArgumentException( + "Cannot merge snapshots of different types: " + + first.getClass().getName() + + " and " + + snapshot.getClass().getName()); + } } List allDataPoints = new ArrayList<>(); diff --git a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/TextFormatUtilTest.java b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/TextFormatUtilTest.java index dbb707f51..e8571bac1 100644 --- a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/TextFormatUtilTest.java +++ b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/TextFormatUtilTest.java @@ -3,6 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import io.prometheus.metrics.model.snapshots.CounterSnapshot; +import io.prometheus.metrics.model.snapshots.Labels; +import io.prometheus.metrics.model.snapshots.MetricSnapshots; import java.io.IOException; import java.io.StringWriter; import org.junit.jupiter.api.Test; @@ -34,4 +37,93 @@ private static String writePrometheusTimestamp(boolean timestampsInMs) throws IO TextFormatUtil.writePrometheusTimestamp(writer, 1000, timestampsInMs); return writer.toString(); } + + @Test + public void testMergeDuplicates_sameName_mergesDataPoints() { + CounterSnapshot counter1 = + CounterSnapshot.builder() + .name("api_responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + .value(100) + .build()) + .build(); + + CounterSnapshot counter2 = + CounterSnapshot.builder() + .name("api_responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "FAILURE")) + .value(10) + .build()) + .build(); + + MetricSnapshots snapshots = new MetricSnapshots(counter1, counter2); + MetricSnapshots result = TextFormatUtil.mergeDuplicates(snapshots); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getMetadata().getName()).isEqualTo("api_responses"); + assertThat(result.get(0).getDataPoints()).hasSize(2); + + CounterSnapshot merged = (CounterSnapshot) result.get(0); + assertThat(merged.getDataPoints()) + .anyMatch( + dp -> + dp.getLabels().equals(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + && dp.getValue() == 100); + assertThat(merged.getDataPoints()) + .anyMatch( + dp -> + dp.getLabels().equals(Labels.of("uri", "/hello", "outcome", "FAILURE")) + && dp.getValue() == 10); + } + + @Test + public void testMergeDuplicates_multipleDataPoints_allMerged() { + CounterSnapshot counter1 = + CounterSnapshot.builder() + .name("api_responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + .value(100) + .build()) + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/world", "outcome", "SUCCESS")) + .value(200) + .build()) + .build(); + + CounterSnapshot counter2 = + CounterSnapshot.builder() + .name("api_responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "FAILURE")) + .value(10) + .build()) + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/world", "outcome", "FAILURE")) + .value(5) + .build()) + .build(); + + MetricSnapshots snapshots = new MetricSnapshots(counter1, counter2); + MetricSnapshots result = TextFormatUtil.mergeDuplicates(snapshots); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getDataPoints()).hasSize(4); + } + + @Test + public void testMergeDuplicates_emptySnapshots_returnsEmpty() { + MetricSnapshots snapshots = MetricSnapshots.builder().build(); + MetricSnapshots result = TextFormatUtil.mergeDuplicates(snapshots); + + assertThat(result).isEmpty(); + } } diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshots.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshots.java index 5cae6fe09..71c0a4730 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshots.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricSnapshots.java @@ -15,7 +15,18 @@ import java.util.Set; import java.util.stream.Stream; -/** Immutable list of metric snapshots. */ +/** + * Immutable list of metric snapshots. + * + *

Snapshots are automatically sorted by Prometheus name. The constructor validates: + * + *

    + *
  • Metrics with the same Prometheus name must have the same type + *
  • Each time series (metric name + label set) must be unique + *
+ * + * throws IllegalArgumentException if validation fails + */ public class MetricSnapshots implements Iterable { private final List snapshots; @@ -34,64 +45,65 @@ public MetricSnapshots(MetricSnapshot... snapshots) { * types or have duplicate label sets */ public MetricSnapshots(Collection snapshots) { - validateTypeConsistency(snapshots); - validateNoDuplicateLabelsAcrossSnapshots(snapshots); List list = new ArrayList<>(snapshots); + if (snapshots.size() <= 1) { + this.snapshots = unmodifiableList(list); + return; + } + + validateSnapshots(snapshots); list.sort(comparing(s -> s.getMetadata().getPrometheusName())); this.snapshots = unmodifiableList(list); } - /** Validates that all snapshots with the same Prometheus name have the same type. */ - private static void validateTypeConsistency(Collection snapshots) { - Map> typesByName = new HashMap<>(); + /** Validates type consistency and duplicate labels. */ + private static void validateSnapshots(Collection snapshots) { + Map groupsByName = new HashMap<>(); + for (MetricSnapshot snapshot : snapshots) { String prometheusName = snapshot.getMetadata().getPrometheusName(); - Class existingType = typesByName.get(prometheusName); - if (existingType != null && !existingType.equals(snapshot.getClass())) { + ValidationGroup group = + groupsByName.computeIfAbsent( + prometheusName, k -> new ValidationGroup(snapshot.getClass())); + + if (!group.type.equals(snapshot.getClass())) { throw new IllegalArgumentException( "Conflicting metric types for Prometheus name '" + prometheusName + "': " - + existingType.getSimpleName() + + group.type.getSimpleName() + " vs " + snapshot.getClass().getSimpleName() + ". All metrics with the same Prometheus name must have the same type."); } - typesByName.put(prometheusName, snapshot.getClass()); - } - } - /** - * Validates that snapshots with the same Prometheus name don't have overlapping label sets. - * - *

This validation ensures that when multiple collectors with the same metric name are - * registered, each time series (metric name + label set) is unique. Validation happens at scrape - * time rather than registration time for efficiency. - */ - private static void validateNoDuplicateLabelsAcrossSnapshots( - Collection snapshots) { - // Group snapshots by Prometheus name - Map> snapshotsByName = new HashMap<>(); - for (MetricSnapshot snapshot : snapshots) { - String prometheusName = snapshot.getMetadata().getPrometheusName(); - snapshotsByName.computeIfAbsent(prometheusName, k -> new ArrayList<>()).add(snapshot); + group.snapshots.add(snapshot); } - // For each group with multiple snapshots, check for duplicate labels - for (Map.Entry> entry : snapshotsByName.entrySet()) { - List group = entry.getValue(); - if (group.size() > 1) { - validateNoDuplicateLabelsInGroup(group); + for (Map.Entry entry : groupsByName.entrySet()) { + ValidationGroup group = entry.getValue(); + if (group.snapshots.size() > 1) { + validateNoDuplicateLabelsInGroup(entry.getKey(), group.snapshots); } } } + /** Helper class to track snapshots and their type during validation. */ + private static class ValidationGroup { + final Class type; + final List snapshots = new ArrayList<>(); + + ValidationGroup(Class type) { + this.type = type; + } + } + /** * Validates that a group of snapshots with the same Prometheus name don't have duplicate label * sets. */ - private static void validateNoDuplicateLabelsInGroup(List snapshots) { - String metricName = snapshots.get(0).getMetadata().getName(); + private static void validateNoDuplicateLabelsInGroup( + String prometheusName, List snapshots) { Set seenLabels = new HashSet<>(); for (MetricSnapshot snapshot : snapshots) { @@ -100,7 +112,7 @@ private static void validateNoDuplicateLabelsInGroup(List snapsh if (!seenLabels.add(labels)) { throw new IllegalArgumentException( "Duplicate labels detected for metric '" - + metricName + + prometheusName + "' with labels " + labels + ". Each time series (metric name + label set) must be unique.");