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..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..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
@@ -1,14 +1,63 @@
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) {
+ 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));
@@ -155,4 +204,61 @@ 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);
+
+ // 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<>();
+ 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..eeb949c3a
--- /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";
+ }
+ });
+
+ 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-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/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:
*
*
- * - During registration to check if a metric with that name already exists.
*
- 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:
*
*
- * - During registration to check if a metric with that name already exists.
*
- 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/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..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
@@ -1,12 +1,8 @@
package io.prometheus.metrics.model.registry;
-import static io.prometheus.metrics.model.snapshots.PrometheusNaming.prometheusName;
-
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;
import javax.annotation.Nullable;
@@ -15,52 +11,28 @@ 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<>();
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.");
- }
- }
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.");
- }
- }
multiCollectors.add(collector);
}
public void unregister(Collector collector) {
collectors.remove(collector);
- String prometheusName = collector.getPrometheusName();
- if (prometheusName != null) {
- prometheusNames.remove(collector.getPrometheusName());
- }
}
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();
}
public MetricSnapshots scrape() {
@@ -73,10 +45,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 +52,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..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
@@ -7,13 +7,26 @@
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;
-/** 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;
@@ -28,24 +41,84 @@ 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 or have duplicate label sets
*/
public MetricSnapshots(Collection snapshots) {
List list = new ArrayList<>(snapshots);
+ if (snapshots.size() <= 1) {
+ this.snapshots = unmodifiableList(list);
+ return;
+ }
+
+ validateSnapshots(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 type consistency and duplicate labels. */
+ private static void validateSnapshots(Collection snapshots) {
+ Map groupsByName = new HashMap<>();
+
+ for (MetricSnapshot snapshot : snapshots) {
+ String prometheusName = snapshot.getMetadata().getPrometheusName();
+ ValidationGroup group =
+ groupsByName.computeIfAbsent(
+ prometheusName, k -> new ValidationGroup(snapshot.getClass()));
+
+ if (!group.type.equals(snapshot.getClass())) {
throw new IllegalArgumentException(
- list.get(i).getMetadata().getPrometheusName() + ": duplicate metric name");
+ "Conflicting metric types for Prometheus name '"
+ + prometheusName
+ + "': "
+ + group.type.getSimpleName()
+ + " vs "
+ + snapshot.getClass().getSimpleName()
+ + ". All metrics with the same Prometheus name must have the same type.");
+ }
+
+ group.snapshots.add(snapshot);
+ }
+
+ 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 extends MetricSnapshot> type;
+ final List snapshots = new ArrayList<>();
+
+ ValidationGroup(Class extends MetricSnapshot> 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(
+ String prometheusName, List snapshots) {
+ 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 '"
+ + prometheusName
+ + "' with labels "
+ + labels
+ + ". Each time series (metric name + label set) must be unique.");
+ }
}
}
- 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..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,21 +1,21 @@
package io.prometheus.metrics.model.registry;
+import static java.util.Arrays.asList;
+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.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.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 +77,59 @@ 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_differentType_failsAtScrapeTime() {
+ Collector counter =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return CounterSnapshot.builder().name("my_metric").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "my_metric";
+ }
+ };
+
+ Collector gauge =
+ new Collector() {
+ @Override
+ public MetricSnapshot collect() {
+ return GaugeSnapshot.builder().name("my_metric").build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "my_metric";
+ }
+ };
+
PrometheusRegistry registry = new PrometheusRegistry();
- registry.register(counterA1);
- assertThatExceptionOfType(IllegalStateException.class)
- .isThrownBy(() -> registry.register(counterA2));
+ registry.register(counter);
+ registry.register(gauge); // Registration succeeds
+
+ // But scrape fails due to type conflict
+ assertThatThrownBy(registry::scrape)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Conflicting metric types")
+ .hasMessageContaining("my_metric")
+ .hasMessageContaining("CounterSnapshot")
+ .hasMessageContaining("GaugeSnapshot");
}
@Test
@@ -122,11 +151,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 +183,527 @@ 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)
+ .build())
+ .build();
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return "api_responses_total";
+ }
+ };
+
+ registry.register(counter1);
+ 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");
+ }
+
+ @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_throwsExceptionAtScrape() {
+ 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";
+ }
+ };
+
+ 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
+ }
+ };
+
+ registry.register(counter);
+ registry.register(gauge); // Registration succeeds
+
+ // Scrape should throw exception due to conflicting metric types
+ assertThatThrownBy(registry::scrape)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("api_metrics")
+ .hasMessageContaining("CounterSnapshot")
+ .hasMessageContaining("GaugeSnapshot");
+ }
+
+ @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 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");
+ }
}