From 0849a66f2072de15ca988ce20f7754f31f53da62 Mon Sep 17 00:00:00 2001 From: Felix Barnsteiner Date: Wed, 25 Mar 2026 15:18:24 +0100 Subject: [PATCH 1/2] Prometheus label values API: add plan builder Builds the ESQL query plan for the Prometheus label values API, supporting both the `__name__` special label (via MetricsInfo) and regular label columns. --- .../promql/TranslatePromqlToEsqlPlan.java | 2 +- .../PrometheusLabelValuesPlanBuilder.java | 143 ++++++++++++++ .../rest/PrometheusPlanBuilderUtils.java | 155 +++++++++++++++ ...PrometheusLabelValuesPlanBuilderTests.java | 180 ++++++++++++++++++ 4 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesPlanBuilder.java create mode 100644 x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusPlanBuilderUtils.java create mode 100644 x-pack/plugin/prometheus/src/test/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesPlanBuilderTests.java diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/promql/TranslatePromqlToEsqlPlan.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/promql/TranslatePromqlToEsqlPlan.java index 30558828a6db0..fce7c7ce134fd 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/promql/TranslatePromqlToEsqlPlan.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/logical/promql/TranslatePromqlToEsqlPlan.java @@ -803,7 +803,7 @@ private static Expression translateLabelMatchers(Source source, List * @param matcher the label matcher to translate * @return the ESQL Expression, or null if the matcher matches all or none */ - private static Expression translateLabelMatcher(Source source, Expression field, LabelMatcher matcher) { + public static Expression translateLabelMatcher(Source source, Expression field, LabelMatcher matcher) { // Check for universal matchers if (matcher.matchesAll()) { return Literal.fromBoolean(source, true); // No filter needed (matches everything) 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 new file mode 100644 index 0000000000000..a8365ee1c3d9a --- /dev/null +++ b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesPlanBuilder.java @@ -0,0 +1,143 @@ +/* + * 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.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.expression.Literal; +import org.elasticsearch.xpack.esql.core.expression.UnresolvedAttribute; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.expression.Order; +import org.elasticsearch.xpack.esql.expression.predicate.nulls.IsNotNull; +import org.elasticsearch.xpack.esql.plan.logical.Aggregate; +import org.elasticsearch.xpack.esql.plan.logical.Filter; +import org.elasticsearch.xpack.esql.plan.logical.Limit; +import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan; +import org.elasticsearch.xpack.esql.plan.logical.MetricsInfo; +import org.elasticsearch.xpack.esql.plan.logical.OrderBy; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import static org.elasticsearch.xpack.esql.expression.predicate.Predicates.combineAnd; +import static org.elasticsearch.xpack.esql.expression.predicate.Predicates.combineOr; + +/** + * Builds the {@link LogicalPlan} for a Prometheus {@code /api/v1/label/{name}/values} request. + * + *

Two distinct plan shapes are used: + * + *

For {@code __name__}: + *

+ * [Limit(limit+1)]
+ *   └── OrderBy([metric_name ASC NULLS LAST])
+ *         └── Aggregate(groupings=[metric_name])
+ *               └── MetricsInfo
+ *                     └── Filter(timeCond [AND OR(selectorConds...)])
+ *                           └── UnresolvedRelation("*", TS)
+ * 
+ * + *

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

+ * [Limit(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. + */ +final class PrometheusLabelValuesPlanBuilder { + + /** The {@code __name__} pseudo-label backed by {@code metric_name} in the MetricsInfo output. */ + private static final String NAME_LABEL = "__name__"; + + /** The field name produced by {@link MetricsInfo} for the metric name. */ + static final String METRIC_NAME_FIELD = "metric_name"; + + private PrometheusLabelValuesPlanBuilder() {} + + /** + * Builds the logical plan for a label values request. + * + * @param labelName the decoded label name (e.g. {@code "job"} or {@code "__name__"}) + * @param index index pattern, e.g. {@code "*"} or a concrete name + * @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) + * @return the logical plan + * @throws IllegalArgumentException if a selector is not a valid instant vector selector + */ + static LogicalPlan buildPlan(String labelName, String index, List matchSelectors, Instant start, Instant end, int limit) { + if (NAME_LABEL.equals(labelName)) { + return buildNamePlan(index, matchSelectors, start, end, limit); + } else { + return buildRegularLabelPlan(labelName, index, matchSelectors, start, end, limit); + } + } + + private static LogicalPlan buildNamePlan(String index, List matchSelectors, Instant start, Instant end, int limit) { + LogicalPlan plan = PrometheusPlanBuilderUtils.tsSource(index); + plan = new Filter(Source.EMPTY, plan, PrometheusPlanBuilderUtils.filterExpression(matchSelectors, start, end)); + plan = new MetricsInfo(Source.EMPTY, plan); + + UnresolvedAttribute metricNameField = new UnresolvedAttribute(Source.EMPTY, METRIC_NAME_FIELD); + plan = new Aggregate(Source.EMPTY, plan, List.of(metricNameField), List.of(metricNameField)); + plan = new OrderBy( + Source.EMPTY, + 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); + } + return plan; + } + + private static LogicalPlan buildRegularLabelPlan( + String labelName, + String index, + List matchSelectors, + Instant start, + Instant end, + int limit + ) { + LogicalPlan plan = PrometheusPlanBuilderUtils.tsSource(index); + + // Build filter: timeCond AND IS_NOT_NULL(labelName) [AND OR(selectorConds...)] + UnresolvedAttribute labelField = new UnresolvedAttribute(Source.EMPTY, labelName); + Expression isNotNull = new IsNotNull(Source.EMPTY, labelField); + + Expression timeCond = PrometheusPlanBuilderUtils.buildTimeCondition(start, end); + List selectorConditions = PrometheusPlanBuilderUtils.parseSelectorConditions(matchSelectors); + + List filterParts = new ArrayList<>(); + filterParts.add(timeCond); + filterParts.add(isNotNull); + if (selectorConditions.isEmpty() == false) { + filterParts.add(combineOr(selectorConditions)); + } + Expression filterExpr = filterParts.size() == 1 ? filterParts.get(0) : combineAnd(filterParts); + plan = new Filter(Source.EMPTY, plan, filterExpr); + + plan = new Aggregate(Source.EMPTY, plan, List.of(labelField), List.of(labelField)); + plan = new OrderBy( + Source.EMPTY, + 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); + } + return plan; + } +} diff --git a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusPlanBuilderUtils.java b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusPlanBuilderUtils.java new file mode 100644 index 0000000000000..79ee5b23790d9 --- /dev/null +++ b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusPlanBuilderUtils.java @@ -0,0 +1,155 @@ +/* + * 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.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.expression.Literal; +import org.elasticsearch.xpack.esql.core.expression.UnresolvedAttribute; +import org.elasticsearch.xpack.esql.core.expression.UnresolvedTimestamp; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.expression.predicate.nulls.IsNotNull; +import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.GreaterThanOrEqual; +import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.LessThanOrEqual; +import org.elasticsearch.xpack.esql.optimizer.rules.logical.promql.TranslatePromqlToEsqlPlan; +import org.elasticsearch.xpack.esql.parser.ParsingException; +import org.elasticsearch.xpack.esql.parser.PromqlParser; +import org.elasticsearch.xpack.esql.plan.IndexPattern; +import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan; +import org.elasticsearch.xpack.esql.plan.logical.SourceCommand; +import org.elasticsearch.xpack.esql.plan.logical.UnresolvedRelation; +import org.elasticsearch.xpack.esql.plan.logical.promql.selector.InstantSelector; +import org.elasticsearch.xpack.esql.plan.logical.promql.selector.LabelMatcher; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; + +import static org.elasticsearch.xpack.esql.expression.predicate.Predicates.combineAnd; +import static org.elasticsearch.xpack.esql.expression.predicate.Predicates.combineOr; + +/** + * Shared plan-building utilities for Prometheus REST handlers. + */ +final class PrometheusPlanBuilderUtils { + + /** Column produced by {@link org.elasticsearch.xpack.esql.plan.logical.TsInfo} that lists the dimension field names. */ + static final String DIMENSION_FIELDS = "dimension_fields"; + + private PrometheusPlanBuilderUtils() {} + + /** + * Returns an {@link UnresolvedRelation} for the given index pattern using the {@code TS} source command. + */ + static UnresolvedRelation tsSource(String index) { + IndexPattern pattern = new IndexPattern(Source.EMPTY, index); + return new UnresolvedRelation(Source.EMPTY, pattern, false, List.of(), null, SourceCommand.TS); + } + + /** + * Builds a filter expression combining a time-range condition with optional selector conditions. + * + * @param matchSelectors PromQL instant vector selectors (may be empty) + * @param start start of the time range (inclusive) + * @param end end of the time range (inclusive) + * @return a single {@link Expression} suitable for use in a {@link org.elasticsearch.xpack.esql.plan.logical.Filter} node + */ + static Expression filterExpression(List matchSelectors, Instant start, Instant end) { + List allParts = new ArrayList<>(); + allParts.add(buildTimeCondition(start, end)); + List selectorConditions = parseSelectorConditions(matchSelectors); + if (selectorConditions.isEmpty() == false) { + allParts.add(combineOr(selectorConditions)); + } + return allParts.size() == 1 ? allParts.get(0) : combineAnd(allParts); + } + + /** + * Parses each {@code match[]} selector string into an ESQL {@link Expression} condition, + * delegating per-selector translation to {@link #buildSelectorCondition(InstantSelector)}. + * + * @param matchSelectors PromQL selector strings (may be empty) + * @return list of per-selector conditions; empty if {@code matchSelectors} is empty + * @throws IllegalArgumentException if a selector is syntactically invalid or not an instant vector selector + */ + static List parseSelectorConditions(List matchSelectors) { + List selectorConditions = new ArrayList<>(); + PromqlParser parser = new PromqlParser(); + for (String selector : matchSelectors) { + LogicalPlan parsed; + try { + parsed = parser.createStatement(selector); + } catch (ParsingException e) { + throw new IllegalArgumentException("Invalid match[] selector [" + selector + "]: " + e.getMessage(), e); + } + if (parsed instanceof InstantSelector instantSelector) { + Expression cond = buildSelectorCondition(instantSelector); + if (cond != null) { + selectorConditions.add(cond); + } + } else { + throw new IllegalArgumentException("match[] selector must be an instant vector selector, got: [" + selector + "]"); + } + } + return selectorConditions; + } + + /** + * Converts an InstantSelector's LabelMatchers into a single AND expression. + * Returns {@code null} if all matchers match everything (e.g. bare metric name with no labels). + * + *

Special handling for {@code __name__}: + *

+ */ + static Expression buildSelectorCondition(InstantSelector selector) { + List conditions = new ArrayList<>(); + for (LabelMatcher matcher : selector.labelMatchers().matchers()) { + if (LabelMatcher.NAME.equals(matcher.name())) { + if (matcher.matcher() == LabelMatcher.Matcher.EQ) { + // Parser contract: EQ __name__ always carries a non-null series expression + assert selector.series() != null : "EQ __name__ matcher should always have a non-null series"; + conditions.add(new IsNotNull(Source.EMPTY, selector.series())); + } else if (matcher.matchesAll() == false) { + // NEQ / REG / NREG: use __name__ for filtering. + // OTel metrics that lack this label will be excluded — unavoidable, as we have no + // way to enumerate all field names by regex or negation. + Expression nameField = new UnresolvedAttribute(Source.EMPTY, "__name__"); + Expression matcherCond = TranslatePromqlToEsqlPlan.translateLabelMatcher(Source.EMPTY, nameField, matcher); + conditions.add(combineAnd(List.of(new IsNotNull(Source.EMPTY, nameField), matcherCond))); + } + // matchesAll() == true: universal automaton — no constraint on metric name + } else { + Expression field = new UnresolvedAttribute(Source.EMPTY, matcher.name()); + Expression cond = TranslatePromqlToEsqlPlan.translateLabelMatcher(Source.EMPTY, field, matcher); + if (cond != null) { + conditions.add(cond); + } + } + } + return conditions.isEmpty() ? null : combineAnd(conditions); + } + + /** + * Builds {@code @timestamp >= start AND @timestamp <= end}. + */ + static Expression buildTimeCondition(Instant start, Instant end) { + Expression ts = new UnresolvedTimestamp(Source.EMPTY); + Expression ge = new GreaterThanOrEqual(Source.EMPTY, ts, Literal.dateTime(Source.EMPTY, start), ZoneOffset.UTC); + Expression le = new LessThanOrEqual(Source.EMPTY, ts, Literal.dateTime(Source.EMPTY, end), ZoneOffset.UTC); + return combineAnd(List.of(ge, le)); + } +} 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 new file mode 100644 index 0000000000000..0992216fe0ba1 --- /dev/null +++ b/x-pack/plugin/prometheus/src/test/java/org/elasticsearch/xpack/prometheus/rest/PrometheusLabelValuesPlanBuilderTests.java @@ -0,0 +1,180 @@ +/* + * 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 org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.expression.predicate.nulls.IsNotNull; +import org.elasticsearch.xpack.esql.plan.logical.Aggregate; +import org.elasticsearch.xpack.esql.plan.logical.Filter; +import org.elasticsearch.xpack.esql.plan.logical.Limit; +import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan; +import org.elasticsearch.xpack.esql.plan.logical.MetricsInfo; +import org.elasticsearch.xpack.esql.plan.logical.OrderBy; +import org.elasticsearch.xpack.esql.plan.logical.UnresolvedRelation; + +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; + +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() { + LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("__name__", "*", List.of(), START, END, 0); + assertThat(plan, instanceOf(OrderBy.class)); + } + + public void testNameLabelPlanTopIsLimitWhenLimitSet() { + LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("__name__", "*", List.of(), START, END, 10); + assertThat(plan, instanceOf(Limit.class)); + 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; + // limit node value should be limit + 1 = 6 + assertThat(limit.limit().toString(), containsString("6")); + } + + public void testNameLabelPlanContainsAggregateUnderOrderBy() { + LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("__name__", "*", List.of(), START, END, 0); + assertThat(((OrderBy) plan).child(), instanceOf(Aggregate.class)); + } + + public void testNameLabelPlanContainsMetricsInfoUnderAggregate() { + LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("__name__", "*", List.of(), START, END, 0); + Aggregate agg = (Aggregate) ((OrderBy) plan).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(); + 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(); + MetricsInfo metricsInfo = (MetricsInfo) agg.child(); + Filter filter = (Filter) metricsInfo.child(); + assertThat(filter.child(), instanceOf(UnresolvedRelation.class)); + } + + public void testNameLabelPlanFilterConditionIsNotNull() { + LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("__name__", "*", List.of(), START, END, 0); + Aggregate agg = (Aggregate) ((OrderBy) plan).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() { + LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of(), START, END, 0); + assertThat(plan, instanceOf(OrderBy.class)); + } + + public void testRegularLabelPlanTopIsLimitWhenLimitSet() { + LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of(), START, END, 10); + assertThat(plan, instanceOf(Limit.class)); + 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; + assertThat(limit.limit().toString(), containsString("8")); + } + + public void testRegularLabelPlanContainsAggregateUnderOrderBy() { + LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of(), START, END, 0); + assertThat(((OrderBy) plan).child(), instanceOf(Aggregate.class)); + } + + public void testRegularLabelPlanHasNoMetricsInfo() { + LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of(), START, END, 0); + Aggregate agg = (Aggregate) ((OrderBy) plan).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(); + Filter filter = (Filter) agg.child(); + assertThat("Filter condition must contain an IsNotNull node", containsIsNotNull(filter.condition()), is(true)); + } + + /** Recursively checks whether an expression tree contains an {@link IsNotNull} node. */ + private static boolean containsIsNotNull(Expression expr) { + if (expr == null) { + return false; + } + if (expr instanceof IsNotNull) { + return true; + } + Deque stack = new ArrayDeque<>(expr.children()); + while (stack.isEmpty() == false) { + Expression current = stack.pop(); + if (current instanceof IsNotNull) { + return true; + } + stack.addAll(current.children()); + } + return false; + } + + public void testRegularLabelPlanSourceIsUnresolvedRelation() { + LogicalPlan plan = PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of(), START, END, 0); + Aggregate agg = (Aggregate) ((OrderBy) plan).child(); + Filter filter = (Filter) agg.child(); + assertThat(filter.child(), instanceOf(UnresolvedRelation.class)); + } + + public void testEmptyMatchSelectorsAllowed() { + // Should not throw + PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of(), START, END, 100); + } + + public void testInvalidSelectorThrowsIllegalArgument() { + Exception ex = expectThrows( + IllegalArgumentException.class, + () -> PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of("not_a_selector{{{"), START, END, 100) + ); + assertThat(ex.getMessage(), containsString("Invalid match[] selector")); + } + + public void testRangeSelectorRejected() { + Exception ex = expectThrows( + IllegalArgumentException.class, + () -> PrometheusLabelValuesPlanBuilder.buildPlan("job", "*", List.of("up[5m]"), START, END, 100) + ); + assertThat(ex.getMessage(), containsString("instant vector selector")); + } +} From 22eeae987dffe051fad88e96c9fda34301b0c082 Mon Sep 17 00:00:00 2001 From: Felix Barnsteiner Date: Thu, 26 Mar 2026 14:34:02 +0100 Subject: [PATCH 2/2] Elaborate on matchesAll() comment in buildSelectorCondition --- .../rest/PrometheusPlanBuilderUtils.java | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusPlanBuilderUtils.java b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusPlanBuilderUtils.java index 79ee5b23790d9..cc60a00dbf878 100644 --- a/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusPlanBuilderUtils.java +++ b/x-pack/plugin/prometheus/src/main/java/org/elasticsearch/xpack/prometheus/rest/PrometheusPlanBuilderUtils.java @@ -106,13 +106,15 @@ static List parseSelectorConditions(List matchSelectors) { *

Special handling for {@code __name__}: *

    *
  • EQ (e.g. {@code {__name__="up"}}): emits {@code IsNotNull(series)} — checks the metric - * field itself exists, which works for both Prometheus (labels.__name__ present) and OTel - * (field named "up" exists). The parser always provides a non-null {@code series()} for EQ.
  • + * field itself exists, which works for both Prometheus ({@code labels.__name__} present) and + * OTel (field named "up" exists). The parser always provides a non-null {@code series()} for + * EQ. *
  • NEQ / REG / NREG whose automaton does not match all strings: falls back to filtering on * {@code __name__}. OTel metrics that lack this label will be excluded — unavoidable, * as we have no way to enumerate all field names by regex or negation.
  • *
  • NEQ / REG / NREG whose automaton matches all strings (e.g. {@code =~".*"}): no constraint - * is emitted, matching all series including OTel.
  • + * is emitted — the constraint would always be satisfied, and omitting it also preserves + * OTel metrics that lack {@code __name__}. *
*/ static Expression buildSelectorCondition(InstantSelector selector) { @@ -131,10 +133,15 @@ static Expression buildSelectorCondition(InstantSelector selector) { Expression matcherCond = TranslatePromqlToEsqlPlan.translateLabelMatcher(Source.EMPTY, nameField, matcher); conditions.add(combineAnd(List.of(new IsNotNull(Source.EMPTY, nameField), matcherCond))); } - // matchesAll() == true: universal automaton — no constraint on metric name + // matchesAll() == true: the automaton accepts every string (e.g. =~".*"), so this + // constraint would always be satisfied — omitting it also preserves OTel metrics + // that lack __name__. } else { - Expression field = new UnresolvedAttribute(Source.EMPTY, matcher.name()); - Expression cond = TranslatePromqlToEsqlPlan.translateLabelMatcher(Source.EMPTY, field, matcher); + Expression cond = TranslatePromqlToEsqlPlan.translateLabelMatcher( + Source.EMPTY, + new UnresolvedAttribute(Source.EMPTY, matcher.name()), + matcher + ); if (cond != null) { conditions.add(cond); }