diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/TimeSeriesIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/TimeSeriesIT.java
index 406361438fc42..f82e554623085 100644
--- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/TimeSeriesIT.java
+++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/TimeSeriesIT.java
@@ -8,9 +8,17 @@
package org.elasticsearch.xpack.esql.action;
import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.index.mapper.DateFieldMapper;
+import org.elasticsearch.xpack.esql.EsqlTestUtils;
import org.elasticsearch.xpack.esql.plugin.QueryPragmas;
+import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
public class TimeSeriesIT extends AbstractEsqlIntegTestCase {
@@ -37,6 +45,48 @@ public void testEmpty() {
"type=long,time_series_metric=gauge"
)
.get();
- run("FROM pods | LIMIT 1").close();
+ run("METRICS pods | LIMIT 1").close();
+ }
+
+ public void testSimpleMetrics() {
+ Settings settings = Settings.builder().put("mode", "time_series").putList("routing_path", List.of("pod")).build();
+ client().admin()
+ .indices()
+ .prepareCreate("pods")
+ .setSettings(settings)
+ .setMapping(
+ "@timestamp",
+ "type=date",
+ "pod",
+ "type=keyword,time_series_dimension=true",
+ "cpu",
+ "type=double,time_series_metric=gauge"
+ )
+ .get();
+ List pods = List.of("p1", "p2", "p3");
+ long startTime = DateFieldMapper.DEFAULT_DATE_TIME_FORMATTER.parseMillis("2024-04-15T00:00:00Z");
+ int numDocs = between(10, 10);
+ Map> cpus = new HashMap<>();
+ for (int i = 0; i < numDocs; i++) {
+ String pod = randomFrom(pods);
+ int cpu = randomIntBetween(0, 100);
+ cpus.computeIfAbsent(pod, k -> new ArrayList<>()).add(cpu);
+ long timestamp = startTime + (1000L * i);
+ client().prepareIndex("pods").setSource("@timestamp", timestamp, "pod", pod, "cpu", cpu).get();
+ }
+ List sortedGroups = cpus.keySet().stream().sorted().toList();
+ client().admin().indices().prepareRefresh("pods").get();
+ try (EsqlQueryResponse resp = run("METRICS pods load=avg(cpu) BY pod | SORT pod")) {
+ List> rows = EsqlTestUtils.getValuesList(resp);
+ assertThat(rows, hasSize(sortedGroups.size()));
+ for (int i = 0; i < rows.size(); i++) {
+ List
*/
@Override public T visitDeprecated_metadata(EsqlBaseParser.Deprecated_metadataContext ctx) { return visitChildren(ctx); }
+ /**
+ * {@inheritDoc}
+ *
+ * The default implementation returns the result of calling
+ * {@link #visitChildren} on {@code ctx}.
+ */
+ @Override public T visitMetricsCommand(EsqlBaseParser.MetricsCommandContext ctx) { return visitChildren(ctx); }
/**
* {@inheritDoc}
*
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserListener.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserListener.java
index 6e8000f7fcf8e..ac4047ffbd22f 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserListener.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserListener.java
@@ -336,15 +336,15 @@ public interface EsqlBaseParserListener extends ParseTreeListener {
*/
void exitFromCommand(EsqlBaseParser.FromCommandContext ctx);
/**
- * Enter a parse tree produced by {@link EsqlBaseParser#fromIdentifier}.
+ * Enter a parse tree produced by {@link EsqlBaseParser#indexIdentifier}.
* @param ctx the parse tree
*/
- void enterFromIdentifier(EsqlBaseParser.FromIdentifierContext ctx);
+ void enterIndexIdentifier(EsqlBaseParser.IndexIdentifierContext ctx);
/**
- * Exit a parse tree produced by {@link EsqlBaseParser#fromIdentifier}.
+ * Exit a parse tree produced by {@link EsqlBaseParser#indexIdentifier}.
* @param ctx the parse tree
*/
- void exitFromIdentifier(EsqlBaseParser.FromIdentifierContext ctx);
+ void exitIndexIdentifier(EsqlBaseParser.IndexIdentifierContext ctx);
/**
* Enter a parse tree produced by {@link EsqlBaseParser#fromOptions}.
* @param ctx the parse tree
@@ -395,6 +395,16 @@ public interface EsqlBaseParserListener extends ParseTreeListener {
* @param ctx the parse tree
*/
void exitDeprecated_metadata(EsqlBaseParser.Deprecated_metadataContext ctx);
+ /**
+ * Enter a parse tree produced by {@link EsqlBaseParser#metricsCommand}.
+ * @param ctx the parse tree
+ */
+ void enterMetricsCommand(EsqlBaseParser.MetricsCommandContext ctx);
+ /**
+ * Exit a parse tree produced by {@link EsqlBaseParser#metricsCommand}.
+ * @param ctx the parse tree
+ */
+ void exitMetricsCommand(EsqlBaseParser.MetricsCommandContext ctx);
/**
* Enter a parse tree produced by {@link EsqlBaseParser#evalCommand}.
* @param ctx the parse tree
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserVisitor.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserVisitor.java
index d6e83b37a0f39..37b94cd585c11 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserVisitor.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserVisitor.java
@@ -204,11 +204,11 @@ public interface EsqlBaseParserVisitor extends ParseTreeVisitor {
*/
T visitFromCommand(EsqlBaseParser.FromCommandContext ctx);
/**
- * Visit a parse tree produced by {@link EsqlBaseParser#fromIdentifier}.
+ * Visit a parse tree produced by {@link EsqlBaseParser#indexIdentifier}.
* @param ctx the parse tree
* @return the visitor result
*/
- T visitFromIdentifier(EsqlBaseParser.FromIdentifierContext ctx);
+ T visitIndexIdentifier(EsqlBaseParser.IndexIdentifierContext ctx);
/**
* Visit a parse tree produced by {@link EsqlBaseParser#fromOptions}.
* @param ctx the parse tree
@@ -239,6 +239,12 @@ public interface EsqlBaseParserVisitor extends ParseTreeVisitor {
* @return the visitor result
*/
T visitDeprecated_metadata(EsqlBaseParser.Deprecated_metadataContext ctx);
+ /**
+ * Visit a parse tree produced by {@link EsqlBaseParser#metricsCommand}.
+ * @param ctx the parse tree
+ * @return the visitor result
+ */
+ T visitMetricsCommand(EsqlBaseParser.MetricsCommandContext ctx);
/**
* Visit a parse tree produced by {@link EsqlBaseParser#evalCommand}.
* @param ctx the parse tree
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/IdentifierBuilder.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/IdentifierBuilder.java
index 7f0b5c73b9fb7..b5e348589fa7b 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/IdentifierBuilder.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/IdentifierBuilder.java
@@ -9,8 +9,8 @@
import org.antlr.v4.runtime.tree.TerminalNode;
import org.elasticsearch.common.Strings;
-import org.elasticsearch.xpack.esql.parser.EsqlBaseParser.FromIdentifierContext;
import org.elasticsearch.xpack.esql.parser.EsqlBaseParser.IdentifierContext;
+import org.elasticsearch.xpack.esql.parser.EsqlBaseParser.IndexIdentifierContext;
import java.util.List;
@@ -24,8 +24,8 @@ public String visitIdentifier(IdentifierContext ctx) {
}
@Override
- public String visitFromIdentifier(FromIdentifierContext ctx) {
- return ctx == null ? null : unquoteIdentifier(null, ctx.FROM_UNQUOTED_IDENTIFIER());
+ public String visitIndexIdentifier(IndexIdentifierContext ctx) {
+ return ctx == null ? null : unquoteIdentifier(null, ctx.INDEX_UNQUOTED_IDENTIFIER());
}
protected static String unquoteIdentifier(TerminalNode quotedNode, TerminalNode unquotedNode) {
@@ -42,7 +42,7 @@ protected static String unquoteIdString(String quotedString) {
return quotedString.substring(1, quotedString.length() - 1).replace("``", "`");
}
- public String visitFromIdentifiers(List ctx) {
+ public String visitIndexIdentifiers(List ctx) {
return Strings.collectionToDelimitedString(visitList(this, ctx, String.class), ",");
}
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/LogicalPlanBuilder.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/LogicalPlanBuilder.java
index aea835c11ad3d..b8fc29e4ef64d 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/LogicalPlanBuilder.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/LogicalPlanBuilder.java
@@ -10,6 +10,7 @@
import org.antlr.v4.runtime.ParserRuleContext;
import org.antlr.v4.runtime.Token;
import org.antlr.v4.runtime.tree.ParseTree;
+import org.elasticsearch.Build;
import org.elasticsearch.core.Tuple;
import org.elasticsearch.dissect.DissectException;
import org.elasticsearch.dissect.DissectParser;
@@ -205,7 +206,7 @@ public LogicalPlan visitRowCommand(EsqlBaseParser.RowCommandContext ctx) {
@Override
public LogicalPlan visitFromCommand(EsqlBaseParser.FromCommandContext ctx) {
Source source = source(ctx);
- TableIdentifier table = new TableIdentifier(source, null, visitFromIdentifiers(ctx.fromIdentifier()));
+ TableIdentifier table = new TableIdentifier(source, null, visitIndexIdentifiers(ctx.indexIdentifier()));
Map metadataMap = new LinkedHashMap<>();
if (ctx.metadata() != null) {
var deprecatedContext = ctx.metadata().deprecated_metadata();
@@ -222,8 +223,8 @@ public LogicalPlan visitFromCommand(EsqlBaseParser.FromCommandContext ctx) {
metadataOptionContext = ctx.metadata().metadataOption();
}
- for (var c : metadataOptionContext.fromIdentifier()) {
- String id = visitFromIdentifier(c);
+ for (var c : metadataOptionContext.indexIdentifier()) {
+ String id = visitIndexIdentifier(c);
Source src = source(c);
if (MetadataAttribute.isSupported(id) == false) {
throw new ParsingException(src, "unsupported metadata field [" + id + "]");
@@ -253,10 +254,19 @@ public LogicalPlan visitFromCommand(EsqlBaseParser.FromCommandContext ctx) {
@Override
public PlanFactory visitStatsCommand(EsqlBaseParser.StatsCommandContext ctx) {
- List aggregates = new ArrayList<>(visitFields(ctx.stats));
- List groupings = visitGrouping(ctx.grouping);
+ final Stats stats = stats(source(ctx), ctx.grouping, ctx.stats);
+ return input -> new EsqlAggregate(source(ctx), input, stats.groupings, stats.aggregates);
+ }
+
+ private record Stats(List groupings, List extends NamedExpression> aggregates) {
+
+ }
+
+ private Stats stats(Source source, EsqlBaseParser.FieldsContext groupingsCtx, EsqlBaseParser.FieldsContext aggregatesCtx) {
+ List groupings = visitGrouping(groupingsCtx);
+ List aggregates = new ArrayList<>(visitFields(aggregatesCtx));
if (aggregates.isEmpty() && groupings.isEmpty()) {
- throw new ParsingException(source(ctx), "At least one aggregation or grouping expression required in [{}]", ctx.getText());
+ throw new ParsingException(source, "At least one aggregation or grouping expression required in [{}]", source.text());
}
// grouping keys are automatically added as aggregations however the user is not allowed to specify them
if (groupings.isEmpty() == false && aggregates.isEmpty() == false) {
@@ -279,8 +289,7 @@ public PlanFactory visitStatsCommand(EsqlBaseParser.StatsCommandContext ctx) {
for (Expression group : groupings) {
aggregates.add(Expressions.attribute(group));
}
-
- return input -> new EsqlAggregate(source(ctx), input, new ArrayList<>(groupings), aggregates);
+ return new Stats(new ArrayList<>(groupings), aggregates);
}
private void fail(Expression exp, String message, Object... args) {
@@ -427,5 +436,20 @@ private static Tuple parsePolicyName(Token policyToken) {
return new Tuple<>(mode, policyName);
}
+ @Override
+ public LogicalPlan visitMetricsCommand(EsqlBaseParser.MetricsCommandContext ctx) {
+ if (Build.current().isSnapshot() == false) {
+ throw new IllegalArgumentException("METRICS command currently requires a snapshot build");
+ }
+ Source source = source(ctx);
+ TableIdentifier table = new TableIdentifier(source, null, visitIndexIdentifiers(ctx.indexIdentifier()));
+ var unresolvedRelation = new EsqlUnresolvedRelation(source, table, List.of());
+ if (ctx.aggregates == null && ctx.grouping == null) {
+ return unresolvedRelation;
+ }
+ final Stats stats = stats(source, ctx.grouping, ctx.aggregates);
+ return new EsqlAggregate(source, unresolvedRelation, stats.groupings, stats.aggregates);
+ }
+
interface PlanFactory extends Function {}
}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/parser/StatementParserTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/parser/StatementParserTests.java
index 1a36616cb647b..ddd53cad8ec6d 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/parser/StatementParserTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/parser/StatementParserTests.java
@@ -8,6 +8,7 @@
package org.elasticsearch.xpack.esql.parser;
import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.Build;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.common.Randomness;
import org.elasticsearch.core.Tuple;
@@ -44,6 +45,7 @@
import org.elasticsearch.xpack.ql.expression.function.UnresolvedFunction;
import org.elasticsearch.xpack.ql.expression.predicate.logical.Not;
import org.elasticsearch.xpack.ql.expression.predicate.operator.comparison.BinaryComparison;
+import org.elasticsearch.xpack.ql.plan.TableIdentifier;
import org.elasticsearch.xpack.ql.plan.logical.Filter;
import org.elasticsearch.xpack.ql.plan.logical.Limit;
import org.elasticsearch.xpack.ql.plan.logical.LogicalPlan;
@@ -1049,6 +1051,147 @@ public void testInlineConvertUnsupportedType() {
expectError("ROW 3::BYTE", "line 1:6: Unsupported conversion to type [BYTE]");
}
+ public void testMetricsWithoutStats() {
+ assumeTrue("requires snapshot build", Build.current().isSnapshot());
+
+ assertStatement("METRICS foo", new EsqlUnresolvedRelation(EMPTY, new TableIdentifier(EMPTY, null, "foo"), List.of()));
+ assertStatement("METRICS foo,bar", new EsqlUnresolvedRelation(EMPTY, new TableIdentifier(EMPTY, null, "foo,bar"), List.of()));
+ assertStatement("METRICS foo*,bar", new EsqlUnresolvedRelation(EMPTY, new TableIdentifier(EMPTY, null, "foo*,bar"), List.of()));
+ assertStatement("METRICS foo-*,bar", new EsqlUnresolvedRelation(EMPTY, new TableIdentifier(EMPTY, null, "foo-*,bar"), List.of()));
+ assertStatement(
+ "METRICS foo-*,bar+*",
+ new EsqlUnresolvedRelation(EMPTY, new TableIdentifier(EMPTY, null, "foo-*,bar+*"), List.of())
+ );
+ }
+
+ public void testMetricsIdentifiers() {
+ assumeTrue("requires snapshot build", Build.current().isSnapshot());
+ Map patterns = Map.of(
+ "metrics foo,test-*",
+ "foo,test-*",
+ "metrics 123-test@foo_bar+baz1",
+ "123-test@foo_bar+baz1",
+ "metrics foo, test,xyz",
+ "foo,test,xyz",
+ "metrics >",
+ ">"
+ );
+ for (Map.Entry e : patterns.entrySet()) {
+ assertStatement(e.getKey(), new EsqlUnresolvedRelation(EMPTY, new TableIdentifier(EMPTY, null, e.getValue()), List.of()));
+ }
+ }
+
+ public void testSimpleMetricsWithStats() {
+ assumeTrue("requires snapshot build", Build.current().isSnapshot());
+ assertStatement(
+ "METRICS foo load=avg(cpu) BY ts",
+ new EsqlAggregate(
+ EMPTY,
+ new EsqlUnresolvedRelation(EMPTY, new TableIdentifier(EMPTY, null, "foo"), List.of()),
+ List.of(attribute("ts")),
+ List.of(new Alias(EMPTY, "load", new UnresolvedFunction(EMPTY, "avg", DEFAULT, List.of(attribute("cpu")))), attribute("ts"))
+ )
+ );
+ assertStatement(
+ "METRICS foo,bar load=avg(cpu) BY ts",
+ new EsqlAggregate(
+ EMPTY,
+ new EsqlUnresolvedRelation(EMPTY, new TableIdentifier(EMPTY, null, "foo,bar"), List.of()),
+ List.of(attribute("ts")),
+ List.of(new Alias(EMPTY, "load", new UnresolvedFunction(EMPTY, "avg", DEFAULT, List.of(attribute("cpu")))), attribute("ts"))
+ )
+ );
+ assertStatement(
+ "METRICS foo,bar load=avg(cpu),max(rate(requests)) BY ts",
+ new EsqlAggregate(
+ EMPTY,
+ new EsqlUnresolvedRelation(EMPTY, new TableIdentifier(EMPTY, null, "foo,bar"), List.of()),
+ List.of(attribute("ts")),
+ List.of(
+ new Alias(EMPTY, "load", new UnresolvedFunction(EMPTY, "avg", DEFAULT, List.of(attribute("cpu")))),
+ new Alias(
+ EMPTY,
+ "max(rate(requests))",
+ new UnresolvedFunction(
+ EMPTY,
+ "max",
+ DEFAULT,
+ List.of(new UnresolvedFunction(EMPTY, "rate", DEFAULT, List.of(attribute("requests"))))
+ )
+ ),
+ attribute("ts")
+ )
+ )
+ );
+ assertStatement(
+ "METRICS foo* count(errors)",
+ new EsqlAggregate(
+ EMPTY,
+ new EsqlUnresolvedRelation(EMPTY, new TableIdentifier(EMPTY, null, "foo*"), List.of()),
+ List.of(),
+ List.of(new Alias(EMPTY, "count(errors)", new UnresolvedFunction(EMPTY, "count", DEFAULT, List.of(attribute("errors")))))
+ )
+ );
+ assertStatement(
+ "METRICS foo* a(b)",
+ new EsqlAggregate(
+ EMPTY,
+ new EsqlUnresolvedRelation(EMPTY, new TableIdentifier(EMPTY, null, "foo*"), List.of()),
+ List.of(),
+ List.of(new Alias(EMPTY, "a(b)", new UnresolvedFunction(EMPTY, "a", DEFAULT, List.of(attribute("b")))))
+ )
+ );
+ assertStatement(
+ "METRICS foo* a(b)",
+ new EsqlAggregate(
+ EMPTY,
+ new EsqlUnresolvedRelation(EMPTY, new TableIdentifier(EMPTY, null, "foo*"), List.of()),
+ List.of(),
+ List.of(new Alias(EMPTY, "a(b)", new UnresolvedFunction(EMPTY, "a", DEFAULT, List.of(attribute("b")))))
+ )
+ );
+ assertStatement(
+ "METRICS foo* a1(b2)",
+ new EsqlAggregate(
+ EMPTY,
+ new EsqlUnresolvedRelation(EMPTY, new TableIdentifier(EMPTY, null, "foo*"), List.of()),
+ List.of(),
+ List.of(new Alias(EMPTY, "a1(b2)", new UnresolvedFunction(EMPTY, "a1", DEFAULT, List.of(attribute("b2")))))
+ )
+ );
+ assertStatement(
+ "METRICS foo*,bar* b = min(a) by c, d.e",
+ new EsqlAggregate(
+ EMPTY,
+ new EsqlUnresolvedRelation(EMPTY, new TableIdentifier(EMPTY, null, "foo*,bar*"), List.of()),
+ List.of(attribute("c"), attribute("d.e")),
+ List.of(
+ new Alias(EMPTY, "b", new UnresolvedFunction(EMPTY, "min", DEFAULT, List.of(attribute("a")))),
+ attribute("c"),
+ attribute("d.e")
+ )
+ )
+ );
+ }
+
+ public void testMetricWithGroupKeyAsAgg() {
+ assumeTrue("requires snapshot build", Build.current().isSnapshot());
+ var queries = List.of("METRICS foo a BY a");
+ for (String query : queries) {
+ expectVerificationError(query, "grouping key [a] already specified in the STATS BY clause");
+ }
+ }
+
+ private void assertStatement(String statement, LogicalPlan expected) {
+ final LogicalPlan actual;
+ try {
+ actual = statement(statement);
+ } catch (Exception e) {
+ throw new AssertionError("parsing error for [" + statement + "]", e);
+ }
+ assertThat(statement, actual, equalTo(expected));
+ }
+
private LogicalPlan statement(String e) {
return statement(e, List.of());
}