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));
+ }
+
+}