Skip to content
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<String, Object> 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<String, Object> 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<String> 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<String> 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<String> 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<String> queryLabelsData(String... matchers) throws IOException {
Map<String, Object> body = entityAsMap(queryLabels(matchers));
return (List<String>) body.get("data");
}

private void writeMetric(String metricName, Map<String, String> 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -99,6 +100,7 @@ public Collection<RestHandler> getRestHandlers(
return List.of(
new PrometheusRemoteWriteRestAction(indexingPressure.get(), maxProtobufContentLengthBytes, recycler.get()),
new PrometheusQueryRangeRestAction(),
new PrometheusLabelsRestAction(),
new PrometheusLabelValuesRestAction()
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Route> 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<String> 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));
}

}
Loading