diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index 0a9b6b940..1450009fa 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -17,7 +17,7 @@ jobs: with: version: v2025.12.0 sha256: 5b7b9992950b65569b4f6f1c78b05abceea2506f15b750a4b9781f33c6cc1ae4 - env: - MISE_ENV: native + working_directory: .mise/envs/native - name: Run native tests - run: mise run test + working-directory: .mise/envs/native + run: mise native-test diff --git a/.mise/envs/native/mise.toml b/.mise/envs/native/mise.toml new file mode 100644 index 000000000..de9c06a68 --- /dev/null +++ b/.mise/envs/native/mise.toml @@ -0,0 +1,8 @@ +[tools] +java = "oracle-graalvm-25.0.1" + +[tasks.native-test] +depends = "build" +run = "../../mvnw test -PnativeTest" +dir = "../../../integration-tests/it-spring-boot-smoke-test" + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 50d60bfcc..5be7b073d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,7 @@ If you're getting errors when running tests: ### Running native tests ```shell -mise --env native test +mise --cd .mise/envs/native run native-test ``` ### Avoid failures while running tests diff --git a/examples/example-exporter-opentelemetry/oats-tests/agent/Dockerfile b/examples/example-exporter-opentelemetry/oats-tests/agent/Dockerfile index 038d93261..861890709 100644 --- a/examples/example-exporter-opentelemetry/oats-tests/agent/Dockerfile +++ b/examples/example-exporter-opentelemetry/oats-tests/agent/Dockerfile @@ -1,4 +1,4 @@ -FROM eclipse-temurin:25.0.1_8-jre@sha256:73fe03b88c2a48ec41f9a86428c65ea5913229488ca218cd6a4f55f8cb0ece1f +FROM eclipse-temurin:25.0.1_8-jre@sha256:f6b092537e68d9836e86f676344e94102f2be325bbc652133cd9ef85b27d3ea9 COPY target/example-exporter-opentelemetry.jar ./app.jar # check that the resource attributes from the agent are used, epsecially the service.instance.id should be the same diff --git a/examples/example-exporter-opentelemetry/oats-tests/http/Dockerfile b/examples/example-exporter-opentelemetry/oats-tests/http/Dockerfile index 86c75f67c..ea011f6c3 100644 --- a/examples/example-exporter-opentelemetry/oats-tests/http/Dockerfile +++ b/examples/example-exporter-opentelemetry/oats-tests/http/Dockerfile @@ -1,4 +1,4 @@ -FROM eclipse-temurin:25.0.1_8-jre@sha256:73fe03b88c2a48ec41f9a86428c65ea5913229488ca218cd6a4f55f8cb0ece1f +FROM eclipse-temurin:25.0.1_8-jre@sha256:f6b092537e68d9836e86f676344e94102f2be325bbc652133cd9ef85b27d3ea9 COPY target/example-exporter-opentelemetry.jar ./app.jar diff --git a/examples/example-native-histogram/docker-compose.yaml b/examples/example-native-histogram/docker-compose.yaml index c2ab43a33..ec3fa7895 100644 --- a/examples/example-native-histogram/docker-compose.yaml +++ b/examples/example-native-histogram/docker-compose.yaml @@ -1,7 +1,7 @@ version: "3" services: example-application: - image: eclipse-temurin:25.0.1_8-jre@sha256:73fe03b88c2a48ec41f9a86428c65ea5913229488ca218cd6a4f55f8cb0ece1f + image: eclipse-temurin:25.0.1_8-jre@sha256:f6b092537e68d9836e86f676344e94102f2be325bbc652133cd9ef85b27d3ea9 network_mode: host volumes: - ./target/example-native-histogram.jar:/example-native-histogram.jar diff --git a/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml b/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml new file mode 100644 index 000000000..35900a8f2 --- /dev/null +++ b/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + + io.prometheus + it-exporter + 1.5.0-SNAPSHOT + + + it-exporter-duplicate-metrics-sample + + Integration Tests - Duplicate Metrics Sample + + HTTPServer Sample demonstrating duplicate metric names with different label sets + + + + + io.prometheus + prometheus-metrics-exporter-httpserver + ${project.version} + + + io.prometheus + prometheus-metrics-core + ${project.version} + + + + + exporter-duplicate-metrics-sample + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + + + + io.prometheus.metrics.it.exporter.duplicatemetrics.DuplicateMetricsSample + + + + + + + + + + diff --git a/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/src/main/java/io/prometheus/metrics/it/exporter/duplicatemetrics/DuplicateMetricsSample.java b/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/src/main/java/io/prometheus/metrics/it/exporter/duplicatemetrics/DuplicateMetricsSample.java new file mode 100644 index 000000000..4c548b6e3 --- /dev/null +++ b/integration-tests/it-exporter/it-exporter-duplicate-metrics-sample/src/main/java/io/prometheus/metrics/it/exporter/duplicatemetrics/DuplicateMetricsSample.java @@ -0,0 +1,89 @@ +package io.prometheus.metrics.it.exporter.duplicatemetrics; + +import io.prometheus.metrics.core.metrics.Counter; +import io.prometheus.metrics.core.metrics.Gauge; +import io.prometheus.metrics.exporter.httpserver.HTTPServer; +import io.prometheus.metrics.model.snapshots.Unit; +import java.io.IOException; + +/** Integration test sample demonstrating metrics with duplicate names but different label sets. */ +public class DuplicateMetricsSample { + + public static void main(String[] args) throws IOException, InterruptedException { + if (args.length < 1 || args.length > 2) { + System.err.println("Usage: java -jar duplicate-metrics-sample.jar "); + System.exit(1); + } + + int port = parsePortOrExit(args[0]); + run(port); + } + + private static void run(int port) throws IOException, InterruptedException { + // Register multiple counters with the same Prometheus name "http_requests_total" + // but different label sets + Counter requestsSuccess = + Counter.builder() + .name("http_requests_total") + .help("Total HTTP requests by status") + .labelNames("status", "method") + .register(); + requestsSuccess.labelValues("success", "GET").inc(150); + requestsSuccess.labelValues("success", "POST").inc(45); + + Counter requestsError = + Counter.builder() + .name("http_requests_total") + .help("Total HTTP requests by status") + .labelNames("status", "endpoint") + .register(); + requestsError.labelValues("error", "/api").inc(5); + requestsError.labelValues("error", "/health").inc(2); + + // Register multiple gauges with the same Prometheus name "active_connections" + // but different label sets + Gauge connectionsByRegion = + Gauge.builder() + .name("active_connections") + .help("Active connections") + .labelNames("region", "protocol") + .register(); + connectionsByRegion.labelValues("us-east", "http").set(42); + connectionsByRegion.labelValues("us-west", "http").set(38); + connectionsByRegion.labelValues("eu-west", "https").set(55); + + Gauge connectionsByPool = + Gauge.builder() + .name("active_connections") + .help("Active connections") + .labelNames("pool", "type") + .register(); + connectionsByPool.labelValues("primary", "read").set(30); + connectionsByPool.labelValues("replica", "write").set(10); + + // Also add a regular metric without duplicates for reference + Counter uniqueMetric = + Counter.builder() + .name("unique_metric_total") + .help("A unique metric for reference") + .unit(Unit.BYTES) + .register(); + uniqueMetric.inc(1024); + + HTTPServer server = HTTPServer.builder().port(port).buildAndStart(); + + System.out.println( + "DuplicateMetricsSample listening on http://localhost:" + server.getPort() + "/metrics"); + Thread.currentThread().join(); // wait forever + } + + private static int parsePortOrExit(String port) { + try { + return Integer.parseInt(port); + } catch (NumberFormatException e) { + System.err.println("\"" + port + "\": Invalid port number."); + System.exit(1); + } + return 0; // this won't happen + } +} diff --git a/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/DuplicateMetricsIT.java b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/DuplicateMetricsIT.java new file mode 100644 index 000000000..a1dbaa35f --- /dev/null +++ b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/DuplicateMetricsIT.java @@ -0,0 +1,194 @@ +package io.prometheus.metrics.it.exporter.test; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.prometheus.client.it.common.ExporterTest; +import io.prometheus.metrics.expositionformats.generated.com_google_protobuf_4_33_2.Metrics; +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Integration test for duplicate metric names with different label sets. + * + *

This test validates that: + * + *

+ */ +class DuplicateMetricsIT extends ExporterTest { + + public DuplicateMetricsIT() throws IOException, URISyntaxException { + super("exporter-duplicate-metrics-sample"); + } + + @Test + void testDuplicateMetricsInPrometheusTextFormat() throws IOException { + start(); + Response response = scrape("GET", ""); + assertThat(response.status).isEqualTo(200); + assertContentType( + "text/plain; version=0.0.4; charset=utf-8", response.getHeader("Content-Type")); + + String expected = + """ + # HELP active_connections Active connections + # TYPE active_connections gauge + active_connections{pool="primary",type="read"} 30.0 + active_connections{pool="replica",type="write"} 10.0 + active_connections{protocol="http",region="us-east"} 42.0 + active_connections{protocol="http",region="us-west"} 38.0 + active_connections{protocol="https",region="eu-west"} 55.0 + # HELP http_requests_total Total HTTP requests by status + # TYPE http_requests_total counter + http_requests_total{endpoint="/api",status="error"} 5.0 + http_requests_total{endpoint="/health",status="error"} 2.0 + http_requests_total{method="GET",status="success"} 150.0 + http_requests_total{method="POST",status="success"} 45.0 + # HELP unique_metric_bytes_total A unique metric for reference + # TYPE unique_metric_bytes_total counter + unique_metric_bytes_total 1024.0 + """; + + assertThat(response.stringBody()).isEqualTo(expected); + } + + @Test + void testDuplicateMetricsInOpenMetricsTextFormat() throws IOException { + start(); + Response response = + scrape("GET", "", "Accept", "application/openmetrics-text; version=1.0.0; charset=utf-8"); + assertThat(response.status).isEqualTo(200); + assertContentType( + "application/openmetrics-text; version=1.0.0; charset=utf-8", + response.getHeader("Content-Type")); + + // OpenMetrics format should have UNIT for unique_metric_bytes (base name without _total) + String expected = + """ + # TYPE active_connections gauge + # HELP active_connections Active connections + active_connections{pool="primary",type="read"} 30.0 + active_connections{pool="replica",type="write"} 10.0 + active_connections{protocol="http",region="us-east"} 42.0 + active_connections{protocol="http",region="us-west"} 38.0 + active_connections{protocol="https",region="eu-west"} 55.0 + # TYPE http_requests counter + # HELP http_requests Total HTTP requests by status + http_requests_total{endpoint="/api",status="error"} 5.0 + http_requests_total{endpoint="/health",status="error"} 2.0 + http_requests_total{method="GET",status="success"} 150.0 + http_requests_total{method="POST",status="success"} 45.0 + # TYPE unique_metric_bytes counter + # UNIT unique_metric_bytes bytes + # HELP unique_metric_bytes A unique metric for reference + unique_metric_bytes_total 1024.0 + # EOF + """; + + assertThat(response.stringBody()).isEqualTo(expected); + } + + @Test + void testDuplicateMetricsInPrometheusProtobufFormat() throws IOException { + start(); + Response response = + scrape( + "GET", + "", + "Accept", + "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily;" + + " encoding=delimited"); + assertThat(response.status).isEqualTo(200); + assertContentType( + "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily;" + + " encoding=delimited", + response.getHeader("Content-Type")); + + List metrics = response.protoBody(); + + // Should have exactly 3 metric families (active_connections, http_requests_total, + // unique_metric_bytes_total) + assertThat(metrics).hasSize(3); + + // Metrics are sorted by name + assertThat(metrics.get(0).getName()).isEqualTo("active_connections"); + assertThat(metrics.get(1).getName()).isEqualTo("http_requests_total"); + assertThat(metrics.get(2).getName()).isEqualTo("unique_metric_bytes_total"); + + // Verify active_connections has all 5 data points merged + Metrics.MetricFamily activeConnections = metrics.get(0); + assertThat(activeConnections.getType()).isEqualTo(Metrics.MetricType.GAUGE); + assertThat(activeConnections.getHelp()).isEqualTo("Active connections"); + assertThat(activeConnections.getMetricList()).hasSize(5); + + // Verify http_requests_total has all 4 data points merged + Metrics.MetricFamily httpRequests = metrics.get(1); + assertThat(httpRequests.getType()).isEqualTo(Metrics.MetricType.COUNTER); + assertThat(httpRequests.getHelp()).isEqualTo("Total HTTP requests by status"); + assertThat(httpRequests.getMetricList()).hasSize(4); + + // Verify each data point has the expected labels + boolean foundSuccessGet = false; + boolean foundSuccessPost = false; + boolean foundErrorApi = false; + boolean foundErrorHealth = false; + + for (Metrics.Metric metric : httpRequests.getMetricList()) { + List labels = metric.getLabelList(); + if (hasLabel(labels, "status", "success") && hasLabel(labels, "method", "GET")) { + assertThat(metric.getCounter().getValue()).isEqualTo(150.0); + foundSuccessGet = true; + } else if (hasLabel(labels, "status", "success") && hasLabel(labels, "method", "POST")) { + assertThat(metric.getCounter().getValue()).isEqualTo(45.0); + foundSuccessPost = true; + } else if (hasLabel(labels, "status", "error") && hasLabel(labels, "endpoint", "/api")) { + assertThat(metric.getCounter().getValue()).isEqualTo(5.0); + foundErrorApi = true; + } else if (hasLabel(labels, "status", "error") && hasLabel(labels, "endpoint", "/health")) { + assertThat(metric.getCounter().getValue()).isEqualTo(2.0); + foundErrorHealth = true; + } + } + + assertThat(foundSuccessGet).isTrue(); + assertThat(foundSuccessPost).isTrue(); + assertThat(foundErrorApi).isTrue(); + assertThat(foundErrorHealth).isTrue(); + + Metrics.MetricFamily uniqueMetric = metrics.get(2); + assertThat(uniqueMetric.getType()).isEqualTo(Metrics.MetricType.COUNTER); + assertThat(uniqueMetric.getMetricList()).hasSize(1); + assertThat(uniqueMetric.getMetric(0).getCounter().getValue()).isEqualTo(1024.0); + } + + @Test + void testDuplicateMetricsWithNameFilter() throws IOException { + start(); + // Only scrape http_requests_total + Response response = scrape("GET", nameParam()); + assertThat(response.status).isEqualTo(200); + + String body = response.stringBody(); + + assertThat(body) + .contains("http_requests_total{method=\"GET\",status=\"success\"} 150.0") + .contains("http_requests_total{endpoint=\"/api\",status=\"error\"} 5.0"); + + // Should NOT contain active_connections or unique_metric_total + assertThat(body).doesNotContain("active_connections").doesNotContain("unique_metric_total"); + } + + private boolean hasLabel(List labels, String name, String value) { + return labels.stream() + .anyMatch(label -> label.getName().equals(name) && label.getValue().equals(value)); + } + + private String nameParam() { + return "name[]=" + "http_requests_total"; + } +} diff --git a/integration-tests/it-exporter/pom.xml b/integration-tests/it-exporter/pom.xml index a442b9086..c4a29fe74 100644 --- a/integration-tests/it-exporter/pom.xml +++ b/integration-tests/it-exporter/pom.xml @@ -21,6 +21,7 @@ it-exporter-servlet-tomcat-sample it-exporter-servlet-jetty-sample it-exporter-httpserver-sample + it-exporter-duplicate-metrics-sample it-exporter-no-protobuf it-exporter-test it-no-protobuf-test diff --git a/integration-tests/it-spring-boot-smoke-test/pom.xml b/integration-tests/it-spring-boot-smoke-test/pom.xml index 747756671..a5b17b39c 100644 --- a/integration-tests/it-spring-boot-smoke-test/pom.xml +++ b/integration-tests/it-spring-boot-smoke-test/pom.xml @@ -136,6 +136,21 @@ --initialize-at-build-time=org.junit.platform.suite.engine.SuiteTestDescriptor$LifecycleMethods + + --initialize-at-build-time=org.junit.platform.commons.logging.LoggerFactory$DelegatingLogger + + + --initialize-at-build-time=org.junit.jupiter.engine.execution.ConditionEvaluator + + + --initialize-at-build-time=org.junit.jupiter.engine.execution.InterceptingExecutableInvoker + + + --initialize-at-build-time=org.junit.jupiter.api.extension.ConditionEvaluationResult + + + --initialize-at-build-time=org.junit.jupiter.engine.execution.InvocationInterceptorChain + diff --git a/mise.native.toml b/mise.native.toml deleted file mode 100644 index 67ad3940f..000000000 --- a/mise.native.toml +++ /dev/null @@ -1,8 +0,0 @@ -[tools] -java = "graalvm-community-24.0.1" - -[tasks.test] -depends = "build" -run = "../../mvnw test -PnativeTest" -dir = "integration-tests/it-spring-boot-smoke-test" - diff --git a/pom.xml b/pom.xml index 50f35c3fe..7eb626e2d 100644 --- a/pom.xml +++ b/pom.xml @@ -381,7 +381,7 @@ org.mockito mockito-core - 5.20.0 + 5.21.0 test diff --git a/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java b/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java index 13f7f34b9..76be1d57f 100644 --- a/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java +++ b/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java @@ -6,6 +6,7 @@ import com.google.protobuf.TextFormat; import io.prometheus.metrics.config.EscapingScheme; import io.prometheus.metrics.expositionformats.ExpositionFormatWriter; +import io.prometheus.metrics.expositionformats.TextFormatUtil; import io.prometheus.metrics.expositionformats.generated.com_google_protobuf_4_33_2.Metrics; import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets; import io.prometheus.metrics.model.snapshots.CounterSnapshot; @@ -43,8 +44,9 @@ public String getContentType() { @Override public String toDebugString(MetricSnapshots metricSnapshots, EscapingScheme escapingScheme) { + MetricSnapshots merged = TextFormatUtil.mergeDuplicates(metricSnapshots); StringBuilder stringBuilder = new StringBuilder(); - for (MetricSnapshot s : metricSnapshots) { + for (MetricSnapshot s : merged) { MetricSnapshot snapshot = SnapshotEscaper.escapeMetricSnapshot(s, escapingScheme); if (!snapshot.getDataPoints().isEmpty()) { stringBuilder.append(TextFormat.printer().printToString(convert(snapshot, escapingScheme))); @@ -57,7 +59,8 @@ public String toDebugString(MetricSnapshots metricSnapshots, EscapingScheme esca public void write( OutputStream out, MetricSnapshots metricSnapshots, EscapingScheme escapingScheme) throws IOException { - for (MetricSnapshot s : metricSnapshots) { + MetricSnapshots merged = TextFormatUtil.mergeDuplicates(metricSnapshots); + for (MetricSnapshot s : merged) { MetricSnapshot snapshot = SnapshotEscaper.escapeMetricSnapshot(s, escapingScheme); if (!snapshot.getDataPoints().isEmpty()) { convert(snapshot, escapingScheme).writeDelimitedTo(out); diff --git a/prometheus-metrics-exposition-formats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesProtobufTest.java b/prometheus-metrics-exposition-formats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesProtobufTest.java new file mode 100644 index 000000000..cb4aac1da --- /dev/null +++ b/prometheus-metrics-exposition-formats/src/test/java/io/prometheus/metrics/expositionformats/DuplicateNamesProtobufTest.java @@ -0,0 +1,300 @@ +package io.prometheus.metrics.expositionformats; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.prometheus.metrics.config.EscapingScheme; +import io.prometheus.metrics.expositionformats.generated.com_google_protobuf_4_33_2.Metrics; +import io.prometheus.metrics.expositionformats.internal.PrometheusProtobufWriterImpl; +import io.prometheus.metrics.model.registry.Collector; +import io.prometheus.metrics.model.registry.PrometheusRegistry; +import io.prometheus.metrics.model.snapshots.CounterSnapshot; +import io.prometheus.metrics.model.snapshots.GaugeSnapshot; +import io.prometheus.metrics.model.snapshots.Labels; +import io.prometheus.metrics.model.snapshots.MetricSnapshot; +import io.prometheus.metrics.model.snapshots.MetricSnapshots; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class DuplicateNamesProtobufTest { + + private static PrometheusRegistry getPrometheusRegistry() { + PrometheusRegistry registry = new PrometheusRegistry(); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + .value(100) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels( + Labels.of("uri", "/hello", "outcome", "FAILURE", "error", "TIMEOUT")) + .value(10) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }); + return registry; + } + + @Test + void testDuplicateNames_differentLabels_producesSingleMetricFamily() throws IOException { + PrometheusRegistry registry = getPrometheusRegistry(); + + MetricSnapshots snapshots = registry.scrape(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + PrometheusProtobufWriterImpl writer = new PrometheusProtobufWriterImpl(); + writer.write(out, snapshots, EscapingScheme.UNDERSCORE_ESCAPING); + + List metricFamilies = parseProtobufOutput(out); + + assertThat(metricFamilies).hasSize(1); + Metrics.MetricFamily family = metricFamilies.get(0); + assertThat(family.getName()).isEqualTo("api_responses_total"); + assertThat(family.getHelp()).isEqualTo("API responses"); + assertThat(family.getType()).isEqualTo(Metrics.MetricType.COUNTER); + assertThat(family.getMetricCount()).isEqualTo(2); + + Metrics.Metric successMetric = + family.getMetricList().stream() + .filter( + m -> + m.getLabelList().stream() + .anyMatch( + l -> l.getName().equals("outcome") && l.getValue().equals("SUCCESS"))) + .findFirst() + .orElseThrow(() -> new AssertionError("SUCCESS metric not found")); + assertThat(successMetric.getCounter().getValue()).isEqualTo(100.0); + + Metrics.Metric failureMetric = + family.getMetricList().stream() + .filter( + m -> + m.getLabelList().stream() + .anyMatch( + l -> + l.getName().equals("outcome") && l.getValue().equals("FAILURE")) + && m.getLabelList().stream() + .anyMatch( + l -> l.getName().equals("error") && l.getValue().equals("TIMEOUT"))) + .findFirst() + .orElseThrow(() -> new AssertionError("FAILURE metric not found")); + assertThat(failureMetric.getCounter().getValue()).isEqualTo(10.0); + } + + @Test + void testDuplicateNames_multipleDataPoints_producesSingleMetricFamily() throws IOException { + PrometheusRegistry registry = new PrometheusRegistry(); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/hello", "outcome", "SUCCESS")) + .value(100) + .build()) + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("uri", "/world", "outcome", "SUCCESS")) + .value(200) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("api_responses") + .help("API responses") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels( + Labels.of("uri", "/hello", "outcome", "FAILURE", "error", "TIMEOUT")) + .value(10) + .build()) + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels( + Labels.of("uri", "/world", "outcome", "FAILURE", "error", "NOT_FOUND")) + .value(5) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "api_responses_total"; + } + }); + + MetricSnapshots snapshots = registry.scrape(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + PrometheusProtobufWriterImpl writer = new PrometheusProtobufWriterImpl(); + writer.write(out, snapshots, EscapingScheme.UNDERSCORE_ESCAPING); + + List metricFamilies = parseProtobufOutput(out); + + assertThat(metricFamilies).hasSize(1); + Metrics.MetricFamily family = metricFamilies.get(0); + assertThat(family.getName()).isEqualTo("api_responses_total"); + assertThat(family.getMetricCount()).isEqualTo(4); + + long successCount = + family.getMetricList().stream() + .filter( + m -> + m.getLabelList().stream() + .anyMatch( + l -> l.getName().equals("outcome") && l.getValue().equals("SUCCESS"))) + .count(); + + long failureCount = + family.getMetricList().stream() + .filter( + m -> + m.getLabelList().stream() + .anyMatch( + l -> l.getName().equals("outcome") && l.getValue().equals("FAILURE"))) + .count(); + + assertThat(successCount).isEqualTo(2); + assertThat(failureCount).isEqualTo(2); + } + + @Test + void testDifferentMetrics_producesSeparateMetricFamilies() throws IOException { + MetricSnapshots snapshots = getMetricSnapshots(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + PrometheusProtobufWriterImpl writer = new PrometheusProtobufWriterImpl(); + writer.write(out, snapshots, EscapingScheme.UNDERSCORE_ESCAPING); + + List metricFamilies = parseProtobufOutput(out); + + assertThat(metricFamilies).hasSize(2); + + Metrics.MetricFamily counterFamily = null; + Metrics.MetricFamily gaugeFamily = null; + for (Metrics.MetricFamily family : metricFamilies) { + if (family.getName().equals("http_requests_total")) { + counterFamily = family; + } else if (family.getName().equals("active_sessions")) { + gaugeFamily = family; + } + } + + assertThat(counterFamily).isNotNull(); + assertThat(counterFamily.getType()).isEqualTo(Metrics.MetricType.COUNTER); + assertThat(counterFamily.getMetricCount()).isEqualTo(1); + assertThat(counterFamily.getMetric(0).getCounter().getValue()).isEqualTo(100.0); + + assertThat(gaugeFamily).isNotNull(); + assertThat(gaugeFamily.getType()).isEqualTo(Metrics.MetricType.GAUGE); + assertThat(gaugeFamily.getMetricCount()).isEqualTo(1); + assertThat(gaugeFamily.getMetric(0).getGauge().getValue()).isEqualTo(50.0); + } + + private static MetricSnapshots getMetricSnapshots() { + PrometheusRegistry registry = new PrometheusRegistry(); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return CounterSnapshot.builder() + .name("http_requests") + .help("HTTP Request counter") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .labels(Labels.of("method", "GET")) + .value(100) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "http_requests_total"; + } + }); + + registry.register( + new Collector() { + @Override + public MetricSnapshot collect() { + return GaugeSnapshot.builder() + .name("active_sessions") + .help("Active sessions gauge") + .dataPoint( + GaugeSnapshot.GaugeDataPointSnapshot.builder() + .labels(Labels.of("region", "us-east-1")) + .value(50) + .build()) + .build(); + } + + @Override + public String getPrometheusName() { + return "active_sessions"; + } + }); + + return registry.scrape(); + } + + private static List parseProtobufOutput(ByteArrayOutputStream out) + throws IOException { + List 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: * *

    - *
  1. During registration to check if a metric with that name already exists. *
  2. During scrape to check if this collector can be skipped because a name filter is present * and the metric name is excluded. *
diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java index d1051958d..a3ae8a740 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/registry/MultiCollector.java @@ -55,7 +55,6 @@ default MetricSnapshots collect( * This is called in two places: * *
    - *
  1. During registration to check if a metric with that name already exists. *
  2. During scrape to check if the collector can be skipped because a name filter is present * and all names are excluded. *
diff --git a/prometheus-metrics-model/src/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 type; + final List snapshots = new ArrayList<>(); + + ValidationGroup(Class type) { + this.type = type; + } + } + + /** + * Validates that a group of snapshots with the same Prometheus name don't have duplicate label + * sets. + */ + private static void validateNoDuplicateLabelsInGroup( + 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"); + } }