Skip to content

Commit 5d29b03

Browse files
authored
Prometheus label values API: add rest action (elastic#145098)
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.
1 parent 1e7ce86 commit 5d29b03

File tree

7 files changed

+520
-36
lines changed

7 files changed

+520
-36
lines changed
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.prometheus;
9+
10+
import io.netty.buffer.ByteBuf;
11+
import io.netty.buffer.Unpooled;
12+
import io.netty.handler.codec.compression.Snappy;
13+
14+
import org.apache.http.HttpHeaders;
15+
import org.apache.http.entity.ByteArrayEntity;
16+
import org.apache.http.entity.ContentType;
17+
import org.elasticsearch.client.Request;
18+
import org.elasticsearch.client.Response;
19+
import org.elasticsearch.client.ResponseException;
20+
import org.elasticsearch.common.settings.SecureString;
21+
import org.elasticsearch.common.settings.Settings;
22+
import org.elasticsearch.common.util.concurrent.ThreadContext;
23+
import org.elasticsearch.test.cluster.ElasticsearchCluster;
24+
import org.elasticsearch.test.cluster.local.distribution.DistributionType;
25+
import org.elasticsearch.test.rest.ESRestTestCase;
26+
import org.elasticsearch.xpack.prometheus.proto.RemoteWrite;
27+
import org.junit.ClassRule;
28+
29+
import java.io.IOException;
30+
import java.util.List;
31+
import java.util.Map;
32+
33+
import static org.hamcrest.Matchers.containsString;
34+
import static org.hamcrest.Matchers.equalTo;
35+
import static org.hamcrest.Matchers.hasItem;
36+
import static org.hamcrest.Matchers.not;
37+
import static org.hamcrest.Matchers.notNullValue;
38+
39+
/**
40+
* Integration tests for the Prometheus {@code GET /_prometheus/api/v1/label/{name}/values} endpoint.
41+
*
42+
* <p>Tests focus on high-level HTTP concerns: routing, request/response format, status codes.
43+
* Detailed plan-building and response-parsing logic is covered by unit tests.
44+
*/
45+
public class PrometheusLabelValuesRestIT extends ESRestTestCase {
46+
47+
private static final String USER = "test_admin";
48+
private static final String PASS = "x-pack-test-password";
49+
private static final String DEFAULT_DATA_STREAM = "metrics-generic.prometheus-default";
50+
51+
@ClassRule
52+
public static ElasticsearchCluster cluster = ElasticsearchCluster.local()
53+
.distribution(DistributionType.DEFAULT)
54+
.user(USER, PASS, "superuser", false)
55+
.setting("xpack.security.enabled", "true")
56+
.setting("xpack.security.autoconfiguration.enabled", "false")
57+
.setting("xpack.license.self_generated.type", "trial")
58+
.setting("xpack.ml.enabled", "false")
59+
.setting("xpack.watcher.enabled", "false")
60+
.build();
61+
62+
@Override
63+
protected String getTestRestCluster() {
64+
return cluster.getHttpAddresses();
65+
}
66+
67+
@Override
68+
protected Settings restClientSettings() {
69+
String token = basicAuthHeaderValue(USER, new SecureString(PASS.toCharArray()));
70+
return Settings.builder().put(super.restClientSettings()).put(ThreadContext.PREFIX + ".Authorization", token).build();
71+
}
72+
73+
public void testInvalidSelectorSyntaxReturnsBadRequest() throws Exception {
74+
Request request = labelValuesRequest("job", "{not valid!!!}");
75+
ResponseException e = expectThrows(ResponseException.class, () -> client().performRequest(request));
76+
assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(400));
77+
}
78+
79+
public void testRangeSelectorReturnsBadRequest() throws Exception {
80+
// up[5m] is a range vector, not an instant vector
81+
Request request = labelValuesRequest("job", "up[5m]");
82+
ResponseException e = expectThrows(ResponseException.class, () -> client().performRequest(request));
83+
assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(400));
84+
}
85+
86+
public void testGetResponseIsJsonWithSuccessEnvelope() throws Exception {
87+
writeMetric("test_gauge", Map.of("job", "prometheus"));
88+
89+
Response response = client().performRequest(labelValuesRequest("job"));
90+
91+
assertThat(response.getStatusLine().getStatusCode(), equalTo(200));
92+
assertThat(response.getEntity().getContentType().getValue(), containsString("application/json"));
93+
94+
Map<String, Object> body = entityAsMap(response);
95+
assertThat(body.get("status"), equalTo("success"));
96+
assertThat(body.get("data"), notNullValue());
97+
}
98+
99+
public void testUnknownLabelReturnsEmptyData() throws Exception {
100+
Response response = client().performRequest(labelValuesRequest("label_that_does_not_exist_anywhere"));
101+
102+
assertThat(response.getStatusLine().getStatusCode(), equalTo(200));
103+
List<String> data = labelValuesData(response);
104+
assertThat(data.isEmpty(), equalTo(true));
105+
}
106+
107+
public void testGetReturnsValuesForRegularLabel() throws Exception {
108+
writeMetric("roundtrip_gauge", Map.of("job", "node_exporter", "instance", "host1:9100"));
109+
writeMetric("roundtrip_gauge", Map.of("job", "prometheus", "instance", "host2:9090"));
110+
111+
List<String> values = labelValuesData(client().performRequest(labelValuesRequest("job")));
112+
113+
assertThat(values, hasItem("node_exporter"));
114+
assertThat(values, hasItem("prometheus"));
115+
}
116+
117+
public void testGetReturnsValuesForNameLabel() throws Exception {
118+
writeMetric("name_label_metric_a", Map.of("job", "test"));
119+
writeMetric("name_label_metric_b", Map.of("job", "test"));
120+
121+
List<String> values = labelValuesData(client().performRequest(labelValuesRequest("__name__")));
122+
123+
assertThat(values, hasItem("name_label_metric_a"));
124+
assertThat(values, hasItem("name_label_metric_b"));
125+
}
126+
127+
public void testGetWithMatchSelectorFiltersValues() throws Exception {
128+
writeMetric("selector_metric", Map.of("job", "filtered_job", "env", "prod"));
129+
writeMetric("other_metric", Map.of("job", "other_job", "env", "staging"));
130+
131+
// Only request values for "job" where the metric is selector_metric
132+
List<String> values = labelValuesData(client().performRequest(labelValuesRequest("job", "selector_metric")));
133+
134+
assertThat(values, hasItem("filtered_job"));
135+
assertThat(values, not(hasItem("other_job")));
136+
}
137+
138+
public void testGetValuesAreSorted() throws Exception {
139+
writeMetric("sorted_gauge", Map.of("job", "zebra"));
140+
writeMetric("sorted_gauge", Map.of("job", "alpha"));
141+
writeMetric("sorted_gauge", Map.of("job", "middle"));
142+
143+
List<String> values = labelValuesData(client().performRequest(labelValuesRequest("job")));
144+
145+
// Extract just the values that we wrote (there may be others from earlier tests)
146+
List<String> ours = values.stream().filter(v -> List.of("zebra", "alpha", "middle").contains(v)).toList();
147+
assertThat(ours, equalTo(List.of("alpha", "middle", "zebra")));
148+
}
149+
150+
public void testUEncodedLabelNameIsDecoded() throws Exception {
151+
// U__http_2e_requests decodes to http.requests — which doesn't exist, so we just
152+
// verify the endpoint is reachable and returns a 200 with an empty data array.
153+
Response response = client().performRequest(new Request("GET", "/_prometheus/api/v1/label/U__http_2e_requests/values"));
154+
assertThat(response.getStatusLine().getStatusCode(), equalTo(200));
155+
assertThat(entityAsMap(response).get("status"), equalTo("success"));
156+
}
157+
158+
private static Request labelValuesRequest(String labelName, String... matchers) {
159+
Request request = new Request("GET", "/_prometheus/api/v1/label/" + labelName + "/values");
160+
for (String matcher : matchers) {
161+
request.addParameter("match[]", matcher);
162+
}
163+
return request;
164+
}
165+
166+
@SuppressWarnings("unchecked")
167+
private List<String> labelValuesData(Response response) throws IOException {
168+
Map<String, Object> body = entityAsMap(response);
169+
return (List<String>) body.get("data");
170+
}
171+
172+
private void writeMetric(String metricName, Map<String, String> labels) throws IOException {
173+
writeMetric(metricName, labels, 1.0);
174+
}
175+
176+
private void writeMetric(String metricName, Map<String, String> labels, double value) throws IOException {
177+
RemoteWrite.TimeSeries.Builder ts = RemoteWrite.TimeSeries.newBuilder().addLabels(label("__name__", metricName));
178+
labels.forEach((k, v) -> ts.addLabels(label(k, v)));
179+
ts.addSamples(sample(value, System.currentTimeMillis()));
180+
181+
RemoteWrite.WriteRequest writeRequest = RemoteWrite.WriteRequest.newBuilder().addTimeseries(ts.build()).build();
182+
183+
Request request = new Request("POST", "/_prometheus/api/v1/write");
184+
request.setEntity(new ByteArrayEntity(snappyEncode(writeRequest.toByteArray()), ContentType.create("application/x-protobuf")));
185+
request.setOptions(request.getOptions().toBuilder().addHeader(HttpHeaders.CONTENT_ENCODING, "snappy"));
186+
client().performRequest(request);
187+
client().performRequest(new Request("POST", "/" + DEFAULT_DATA_STREAM + "/_refresh"));
188+
}
189+
190+
private static RemoteWrite.Label label(String name, String value) {
191+
return RemoteWrite.Label.newBuilder().setName(name).setValue(value).build();
192+
}
193+
194+
private static RemoteWrite.Sample sample(double value, long timestamp) {
195+
return RemoteWrite.Sample.newBuilder().setValue(value).setTimestamp(timestamp).build();
196+
}
197+
198+
private static byte[] snappyEncode(byte[] input) {
199+
ByteBuf in = Unpooled.wrappedBuffer(input);
200+
ByteBuf out = Unpooled.buffer(input.length);
201+
try {
202+
new Snappy().encode(in, out, input.length);
203+
byte[] result = new byte[out.readableBytes()];
204+
out.readBytes(result);
205+
return result;
206+
} finally {
207+
in.release();
208+
out.release();
209+
}
210+
}
211+
}

x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/PrometheusPlugin.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import org.elasticsearch.plugins.Plugin;
2222
import org.elasticsearch.rest.RestHandler;
2323
import org.elasticsearch.xpack.core.XPackSettings;
24+
import org.elasticsearch.xpack.prometheus.rest.PrometheusLabelValuesRestAction;
2425
import org.elasticsearch.xpack.prometheus.rest.PrometheusQueryRangeRestAction;
2526
import org.elasticsearch.xpack.prometheus.rest.PrometheusRemoteWriteRestAction;
2627
import org.elasticsearch.xpack.prometheus.rest.PrometheusRemoteWriteTransportAction;
@@ -97,7 +98,8 @@ public Collection<RestHandler> getRestHandlers(
9798
assert indexingPressure.get() != null : "indexing pressure must be set if plugin is enabled";
9899
return List.of(
99100
new PrometheusRemoteWriteRestAction(indexingPressure.get(), maxProtobufContentLengthBytes, recycler.get()),
100-
new PrometheusQueryRangeRestAction()
101+
new PrometheusQueryRangeRestAction(),
102+
new PrometheusLabelValuesRestAction()
101103
);
102104
}
103105
return List.of();
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.prometheus.rest;
9+
10+
/**
11+
* Utilities for working with Prometheus label names, including decoding of the
12+
* {@code U__} encoding defined by the OpenMetrics spec to represent characters
13+
* that are not valid in Prometheus label names (e.g. dots, colons).
14+
*/
15+
final class PrometheusLabelNameUtils {
16+
17+
private PrometheusLabelNameUtils() {}
18+
19+
/**
20+
* Decodes a label name that may use the {@code U__} encoding.
21+
*
22+
* <p>If the name does not start with {@code "U__"} it is returned as-is.
23+
* Otherwise the {@code "U__"} prefix is stripped and the rest is decoded
24+
* left-to-right:
25+
* <ul>
26+
* <li>{@code "__"} → {@code '_'}</li>
27+
* <li>{@code "_HEX_"} where HEX is a hex codepoint → the corresponding Unicode character</li>
28+
* <li>any other character → passed through unchanged</li>
29+
* </ul>
30+
*/
31+
static String decodeLabelName(String name) {
32+
if (name == null || name.startsWith("U__") == false) {
33+
return name;
34+
}
35+
String encoded = name.substring(3); // strip "U__"
36+
StringBuilder sb = new StringBuilder(encoded.length());
37+
int i = 0;
38+
while (i < encoded.length()) {
39+
if (encoded.startsWith("__", i)) {
40+
sb.append('_');
41+
i += 2;
42+
} else if (encoded.charAt(i) == '_') {
43+
// Possibly a hex escape: _HEX_ where HEX is one or more hex digits
44+
int closeIdx = encoded.indexOf('_', i + 1);
45+
if (closeIdx > i + 1) {
46+
String hexPart = encoded.substring(i + 1, closeIdx);
47+
if (hexPart.isEmpty() == false && isHex(hexPart)) {
48+
try {
49+
int codePoint = Integer.parseInt(hexPart, 16);
50+
sb.appendCodePoint(codePoint);
51+
i = closeIdx + 1;
52+
continue;
53+
} catch (IllegalArgumentException ignored) {
54+
// fall through to pass-through
55+
}
56+
}
57+
}
58+
// Graceful degradation: not a valid hex escape, pass through
59+
sb.append(encoded.charAt(i));
60+
i++;
61+
} else {
62+
sb.append(encoded.charAt(i));
63+
i++;
64+
}
65+
}
66+
return sb.toString();
67+
}
68+
69+
private static boolean isHex(String s) {
70+
for (int i = 0; i < s.length(); i++) {
71+
char c = s.charAt(i);
72+
if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
73+
continue;
74+
}
75+
return false;
76+
}
77+
return true;
78+
}
79+
}

x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesPlanBuilder.java

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
*
3535
* <p><b>For {@code __name__}:</b>
3636
* <pre>
37-
* [Limit(limit+1)]
37+
* Limit(limit==0 ? MAX_VALUE : limit+1)
3838
* └── OrderBy([metric_name ASC NULLS LAST])
3939
* └── Aggregate(groupings=[metric_name])
4040
* └── MetricsInfo
@@ -44,16 +44,19 @@
4444
*
4545
* <p><b>For regular labels (e.g. {@code job}):</b>
4646
* <pre>
47-
* [Limit(limit+1)]
47+
* Limit(limit==0 ? MAX_VALUE : limit+1)
4848
* └── OrderBy([job ASC NULLS LAST])
4949
* └── Aggregate(groupings=[job])
5050
* └── Filter(timeCond AND IS_NOT_NULL(job) [AND OR(selectorConds...)])
5151
* └── UnresolvedRelation("*", TS)
5252
* </pre>
5353
*
54-
* <p>The Limit node uses {@code limit + 1} as a sentinel: if the result contains {@code limit + 1}
55-
* rows the response listener will truncate to {@code limit} and emit a warning. When {@code limit == 0}
56-
* the Limit node is omitted entirely.
54+
* <p>A Limit node is always emitted. When {@code limit == 0} (Prometheus "disabled" semantics) the
55+
* value {@link Integer#MAX_VALUE} is used so that ESQL silently caps results to its own
56+
* {@code esql.query.result_truncation_max_size} setting without emitting a "No limit defined"
57+
* warning. When {@code limit > 0} the value {@code limit + 1} is used as a sentinel: if the result
58+
* contains exactly {@code limit + 1} rows the response listener truncates to {@code limit} and emits
59+
* a warning.
5760
*/
5861
final class PrometheusLabelValuesPlanBuilder {
5962

@@ -73,7 +76,7 @@ private PrometheusLabelValuesPlanBuilder() {}
7376
* @param matchSelectors list of {@code match[]} selector strings (may be empty)
7477
* @param start start of the time range (inclusive)
7578
* @param end end of the time range (inclusive)
76-
* @param limit maximum number of values to return (0 = disabled)
79+
* @param limit maximum number of values to return (0 = disabled, defers to ESQL max)
7780
* @return the logical plan
7881
* @throws IllegalArgumentException if a selector is not a valid instant vector selector
7982
*/
@@ -97,9 +100,7 @@ private static LogicalPlan buildNamePlan(String index, List<String> matchSelecto
97100
plan,
98101
List.of(new Order(Source.EMPTY, metricNameField, Order.OrderDirection.ASC, Order.NullsPosition.LAST))
99102
);
100-
if (limit > 0) {
101-
plan = new Limit(Source.EMPTY, Literal.integer(Source.EMPTY, limit + 1), plan);
102-
}
103+
plan = new Limit(Source.EMPTY, Literal.integer(Source.EMPTY, limit == 0 ? Integer.MAX_VALUE : limit + 1), plan);
103104
return plan;
104105
}
105106

@@ -135,9 +136,7 @@ private static LogicalPlan buildRegularLabelPlan(
135136
plan,
136137
List.of(new Order(Source.EMPTY, labelField, Order.OrderDirection.ASC, Order.NullsPosition.LAST))
137138
);
138-
if (limit > 0) {
139-
plan = new Limit(Source.EMPTY, Literal.integer(Source.EMPTY, limit + 1), plan);
140-
}
139+
plan = new Limit(Source.EMPTY, Literal.integer(Source.EMPTY, limit == 0 ? Integer.MAX_VALUE : limit + 1), plan);
141140
return plan;
142141
}
143142
}

0 commit comments

Comments
 (0)