From 16e09eed714730fa9afe2c4493c48487407dc9fd Mon Sep 17 00:00:00 2001 From: Felix Barnsteiner Date: Tue, 31 Mar 2026 11:07:58 +0200 Subject: [PATCH 1/2] Prometheus labels/series APIs: support multiple match[] selectors Re-parses the raw URI via RequestParams.fromUri to capture all repeated match[] values, working around the current limitation where request processing keeps only the last value for repeated parameters (tracked in elastic/elasticsearch#145223). Also calls repeatedParamAsList to mark the parameter as consumed so Elasticsearch does not reject the request as having unrecognized params. Adds IT test cases for all three endpoints (labels, label values, series) that send two match[] selectors and verify only the matched series contribute to the response. --- .../PrometheusLabelValuesRestIT.java | 21 +++++++++++++++ .../prometheus/PrometheusLabelsRestIT.java | 21 +++++++++++++++ .../prometheus/PrometheusSeriesRestIT.java | 26 ++++++++++++++++--- .../rest/PrometheusLabelValuesRestAction.java | 8 +++--- .../rest/PrometheusLabelsRestAction.java | 7 +++-- .../rest/PrometheusSeriesRestAction.java | 10 ++++--- 6 files changed, 80 insertions(+), 13 deletions(-) diff --git a/x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusLabelValuesRestIT.java b/x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusLabelValuesRestIT.java index 79e0261259c04..86ce1e080a4db 100644 --- a/x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusLabelValuesRestIT.java +++ b/x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusLabelValuesRestIT.java @@ -12,6 +12,7 @@ import io.netty.handler.codec.compression.Snappy; import org.apache.http.HttpHeaders; +import org.apache.http.client.utils.URIBuilder; import org.apache.http.entity.ByteArrayEntity; import org.apache.http.entity.ContentType; import org.elasticsearch.client.Request; @@ -30,6 +31,7 @@ import java.util.List; import java.util.Map; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItem; @@ -135,6 +137,25 @@ public void testGetWithMatchSelectorFiltersValues() throws Exception { assertThat(values, not(hasItem("other_job"))); } + public void testGetWithMultipleMatchSelectorsReturnsCombinedValues() throws Exception { + writeMetric("multi_selector_metric_a", Map.of("job", "multi_job_a")); + writeMetric("multi_selector_metric_b", Map.of("job", "multi_job_b")); + writeMetric("multi_selector_metric_c", Map.of("job", "multi_job_c")); // must not appear in results + + // Use URIBuilder to send two match[] selectors in a single request, working around the + // test client's single-value-per-key restriction on Request.addParameter. + Request request = new Request( + "GET", + new URIBuilder("/_prometheus/api/v1/label/job/values").addParameter("match[]", "multi_selector_metric_a") + .addParameter("match[]", "multi_selector_metric_b") + .build() + .toString() + ); + List values = labelValuesData(client().performRequest(request)); + + assertThat(values, containsInAnyOrder("multi_job_a", "multi_job_b")); + } + public void testGetValuesAreSorted() throws Exception { writeMetric("sorted_gauge", Map.of("job", "zebra")); writeMetric("sorted_gauge", Map.of("job", "alpha")); diff --git a/x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusLabelsRestIT.java b/x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusLabelsRestIT.java index fd856acea0c66..8b45597d62aef 100644 --- a/x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusLabelsRestIT.java +++ b/x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusLabelsRestIT.java @@ -12,6 +12,7 @@ import io.netty.handler.codec.compression.Snappy; import org.apache.http.HttpHeaders; +import org.apache.http.client.utils.URIBuilder; import org.apache.http.entity.ByteArrayEntity; import org.apache.http.entity.ContentType; import org.elasticsearch.client.Request; @@ -135,6 +136,26 @@ public void testGetWithMatchSelectorFiltersToMatchingLabels() throws Exception { assertThat(data, hasItem("unique_label")); } + @SuppressWarnings("unchecked") + public void testGetWithMultipleMatchSelectorsReturnsCombinedLabels() throws Exception { + writeMetric("multi_labels_metric_a", Map.of("label_only_in_a", "value_a")); + writeMetric("multi_labels_metric_b", Map.of("label_only_in_b", "value_b")); + writeMetric("multi_labels_metric_c", Map.of("label_only_in_c", "value_c")); // must not appear in results + + // Use URIBuilder to send two match[] selectors in a single request, working around the + // test client's single-value-per-key restriction on Request.addParameter. + Request request = new Request( + "GET", + new URIBuilder("/_prometheus/api/v1/labels").addParameter("match[]", "multi_labels_metric_a") + .addParameter("match[]", "multi_labels_metric_b") + .build() + .toString() + ); + List data = (List) entityAsMap(client().performRequest(request)).get("data"); + + assertThat(data, containsInAnyOrder("__name__", "label_only_in_a", "label_only_in_b")); + } + /** Builds a labels request with optional {@code match[]} parameters. */ private static Request labelsRequest(String... matchers) { Request request = new Request("GET", "/_prometheus/api/v1/labels"); diff --git a/x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusSeriesRestIT.java b/x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusSeriesRestIT.java index 2a7d7f132adff..51a7e7c73b03c 100644 --- a/x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusSeriesRestIT.java +++ b/x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusSeriesRestIT.java @@ -12,6 +12,7 @@ import io.netty.handler.codec.compression.Snappy; import org.apache.http.HttpHeaders; +import org.apache.http.client.utils.URIBuilder; import org.apache.http.entity.ByteArrayEntity; import org.apache.http.entity.ContentType; import org.apache.http.util.EntityUtils; @@ -31,6 +32,7 @@ import java.util.List; import java.util.Map; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; @@ -143,12 +145,28 @@ public void testSeriesWithIndexPattern() throws Exception { assertThat(data.getFirst().get("__name__"), equalTo("test_gauge_idx")); } + public void testGetWithMultipleMatchSelectorsReturnsCombinedSeries() throws Exception { + writeMetric("multi_series_selector_a", Map.of("job", "job_a")); + writeMetric("multi_series_selector_b", Map.of("job", "job_b")); + writeMetric("multi_series_selector_c", Map.of("job", "job_c")); // must not appear in results + + // Use URIBuilder to send two match[] selectors in a single request, working around the + // test client's single-value-per-key restriction on Request.addParameter. + Request request = new Request( + "GET", + new URIBuilder("/_prometheus/api/v1/series").addParameter("match[]", "multi_series_selector_a") + .addParameter("match[]", "multi_series_selector_b") + .build() + .toString() + ); + List> data = seriesData(client().performRequest(request)); + + List names = data.stream().map(s -> (String) s.get("__name__")).toList(); + assertThat(names, containsInAnyOrder("multi_series_selector_a", "multi_series_selector_b")); + } + // Helpers - /** - * Builds a series request with a single {@code match[]} parameter. - * TODO: support multiple {@code match[]} values once multi-value query param support lands. - */ private static Request seriesRequest(String matcher) { return seriesRequest(null, matcher); } diff --git a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesRestAction.java b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesRestAction.java index 1c2e9becc9e7a..6bdb34d491382 100644 --- a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesRestAction.java +++ b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesRestAction.java @@ -9,6 +9,7 @@ import org.elasticsearch.client.internal.node.NodeClient; import org.elasticsearch.rest.BaseRestHandler; +import org.elasticsearch.rest.RequestParams; import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.Scope; import org.elasticsearch.rest.ServerlessScope; @@ -73,9 +74,10 @@ protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient cli String labelName = PrometheusLabelNameUtils.decodeLabelName(rawName); String index = request.param(INDEX_PARAM, "*"); - // TODO: support multiple match[] selectors once multi-value param support is added - String matchSelector = request.param(MATCH_PARAM); - List matchSelectors = matchSelector != null ? List.of(matchSelector) : List.of(); + // Consume the parameter; re-parse from the raw URI to handle repeated match[] params, + // since request processing currently keeps only the last value for repeated parameters. + request.repeatedParamAsList(MATCH_PARAM); + List matchSelectors = RequestParams.fromUri(request.uri()).getAll(MATCH_PARAM); // Time range String endParam = request.param(END_PARAM); diff --git a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelsRestAction.java b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelsRestAction.java index ce17e81f60884..31be9d8bfc028 100644 --- a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelsRestAction.java +++ b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelsRestAction.java @@ -9,6 +9,7 @@ import org.elasticsearch.client.internal.node.NodeClient; import org.elasticsearch.rest.BaseRestHandler; +import org.elasticsearch.rest.RequestParams; import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.Scope; import org.elasticsearch.rest.ServerlessScope; @@ -59,8 +60,10 @@ public List routes() { @Override protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { - String matchParam = request.param(MATCH_PARAM); - List matchSelectors = matchParam != null ? List.of(matchParam) : List.of(); + // Consume the parameter; re-parse from the raw URI to handle repeated match[] params, + // since request processing currently keeps only the last value for repeated parameters. + request.repeatedParamAsList(MATCH_PARAM); + List matchSelectors = RequestParams.fromUri(request.uri()).getAll(MATCH_PARAM); // Time range String endParam = request.param(END_PARAM); diff --git a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusSeriesRestAction.java b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusSeriesRestAction.java index 8ce07cf3aca4b..fc754c72a524d 100644 --- a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusSeriesRestAction.java +++ b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusSeriesRestAction.java @@ -9,6 +9,7 @@ import org.elasticsearch.client.internal.node.NodeClient; import org.elasticsearch.rest.BaseRestHandler; +import org.elasticsearch.rest.RequestParams; import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.Scope; import org.elasticsearch.rest.ServerlessScope; @@ -57,12 +58,13 @@ public List routes() { @Override protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { - // TODO: support multiple match[] values once multi-value query param support lands - String matchSelector = request.param(MATCH_PARAM); - if (matchSelector == null) { + // Consume the parameter; re-parse from the raw URI to handle repeated match[] params, + // since request processing currently keeps only the last value for repeated parameters. + request.repeatedParamAsList(MATCH_PARAM); + List matchSelectors = RequestParams.fromUri(request.uri()).getAll(MATCH_PARAM); + if (matchSelectors.isEmpty()) { throw new IllegalArgumentException("At least one [match[]] selector is required"); } - List matchSelectors = List.of(matchSelector); // Time range String endParam = request.param(END_PARAM); From d016a4e7b95b92583b38a0932cc2d32bb111d4da Mon Sep 17 00:00:00 2001 From: Felix Barnsteiner Date: Tue, 31 Mar 2026 11:52:45 +0200 Subject: [PATCH 2/2] Add test for missing match[] selector in PrometheusSeriesRestAction --- .../rest/PrometheusSeriesRestActionTests.java | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 x-pack/plugin/prometheus/src/test/java/org/elasticsearch/xpack/prometheus/rest/PrometheusSeriesRestActionTests.java diff --git a/x-pack/plugin/prometheus/src/test/java/org/elasticsearch/xpack/prometheus/rest/PrometheusSeriesRestActionTests.java b/x-pack/plugin/prometheus/src/test/java/org/elasticsearch/xpack/prometheus/rest/PrometheusSeriesRestActionTests.java new file mode 100644 index 0000000000000..e124d73493d9d --- /dev/null +++ b/x-pack/plugin/prometheus/src/test/java/org/elasticsearch/xpack/prometheus/rest/PrometheusSeriesRestActionTests.java @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.prometheus.rest; + +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.client.NoOpNodeClient; +import org.elasticsearch.test.rest.FakeRestRequest; +import org.elasticsearch.threadpool.ThreadPool; +import org.junit.After; +import org.junit.Before; + +import java.util.Map; + +public class PrometheusSeriesRestActionTests extends ESTestCase { + + private ThreadPool threadPool; + private NoOpNodeClient client; + + @Before + public void setUp() throws Exception { + super.setUp(); + threadPool = createThreadPool(); + client = new NoOpNodeClient(threadPool); + } + + @After + @Override + public void tearDown() throws Exception { + super.tearDown(); + terminate(threadPool); + } + + public void testMissingMatchSelectorThrows() { + var action = new PrometheusSeriesRestAction(); + var httpRequest = new FakeRestRequest.FakeHttpRequest(RestRequest.Method.GET, "/_prometheus/api/v1/series", null, Map.of()); + var request = RestRequest.request(parserConfig(), httpRequest, new FakeRestRequest.FakeHttpChannel(null)); + var e = expectThrows(IllegalArgumentException.class, () -> action.prepareRequest(request, client)); + assertEquals("At least one [match[]] selector is required", e.getMessage()); + } +}