metricFamilies = new ArrayList<>();
+ 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/OpenMetricsTextFormatWriter.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetricsTextFormatWriter.java
index 1ba1c627d..293fbfb8c 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
@@ -113,7 +113,8 @@ public String getContentType() {
public void write(OutputStream out, MetricSnapshots metricSnapshots, EscapingScheme scheme)
throws IOException {
Writer writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
- for (MetricSnapshot s : metricSnapshots) {
+ MetricSnapshots merged = TextFormatUtil.mergeDuplicates(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..cc9f067ba 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
@@ -115,7 +115,8 @@ public void write(OutputStream out, MetricSnapshots metricSnapshots, EscapingSch
// "unknown", "gauge", "counter", "stateset", "info", "histogram", "gaugehistogram", and
// "summary".
Writer writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
- for (MetricSnapshot s : metricSnapshots) {
+ MetricSnapshots merged = TextFormatUtil.mergeDuplicates(metricSnapshots);
+ for (MetricSnapshot s : merged) {
MetricSnapshot snapshot = escapeMetricSnapshot(s, scheme);
if (!snapshot.getDataPoints().isEmpty()) {
if (snapshot instanceof CounterSnapshot) {
@@ -136,7 +137,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..e5cbe8b40 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,14 +1,69 @@
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;
+/**
+ * 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) {
+ if (metricSnapshots.size() <= 1) {
+ return metricSnapshots;
+ }
+
+ Map> grouped = new LinkedHashMap<>();
+
+ for (MetricSnapshot snapshot : metricSnapshots) {
+ String prometheusName = snapshot.getMetadata().getPrometheusName();
+ List list = grouped.get(prometheusName);
+ if (list == null) {
+ list = new ArrayList<>();
+ grouped.put(prometheusName, list);
+ }
+ list.add(snapshot);
+ }
+
+ MetricSnapshots.Builder builder = MetricSnapshots.builder();
+ for (List group : grouped.values()) {
+ if (group.size() == 1) {
+ builder.metricSnapshot(group.get(0));
+ } else {
+ MetricSnapshot merged = mergeSnapshots(group);
+ builder.metricSnapshot(merged);
+ }
+ }
+
+ return builder.build();
+ }
static void writeLong(Writer writer, long value) throws IOException {
writer.append(Long.toString(value));
@@ -155,4 +210,71 @@ static void writeName(Writer writer, String name, NameType nameType) throws IOEx
writeEscapedString(writer, name);
writer.write('"');
}
+
+ /**
+ * Merges multiple snapshots of the same type into a single snapshot with combined data points.
+ */
+ @SuppressWarnings("unchecked")
+ private static MetricSnapshot mergeSnapshots(List snapshots) {
+ MetricSnapshot first = snapshots.get(0);
+
+ int totalDataPoints = 0;
+ 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());
+ }
+ if (first instanceof HistogramSnapshot) {
+ HistogramSnapshot histogramFirst = (HistogramSnapshot) first;
+ HistogramSnapshot histogramSnapshot = (HistogramSnapshot) snapshot;
+ if (histogramFirst.isGaugeHistogram() != histogramSnapshot.isGaugeHistogram()) {
+ throw new IllegalArgumentException(
+ "Cannot merge histograms: gauge histogram and classic histogram");
+ }
+ }
+ totalDataPoints += snapshot.getDataPoints().size();
+ }
+
+ List allDataPoints = new ArrayList<>(totalDataPoints);
+ for (MetricSnapshot snapshot : snapshots) {
+ allDataPoints.addAll(snapshot.getDataPoints());
+ }
+
+ 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) {
+ HistogramSnapshot histFirst = (HistogramSnapshot) first;
+ return new HistogramSnapshot(
+ histFirst.isGaugeHistogram(),
+ 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..31b029fa5
--- /dev/null
+++ b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesExpositionTest.java
@@ -0,0 +1,258 @@
+package io.prometheus.metrics.expositionformats;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
+
+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_withCreatedTimestamps_emitsSingleHelpTypeAndNoDuplicateCreatedSeries()
+ throws IOException {
+ long createdTs = 1672850385800L;
+ 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)
+ .createdTimestampMillis(createdTs)
+ .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)
+ .createdTimestampMillis(createdTs + 1000)
+ .build())
+ .build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "api_responses_total";
+ }
+ });
+
+ MetricSnapshots snapshots = registry.scrape();
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ PrometheusTextFormatWriter writer =
+ PrometheusTextFormatWriter.builder().setIncludeCreatedTimestamps(true).build();
+ writer.write(out, snapshots);
+ String output = out.toString(UTF_8);
+
+ // Merged snapshots: one metric family with two data points. Created-timestamp section uses
+ // merged snapshots too, so single HELP/TYPE for _created and one _created line per label set.
+ 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
+ # HELP api_responses_created API responses
+ # TYPE api_responses_created gauge
+ api_responses_created{error="TIMEOUT",outcome="FAILURE",uri="/hello"} 1672850386800
+ api_responses_created{outcome="SUCCESS",uri="/hello"} 1672850385800
+ """;
+
+ assertThat(output).isEqualTo(expected);
+ }
+}
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..3a6fea740 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,11 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets;
+import io.prometheus.metrics.model.snapshots.CounterSnapshot;
+import io.prometheus.metrics.model.snapshots.HistogramSnapshot;
+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 +39,128 @@ private static String writePrometheusTimestamp(boolean timestampsInMs) throws IO
TextFormatUtil.writePrometheusTimestamp(writer, 1000, timestampsInMs);
return writer.toString();
}
+
+ @Test
+ 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
+ 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
+ void testMergeDuplicates_emptySnapshots_returnsEmpty() {
+ MetricSnapshots snapshots = MetricSnapshots.builder().build();
+ MetricSnapshots result = TextFormatUtil.mergeDuplicates(snapshots);
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void testMergeDuplicates_histogramSameGaugeFlag_preservesGaugeHistogram() {
+ HistogramSnapshot gauge1 =
+ HistogramSnapshot.builder()
+ .name("my_histogram")
+ .gaugeHistogram(true)
+ .dataPoint(
+ HistogramSnapshot.HistogramDataPointSnapshot.builder()
+ .labels(Labels.of("a", "1"))
+ .classicHistogramBuckets(
+ ClassicHistogramBuckets.of(
+ new double[] {Double.POSITIVE_INFINITY}, new long[] {0}))
+ .build())
+ .build();
+ HistogramSnapshot gauge2 =
+ HistogramSnapshot.builder()
+ .name("my_histogram")
+ .gaugeHistogram(true)
+ .dataPoint(
+ HistogramSnapshot.HistogramDataPointSnapshot.builder()
+ .labels(Labels.of("a", "2"))
+ .classicHistogramBuckets(
+ ClassicHistogramBuckets.of(
+ new double[] {Double.POSITIVE_INFINITY}, new long[] {0}))
+ .build())
+ .build();
+ MetricSnapshots snapshots = new MetricSnapshots(gauge1, gauge2);
+ MetricSnapshots result = TextFormatUtil.mergeDuplicates(snapshots);
+
+ assertThat(result).hasSize(1);
+ HistogramSnapshot merged = (HistogramSnapshot) result.get(0);
+ assertThat(merged.isGaugeHistogram()).isTrue();
+ assertThat(merged.getDataPoints()).hasSize(2);
+ }
}
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..741364fe4 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
@@ -1,6 +1,8 @@
package io.prometheus.metrics.model.registry;
+import io.prometheus.metrics.model.snapshots.MetricMetadata;
import io.prometheus.metrics.model.snapshots.MetricSnapshot;
+import java.util.Set;
import java.util.function.Predicate;
import javax.annotation.Nullable;
@@ -78,4 +80,60 @@ default MetricSnapshot collect(
default String getPrometheusName() {
return null;
}
+
+ /**
+ * Returns the metric type for registration-time validation.
+ *
+ * This is used to prevent different metric types (e.g., Counter and Gauge) from sharing the
+ * same name. Returning {@code null} means type validation is skipped for this collector.
+ *
+ *
Validation is performed only at registration time. If this method returns {@code null}, no
+ * type validation is performed for this collector, and duplicate or conflicting metrics may
+ * result in invalid exposition output.
+ *
+ * @return the metric type, or {@code null} to skip validation
+ */
+ @Nullable
+ default MetricType getMetricType() {
+ return null;
+ }
+
+ /**
+ * Returns the complete set of label names for this metric.
+ *
+ *
This includes both dynamic label names (specified in {@code labelNames()}) and constant
+ * label names (specified in {@code constLabels()}). Label names are normalized using Prometheus
+ * naming conventions.
+ *
+ *
This is used for registration-time validation to prevent duplicate label schemas for the
+ * same metric name. Two collectors with the same name and type can coexist if they have different
+ * label name sets.
+ *
+ *
Returning {@code null} means label schema validation is skipped for this collector.
+ *
+ *
Validation is performed only at registration time. If this method returns {@code null}, no
+ * label-schema validation is performed for this collector. If such a collector produces the same
+ * metric name and label schema as another at scrape time, the exposition may contain duplicate
+ * time series, which is invalid in Prometheus.
+ *
+ * @return the set of all label names, or {@code null} to skip validation
+ */
+ @Nullable
+ default Set getLabelNames() {
+ return null;
+ }
+
+ /**
+ * Returns the metric metadata (name, help, unit) for registration-time validation.
+ *
+ * When non-null, the registry uses this to validate that metrics with the same name have
+ * consistent help and unit. Returning {@code null} means help/unit validation is skipped for this
+ * collector.
+ *
+ * @return the metric metadata, or {@code null} to skip help/unit validation
+ */
+ @Nullable
+ default MetricMetadata getMetadata() {
+ return null;
+ }
}
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..5258da84e
--- /dev/null
+++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MetricType.java
@@ -0,0 +1,18 @@
+package io.prometheus.metrics.model.registry;
+
+/**
+ * Represents the type of Prometheus metric.
+ *
+ *
This enum is used for registration-time validation to ensure that metrics with the same name
+ * have consistent types across all registered collectors.
+ */
+public enum MetricType {
+ COUNTER,
+ GAUGE,
+ HISTOGRAM,
+ SUMMARY,
+ INFO,
+ STATESET,
+ /** Unknown metric type, used as a fallback. */
+ 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..6c4759995 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
@@ -1,9 +1,11 @@
package io.prometheus.metrics.model.registry;
+import io.prometheus.metrics.model.snapshots.MetricMetadata;
import io.prometheus.metrics.model.snapshots.MetricSnapshot;
import io.prometheus.metrics.model.snapshots.MetricSnapshots;
import java.util.Collections;
import java.util.List;
+import java.util.Set;
import java.util.function.Predicate;
import javax.annotation.Nullable;
@@ -70,4 +72,62 @@ default MetricSnapshots collect(
default List getPrometheusNames() {
return Collections.emptyList();
}
+
+ /**
+ * Returns the metric type for the given Prometheus name.
+ *
+ * This is used for per-name type validation during registration. Returning {@code null} means
+ * type validation is skipped for that specific metric name.
+ *
+ *
Validation is performed only at registration time. If this method returns {@code null}, no
+ * type validation is performed for that name, and duplicate or conflicting metrics may result in
+ * invalid exposition output.
+ *
+ * @param prometheusName the Prometheus metric name
+ * @return the metric type for the given name, or {@code null} to skip validation
+ */
+ @Nullable
+ default MetricType getMetricType(String prometheusName) {
+ return null;
+ }
+
+ /**
+ * Returns the complete set of label names for the given Prometheus name.
+ *
+ *
This includes both dynamic label names and constant label names. Label names are normalized
+ * using Prometheus naming conventions (dots converted to underscores).
+ *
+ *
This is used for per-name label schema validation during registration. Two collectors with
+ * the same name and type can coexist if they have different label name sets.
+ *
+ *
Returning {@code null} means label schema validation is skipped for that specific metric
+ * name.
+ *
+ *
Validation is performed only at registration time. If this method returns {@code null}, no
+ * label-schema validation is performed for that name. If such a collector produces the same
+ * metric name and label schema as another at scrape time, the exposition may contain duplicate
+ * time series, which is invalid in Prometheus.
+ *
+ * @param prometheusName the Prometheus metric name
+ * @return the set of all label names for the given name, or {@code null} to skip validation
+ */
+ @Nullable
+ default Set getLabelNames(String prometheusName) {
+ return null;
+ }
+
+ /**
+ * Returns the metric metadata (name, help, unit) for the given Prometheus name.
+ *
+ * When non-null, the registry uses this to validate that metrics with the same name have
+ * consistent help and unit. Returning {@code null} means help/unit validation is skipped for that
+ * name.
+ *
+ * @param prometheusName the Prometheus metric name
+ * @return the metric metadata for that name, or {@code null} to skip help/unit validation
+ */
+ @Nullable
+ default MetricMetadata getMetadata(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..6c9adadb2 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,13 +1,16 @@
package io.prometheus.metrics.model.registry;
-import static io.prometheus.metrics.model.snapshots.PrometheusNaming.prometheusName;
-
+import io.prometheus.metrics.model.snapshots.MetricMetadata;
import io.prometheus.metrics.model.snapshots.MetricSnapshot;
import io.prometheus.metrics.model.snapshots.MetricSnapshots;
+import io.prometheus.metrics.model.snapshots.Unit;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
+import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Predicate;
import javax.annotation.Nullable;
@@ -16,51 +19,308 @@ 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 Set collectors = ConcurrentHashMap.newKeySet();
+ private final Set multiCollectors = ConcurrentHashMap.newKeySet();
+ private final ConcurrentHashMap registered = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap collectorMetadata =
+ new ConcurrentHashMap<>();
+ private final ConcurrentHashMap>
+ multiCollectorMetadata = new ConcurrentHashMap<>();
+
+ /** Stores the registration details for a Collector at registration time. */
+ private static class CollectorRegistration {
+ final String prometheusName;
+ final Set labelNames;
+
+ CollectorRegistration(String prometheusName, @Nullable Set labelNames) {
+ this.prometheusName = prometheusName;
+ this.labelNames = immutableLabelNames(labelNames);
+ }
+ }
+
+ /**
+ * Stores the registration details for a single metric within a MultiCollector. A MultiCollector
+ * can produce multiple metrics, so we need one of these per metric name.
+ */
+ private static class MultiCollectorRegistration {
+ final String prometheusName;
+ final Set labelNames;
+
+ MultiCollectorRegistration(String prometheusName, @Nullable Set labelNames) {
+ this.prometheusName = prometheusName;
+ this.labelNames = immutableLabelNames(labelNames);
+ }
+ }
+
+ /**
+ * Tracks registration information for each metric name to enable validation of type consistency,
+ * label schema uniqueness, and help/unit consistency. Stores metadata to enable O(1)
+ * unregistration without iterating through all collectors.
+ */
+ private static class RegistrationInfo {
+ private final MetricType type;
+ private final Set> labelSchemas;
+ @Nullable private String help;
+ @Nullable private Unit unit;
+
+ private RegistrationInfo(
+ MetricType type,
+ Set> labelSchemas,
+ @Nullable String help,
+ @Nullable Unit unit) {
+ this.type = type;
+ this.labelSchemas = labelSchemas;
+ this.help = help;
+ this.unit = unit;
+ }
+
+ static RegistrationInfo of(
+ MetricType type,
+ @Nullable Set labelNames,
+ @Nullable String help,
+ @Nullable Unit unit) {
+ Set> labelSchemas = ConcurrentHashMap.newKeySet();
+ Set normalized =
+ (labelNames == null || labelNames.isEmpty()) ? Collections.emptySet() : labelNames;
+ labelSchemas.add(normalized);
+ return new RegistrationInfo(type, labelSchemas, help, unit);
+ }
+
+ /**
+ * Validates that the given help and unit are consistent with this registration. Throws if
+ * non-null values conflict. When stored help/unit is null and the new value is non-null,
+ * captures the first non-null so subsequent registrations are validated consistently.
+ */
+ void validateMetadata(@Nullable String newHelp, @Nullable Unit newUnit) {
+ if (help != null && newHelp != null && !Objects.equals(help, newHelp)) {
+ throw new IllegalArgumentException(
+ "Conflicting help strings. Existing: \"" + help + "\", new: \"" + newHelp + "\"");
+ }
+ if (unit != null && newUnit != null && !Objects.equals(unit, newUnit)) {
+ throw new IllegalArgumentException(
+ "Conflicting unit. Existing: " + unit + ", new: " + newUnit);
+ }
+ if (help == null && newHelp != null) {
+ this.help = newHelp;
+ }
+ if (unit == null && newUnit != null) {
+ this.unit = newUnit;
+ }
+ }
+
+ /**
+ * Adds a label schema to this registration.
+ *
+ * @param labelNames the label names to add (null or empty sets are normalized to empty set)
+ * @return true if the schema was added (new), false if it already existed
+ */
+ boolean addLabelSet(@Nullable Set labelNames) {
+ Set normalized =
+ (labelNames == null || labelNames.isEmpty()) ? Collections.emptySet() : labelNames;
+ return labelSchemas.add(normalized);
+ }
+
+ /**
+ * Removes a label schema from this registration.
+ *
+ * @param labelNames the label names to remove (null or empty sets are normalized to empty set)
+ */
+ void removeLabelSet(@Nullable Set labelNames) {
+ Set normalized =
+ (labelNames == null || labelNames.isEmpty()) ? Collections.emptySet() : labelNames;
+ labelSchemas.remove(normalized);
+ }
+
+ /** Returns true if all label schemas have been unregistered. */
+ boolean isEmpty() {
+ return labelSchemas.isEmpty();
+ }
+
+ MetricType getType() {
+ return type;
+ }
+ }
+
+ /**
+ * Returns an immutable set of label names for storage. Defends against mutation of the set
+ * returned by {@code Collector.getLabelNames()} after registration, which would break duplicate
+ * detection and unregistration.
+ */
+ private static Set immutableLabelNames(@Nullable Set labelNames) {
+ if (labelNames == null || labelNames.isEmpty()) {
+ return Collections.emptySet();
+ }
+ return Collections.unmodifiableSet(new HashSet<>(labelNames));
+ }
public void register(Collector collector) {
+ if (collectors.contains(collector)) {
+ throw new IllegalArgumentException("Collector instance is already registered");
+ }
+
String prometheusName = collector.getPrometheusName();
+ MetricType metricType = collector.getMetricType();
+ Set normalizedLabels = immutableLabelNames(collector.getLabelNames());
+ MetricMetadata metadata = collector.getMetadata();
+ String help = metadata != null ? metadata.getHelp() : null;
+ Unit unit = metadata != null ? metadata.getUnit() : null;
+
+ // Only perform validation if collector provides sufficient metadata.
+ // Collectors that don't implement getPrometheusName()/getMetricType() will skip validation.
+ if (prometheusName != null && metricType != null) {
+ final String name = prometheusName;
+ final MetricType type = metricType;
+ final Set names = normalizedLabels;
+ final String helpForValidation = help;
+ final Unit unitForValidation = unit;
+ registered.compute(
+ prometheusName,
+ (n, existingInfo) -> {
+ if (existingInfo == null) {
+ return RegistrationInfo.of(type, names, helpForValidation, unitForValidation);
+ } else {
+ if (existingInfo.getType() != type) {
+ throw new IllegalArgumentException(
+ name
+ + ": Conflicting metric types. Existing: "
+ + existingInfo.getType()
+ + ", new: "
+ + type);
+ }
+ existingInfo.validateMetadata(helpForValidation, unitForValidation);
+ if (!existingInfo.addLabelSet(names)) {
+ throw new IllegalArgumentException(
+ name + ": duplicate metric name with identical label schema " + names);
+ }
+ return existingInfo;
+ }
+ });
+
+ collectorMetadata.put(collector, new CollectorRegistration(prometheusName, normalizedLabels));
+ }
+
if (prometheusName != null) {
- if (!prometheusNames.add(prometheusName)) {
- throw new IllegalStateException(
- "Can't register "
- + prometheusName
- + " because a metric with that name is already registered.");
- }
+ prometheusNames.add(prometheusName);
}
+
collectors.add(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.");
+ if (multiCollectors.contains(collector)) {
+ throw new IllegalArgumentException("MultiCollector instance is already registered");
+ }
+
+ List prometheusNamesList = collector.getPrometheusNames();
+ List registrations = new ArrayList<>();
+ Set namesOnlyInPrometheusNames = new HashSet<>();
+
+ try {
+ for (String prometheusName : prometheusNamesList) {
+ MetricType metricType = collector.getMetricType(prometheusName);
+ Set normalizedLabels = immutableLabelNames(collector.getLabelNames(prometheusName));
+ MetricMetadata metadata = collector.getMetadata(prometheusName);
+ String help = metadata != null ? metadata.getHelp() : null;
+ Unit unit = metadata != null ? metadata.getUnit() : null;
+
+ if (metricType != null) {
+ final MetricType type = metricType;
+ final Set labelNamesForValidation = normalizedLabels;
+ final String helpForValidation = help;
+ final Unit unitForValidation = unit;
+ registered.compute(
+ prometheusName,
+ (name, existingInfo) -> {
+ if (existingInfo == null) {
+ return RegistrationInfo.of(
+ type, labelNamesForValidation, helpForValidation, unitForValidation);
+ } else {
+ if (existingInfo.getType() != type) {
+ throw new IllegalArgumentException(
+ prometheusName
+ + ": Conflicting metric types. Existing: "
+ + existingInfo.getType()
+ + ", new: "
+ + type);
+ }
+ existingInfo.validateMetadata(helpForValidation, unitForValidation);
+ if (!existingInfo.addLabelSet(labelNamesForValidation)) {
+ throw new IllegalArgumentException(
+ prometheusName
+ + ": duplicate metric name with identical label schema "
+ + labelNamesForValidation);
+ }
+ return existingInfo;
+ }
+ });
+
+ registrations.add(new MultiCollectorRegistration(prometheusName, normalizedLabels));
+ }
+
+ boolean addedToPrometheusNames = prometheusNames.add(prometheusName);
+ if (addedToPrometheusNames && metricType == null) {
+ namesOnlyInPrometheusNames.add(prometheusName);
+ }
}
+
+ multiCollectorMetadata.put(collector, registrations);
+ multiCollectors.add(collector);
+ } catch (Exception e) {
+ for (MultiCollectorRegistration registration : registrations) {
+ unregisterLabelSchema(registration.prometheusName, registration.labelNames);
+ }
+ for (String name : namesOnlyInPrometheusNames) {
+ prometheusNames.remove(name);
+ }
+ throw e;
}
- multiCollectors.add(collector);
}
public void unregister(Collector collector) {
collectors.remove(collector);
- String prometheusName = collector.getPrometheusName();
- if (prometheusName != null) {
- prometheusNames.remove(collector.getPrometheusName());
+
+ CollectorRegistration registration = collectorMetadata.remove(collector);
+ if (registration != null && registration.prometheusName != null) {
+ unregisterLabelSchema(registration.prometheusName, registration.labelNames);
}
}
public void unregister(MultiCollector collector) {
multiCollectors.remove(collector);
- for (String prometheusName : collector.getPrometheusNames()) {
- prometheusNames.remove(prometheusName(prometheusName));
+
+ List registrations = multiCollectorMetadata.remove(collector);
+ if (registrations != null) {
+ for (MultiCollectorRegistration registration : registrations) {
+ unregisterLabelSchema(registration.prometheusName, registration.labelNames);
+ }
}
}
+ /**
+ * Decrements the reference count for a label schema and removes the metric name entirely if no
+ * schemas remain.
+ */
+ private void unregisterLabelSchema(String prometheusName, Set labelNames) {
+ registered.computeIfPresent(
+ prometheusName,
+ (name, info) -> {
+ info.removeLabelSet(labelNames);
+ if (info.isEmpty()) {
+ // No more label schemas for this name, remove it entirely
+ prometheusNames.remove(prometheusName);
+ return null; // remove from registered map
+ }
+ return info; // keep the RegistrationInfo, just with decremented count
+ });
+ }
+
public void clear() {
collectors.clear();
multiCollectors.clear();
prometheusNames.clear();
+ registered.clear();
+ collectorMetadata.clear();
+ multiCollectorMetadata.clear();
}
public MetricSnapshots scrape() {
@@ -68,29 +328,26 @@ public MetricSnapshots scrape() {
}
public MetricSnapshots scrape(@Nullable PrometheusScrapeRequest scrapeRequest) {
- MetricSnapshots.Builder result = MetricSnapshots.builder();
+ List allSnapshots = new ArrayList<>();
for (Collector collector : collectors) {
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);
+ allSnapshots.add(snapshot);
}
}
for (MultiCollector collector : multiCollectors) {
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);
+ allSnapshots.add(snapshot);
}
}
+
+ MetricSnapshots.Builder result = MetricSnapshots.builder();
+ for (MetricSnapshot snapshot : allSnapshots) {
+ result.metricSnapshot(snapshot);
+ }
return result.build();
}
@@ -106,7 +363,7 @@ public MetricSnapshots scrape(
if (includedNames == null) {
return scrape(scrapeRequest);
}
- MetricSnapshots.Builder result = MetricSnapshots.builder();
+ List allSnapshots = new ArrayList<>();
for (Collector collector : collectors) {
String prometheusName = collector.getPrometheusName();
// prometheusName == null means the name is unknown, and we have to scrape to learn the name.
@@ -117,7 +374,7 @@ public MetricSnapshots scrape(
? collector.collect(includedNames)
: collector.collect(includedNames, scrapeRequest);
if (snapshot != null) {
- result.metricSnapshot(snapshot);
+ allSnapshots.add(snapshot);
}
}
}
@@ -141,11 +398,16 @@ public MetricSnapshots scrape(
: collector.collect(includedNames, scrapeRequest);
for (MetricSnapshot snapshot : snapshots) {
if (snapshot != null) {
- result.metricSnapshot(snapshot);
+ allSnapshots.add(snapshot);
}
}
}
}
+
+ MetricSnapshots.Builder result = MetricSnapshots.builder();
+ for (MetricSnapshot snapshot : allSnapshots) {
+ result.metricSnapshot(snapshot);
+ }
return result.build();
}
}
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..949c65f91 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
@@ -28,23 +28,45 @@ 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 contain conflicting metric types (same name but
+ * different metric types like Counter vs Gauge).
*/
public MetricSnapshots(Collection 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())) {
- throw new IllegalArgumentException(
- list.get(i).getMetadata().getPrometheusName() + ": duplicate metric name");
+
+ // Validate no conflicting metric types
+ for (int i = 0; i < list.size() - 1; i++) {
+ String name1 = list.get(i).getMetadata().getPrometheusName();
+ String name2 = list.get(i + 1).getMetadata().getPrometheusName();
+
+ if (name1.equals(name2)) {
+ MetricSnapshot s1 = list.get(i);
+ MetricSnapshot s2 = list.get(i + 1);
+ Class> type1 = s1.getClass();
+ Class> type2 = s2.getClass();
+
+ if (!type1.equals(type2)) {
+ throw new IllegalArgumentException(
+ name1
+ + ": conflicting metric types: "
+ + type1.getSimpleName()
+ + " and "
+ + type2.getSimpleName());
+ }
+
+ // HistogramSnapshot: gauge histogram vs classic histogram are semantically different
+ if (s1 instanceof HistogramSnapshot) {
+ HistogramSnapshot h1 = (HistogramSnapshot) s1;
+ HistogramSnapshot h2 = (HistogramSnapshot) s2;
+ if (h1.isGaugeHistogram() != h2.isGaugeHistogram()) {
+ throw new IllegalArgumentException(
+ name1 + ": conflicting histogram types: gauge histogram and classic histogram");
+ }
+ }
}
}
+
this.snapshots = unmodifiableList(list);
}
diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/OpenTelemetryExporterRegistryCompatibilityTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/OpenTelemetryExporterRegistryCompatibilityTest.java
new file mode 100644
index 000000000..166b374b8
--- /dev/null
+++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/registry/OpenTelemetryExporterRegistryCompatibilityTest.java
@@ -0,0 +1,116 @@
+package io.prometheus.metrics.model.registry;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+import io.prometheus.metrics.model.snapshots.CounterSnapshot;
+import io.prometheus.metrics.model.snapshots.GaugeSnapshot;
+import io.prometheus.metrics.model.snapshots.MetricSnapshot;
+import io.prometheus.metrics.model.snapshots.MetricSnapshots;
+import java.util.Collections;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests that use the Prometheus registry in the same way as the OpenTelemetry Java SDK Prometheus
+ * exporter ({@code io.opentelemetry.exporter.prometheus}). The SDK's {@code PrometheusMetricReader}
+ * implements {@link MultiCollector} with default implementations for all optional methods: {@link
+ * MultiCollector#getPrometheusNames()} returns an empty list, and {@link
+ * MultiCollector#getMetricType(String)}, {@link MultiCollector#getLabelNames(String)}, and {@link
+ * MultiCollector#getMetadata(String)} return null. This test suite ensures that registration,
+ * scrape, and unregister continue to work for that usage pattern and that a shared registry with
+ * both SDK-style and validated collectors behaves correctly.
+ */
+class OpenTelemetryExporterRegistryCompatibilityTest {
+
+ /**
+ * A MultiCollector that mimics the OpenTelemetry Java SDK's PrometheusMetricReader: it does not
+ * override getPrometheusNames() (empty list), getMetricType(String), getLabelNames(String), or
+ * getMetadata(String) (all null). Only collect() is implemented and returns MetricSnapshots.
+ */
+ private static final MultiCollector OTEL_STYLE_MULTI_COLLECTOR =
+ new MultiCollector() {
+ @Override
+ public MetricSnapshots collect() {
+ return new MetricSnapshots(
+ CounterSnapshot.builder()
+ .name("otel_metric")
+ .help("A metric produced by an OTel-style converter")
+ .dataPoint(CounterSnapshot.CounterDataPointSnapshot.builder().value(42.0).build())
+ .build());
+ }
+ };
+
+ @Test
+ void registerOtelStyleMultiCollector_succeeds() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ assertThatCode(() -> registry.register(OTEL_STYLE_MULTI_COLLECTOR)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void scrape_afterRegisteringOtelStyleMultiCollector_returnsSnapshotsFromCollector() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+ registry.register(OTEL_STYLE_MULTI_COLLECTOR);
+
+ MetricSnapshots snapshots = registry.scrape();
+
+ assertThat(snapshots).hasSize(1);
+ MetricSnapshot snapshot = snapshots.get(0);
+ assertThat(snapshot.getMetadata().getPrometheusName()).isEqualTo("otel_metric");
+ }
+
+ @Test
+ void unregisterOtelStyleMultiCollector_succeedsAndScrapeNoLongerIncludesIt() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+ registry.register(OTEL_STYLE_MULTI_COLLECTOR);
+
+ assertThat(registry.scrape()).hasSize(1);
+
+ assertThatCode(() -> registry.unregister(OTEL_STYLE_MULTI_COLLECTOR))
+ .doesNotThrowAnyException();
+
+ assertThat(registry.scrape()).isEmpty();
+ }
+
+ @Test
+ void sharedRegistry_otelStyleMultiCollectorAndValidatedCollector_bothParticipateInScrape() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ Collector validatedCollector =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return GaugeSnapshot.builder().name("app_gauge").help("App gauge").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "app_gauge";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.GAUGE;
+ }
+
+ @Override
+ public java.util.Set getLabelNames() {
+ return Collections.emptySet();
+ }
+ };
+
+ registry.register(validatedCollector);
+ registry.register(OTEL_STYLE_MULTI_COLLECTOR);
+
+ MetricSnapshots snapshots = registry.scrape();
+
+ assertThat(snapshots).hasSize(2);
+ assertThat(snapshots)
+ .extracting(s -> s.getMetadata().getPrometheusName())
+ .containsExactlyInAnyOrder("app_gauge", "otel_metric");
+
+ registry.unregister(OTEL_STYLE_MULTI_COLLECTOR);
+ assertThat(registry.scrape()).hasSize(1);
+ assertThat(registry.scrape().get(0).getMetadata().getPrometheusName()).isEqualTo("app_gauge");
+ }
+}
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 3197dabb0..b1d8e32bf 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,15 +1,19 @@
package io.prometheus.metrics.model.registry;
+import static java.util.Arrays.asList;
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.MetricMetadata;
import io.prometheus.metrics.model.snapshots.MetricSnapshot;
import io.prometheus.metrics.model.snapshots.MetricSnapshots;
import java.util.Arrays;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import org.junit.jupiter.api.Test;
class PrometheusRegistryTest {
@@ -82,25 +86,259 @@ public List getPrometheusNames() {
};
@Test
- void registerNoName() {
+ void register_duplicateName_withoutTypeInfo_notAllowed() {
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");
+
+ assertThatThrownBy(() -> registry.register(noName))
+ .hasMessageContaining("Collector instance is already registered");
}
@Test
- void registerDuplicateName() {
+ void register_duplicateName_differentTypes_notAllowed() {
PrometheusRegistry registry = new PrometheusRegistry();
+
+ Collector counterA1 =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("counter_a").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "counter_a";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+ };
+
+ Collector gaugeA1 =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return GaugeSnapshot.builder().name("counter_a").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "counter_a";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.GAUGE;
+ }
+ };
+
registry.register(counterA1);
- assertThatExceptionOfType(IllegalStateException.class)
- .isThrownBy(() -> registry.register(counterA2));
+
+ assertThatThrownBy(() -> registry.register(gaugeA1))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Conflicting metric types");
+ }
+
+ @Test
+ void register_sameName_sameType_differentLabelSchemas_allowed() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ Collector counterWithPathLabel =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests_total").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests_total";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("path", "status"));
+ }
+ };
+
+ Collector counterWithRegionLabel =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests_total").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests_total";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("region"));
+ }
+ };
+
+ // Both collectors have same name and type, but different label schemas
+ // This should succeed
+ registry.register(counterWithPathLabel);
+ assertThatCode(() -> registry.register(counterWithRegionLabel)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void register_sameName_sameType_sameLabelSchema_notAllowed() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ Collector counter1 =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests_total").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests_total";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("path", "status"));
+ }
+ };
+
+ Collector counter2 =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests_total").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests_total";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("path", "status"));
+ }
+ };
+
+ registry.register(counter1);
+
+ // Second collector has same name, type, and label schema - should fail
+ assertThatThrownBy(() -> registry.register(counter2))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("duplicate metric name with identical label schema");
+ }
+
+ @Test
+ void register_nullType_skipsValidation() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ // Collectors without getMetricType() skip registration-time validation.
+ // This allows legacy collectors to work without implementing all getters.
+ Collector legacyCollector1 =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("legacy_metric").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "legacy_metric";
+ }
+ };
+
+ Collector legacyCollector2 =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return GaugeSnapshot.builder().name("legacy_metric").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "legacy_metric";
+ }
+ };
+
+ // Both collectors can register successfully since validation is skipped
+ assertThatCode(() -> registry.register(legacyCollector1)).doesNotThrowAnyException();
+ assertThatCode(() -> registry.register(legacyCollector2)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void register_multiCollector_withTypeValidation() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ Collector counter =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("shared_metric").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "shared_metric";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+ };
+
+ MultiCollector multiWithGauge =
+ new MultiCollector() {
+ @Override
+ public MetricSnapshots collect() {
+ return new MetricSnapshots(GaugeSnapshot.builder().name("shared_metric").build());
+ }
+
+ @Override
+ public List getPrometheusNames() {
+ return asList("shared_metric");
+ }
+
+ @Override
+ public MetricType getMetricType(String prometheusName) {
+ return MetricType.GAUGE;
+ }
+ };
+
+ registry.register(counter);
+
+ // MultiCollector tries to register a Gauge with the same name as existing Counter
+ assertThatThrownBy(() -> registry.register(multiWithGauge))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Conflicting metric types");
}
@Test
@@ -125,8 +363,10 @@ void registerOk() {
void registerDuplicateMultiCollector() {
PrometheusRegistry registry = new PrometheusRegistry();
registry.register(multiCollector);
- assertThatExceptionOfType(IllegalStateException.class)
- .isThrownBy(() -> registry.register(multiCollector));
+ // Registering the same instance twice should fail
+ assertThatThrownBy(() -> registry.register(multiCollector))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("already registered");
}
@Test
@@ -152,4 +392,623 @@ void clearOk() {
registry.clear();
assertThat(registry.scrape().size()).isZero();
}
+
+ @Test
+ void unregister_shouldRemoveLabelSchemaFromRegistrationInfo() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ Collector counterWithPathLabel =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests_total").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests_total";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("path", "status"));
+ }
+ };
+
+ Collector counterWithRegionLabel =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests_total").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests_total";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("region"));
+ }
+ };
+
+ Collector counterWithPathLabelAgain =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests_total").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests_total";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("path", "status"));
+ }
+ };
+
+ registry.register(counterWithPathLabel);
+ registry.register(counterWithRegionLabel);
+
+ registry.unregister(counterWithPathLabel);
+
+ assertThatCode(() -> registry.register(counterWithPathLabelAgain)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void register_withEmptyLabelSets_shouldDetectDuplicates() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ Collector collector1 =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return GaugeSnapshot.builder().name("requests").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.GAUGE;
+ }
+
+ // getLabelNames() returns null by default
+ };
+
+ // Register another collector with same name and type, also no labels
+ Collector collector2 =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return GaugeSnapshot.builder().name("requests").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.GAUGE;
+ }
+
+ // getLabelNames() returns null by default
+ };
+
+ registry.register(collector1);
+
+ assertThatThrownBy(() -> registry.register(collector2))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("duplicate metric name with identical label schema");
+ }
+
+ @Test
+ void register_withMixedNullAndEmptyLabelSets_shouldDetectDuplicates() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ Collector collector1 =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return GaugeSnapshot.builder().name("requests").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.GAUGE;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>();
+ }
+ };
+
+ Collector collector2 =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return GaugeSnapshot.builder().name("requests").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.GAUGE;
+ }
+
+ // getLabelNames() returns null by default
+ };
+
+ registry.register(collector1);
+
+ // null and empty should be treated the same
+ assertThatThrownBy(() -> registry.register(collector2))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("duplicate metric name with identical label schema");
+ }
+
+ @Test
+ void register_sameName_differentHelp_notAllowed() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ Collector withHelpOne =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests").help("First help").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("path"));
+ }
+
+ @Override
+ public MetricMetadata getMetadata() {
+ return new MetricMetadata("requests", "First help", null);
+ }
+ };
+
+ Collector withHelpTwo =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests").help("Second help").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("status"));
+ }
+
+ @Override
+ public MetricMetadata getMetadata() {
+ return new MetricMetadata("requests", "Second help", null);
+ }
+ };
+
+ registry.register(withHelpOne);
+ assertThatThrownBy(() -> registry.register(withHelpTwo))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Conflicting help strings");
+ }
+
+ @Test
+ void register_sameName_sameHelpAndUnit_allowed() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ Collector withPath =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests").help("Total requests").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("path"));
+ }
+
+ @Override
+ public MetricMetadata getMetadata() {
+ return new MetricMetadata("requests", "Total requests", null);
+ }
+ };
+
+ Collector withStatus =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests").help("Total requests").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("status"));
+ }
+
+ @Override
+ public MetricMetadata getMetadata() {
+ return new MetricMetadata("requests", "Total requests", null);
+ }
+ };
+
+ registry.register(withPath);
+ assertThatCode(() -> registry.register(withStatus)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void register_sameName_oneNullHelp_allowed() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ Collector withHelp =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests").help("Total requests").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("path"));
+ }
+
+ @Override
+ public MetricMetadata getMetadata() {
+ return new MetricMetadata("requests", "Total requests", null);
+ }
+ };
+
+ Collector withoutHelp =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("status"));
+ }
+
+ @Override
+ public MetricMetadata getMetadata() {
+ return new MetricMetadata("requests", null, null);
+ }
+ };
+
+ registry.register(withHelp);
+ // One has help, one doesn't - should be allowed
+ assertThatCode(() -> registry.register(withoutHelp)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void register_firstOmitsHelp_secondProvidesHelp_thirdWithDifferentHelp_throws() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ Collector withoutHelp =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("path"));
+ }
+ };
+
+ Collector withHelpTotal =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests").help("Total requests").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("status"));
+ }
+
+ @Override
+ public MetricMetadata getMetadata() {
+ return new MetricMetadata("requests", "Total requests", null);
+ }
+ };
+
+ Collector withHelpOther =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests").help("Other help").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("method"));
+ }
+
+ @Override
+ public MetricMetadata getMetadata() {
+ return new MetricMetadata("requests", "Other help", null);
+ }
+ };
+
+ registry.register(withoutHelp);
+ registry.register(withHelpTotal);
+ // First had no help, second provided "Total requests" (captured). Third conflicts.
+ assertThatThrownBy(() -> registry.register(withHelpOther))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Conflicting help strings");
+ }
+
+ @Test
+ void unregister_lastCollector_removesPrometheusName() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ Collector counter1 =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("path"));
+ }
+ };
+
+ Collector counter2 =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+
+ @Override
+ public Set getLabelNames() {
+ return new HashSet<>(asList("status"));
+ }
+ };
+
+ registry.register(counter1);
+ registry.register(counter2);
+
+ // Unregister first collector - name should still be registered
+ registry.unregister(counter1);
+ MetricSnapshots snapshots = registry.scrape();
+ assertThat(snapshots.size()).isEqualTo(1);
+
+ // Unregister second collector - name should be removed
+ registry.unregister(counter2);
+ snapshots = registry.scrape();
+ assertThat(snapshots.size()).isEqualTo(0);
+
+ // Should be able to register again with same name
+ assertThatCode(() -> registry.register(counter1)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void unregister_multiCollector_removesAllLabelSchemas() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ MultiCollector multi =
+ new MultiCollector() {
+ @Override
+ public MetricSnapshots collect() {
+ return new MetricSnapshots(
+ CounterSnapshot.builder().name("requests").build(),
+ GaugeSnapshot.builder().name("connections").build());
+ }
+
+ @Override
+ public List getPrometheusNames() {
+ return asList("requests", "connections");
+ }
+
+ @Override
+ public MetricType getMetricType(String prometheusName) {
+ return prometheusName.equals("requests") ? MetricType.COUNTER : MetricType.GAUGE;
+ }
+ };
+
+ registry.register(multi);
+ assertThat(registry.scrape().size()).isEqualTo(2);
+
+ registry.unregister(multi);
+ assertThat(registry.scrape().size()).isEqualTo(0);
+
+ // Should be able to register collectors with same names again
+ Collector counter =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("requests").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "requests";
+ }
+
+ @Override
+ public MetricType getMetricType() {
+ return MetricType.COUNTER;
+ }
+ };
+
+ assertThatCode(() -> registry.register(counter)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void unregister_legacyCollector_noErrors() {
+ PrometheusRegistry registry = new PrometheusRegistry();
+
+ Collector legacy =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return GaugeSnapshot.builder().name("legacy_metric").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "legacy_metric";
+ }
+ // No getMetricType() - returns null
+ };
+
+ registry.register(legacy);
+ assertThat(registry.scrape().size()).isEqualTo(1);
+
+ // Unregister should work without errors even for legacy collectors
+ assertThatCode(() -> registry.unregister(legacy)).doesNotThrowAnyException();
+ assertThat(registry.scrape().size()).isEqualTo(0);
+ }
}
diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/MetricSnapshotsTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/MetricSnapshotsTest.java
index 5d82a06a0..224ca691a 100644
--- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/MetricSnapshotsTest.java
+++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/MetricSnapshotsTest.java
@@ -61,6 +61,34 @@ void testDuplicateName() {
.isThrownBy(() -> new MetricSnapshots(c, g));
}
+ @Test
+ void testDuplicateName_histogramGaugeVsClassic_throws() {
+ HistogramSnapshot classic =
+ HistogramSnapshot.builder()
+ .name("my_histogram")
+ .dataPoint(
+ HistogramSnapshot.HistogramDataPointSnapshot.builder()
+ .classicHistogramBuckets(
+ ClassicHistogramBuckets.of(
+ new double[] {Double.POSITIVE_INFINITY}, new long[] {0}))
+ .build())
+ .build();
+ HistogramSnapshot gauge =
+ HistogramSnapshot.builder()
+ .name("my_histogram")
+ .gaugeHistogram(true)
+ .dataPoint(
+ HistogramSnapshot.HistogramDataPointSnapshot.builder()
+ .classicHistogramBuckets(
+ ClassicHistogramBuckets.of(
+ new double[] {Double.POSITIVE_INFINITY}, new long[] {0}))
+ .build())
+ .build();
+ assertThatExceptionOfType(IllegalArgumentException.class)
+ .isThrownBy(() -> new MetricSnapshots(classic, gauge))
+ .withMessageContaining("conflicting histogram types");
+ }
+
@Test
void testBuilder() {
CounterSnapshot counter =