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 new file mode 100644 index 0000000000000..fd856acea0c66 --- /dev/null +++ b/x-pack/plugin/prometheus/src/javaRestTest/java/org/elasticsearch/xpack/prometheus/PrometheusLabelsRestIT.java @@ -0,0 +1,192 @@ +/* + * 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.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.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.notNullValue; + +/** + * Integration tests for the Prometheus {@code GET /api/v1/labels} 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 PrometheusLabelsRestIT 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") + .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 { + // {not valid!!!} is not valid PromQL + Request request = labelsRequest("{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 = labelsRequest("up[5m]"); + ResponseException e = expectThrows(ResponseException.class, () -> client().performRequest(request)); + assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(400)); + } + + public void testGetWithNoMatchSelectorReturnsSuccess() throws Exception { + // match[] is optional for the labels endpoint (unlike series) + writeMetric("labels_no_selector_gauge", Map.of()); + Request request = new Request("GET", "/_prometheus/api/v1/labels"); + Response response = client().performRequest(request); + + assertThat(response.getStatusLine().getStatusCode(), equalTo(200)); + Map body = entityAsMap(response); + assertThat(body.get("status"), equalTo("success")); + assertThat(body.get("data"), notNullValue()); + } + + public void testGetResponseIsJsonWithSuccessEnvelope() throws Exception { + writeMetric("labels_format_gauge", Map.of()); + + Response response = queryLabels("labels_format_gauge"); + + 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 testGetAlwaysReturnsNameLabel() throws Exception { + writeMetric("labels_name_gauge", Map.of("job", "labels_test")); + + List data = queryLabelsData("labels_name_gauge"); + + assertThat(data, containsInAnyOrder("__name__", "job")); + } + + public void testGetReturnsIndexedLabels() throws Exception { + writeMetric("labels_round_trip_gauge", Map.of("job", "labels_test", "instance", "localhost:9090")); + + List data = queryLabelsData("labels_round_trip_gauge"); + + assertThat(data, containsInAnyOrder("__name__", "instance", "job")); + } + + public void testGetWithMatchSelectorFiltersToMatchingLabels() throws Exception { + writeMetric("labels_filtered_gauge", Map.of("unique_label", "only_here")); + writeMetric("labels_other_gauge", Map.of("other_label", "other_value")); + + // Query by exact metric name — should return labels only from the matched series + List data = queryLabelsData("labels_filtered_gauge"); + + assertThat(data, hasItem("unique_label")); + } + + /** Builds a labels request with optional {@code match[]} parameters. */ + private static Request labelsRequest(String... matchers) { + Request request = new Request("GET", "/_prometheus/api/v1/labels"); + for (String matcher : matchers) { + request.addParameter("match[]", matcher); + } + return request; + } + + private Response queryLabels(String... matchers) throws IOException { + return client().performRequest(labelsRequest(matchers)); + } + + @SuppressWarnings("unchecked") + private List queryLabelsData(String... matchers) throws IOException { + Map body = entityAsMap(queryLabels(matchers)); + return (List) body.get("data"); + } + + private void writeMetric(String metricName, Map labels) 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(1.0, 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 815391a27f6a5..402ce089bdd3c 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.rest.RestHandler; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.prometheus.rest.PrometheusLabelValuesRestAction; +import org.elasticsearch.xpack.prometheus.rest.PrometheusLabelsRestAction; import org.elasticsearch.xpack.prometheus.rest.PrometheusQueryRangeRestAction; import org.elasticsearch.xpack.prometheus.rest.PrometheusRemoteWriteRestAction; import org.elasticsearch.xpack.prometheus.rest.PrometheusRemoteWriteTransportAction; @@ -99,6 +100,7 @@ public Collection getRestHandlers( return List.of( new PrometheusRemoteWriteRestAction(indexingPressure.get(), maxProtobufContentLengthBytes, recycler.get()), new PrometheusQueryRangeRestAction(), + new PrometheusLabelsRestAction(), new PrometheusLabelValuesRestAction() ); } 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 new file mode 100644 index 0000000000000..ce17e81f60884 --- /dev/null +++ b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelsRestAction.java @@ -0,0 +1,86 @@ +/* + * 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 /api/v1/labels} endpoint. + * Returns the sorted list of label names across all matching time series. + * The optional {@code {index}} path parameter restricts the query to a specific index pattern; + * when omitted, all indices ({@code "*"}) are searched. + * 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}. + */ +@ServerlessScope(Scope.PUBLIC) +public class PrometheusLabelsRestAction 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 = 0; // 0 = no limit, matching Prometheus semantics + private static final long DEFAULT_LOOKBACK_HOURS = 24; + + @Override + public String getName() { + return "prometheus_labels_action"; + } + + @Override + public List routes() { + return List.of(new Route(GET, "/_prometheus/api/v1/labels"), new Route(GET, "/_prometheus/{index}/api/v1/labels")); + } + + @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(); + + // 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; 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); + + String index = request.param(INDEX_PARAM, "*"); + LogicalPlan plan = PrometheusLabelsPlanBuilder.buildPlan(index, matchSelectors, start, end, limit); + EsqlStatement statement = new EsqlStatement(plan, List.of()); + PreparedEsqlQueryRequest esqlRequest = PreparedEsqlQueryRequest.sync(statement, "prometheus_labels"); + + return channel -> client.execute(EsqlQueryAction.INSTANCE, esqlRequest, PrometheusLabelsResponseListener.create(channel, limit)); + } + +}