From af721aa5365dd5db20d7f1ad50c30ab080a0903c Mon Sep 17 00:00:00 2001 From: Felix Barnsteiner Date: Wed, 25 Mar 2026 14:12:14 +0100 Subject: [PATCH 1/3] Prometheus label values API: add rest action Implements the REST handler for GET /_prometheus/api/v1/label/{name}/values, wiring together the plan builder and response listener. Registers the action in PrometheusPlugin and adds integration tests. --- .../PrometheusLabelValuesRestIT.java | 213 ++++++++++++++++++ .../xpack/prometheus/PrometheusPlugin.java | 4 +- .../rest/PrometheusLabelNameUtils.java | 79 +++++++ .../rest/PrometheusLabelValuesRestAction.java | 101 +++++++++ .../rest/PrometheusLabelNameUtilsTests.java | 84 +++++++ 5 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusLabelValuesRestIT.java create mode 100644 x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelNameUtils.java create mode 100644 x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesRestAction.java create mode 100644 x-pack/plugin/prometheus/src/test/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelNameUtilsTests.java 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 new file mode 100644 index 0000000000000..c653fe4531a4d --- /dev/null +++ b/x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusLabelValuesRestIT.java @@ -0,0 +1,213 @@ +/* + * 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; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.compression.Snappy; + +import org.apache.http.HttpHeaders; +import org.apache.http.entity.ByteArrayEntity; +import org.apache.http.entity.ContentType; +import org.elasticsearch.client.Request; +import org.elasticsearch.client.Response; +import org.elasticsearch.client.ResponseException; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.test.cluster.ElasticsearchCluster; +import org.elasticsearch.test.cluster.FeatureFlag; +import org.elasticsearch.test.cluster.local.distribution.DistributionType; +import org.elasticsearch.test.rest.ESRestTestCase; +import org.elasticsearch.xpack.prometheus.proto.RemoteWrite; +import org.junit.ClassRule; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; + +/** + * Integration tests for the Prometheus {@code GET /_prometheus/api/v1/label/{name}/values} endpoint. + * + *

Tests focus on high-level HTTP concerns: routing, request/response format, status codes. + * Detailed plan-building and response-parsing logic is covered by unit tests. + */ +public class PrometheusLabelValuesRestIT extends ESRestTestCase { + + private static final String USER = "test_admin"; + private static final String PASS = "x-pack-test-password"; + private static final String DEFAULT_DATA_STREAM = "metrics-generic.prometheus-default"; + + @ClassRule + public static ElasticsearchCluster cluster = ElasticsearchCluster.local() + .distribution(DistributionType.DEFAULT) + .user(USER, PASS, "superuser", false) + .setting("xpack.security.enabled", "true") + .setting("xpack.security.autoconfiguration.enabled", "false") + .setting("xpack.license.self_generated.type", "trial") + .setting("xpack.ml.enabled", "false") + .setting("xpack.watcher.enabled", "false") + .feature(FeatureFlag.PROMETHEUS_FEATURE_FLAG) + .build(); + + @Override + protected String getTestRestCluster() { + return cluster.getHttpAddresses(); + } + + @Override + protected Settings restClientSettings() { + String token = basicAuthHeaderValue(USER, new SecureString(PASS.toCharArray())); + return Settings.builder().put(super.restClientSettings()).put(ThreadContext.PREFIX + ".Authorization", token).build(); + } + + public void testInvalidSelectorSyntaxReturnsBadRequest() throws Exception { + Request request = labelValuesRequest("job", "{not valid!!!}"); + ResponseException e = expectThrows(ResponseException.class, () -> client().performRequest(request)); + assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(400)); + } + + public void testRangeSelectorReturnsBadRequest() throws Exception { + // up[5m] is a range vector, not an instant vector + Request request = labelValuesRequest("job", "up[5m]"); + ResponseException e = expectThrows(ResponseException.class, () -> client().performRequest(request)); + assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(400)); + } + + public void testGetResponseIsJsonWithSuccessEnvelope() throws Exception { + writeMetric("test_gauge", Map.of("job", "prometheus")); + + Response response = client().performRequest(labelValuesRequest("job")); + + assertThat(response.getStatusLine().getStatusCode(), equalTo(200)); + assertThat(response.getEntity().getContentType().getValue(), containsString("application/json")); + + Map body = entityAsMap(response); + assertThat(body.get("status"), equalTo("success")); + assertThat(body.get("data"), notNullValue()); + } + + public void testUnknownLabelReturnsEmptyData() throws Exception { + Response response = client().performRequest(labelValuesRequest("label_that_does_not_exist_anywhere")); + + assertThat(response.getStatusLine().getStatusCode(), equalTo(200)); + List data = labelValuesData(response); + assertThat(data.isEmpty(), equalTo(true)); + } + + public void testGetReturnsValuesForRegularLabel() throws Exception { + writeMetric("roundtrip_gauge", Map.of("job", "node_exporter", "instance", "host1:9100")); + writeMetric("roundtrip_gauge", Map.of("job", "prometheus", "instance", "host2:9090")); + + List values = labelValuesData(client().performRequest(labelValuesRequest("job"))); + + assertThat(values, hasItem("node_exporter")); + assertThat(values, hasItem("prometheus")); + } + + public void testGetReturnsValuesForNameLabel() throws Exception { + writeMetric("name_label_metric_a", Map.of("job", "test")); + writeMetric("name_label_metric_b", Map.of("job", "test")); + + List values = labelValuesData(client().performRequest(labelValuesRequest("__name__"))); + + assertThat(values, hasItem("name_label_metric_a")); + assertThat(values, hasItem("name_label_metric_b")); + } + + public void testGetWithMatchSelectorFiltersValues() throws Exception { + writeMetric("selector_metric", Map.of("job", "filtered_job", "env", "prod")); + writeMetric("other_metric", Map.of("job", "other_job", "env", "staging")); + + // Only request values for "job" where the metric is selector_metric + List values = labelValuesData(client().performRequest(labelValuesRequest("job", "selector_metric"))); + + assertThat(values, hasItem("filtered_job")); + assertThat(values, not(hasItem("other_job"))); + } + + public void testGetValuesAreSorted() throws Exception { + writeMetric("sorted_gauge", Map.of("job", "zebra")); + writeMetric("sorted_gauge", Map.of("job", "alpha")); + writeMetric("sorted_gauge", Map.of("job", "middle")); + + List values = labelValuesData(client().performRequest(labelValuesRequest("job"))); + + // Extract just the values that we wrote (there may be others from earlier tests) + List ours = values.stream().filter(v -> List.of("zebra", "alpha", "middle").contains(v)).toList(); + assertThat(ours, equalTo(List.of("alpha", "middle", "zebra"))); + } + + public void testUEncodedLabelNameIsDecoded() throws Exception { + // U__http_2e_requests decodes to http.requests — which doesn't exist, so we just + // verify the endpoint is reachable and returns a 200 with an empty data array. + Response response = client().performRequest(new Request("GET", "/_prometheus/api/v1/label/U__http_2e_requests/values")); + assertThat(response.getStatusLine().getStatusCode(), equalTo(200)); + assertThat(entityAsMap(response).get("status"), equalTo("success")); + } + + private static Request labelValuesRequest(String labelName, String... matchers) { + Request request = new Request("GET", "/_prometheus/api/v1/label/" + labelName + "/values"); + for (String matcher : matchers) { + request.addParameter("match[]", matcher); + } + return request; + } + + @SuppressWarnings("unchecked") + private List labelValuesData(Response response) throws IOException { + Map body = entityAsMap(response); + return (List) body.get("data"); + } + + private void writeMetric(String metricName, Map labels) throws IOException { + writeMetric(metricName, labels, 1.0); + } + + private void writeMetric(String metricName, Map labels, double value) throws IOException { + RemoteWrite.TimeSeries.Builder ts = RemoteWrite.TimeSeries.newBuilder().addLabels(label("__name__", metricName)); + labels.forEach((k, v) -> ts.addLabels(label(k, v))); + ts.addSamples(sample(value, System.currentTimeMillis())); + + RemoteWrite.WriteRequest writeRequest = RemoteWrite.WriteRequest.newBuilder().addTimeseries(ts.build()).build(); + + Request request = new Request("POST", "/_prometheus/api/v1/write"); + request.setEntity(new ByteArrayEntity(snappyEncode(writeRequest.toByteArray()), ContentType.create("application/x-protobuf"))); + request.setOptions(request.getOptions().toBuilder().addHeader(HttpHeaders.CONTENT_ENCODING, "snappy")); + client().performRequest(request); + client().performRequest(new Request("POST", "/" + DEFAULT_DATA_STREAM + "/_refresh")); + } + + private static RemoteWrite.Label label(String name, String value) { + return RemoteWrite.Label.newBuilder().setName(name).setValue(value).build(); + } + + private static RemoteWrite.Sample sample(double value, long timestamp) { + return RemoteWrite.Sample.newBuilder().setValue(value).setTimestamp(timestamp).build(); + } + + private static byte[] snappyEncode(byte[] input) { + ByteBuf in = Unpooled.wrappedBuffer(input); + ByteBuf out = Unpooled.buffer(input.length); + try { + new Snappy().encode(in, out, input.length); + byte[] result = new byte[out.readableBytes()]; + out.readBytes(result); + return result; + } finally { + in.release(); + out.release(); + } + } +} diff --git a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/PrometheusPlugin.java b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/PrometheusPlugin.java index 9a99022a78aa3..75a0e4f78d114 100644 --- a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/PrometheusPlugin.java +++ b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/PrometheusPlugin.java @@ -22,6 +22,7 @@ import org.elasticsearch.plugins.Plugin; import org.elasticsearch.rest.RestHandler; import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.prometheus.rest.PrometheusLabelValuesRestAction; import org.elasticsearch.xpack.prometheus.rest.PrometheusQueryRangeRestAction; import org.elasticsearch.xpack.prometheus.rest.PrometheusRemoteWriteRestAction; import org.elasticsearch.xpack.prometheus.rest.PrometheusRemoteWriteTransportAction; @@ -100,7 +101,8 @@ public Collection getRestHandlers( assert indexingPressure.get() != null : "indexing pressure must be set if plugin is enabled"; return List.of( new PrometheusRemoteWriteRestAction(indexingPressure.get(), maxProtobufContentLengthBytes, recycler.get()), - new PrometheusQueryRangeRestAction() + new PrometheusQueryRangeRestAction(), + new PrometheusLabelValuesRestAction() ); } return List.of(); diff --git a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelNameUtils.java b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelNameUtils.java new file mode 100644 index 0000000000000..23f789d25ab45 --- /dev/null +++ b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelNameUtils.java @@ -0,0 +1,79 @@ +/* + * 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; + +/** + * Utilities for working with Prometheus label names, including decoding of the + * {@code U__} encoding defined by the OpenMetrics spec to represent characters + * that are not valid in Prometheus label names (e.g. dots, colons). + */ +final class PrometheusLabelNameUtils { + + private PrometheusLabelNameUtils() {} + + /** + * Decodes a label name that may use the {@code U__} encoding. + * + *

If the name does not start with {@code "U__"} it is returned as-is. + * Otherwise the {@code "U__"} prefix is stripped and the rest is decoded + * left-to-right: + *

+ */ + static String decodeLabelName(String name) { + if (name == null || name.startsWith("U__") == false) { + return name; + } + String encoded = name.substring(3); // strip "U__" + StringBuilder sb = new StringBuilder(encoded.length()); + int i = 0; + while (i < encoded.length()) { + if (encoded.startsWith("__", i)) { + sb.append('_'); + i += 2; + } else if (encoded.charAt(i) == '_') { + // Possibly a hex escape: _HEX_ where HEX is one or more hex digits + int closeIdx = encoded.indexOf('_', i + 1); + if (closeIdx > i + 1) { + String hexPart = encoded.substring(i + 1, closeIdx); + if (hexPart.isEmpty() == false && isHex(hexPart)) { + try { + int codePoint = Integer.parseInt(hexPart, 16); + sb.appendCodePoint(codePoint); + i = closeIdx + 1; + continue; + } catch (IllegalArgumentException ignored) { + // fall through to pass-through + } + } + } + // Graceful degradation: not a valid hex escape, pass through + sb.append(encoded.charAt(i)); + i++; + } else { + sb.append(encoded.charAt(i)); + i++; + } + } + return sb.toString(); + } + + private static boolean isHex(String s) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + continue; + } + return false; + } + return true; + } +} 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 new file mode 100644 index 0000000000000..5d48f7db4e68f --- /dev/null +++ b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesRestAction.java @@ -0,0 +1,101 @@ +/* + * 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.client.internal.node.NodeClient; +import org.elasticsearch.rest.BaseRestHandler; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.Scope; +import org.elasticsearch.rest.ServerlessScope; +import org.elasticsearch.xpack.esql.action.EsqlQueryAction; +import org.elasticsearch.xpack.esql.action.PreparedEsqlQueryRequest; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.parser.promql.PromqlParserUtils; +import org.elasticsearch.xpack.esql.plan.EsqlStatement; +import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; + +import static java.time.temporal.ChronoUnit.HOURS; +import static org.elasticsearch.rest.RestRequest.Method.GET; + +/** + * REST handler for the Prometheus {@code GET /_prometheus/api/v1/label/{name}/values} and + * {@code GET /_prometheus/{index}/api/v1/label/{name}/values} endpoints. + * Returns the sorted, deduplicated list of values for a single label name. + * Only GET is supported. POST with {@code application/x-www-form-urlencoded} bodies is rejected + * at the HTTP layer as a CSRF safeguard before this handler is ever reached — see + * {@code RestController#isContentTypeDisallowed}. + * + *

Label names may use the {@code U__} encoding defined by the OpenMetrics spec to represent + * characters that are not valid in Prometheus label names (e.g. dots, colons). This handler + * decodes such names before building the query plan. + * + *

When a label name is absent from all index mappings ESQL returns a {@code "Unknown column"} + * BAD_REQUEST error. The response listener converts that into an empty {@code data:[]} success + * response, which is the correct Prometheus behaviour for a label that has no values. + */ +@ServerlessScope(Scope.PUBLIC) +public class PrometheusLabelValuesRestAction extends BaseRestHandler { + + private static final String MATCH_PARAM = "match[]"; + private static final String START_PARAM = "start"; + private static final String END_PARAM = "end"; + private static final String LIMIT_PARAM = "limit"; + private static final String INDEX_PARAM = "index"; + + private static final int DEFAULT_LIMIT = 10_000; + private static final long DEFAULT_LOOKBACK_HOURS = 24; + + @Override + public String getName() { + return "prometheus_label_values_action"; + } + + @Override + public List routes() { + return List.of( + new Route(GET, "/_prometheus/api/v1/label/{name}/values"), + new Route(GET, "/_prometheus/{index}/api/v1/label/{name}/values") + ); + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + String rawName = request.param("name"); + 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(); + + // Time range + String endParam = request.param(END_PARAM); + String startParam = request.param(START_PARAM); + Instant end = endParam != null ? PromqlParserUtils.parseDate(Source.EMPTY, endParam) : Instant.now(); + Instant start = startParam != null + ? PromqlParserUtils.parseDate(Source.EMPTY, startParam) + : end.minus(DEFAULT_LOOKBACK_HOURS, HOURS); + + // Optional limit; default to DEFAULT_LIMIT to avoid unbounded ESQL scans + int limit = request.paramAsInt(LIMIT_PARAM, DEFAULT_LIMIT); + + LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan(labelName, index, matchSelectors, start, end, limit); + EsqlStatement statement = new EsqlStatement(plan, List.of()); + PreparedEsqlQueryRequest esqlRequest = PreparedEsqlQueryRequest.sync(statement, "prometheus_label_values"); + + return channel -> client.execute( + EsqlQueryAction.INSTANCE, + esqlRequest, + PrometheusLabelValuesResponseListener.create(channel, limit) + ); + } +} diff --git a/x-pack/plugin/prometheus/src/test/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelNameUtilsTests.java b/x-pack/plugin/prometheus/src/test/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelNameUtilsTests.java new file mode 100644 index 0000000000000..3b5726b7952ee --- /dev/null +++ b/x-pack/plugin/prometheus/src/test/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelNameUtilsTests.java @@ -0,0 +1,84 @@ +/* + * 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.test.ESTestCase; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +public class PrometheusLabelNameUtilsTests extends ESTestCase { + + public void testDecodePlainNameReturnedAsIs() { + assertThat(PrometheusLabelNameUtils.decodeLabelName("job"), equalTo("job")); + } + + public void testDecodeNameWithUnderscoresReturnedAsIs() { + assertThat(PrometheusLabelNameUtils.decodeLabelName("my_label"), equalTo("my_label")); + } + + public void testDecodeEmptyStringReturnedAsIs() { + assertThat(PrometheusLabelNameUtils.decodeLabelName(""), equalTo("")); + } + + public void testDecodeNullReturnedAsNull() { + assertThat(PrometheusLabelNameUtils.decodeLabelName(null), is(nullValue())); + } + + public void testDecodeDoubleUnderscoreBecomesUnderscore() { + // U____ → one underscore (U__ prefix stripped, then __ → _) + assertThat(PrometheusLabelNameUtils.decodeLabelName("U____"), equalTo("_")); + } + + public void testDecodeMultipleDoubleUnderscores() { + // U________ = U__ + ______ (6 underscores = 3 pairs of __) → ___ (three underscores) + assertThat(PrometheusLabelNameUtils.decodeLabelName("U________"), equalTo("___")); + } + + public void testDecodeHexEscapeForDot() { + // U__http_2e_status__code → http.status_code + // _2e_ is '.' (0x2E), __ is '_' + assertThat(PrometheusLabelNameUtils.decodeLabelName("U__http_2e_status__code"), equalTo("http.status_code")); + } + + public void testDecodeHexEscapeUpperCase() { + // U___2E_ → '.' (uppercase hex) + assertThat(PrometheusLabelNameUtils.decodeLabelName("U___2E_"), equalTo(".")); + } + + public void testDecodeHexEscapeForColon() { + // colon is 0x3A + assertThat(PrometheusLabelNameUtils.decodeLabelName("U__http_3a_requests"), equalTo("http:requests")); + } + + public void testDecodeMultiByteCodepoint() { + // U+1F600 = 😀 = 0x1F600 + assertThat(PrometheusLabelNameUtils.decodeLabelName("U___1F600_"), equalTo("😀")); + } + + public void testDecodeInvalidHexPassThrough() { + // _xyz_ is not a valid hex sequence → pass through the underscore character + assertThat(PrometheusLabelNameUtils.decodeLabelName("U__abc_xyz_def"), equalTo("abc_xyz_def")); + } + + public void testDecodeTrailingUnderscorePassThrough() { + // Trailing _ with no closing _ → pass through + assertThat(PrometheusLabelNameUtils.decodeLabelName("U__abc_"), equalTo("abc_")); + } + + public void testDecodeMixedExample() { + // U__my__label_2e_value → my_label.value + assertThat(PrometheusLabelNameUtils.decodeLabelName("U__my__label_2e_value"), equalTo("my_label.value")); + } + + public void testDecodePrefixOnlyIsEmpty() { + // "U__" with empty body → empty string + assertThat(PrometheusLabelNameUtils.decodeLabelName("U__"), equalTo("")); + } +} From 48fb3e4143cd90470c1c7673923a1b9b7b7e647d Mon Sep 17 00:00:00 2001 From: Felix Barnsteiner Date: Fri, 27 Mar 2026 15:59:38 +0100 Subject: [PATCH 2/3] Fix compile error: remove non-existent PROMETHEUS_FEATURE_FLAG from PrometheusLabelValuesRestIT The FeatureFlag enum does not have a PROMETHEUS_FEATURE_FLAG constant. The endpoint is already gated by xpack.prometheus.enabled (default: true), matching the pattern used by the other prometheus REST IT tests. --- .../xpack/prometheus/PrometheusLabelValuesRestIT.java | 2 -- 1 file changed, 2 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 c653fe4531a4d..79e0261259c04 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 @@ -21,7 +21,6 @@ import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.test.cluster.ElasticsearchCluster; -import org.elasticsearch.test.cluster.FeatureFlag; import org.elasticsearch.test.cluster.local.distribution.DistributionType; import org.elasticsearch.test.rest.ESRestTestCase; import org.elasticsearch.xpack.prometheus.proto.RemoteWrite; @@ -58,7 +57,6 @@ public class PrometheusLabelValuesRestIT extends ESRestTestCase { .setting("xpack.license.self_generated.type", "trial") .setting("xpack.ml.enabled", "false") .setting("xpack.watcher.enabled", "false") - .feature(FeatureFlag.PROMETHEUS_FEATURE_FLAG) .build(); @Override From 1606cd1a2156ed1727bbfccabb15a7989348cb00 Mon Sep 17 00:00:00 2001 From: Felix Barnsteiner Date: Fri, 27 Mar 2026 16:01:24 +0100 Subject: [PATCH 3/3] Prometheus label values: limit=0 defers to ESQL result_truncation_max_size When limit=0 (Prometheus "disabled" semantics), always emit an explicit LIMIT Integer.MAX_VALUE node so ESQL silently caps to its own result_truncation_max_size setting (default 10 000) instead of adding a default limit of 1 000 with a "No limit defined" warning. Change the default from 10 000 to 0 to match Prometheus semantics where omitting the parameter means no explicit limit. --- .../PrometheusLabelValuesPlanBuilder.java | 23 ++++---- .../rest/PrometheusLabelValuesRestAction.java | 6 ++- ...PrometheusLabelValuesPlanBuilderTests.java | 52 +++++++++++-------- 3 files changed, 44 insertions(+), 37 deletions(-) diff --git a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesPlanBuilder.java b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesPlanBuilder.java index a8365ee1c3d9a..b6ef08486f7d9 100644 --- a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesPlanBuilder.java +++ b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesPlanBuilder.java @@ -34,7 +34,7 @@ * *

For {@code __name__}: *

- * [Limit(limit+1)]
+ * Limit(limit==0 ? MAX_VALUE : limit+1)
  *   └── OrderBy([metric_name ASC NULLS LAST])
  *         └── Aggregate(groupings=[metric_name])
  *               └── MetricsInfo
@@ -44,16 +44,19 @@
  *
  * 

For regular labels (e.g. {@code job}): *

- * [Limit(limit+1)]
+ * Limit(limit==0 ? MAX_VALUE : limit+1)
  *   └── OrderBy([job ASC NULLS LAST])
  *         └── Aggregate(groupings=[job])
  *               └── Filter(timeCond AND IS_NOT_NULL(job) [AND OR(selectorConds...)])
  *                     └── UnresolvedRelation("*", TS)
  * 
* - *

The Limit node uses {@code limit + 1} as a sentinel: if the result contains {@code limit + 1} - * rows the response listener will truncate to {@code limit} and emit a warning. When {@code limit == 0} - * the Limit node is omitted entirely. + *

A Limit node is always emitted. When {@code limit == 0} (Prometheus "disabled" semantics) the + * value {@link Integer#MAX_VALUE} is used so that ESQL silently caps results to its own + * {@code esql.query.result_truncation_max_size} setting without emitting a "No limit defined" + * warning. When {@code limit > 0} the value {@code limit + 1} is used as a sentinel: if the result + * contains exactly {@code limit + 1} rows the response listener truncates to {@code limit} and emits + * a warning. */ final class PrometheusLabelValuesPlanBuilder { @@ -73,7 +76,7 @@ private PrometheusLabelValuesPlanBuilder() {} * @param matchSelectors list of {@code match[]} selector strings (may be empty) * @param start start of the time range (inclusive) * @param end end of the time range (inclusive) - * @param limit maximum number of values to return (0 = disabled) + * @param limit maximum number of values to return (0 = disabled, defers to ESQL max) * @return the logical plan * @throws IllegalArgumentException if a selector is not a valid instant vector selector */ @@ -97,9 +100,7 @@ private static LogicalPlan buildNamePlan(String index, List matchSelecto plan, List.of(new Order(Source.EMPTY, metricNameField, Order.OrderDirection.ASC, Order.NullsPosition.LAST)) ); - if (limit > 0) { - plan = new Limit(Source.EMPTY, Literal.integer(Source.EMPTY, limit + 1), plan); - } + plan = new Limit(Source.EMPTY, Literal.integer(Source.EMPTY, limit == 0 ? Integer.MAX_VALUE : limit + 1), plan); return plan; } @@ -135,9 +136,7 @@ private static LogicalPlan buildRegularLabelPlan( plan, List.of(new Order(Source.EMPTY, labelField, Order.OrderDirection.ASC, Order.NullsPosition.LAST)) ); - if (limit > 0) { - plan = new Limit(Source.EMPTY, Literal.integer(Source.EMPTY, limit + 1), plan); - } + plan = new Limit(Source.EMPTY, Literal.integer(Source.EMPTY, limit == 0 ? Integer.MAX_VALUE : limit + 1), plan); return plan; } } 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 5d48f7db4e68f..1c2e9becc9e7a 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 @@ -51,7 +51,7 @@ public class PrometheusLabelValuesRestAction extends BaseRestHandler { private static final String LIMIT_PARAM = "limit"; private static final String INDEX_PARAM = "index"; - private static final int DEFAULT_LIMIT = 10_000; + private static final int DEFAULT_LIMIT = 0; private static final long DEFAULT_LOOKBACK_HOURS = 24; @Override @@ -85,7 +85,9 @@ protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient cli ? PromqlParserUtils.parseDate(Source.EMPTY, startParam) : end.minus(DEFAULT_LOOKBACK_HOURS, HOURS); - // Optional limit; default to DEFAULT_LIMIT to avoid unbounded ESQL scans + // Optional limit; 0 means "disabled" (Prometheus semantics), which defers to the ESQL + // result_truncation_max_size cluster setting (default 10 000). Positive values use a + // limit+1 sentinel to detect and report truncation. int limit = request.paramAsInt(LIMIT_PARAM, DEFAULT_LIMIT); LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan(labelName, index, matchSelectors, start, end, limit); diff --git a/x-pack/plugin/prometheus/src/test/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesPlanBuilderTests.java b/x-pack/plugin/prometheus/src/test/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesPlanBuilderTests.java index 0992216fe0ba1..f1d8d2acd27b4 100644 --- a/x-pack/plugin/prometheus/src/test/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesPlanBuilderTests.java +++ b/x-pack/plugin/prometheus/src/test/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesPlanBuilderTests.java @@ -32,9 +32,16 @@ public class PrometheusLabelValuesPlanBuilderTests extends ESTestCase { private static final Instant START = Instant.ofEpochSecond(1_700_000_000L); private static final Instant END = Instant.ofEpochSecond(1_700_003_600L); - public void testNameLabelPlanTopIsOrderByWhenNoLimit() { + public void testNameLabelPlanZeroLimitTopIsLimit() { LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("__name__", "*", List.of(), START, END, 0); - assertThat(plan, instanceOf(OrderBy.class)); + assertThat(plan, instanceOf(Limit.class)); + assertThat(((Limit) plan).child(), instanceOf(OrderBy.class)); + } + + public void testNameLabelPlanZeroLimitUsesIntMaxValue() { + LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("__name__", "*", List.of(), START, END, 0); + Limit limit = (Limit) plan; + assertThat(limit.limit().toString(), containsString(String.valueOf(Integer.MAX_VALUE))); } public void testNameLabelPlanTopIsLimitWhenLimitSet() { @@ -43,11 +50,6 @@ public void testNameLabelPlanTopIsLimitWhenLimitSet() { assertThat(((Limit) plan).child(), instanceOf(OrderBy.class)); } - public void testNameLabelPlanZeroLimitOmitsLimitNode() { - LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("__name__", "*", List.of(), START, END, 0); - assertThat(plan, instanceOf(OrderBy.class)); - } - public void testNameLabelPlanLimitSentinelIsLimitPlusOne() { LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("__name__", "*", List.of(), START, END, 5); Limit limit = (Limit) plan; @@ -57,25 +59,26 @@ public void testNameLabelPlanLimitSentinelIsLimitPlusOne() { public void testNameLabelPlanContainsAggregateUnderOrderBy() { LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("__name__", "*", List.of(), START, END, 0); - assertThat(((OrderBy) plan).child(), instanceOf(Aggregate.class)); + OrderBy orderBy = (OrderBy) ((Limit) plan).child(); + assertThat(orderBy.child(), instanceOf(Aggregate.class)); } public void testNameLabelPlanContainsMetricsInfoUnderAggregate() { LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("__name__", "*", List.of(), START, END, 0); - Aggregate agg = (Aggregate) ((OrderBy) plan).child(); + Aggregate agg = (Aggregate) ((OrderBy) ((Limit) plan).child()).child(); assertThat(agg.child(), instanceOf(MetricsInfo.class)); } public void testNameLabelPlanContainsFilterUnderMetricsInfo() { LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("__name__", "*", List.of(), START, END, 0); - Aggregate agg = (Aggregate) ((OrderBy) plan).child(); + Aggregate agg = (Aggregate) ((OrderBy) ((Limit) plan).child()).child(); MetricsInfo metricsInfo = (MetricsInfo) agg.child(); assertThat(metricsInfo.child(), instanceOf(Filter.class)); } public void testNameLabelPlanSourceIsUnresolvedRelation() { LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("__name__", "*", List.of(), START, END, 0); - Aggregate agg = (Aggregate) ((OrderBy) plan).child(); + Aggregate agg = (Aggregate) ((OrderBy) ((Limit) plan).child()).child(); MetricsInfo metricsInfo = (MetricsInfo) agg.child(); Filter filter = (Filter) metricsInfo.child(); assertThat(filter.child(), instanceOf(UnresolvedRelation.class)); @@ -83,16 +86,23 @@ public void testNameLabelPlanSourceIsUnresolvedRelation() { public void testNameLabelPlanFilterConditionIsNotNull() { LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("__name__", "*", List.of(), START, END, 0); - Aggregate agg = (Aggregate) ((OrderBy) plan).child(); + Aggregate agg = (Aggregate) ((OrderBy) ((Limit) plan).child()).child(); MetricsInfo metricsInfo = (MetricsInfo) agg.child(); Filter filter = (Filter) metricsInfo.child(); // Just verify the filter condition is present (non-null) — structural checks above cover the plan shape assertThat(filter.condition(), instanceOf(Expression.class)); } - public void testRegularLabelPlanTopIsOrderByWhenNoLimit() { + public void testRegularLabelPlanZeroLimitTopIsLimit() { LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of(), START, END, 0); - assertThat(plan, instanceOf(OrderBy.class)); + assertThat(plan, instanceOf(Limit.class)); + assertThat(((Limit) plan).child(), instanceOf(OrderBy.class)); + } + + public void testRegularLabelPlanZeroLimitUsesIntMaxValue() { + LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of(), START, END, 0); + Limit limit = (Limit) plan; + assertThat(limit.limit().toString(), containsString(String.valueOf(Integer.MAX_VALUE))); } public void testRegularLabelPlanTopIsLimitWhenLimitSet() { @@ -101,11 +111,6 @@ public void testRegularLabelPlanTopIsLimitWhenLimitSet() { assertThat(((Limit) plan).child(), instanceOf(OrderBy.class)); } - public void testRegularLabelPlanZeroLimitOmitsLimitNode() { - LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of(), START, END, 0); - assertThat(plan, instanceOf(OrderBy.class)); - } - public void testRegularLabelPlanLimitSentinelIsLimitPlusOne() { LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of(), START, END, 7); Limit limit = (Limit) plan; @@ -114,19 +119,20 @@ public void testRegularLabelPlanLimitSentinelIsLimitPlusOne() { public void testRegularLabelPlanContainsAggregateUnderOrderBy() { LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of(), START, END, 0); - assertThat(((OrderBy) plan).child(), instanceOf(Aggregate.class)); + OrderBy orderBy = (OrderBy) ((Limit) plan).child(); + assertThat(orderBy.child(), instanceOf(Aggregate.class)); } public void testRegularLabelPlanHasNoMetricsInfo() { LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of(), START, END, 0); - Aggregate agg = (Aggregate) ((OrderBy) plan).child(); + Aggregate agg = (Aggregate) ((OrderBy) ((Limit) plan).child()).child(); // The child of Aggregate must be Filter (not MetricsInfo) assertThat(agg.child(), instanceOf(Filter.class)); } public void testRegularLabelPlanFilterContainsIsNotNull() { LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of(), START, END, 0); - Aggregate agg = (Aggregate) ((OrderBy) plan).child(); + Aggregate agg = (Aggregate) ((OrderBy) ((Limit) plan).child()).child(); Filter filter = (Filter) agg.child(); assertThat("Filter condition must contain an IsNotNull node", containsIsNotNull(filter.condition()), is(true)); } @@ -152,7 +158,7 @@ private static boolean containsIsNotNull(Expression expr) { public void testRegularLabelPlanSourceIsUnresolvedRelation() { LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of(), START, END, 0); - Aggregate agg = (Aggregate) ((OrderBy) plan).child(); + Aggregate agg = (Aggregate) ((OrderBy) ((Limit) plan).child()).child(); Filter filter = (Filter) agg.child(); assertThat(filter.child(), instanceOf(UnresolvedRelation.class)); }