Browse Source

ES|QL Absent and AbsentOverTime functions (#134475)

* ES|QL: Add ABSENT ES|QL function

- Add an ABSENT function
- Add an ABSENT_OVER_TIME function

Closes #131069
Dmitry Leontyev 1 month ago
parent
commit
d73f051ffd
32 changed files with 1656 additions and 5 deletions
  1. 6 0
      docs/changelog/134475.yaml
  2. 6 0
      docs/reference/query-languages/esql/_snippets/functions/description/absent.md
  3. 11 0
      docs/reference/query-languages/esql/_snippets/functions/description/absent_over_time.md
  4. 43 0
      docs/reference/query-languages/esql/_snippets/functions/examples/absent.md
  5. 18 0
      docs/reference/query-languages/esql/_snippets/functions/examples/absent_over_time.md
  6. 23 0
      docs/reference/query-languages/esql/_snippets/functions/layout/absent.md
  7. 26 0
      docs/reference/query-languages/esql/_snippets/functions/layout/absent_over_time.md
  8. 7 0
      docs/reference/query-languages/esql/_snippets/functions/parameters/absent.md
  9. 7 0
      docs/reference/query-languages/esql/_snippets/functions/parameters/absent_over_time.md
  10. 25 0
      docs/reference/query-languages/esql/_snippets/functions/types/absent.md
  11. 25 0
      docs/reference/query-languages/esql/_snippets/functions/types/absent_over_time.md
  12. 3 0
      docs/reference/query-languages/esql/functions-operators/aggregation-functions.md
  13. 1 0
      docs/reference/query-languages/esql/images/functions/absent.svg
  14. 1 0
      docs/reference/query-languages/esql/images/functions/absent_over_time.svg
  15. 231 0
      docs/reference/query-languages/esql/kibana/definition/functions/absent.json
  16. 230 0
      docs/reference/query-languages/esql/kibana/definition/functions/absent_over_time.json
  17. 10 0
      docs/reference/query-languages/esql/kibana/docs/functions/absent.md
  18. 12 0
      docs/reference/query-languages/esql/kibana/docs/functions/absent_over_time.md
  19. 129 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/absent.csv-spec
  20. 323 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/k8s-timeseries-absent-over-time.csv-spec
  21. 27 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/k8s-timeseries.csv-spec
  22. 11 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
  23. 5 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Analyzer.java
  24. 6 2
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
  25. 134 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Absent.java
  26. 118 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AbsentOverTime.java
  27. 3 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AggregateWritables.java
  28. 43 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AbsentErrorTests.java
  29. 35 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AbsentOverTimeTests.java
  30. 24 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AbsentSerializationTests.java
  31. 111 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AbsentTests.java
  32. 2 1
      x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml

+ 6 - 0
docs/changelog/134475.yaml

@@ -0,0 +1,6 @@
+pr: 134475
+summary: ES|QL Absent and `AbsentOverTime` functions
+area: ES|QL
+type: enhancement
+issues:
+ - 131069

+ 6 - 0
docs/reference/query-languages/esql/_snippets/functions/description/absent.md

@@ -0,0 +1,6 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Description**
+
+Returns true if the input expression yields no non-null values within the current aggregation context. Otherwise it returns false.
+

+ 11 - 0
docs/reference/query-languages/esql/_snippets/functions/description/absent_over_time.md

@@ -0,0 +1,11 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Description**
+
+The absence of a field in the output result over time range.
+
+::::{note}
+Available with the [TS](/reference/query-languages/esql/commands/source-commands.md#esql-ts) command in snapshot builds
+::::
+
+

+ 43 - 0
docs/reference/query-languages/esql/_snippets/functions/examples/absent.md

@@ -0,0 +1,43 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Examples**
+
+```esql
+FROM employees
+| WHERE emp_no == 10020
+| STATS is_absent = ABSENT(languages)
+```
+
+| is_absent:boolean |
+| --- |
+| true |
+
+To check for the absence inside a group use `ABSENT()` and `BY` clauses
+
+```esql
+FROM employees
+| STATS is_absent = ABSENT(salary) BY languages
+```
+
+| is_absent:boolean | languages:integer |
+| --- | --- |
+| false | 1 |
+| false | 2 |
+| false | 3 |
+| false | 4 |
+| false | 5 |
+| false | null |
+
+To check for the absence and return 1 when it's true and 0 when it's false you can use to_integer()
+
+```esql
+FROM employees
+| WHERE emp_no == 10020
+| STATS is_absent = TO_INTEGER(ABSENT(languages))
+```
+
+| is_absent:integer |
+| --- |
+| 1 |
+
+

+ 18 - 0
docs/reference/query-languages/esql/_snippets/functions/examples/absent_over_time.md

@@ -0,0 +1,18 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Example**
+
+```esql
+TS k8s
+| WHERE cluster == "prod" AND pod == "two"
+| STATS events_received = max(absent_over_time(events_received)) BY pod, time_bucket = tbucket(2 minute)
+```
+
+| events_received:boolean | pod:keyword | time_bucket:datetime |
+| --- | --- | --- |
+| false | two | 2024-05-10T00:02:00.000Z |
+| false | two | 2024-05-10T00:08:00.000Z |
+| true | two | 2024-05-10T00:10:00.000Z |
+| true | two | 2024-05-10T00:12:00.000Z |
+
+

+ 23 - 0
docs/reference/query-languages/esql/_snippets/functions/layout/absent.md

@@ -0,0 +1,23 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+## `ABSENT` [esql-absent]
+
+**Syntax**
+
+:::{image} ../../../images/functions/absent.svg
+:alt: Embedded
+:class: text-center
+:::
+
+
+:::{include} ../parameters/absent.md
+:::
+
+:::{include} ../description/absent.md
+:::
+
+:::{include} ../types/absent.md
+:::
+
+:::{include} ../examples/absent.md
+:::

+ 26 - 0
docs/reference/query-languages/esql/_snippets/functions/layout/absent_over_time.md

@@ -0,0 +1,26 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+## `ABSENT_OVER_TIME` [esql-absent_over_time]
+```{applies_to}
+stack: unavailable
+```
+
+**Syntax**
+
+:::{image} ../../../images/functions/absent_over_time.svg
+:alt: Embedded
+:class: text-center
+:::
+
+
+:::{include} ../parameters/absent_over_time.md
+:::
+
+:::{include} ../description/absent_over_time.md
+:::
+
+:::{include} ../types/absent_over_time.md
+:::
+
+:::{include} ../examples/absent_over_time.md
+:::

+ 7 - 0
docs/reference/query-languages/esql/_snippets/functions/parameters/absent.md

@@ -0,0 +1,7 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Parameters**
+
+`field`
+:   Expression that outputs values to be checked for absence.
+

+ 7 - 0
docs/reference/query-languages/esql/_snippets/functions/parameters/absent_over_time.md

@@ -0,0 +1,7 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Parameters**
+
+`field`
+:   
+

+ 25 - 0
docs/reference/query-languages/esql/_snippets/functions/types/absent.md

@@ -0,0 +1,25 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Supported types**
+
+| field | result |
+| --- | --- |
+| boolean | boolean |
+| cartesian_point | boolean |
+| cartesian_shape | boolean |
+| date | boolean |
+| date_nanos | boolean |
+| double | boolean |
+| geo_point | boolean |
+| geo_shape | boolean |
+| geohash | boolean |
+| geohex | boolean |
+| geotile | boolean |
+| integer | boolean |
+| ip | boolean |
+| keyword | boolean |
+| long | boolean |
+| text | boolean |
+| unsigned_long | boolean |
+| version | boolean |
+

+ 25 - 0
docs/reference/query-languages/esql/_snippets/functions/types/absent_over_time.md

@@ -0,0 +1,25 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Supported types**
+
+| field | result |
+| --- | --- |
+| boolean | boolean |
+| cartesian_point | boolean |
+| cartesian_shape | boolean |
+| date | boolean |
+| date_nanos | boolean |
+| double | boolean |
+| geo_point | boolean |
+| geo_shape | boolean |
+| geohash | boolean |
+| geohex | boolean |
+| geotile | boolean |
+| integer | boolean |
+| ip | boolean |
+| keyword | boolean |
+| long | boolean |
+| text | boolean |
+| unsigned_long | boolean |
+| version | boolean |
+

+ 3 - 0
docs/reference/query-languages/esql/functions-operators/aggregation-functions.md

@@ -62,3 +62,6 @@ The [`STATS`](/reference/query-languages/esql/commands/stats-by.md) and [`INLINE
 
 :::{include} ../_snippets/functions/layout/present.md
 :::
+
+:::{include} ../_snippets/functions/layout/absent.md
+:::

+ 1 - 0
docs/reference/query-languages/esql/images/functions/absent.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="276" height="46" viewbox="0 0 276 46"><defs><style type="text/css">.c{fill:none;stroke:#222222;}.k{fill:#000000;font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:20px;}.s{fill:#e4f4ff;stroke:#222222;}.syn{fill:#8D8D8D;font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:20px;}</style></defs><path class="c" d="M0 31h5m92 0h10m32 0h10m80 0h10m32 0h5"/><rect class="s" x="5" y="5" width="92" height="36"/><text class="k" x="15" y="31">ABSENT</text><rect class="s" x="107" y="5" width="32" height="36" rx="7"/><text class="syn" x="117" y="31">(</text><rect class="s" x="149" y="5" width="80" height="36" rx="7"/><text class="k" x="159" y="31">field</text><rect class="s" x="239" y="5" width="32" height="36" rx="7"/><text class="syn" x="249" y="31">)</text></svg>

+ 1 - 0
docs/reference/query-languages/esql/images/functions/absent_over_time.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="396" height="46" viewbox="0 0 396 46"><defs><style type="text/css">.c{fill:none;stroke:#222222;}.k{fill:#000000;font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:20px;}.s{fill:#e4f4ff;stroke:#222222;}.syn{fill:#8D8D8D;font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:20px;}</style></defs><path class="c" d="M0 31h5m212 0h10m32 0h10m80 0h10m32 0h5"/><rect class="s" x="5" y="5" width="212" height="36"/><text class="k" x="15" y="31">ABSENT_OVER_TIME</text><rect class="s" x="227" y="5" width="32" height="36" rx="7"/><text class="syn" x="237" y="31">(</text><rect class="s" x="269" y="5" width="80" height="36" rx="7"/><text class="k" x="279" y="31">field</text><rect class="s" x="359" y="5" width="32" height="36" rx="7"/><text class="syn" x="369" y="31">)</text></svg>

+ 231 - 0
docs/reference/query-languages/esql/kibana/definition/functions/absent.json

@@ -0,0 +1,231 @@
+{
+  "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.",
+  "type" : "agg",
+  "name" : "absent",
+  "description" : "Returns true if the input expression yields no non-null values within the current aggregation context. Otherwise it returns false.",
+  "signatures" : [
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "boolean",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "cartesian_point",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "cartesian_shape",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "date",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "date_nanos",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "double",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geo_point",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geo_shape",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geohash",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geohex",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geotile",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "integer",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "ip",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "long",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "text",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "unsigned_long",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "version",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for absence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    }
+  ],
+  "examples" : [
+    "FROM employees\n| WHERE emp_no == 10020\n| STATS is_absent = ABSENT(languages)",
+    "FROM employees\n| STATS is_absent = ABSENT(salary) BY languages",
+    "FROM employees\n| WHERE emp_no == 10020\n| STATS is_absent = TO_INTEGER(ABSENT(languages))"
+  ],
+  "preview" : false,
+  "snapshot_only" : false
+}

+ 230 - 0
docs/reference/query-languages/esql/kibana/definition/functions/absent_over_time.json

@@ -0,0 +1,230 @@
+{
+  "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.",
+  "type" : "time_series_agg",
+  "name" : "absent_over_time",
+  "description" : "The absence of a field in the output result over time range.",
+  "note" : "Available with the TS command in snapshot builds",
+  "signatures" : [
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "boolean",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "cartesian_point",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "cartesian_shape",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "date",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "date_nanos",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "double",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geo_point",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geo_shape",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geohash",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geohex",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geotile",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "integer",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "ip",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "long",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "text",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "unsigned_long",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "version",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    }
+  ],
+  "examples" : [
+    "TS k8s\n| WHERE cluster == \"prod\" AND pod == \"two\"\n| STATS events_received = max(absent_over_time(events_received)) BY pod, time_bucket = tbucket(2 minute)"
+  ],
+  "preview" : false,
+  "snapshot_only" : true
+}

+ 10 - 0
docs/reference/query-languages/esql/kibana/docs/functions/absent.md

@@ -0,0 +1,10 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+### ABSENT
+Returns true if the input expression yields no non-null values within the current aggregation context. Otherwise it returns false.
+
+```esql
+FROM employees
+| WHERE emp_no == 10020
+| STATS is_absent = ABSENT(languages)
+```

+ 12 - 0
docs/reference/query-languages/esql/kibana/docs/functions/absent_over_time.md

@@ -0,0 +1,12 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+### ABSENT OVER TIME
+The absence of a field in the output result over time range.
+
+Note: Available with the [TS](https://www.elastic.co/docs/reference/query-languages/esql/commands/source-commands#esql-ts) command in snapshot builds
+
+```esql
+TS k8s
+| WHERE cluster == "prod" AND pod == "two"
+| STATS events_received = max(absent_over_time(events_received)) BY pod, time_bucket = tbucket(2 minute)
+```

+ 129 - 0
x-pack/plugin/esql/qa/testFixtures/src/main/resources/absent.csv-spec

@@ -0,0 +1,129 @@
+// ABSENT-specific tests
+
+ABSENT when field is absent 
+required_capability: fn_absent
+
+// tag::absent[]
+FROM employees
+| WHERE emp_no == 10020
+| STATS is_absent = ABSENT(languages)
+// end::absent[]
+;
+
+// tag::absent-result[]
+is_absent:boolean
+true
+// end::absent-result[]
+;
+
+Not ABSENT with filter
+required_capability: fn_absent
+
+FROM employees
+| WHERE emp_no == 10019
+| STATS is_absent = ABSENT(languages)
+;
+
+is_absent:boolean
+false
+;
+
+ABSENT with grouping
+required_capability: fn_absent
+
+// tag::absent-by[]
+FROM employees
+| STATS is_absent = ABSENT(salary) BY languages
+// end::absent-by[]
+;
+ignoreOrder:true
+
+// tag::absent-by-result[]
+is_absent:boolean    | languages:integer
+false                  | 1
+false                  | 2
+false                  | 3
+false                  | 4
+false                  | 5
+false                  | null
+// end::absent-by-result[]
+;
+
+ABSENT with null evaluated field
+required_capability: fn_absent
+
+FROM employees
+| EVAL null_field = null
+| STATS is_absent = ABSENT(null_field)
+;
+
+is_absent:boolean
+true
+;
+
+ABSENT with an expression
+required_capability: fn_absent
+
+FROM employees
+| STATS is_absent = ABSENT(salary + 10)
+;
+
+is_absent:boolean
+false
+;
+
+Fields are ABSENT with grouping
+required_capability: fn_absent
+
+FROM employees
+| WHERE gender IS NULL
+| STATS is_absent = ABSENT(gender) BY languages
+;
+ignoreOrder:true
+
+is_absent:boolean     | languages:integer
+true                  | 4
+true                  | 5
+true                  | 1
+true                  | 2
+;
+
+ABSENT with per-agg filter
+required_capability: fn_absent
+
+FROM employees
+| STATS a_false = ABSENT(salary) WHERE gender == "M",
+        a_true = ABSENT(salary) WHERE gender == "X"
+;
+
+a_false:boolean    | a_true:boolean
+false              | true
+;
+
+TO_INTEGER(ABSENT) with filter returns 0
+required_capability: fn_absent
+
+FROM employees
+| WHERE emp_no IN (10019, 10020)
+| STATS is_absent = TO_INTEGER(ABSENT(languages))
+;
+
+is_absent:integer
+0
+;
+
+TO_INTEGER(ABSENT) with filter returns 1
+required_capability: fn_absent
+
+// tag::absent-as-integer[]
+FROM employees
+| WHERE emp_no == 10020
+| STATS is_absent = TO_INTEGER(ABSENT(languages))
+// end::absent-as-integer[]
+;
+
+// tag::absent-as-integer-result[]
+is_absent:integer
+1
+// end::absent-as-integer-result[]
+;

+ 323 - 0
x-pack/plugin/esql/qa/testFixtures/src/main/resources/k8s-timeseries-absent-over-time.csv-spec

@@ -0,0 +1,323 @@
+absent_over_time_events_received
+required_capability: metrics_command
+required_capability: absent_over_time
+
+TS k8s 
+| WHERE cluster == "prod" AND pod == "two" 
+| STATS events_received = max(absent_over_time(events_received)) BY pod, time_bucket = tbucket(2 minute) 
+| SORT time_bucket
+;
+ignoreOrder:true
+
+events_received:boolean  | pod:keyword | time_bucket:datetime
+false                    | two         | 2024-05-10T00:02:00.000Z
+false                    | two         | 2024-05-10T00:08:00.000Z
+true                     | two         | 2024-05-10T00:10:00.000Z
+true                     | two         | 2024-05-10T00:12:00.000Z
+false                    | two         | 2024-05-10T00:14:00.000Z
+false                    | two         | 2024-05-10T00:16:00.000Z
+true                     | two         | 2024-05-10T00:18:00.000Z
+false                    | two         | 2024-05-10T00:20:00.000Z
+false                    | two         | 2024-05-10T00:22:00.000Z
+;
+
+absent_over_time_of_long
+required_capability: metrics_command
+required_capability: absent_over_time
+TS k8s | STATS is_present = max(absent_over_time(network.bytes_in)) BY cluster, time_bucket = tbucket(10minute) | SORT cluster, time_bucket | LIMIT 10;
+
+is_present:boolean | cluster:keyword | time_bucket:datetime
+false              | prod            | 2024-05-10T00:00:00.000Z
+false              | prod            | 2024-05-10T00:10:00.000Z
+false              | prod            | 2024-05-10T00:20:00.000Z
+false              | qa              | 2024-05-10T00:00:00.000Z
+false              | qa              | 2024-05-10T00:10:00.000Z
+false              | qa              | 2024-05-10T00:20:00.000Z
+false              | staging         | 2024-05-10T00:00:00.000Z
+false              | staging         | 2024-05-10T00:10:00.000Z
+false              | staging         | 2024-05-10T00:20:00.000Z
+;
+
+absent_over_time_of_boolean
+required_capability: metrics_command
+required_capability: absent_over_time
+required_capability: k8s_dataset_additional_fields
+TS k8s | STATS is_present = max(absent_over_time(network.eth0.up)) BY cluster, time_bucket = tbucket(10minute) | SORT time_bucket, cluster | LIMIT 10;
+
+is_present:boolean | cluster:keyword | time_bucket:datetime
+false              | prod            | 2024-05-10T00:00:00.000Z
+false              | qa              | 2024-05-10T00:00:00.000Z
+false              | staging         | 2024-05-10T00:00:00.000Z
+false              | prod            | 2024-05-10T00:10:00.000Z
+false              | qa              | 2024-05-10T00:10:00.000Z
+false              | staging         | 2024-05-10T00:10:00.000Z
+false              | prod            | 2024-05-10T00:20:00.000Z
+false              | qa              | 2024-05-10T00:20:00.000Z
+false              | staging         | 2024-05-10T00:20:00.000Z
+;
+
+absent_over_time_of_date_nanos
+required_capability: metrics_command
+required_capability: absent_over_time
+required_capability: k8s_dataset_additional_fields
+TS k8s | STATS is_present = max(absent_over_time(network.eth0.last_up)) BY cluster, time_bucket = tbucket(10minute) | SORT time_bucket, cluster | LIMIT 10;
+
+is_present:boolean | cluster:keyword | time_bucket:datetime
+false              | prod            | 2024-05-10T00:00:00.000Z
+false              | qa              | 2024-05-10T00:00:00.000Z
+false              | staging         | 2024-05-10T00:00:00.000Z
+false              | prod            | 2024-05-10T00:10:00.000Z
+false              | qa              | 2024-05-10T00:10:00.000Z
+false              | staging         | 2024-05-10T00:10:00.000Z
+false              | prod            | 2024-05-10T00:20:00.000Z
+false              | qa              | 2024-05-10T00:20:00.000Z
+false              | staging         | 2024-05-10T00:20:00.000Z
+;
+
+absent_over_time_of_date
+required_capability: metrics_command
+required_capability: absent_over_time
+required_capability: k8s_dataset_additional_fields
+TS k8s | STATS is_present = max(absent_over_time(to_datetime(network.eth0.last_up))) BY cluster, time_bucket = tbucket(10minute) | SORT time_bucket, cluster | LIMIT 10;
+
+is_present:boolean | cluster:keyword | time_bucket:datetime
+false              | prod            | 2024-05-10T00:00:00.000Z
+false              | qa              | 2024-05-10T00:00:00.000Z
+false              | staging         | 2024-05-10T00:00:00.000Z
+false              | prod            | 2024-05-10T00:10:00.000Z
+false              | qa              | 2024-05-10T00:10:00.000Z
+false              | staging         | 2024-05-10T00:10:00.000Z
+false              | prod            | 2024-05-10T00:20:00.000Z
+false              | qa              | 2024-05-10T00:20:00.000Z
+false              | staging         | 2024-05-10T00:20:00.000Z
+;
+
+absent_over_time_of_version
+required_capability: metrics_command
+required_capability: absent_over_time
+required_capability: k8s_dataset_additional_fields
+TS k8s | STATS is_present = max(absent_over_time(network.eth0.firmware_version)) BY cluster, time_bucket = tbucket(10minute) | SORT time_bucket, cluster | LIMIT 10;
+
+is_present:boolean | cluster:keyword | time_bucket:datetime
+false              | prod            | 2024-05-10T00:00:00.000Z
+false              | qa              | 2024-05-10T00:00:00.000Z
+false              | staging         | 2024-05-10T00:00:00.000Z
+false              | prod            | 2024-05-10T00:10:00.000Z
+false              | qa              | 2024-05-10T00:10:00.000Z
+false              | staging         | 2024-05-10T00:10:00.000Z
+false              | prod            | 2024-05-10T00:20:00.000Z
+false              | qa              | 2024-05-10T00:20:00.000Z
+false              | staging         | 2024-05-10T00:20:00.000Z
+;
+
+absent_over_time_of_integer
+required_capability: metrics_command
+required_capability: absent_over_time
+required_capability: k8s_dataset_additional_fields
+TS k8s | STATS is_present = max(absent_over_time(network.eth0.currently_connected_clients)) BY cluster, time_bucket = tbucket(10minute) | SORT time_bucket, cluster | LIMIT 10;
+
+is_present:boolean     | cluster:keyword | time_bucket:datetime
+false                  | prod            | 2024-05-10T00:00:00.000Z
+false                  | qa              | 2024-05-10T00:00:00.000Z
+false                  | staging         | 2024-05-10T00:00:00.000Z
+false                  | prod            | 2024-05-10T00:10:00.000Z
+false                  | qa              | 2024-05-10T00:10:00.000Z
+false                  | staging         | 2024-05-10T00:10:00.000Z
+false                  | prod            | 2024-05-10T00:20:00.000Z
+false                  | qa              | 2024-05-10T00:20:00.000Z
+false                  | staging         | 2024-05-10T00:20:00.000Z
+;
+
+absent_over_time_of_text
+required_capability: metrics_command
+required_capability: absent_over_time
+TS k8s | STATS is_present = max(absent_over_time(event_log)) BY cluster, time_bucket = tbucket(10minute) | SORT cluster, time_bucket | LIMIT 10;
+
+is_present:boolean | cluster:keyword | time_bucket:datetime
+false              | prod            | 2024-05-10T00:00:00.000Z
+false              | prod            | 2024-05-10T00:10:00.000Z
+false              | prod            | 2024-05-10T00:20:00.000Z
+false              | qa              | 2024-05-10T00:00:00.000Z
+false              | qa              | 2024-05-10T00:10:00.000Z
+false              | qa              | 2024-05-10T00:20:00.000Z
+false              | staging         | 2024-05-10T00:00:00.000Z
+false              | staging         | 2024-05-10T00:10:00.000Z
+false              | staging         | 2024-05-10T00:20:00.000Z
+;
+
+absent_over_time_of_keyword
+required_capability: metrics_command
+required_capability: absent_over_time
+required_capability: k8s_dataset_additional_fields
+TS k8s | STATS is_present = max(absent_over_time(pod)) BY cluster, time_bucket = tbucket(10minute) | SORT time_bucket, cluster | LIMIT 10;
+
+is_present:boolean | cluster:keyword | time_bucket:datetime
+false              | prod            | 2024-05-10T00:00:00.000Z
+false              | qa              | 2024-05-10T00:00:00.000Z
+false              | staging         | 2024-05-10T00:00:00.000Z
+false              | prod            | 2024-05-10T00:10:00.000Z
+false              | qa              | 2024-05-10T00:10:00.000Z
+false              | staging         | 2024-05-10T00:10:00.000Z
+false              | prod            | 2024-05-10T00:20:00.000Z
+false              | qa              | 2024-05-10T00:20:00.000Z
+false              | staging         | 2024-05-10T00:20:00.000Z
+;
+
+absent_over_time_of_aggregate_metric_double
+required_capability: metrics_command
+required_capability: absent_over_time
+TS k8s-downsampled | STATS is_present = max(absent_over_time(network.eth0.tx)) BY cluster, time_bucket = tbucket(10 minute) | SORT time_bucket, cluster | LIMIT 10;
+
+is_present:boolean | cluster:keyword | time_bucket:datetime
+false              | prod            | 2024-05-09T23:30:00.000Z
+false              | qa              | 2024-05-09T23:30:00.000Z
+false              | staging         | 2024-05-09T23:30:00.000Z
+false              | prod            | 2024-05-09T23:40:00.000Z
+false              | qa              | 2024-05-09T23:40:00.000Z
+false              | staging         | 2024-05-09T23:40:00.000Z
+false              | prod            | 2024-05-09T23:50:00.000Z
+false              | qa              | 2024-05-09T23:50:00.000Z
+false              | staging         | 2024-05-09T23:50:00.000Z
+;
+
+absent_over_time_of_geopoint
+required_capability: metrics_command
+required_capability: absent_over_time
+required_capability: k8s_datasets_geospatial_fields
+TS k8s | STATS is_present = max(absent_over_time(event_city)) BY cluster, time_bucket = tbucket(10minute) | SORT time_bucket, cluster | LIMIT 10;
+
+is_present:boolean | cluster:keyword | time_bucket:datetime
+false              | prod            | 2024-05-10T00:00:00.000Z
+false              | qa              | 2024-05-10T00:00:00.000Z
+false              | staging         | 2024-05-10T00:00:00.000Z
+false              | prod            | 2024-05-10T00:10:00.000Z
+false              | qa              | 2024-05-10T00:10:00.000Z
+false              | staging         | 2024-05-10T00:10:00.000Z
+false              | prod            | 2024-05-10T00:20:00.000Z
+false              | qa              | 2024-05-10T00:20:00.000Z
+false              | staging         | 2024-05-10T00:20:00.000Z
+;
+
+absent_over_time_of_geoshape
+required_capability: metrics_command
+required_capability: absent_over_time
+required_capability: k8s_datasets_geospatial_fields
+TS k8s | STATS is_present = max(absent_over_time(event_city_boundary)) BY cluster, time_bucket = tbucket(10minute) | SORT time_bucket, cluster | LIMIT 10;
+
+is_present:boolean | cluster:keyword | time_bucket:datetime
+false              | prod            | 2024-05-10T00:00:00.000Z
+false              | qa              | 2024-05-10T00:00:00.000Z
+false              | staging         | 2024-05-10T00:00:00.000Z
+false              | prod            | 2024-05-10T00:10:00.000Z
+false              | qa              | 2024-05-10T00:10:00.000Z
+false              | staging         | 2024-05-10T00:10:00.000Z
+false              | prod            | 2024-05-10T00:20:00.000Z
+false              | qa              | 2024-05-10T00:20:00.000Z
+false              | staging         | 2024-05-10T00:20:00.000Z
+;
+
+absent_over_time_of_shape
+required_capability: metrics_command
+required_capability: absent_over_time
+required_capability: k8s_datasets_geospatial_fields
+TS k8s | STATS is_present = max(absent_over_time(event_shape)) BY cluster, time_bucket = tbucket(10minute) | SORT time_bucket, cluster | LIMIT 10;
+
+is_present:boolean | cluster:keyword | time_bucket:datetime
+false              | prod            | 2024-05-10T00:00:00.000Z
+false              | qa              | 2024-05-10T00:00:00.000Z
+false              | staging         | 2024-05-10T00:00:00.000Z
+false              | prod            | 2024-05-10T00:10:00.000Z
+false              | qa              | 2024-05-10T00:10:00.000Z
+false              | staging         | 2024-05-10T00:10:00.000Z
+false              | prod            | 2024-05-10T00:20:00.000Z
+false              | qa              | 2024-05-10T00:20:00.000Z
+false              | staging         | 2024-05-10T00:20:00.000Z
+;
+
+absent_over_time_of_point
+required_capability: metrics_command
+required_capability: absent_over_time
+required_capability: k8s_datasets_geospatial_fields
+TS k8s | STATS is_present = max(absent_over_time(event_location)) BY cluster, time_bucket = tbucket(10minute) | SORT time_bucket, cluster | LIMIT 10;
+
+is_present:boolean | cluster:keyword | time_bucket:datetime
+false              | prod            | 2024-05-10T00:00:00.000Z
+false              | qa              | 2024-05-10T00:00:00.000Z
+false              | staging         | 2024-05-10T00:00:00.000Z
+false              | prod            | 2024-05-10T00:10:00.000Z
+false              | qa              | 2024-05-10T00:10:00.000Z
+false              | staging         | 2024-05-10T00:10:00.000Z
+false              | prod            | 2024-05-10T00:20:00.000Z
+false              | qa              | 2024-05-10T00:20:00.000Z
+false              | staging         | 2024-05-10T00:20:00.000Z
+;
+
+absent_over_time_with_filtering
+required_capability: metrics_command
+required_capability: absent_over_time
+TS k8s | WHERE pod != "three" | STATS is_present = max(absent_over_time(network.bytes_in)) BY cluster, time_bucket = tbucket(10 minute) | SORT time_bucket, cluster | LIMIT 10;
+
+is_present:boolean | cluster:keyword | time_bucket:datetime
+false              | prod            | 2024-05-10T00:00:00.000Z
+false              | qa              | 2024-05-10T00:00:00.000Z
+false              | staging         | 2024-05-10T00:00:00.000Z
+false              | prod            | 2024-05-10T00:10:00.000Z
+false              | qa              | 2024-05-10T00:10:00.000Z
+false              | staging         | 2024-05-10T00:10:00.000Z
+false              | prod            | 2024-05-10T00:20:00.000Z
+false              | qa              | 2024-05-10T00:20:00.000Z
+false              | staging         | 2024-05-10T00:20:00.000Z
+;
+
+absent_over_time_older_than_10d
+required_capability: metrics_command
+required_capability: absent_over_time
+TS k8s-downsampled | WHERE cluster == "qa" AND @timestamp < now() - 10 day | STATS is_present = max(absent_over_time(network.eth0.rx)) BY pod, time_bucket = tbucket(10 minute) | SORT time_bucket, pod | LIMIT 5;
+
+is_present:boolean | pod:keyword | time_bucket:datetime
+false              | one         | 2024-05-09T23:30:00.000Z
+false              | three       | 2024-05-09T23:30:00.000Z
+false              | two         | 2024-05-09T23:30:00.000Z
+false              | one         | 2024-05-09T23:40:00.000Z
+false              | three       | 2024-05-09T23:40:00.000Z
+;
+
+eval_on_absent_over_time
+required_capability: metrics_command
+required_capability: absent_over_time
+TS k8s | STATS is_present = max(absent_over_time(network.bytes_in)) BY pod, time_bucket = tbucket(10 minute) | EVAL int = to_integer(is_present) | LIMIT 10 | SORT time_bucket, pod;
+
+is_present:boolean | pod:keyword | time_bucket:datetime     | int:integer
+false              | one         | 2024-05-10T00:00:00.000Z | 0
+false              | three       | 2024-05-10T00:00:00.000Z | 0
+false              | two         | 2024-05-10T00:00:00.000Z | 0
+false              | one         | 2024-05-10T00:10:00.000Z | 0
+false              | three       | 2024-05-10T00:10:00.000Z | 0
+false              | two         | 2024-05-10T00:10:00.000Z | 0
+false              | one         | 2024-05-10T00:20:00.000Z | 0
+false              | three       | 2024-05-10T00:20:00.000Z | 0
+false              | two         | 2024-05-10T00:20:00.000Z | 0
+;
+
+absent_over_time_events_received_as_integer
+required_capability: metrics_command
+required_capability: absent_over_time
+
+TS k8s 
+| WHERE cluster == "prod" AND pod == "two" 
+| STATS events_received = max(to_integer(absent_over_time(events_received))) BY pod, time_bucket = tbucket(2 minute) 
+| SORT time_bucket
+;
+ignoreOrder:true
+
+events_received:integer | pod:keyword | time_bucket:datetime
+0                       | two         | 2024-05-10T00:02:00.000Z
+0                       | two         | 2024-05-10T00:08:00.000Z
+1                       | two         | 2024-05-10T00:10:00.000Z
+1                       | two         | 2024-05-10T00:12:00.000Z
+0                       | two         | 2024-05-10T00:14:00.000Z
+0                       | two         | 2024-05-10T00:16:00.000Z
+1                       | two         | 2024-05-10T00:18:00.000Z
+0                       | two         | 2024-05-10T00:20:00.000Z
+0                       | two         | 2024-05-10T00:22:00.000Z
+;

+ 27 - 0
x-pack/plugin/esql/qa/testFixtures/src/main/resources/k8s-timeseries.csv-spec

@@ -491,3 +491,30 @@ false                   | two         | 2024-05-10T00:18:00.000Z
 true                    | two         | 2024-05-10T00:20:00.000Z
 true                    | two         | 2024-05-10T00:22:00.000Z
 ;
+
+absent_over_time
+required_capability: metrics_command
+required_capability: absent_over_time
+
+// tag::absent_over_time[]
+TS k8s 
+| WHERE cluster == "prod" AND pod == "two" 
+| STATS events_received = max(absent_over_time(events_received)) BY pod, time_bucket = tbucket(2 minute)
+// end::absent_over_time[] 
+| SORT time_bucket
+;
+ignoreOrder:true
+
+// tag::absent_over_time-result[]
+events_received:boolean  | pod:keyword | time_bucket:datetime
+false                    | two         | 2024-05-10T00:02:00.000Z
+false                    | two         | 2024-05-10T00:08:00.000Z
+true                     | two         | 2024-05-10T00:10:00.000Z
+true                     | two         | 2024-05-10T00:12:00.000Z
+// end::absent_over_time-result[]
+false                    | two         | 2024-05-10T00:14:00.000Z
+false                    | two         | 2024-05-10T00:16:00.000Z
+true                     | two         | 2024-05-10T00:18:00.000Z
+false                    | two         | 2024-05-10T00:20:00.000Z
+false                    | two         | 2024-05-10T00:22:00.000Z
+;

+ 11 - 1
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java

@@ -1484,7 +1484,17 @@ public class EsqlCapabilities {
          */
         QUERY_PARAMS_MULTI_VALUES(),
 
-        FIX_PERCENTILE_PRECISION();
+        FIX_PERCENTILE_PRECISION(),
+
+        /**
+         * Support for the Absent function
+         */
+        FN_ABSENT,
+
+        /**
+         * Support absent_over_time aggregation that gets evaluated per time-series
+         */
+        ABSENT_OVER_TIME(Build.current().isSnapshot());
 
         private final boolean enabled;
 

+ 5 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Analyzer.java

@@ -54,6 +54,8 @@ import org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry;
 import org.elasticsearch.xpack.esql.expression.function.FunctionDefinition;
 import org.elasticsearch.xpack.esql.expression.function.UnresolvedFunction;
 import org.elasticsearch.xpack.esql.expression.function.UnsupportedAttribute;
+import org.elasticsearch.xpack.esql.expression.function.aggregate.Absent;
+import org.elasticsearch.xpack.esql.expression.function.aggregate.AbsentOverTime;
 import org.elasticsearch.xpack.esql.expression.function.aggregate.AggregateFunction;
 import org.elasticsearch.xpack.esql.expression.function.aggregate.Avg;
 import org.elasticsearch.xpack.esql.expression.function.aggregate.AvgOverTime;
@@ -2134,6 +2136,9 @@ public class Analyzer extends ParameterizedRuleExecutor<LogicalPlan, AnalyzerCon
             if (aggFunc instanceof Present || aggFunc instanceof PresentOverTime) {
                 return AggregateMetricDoubleBlockBuilder.Metric.COUNT;
             }
+            if (aggFunc instanceof Absent || aggFunc instanceof AbsentOverTime) {
+                return AggregateMetricDoubleBlockBuilder.Metric.COUNT;
+            }
             return null;
         }
     }

+ 6 - 2
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java

@@ -19,6 +19,8 @@ import org.elasticsearch.xpack.esql.core.tree.Source;
 import org.elasticsearch.xpack.esql.core.type.DataType;
 import org.elasticsearch.xpack.esql.core.util.Check;
 import org.elasticsearch.xpack.esql.expression.SurrogateExpression;
+import org.elasticsearch.xpack.esql.expression.function.aggregate.Absent;
+import org.elasticsearch.xpack.esql.expression.function.aggregate.AbsentOverTime;
 import org.elasticsearch.xpack.esql.expression.function.aggregate.Avg;
 import org.elasticsearch.xpack.esql.expression.function.aggregate.AvgOverTime;
 import org.elasticsearch.xpack.esql.expression.function.aggregate.Count;
@@ -348,7 +350,8 @@ public class EsqlFunctionRegistry {
                 def(Top.class, tri(Top::new), "top"),
                 def(Values.class, uni(Values::new), "values"),
                 def(WeightedAvg.class, bi(WeightedAvg::new), "weighted_avg"),
-                def(Present.class, uni(Present::new), "present") },
+                def(Present.class, uni(Present::new), "present"),
+                def(Absent.class, uni(Absent::new), "absent") },
             // math
             new FunctionDefinition[] {
                 def(Abs.class, Abs::new, "abs"),
@@ -531,7 +534,8 @@ public class EsqlFunctionRegistry {
                 def(Hamming.class, Hamming::new, "v_hamming"),
                 def(UrlEncode.class, UrlEncode::new, "url_encode"),
                 def(UrlDecode.class, UrlDecode::new, "url_decode"),
-                def(PresentOverTime.class, uni(PresentOverTime::new), "present_over_time") } };
+                def(PresentOverTime.class, uni(PresentOverTime::new), "present_over_time"),
+                def(AbsentOverTime.class, uni(AbsentOverTime::new), "absent_over_time") } };
     }
 
     public EsqlFunctionRegistry snapshotRegistry() {

+ 134 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Absent.java

@@ -0,0 +1,134 @@
+/*
+ * 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.esql.expression.function.aggregate;
+
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.expression.Literal;
+import org.elasticsearch.xpack.esql.core.expression.Nullability;
+import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.SurrogateExpression;
+import org.elasticsearch.xpack.esql.expression.function.Example;
+import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.FunctionType;
+import org.elasticsearch.xpack.esql.expression.function.Param;
+import org.elasticsearch.xpack.esql.expression.predicate.logical.Not;
+
+import java.io.IOException;
+import java.util.List;
+
+import static java.util.Collections.emptyList;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.DEFAULT;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType;
+
+/**
+ * The function that checks for the absence of a field in the output result.
+ * An absence means that the input expression does not yield a non-null value.
+ */
+public class Absent extends AggregateFunction implements SurrogateExpression {
+    public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(Expression.class, "Absent", Absent::new);
+
+    @FunctionInfo(
+        returnType = "boolean",
+        description = "Returns true if the input expression yields no non-null values within the current aggregation context. "
+            + "Otherwise it returns false.",
+        type = FunctionType.AGGREGATE,
+        examples = {
+            @Example(file = "absent", tag = "absent"),
+            @Example(
+                description = "To check for the absence inside a group use `ABSENT()` and `BY` clauses",
+                file = "absent",
+                tag = "absent-by"
+            ),
+            @Example(
+                description = "To check for the absence and return 1 when it's true and 0 when it's false you can use to_integer()",
+                file = "absent",
+                tag = "absent-as-integer"
+            ) }
+    )
+    public Absent(
+        Source source,
+        @Param(
+            name = "field",
+            type = {
+                "aggregate_metric_double",
+                "boolean",
+                "cartesian_point",
+                "cartesian_shape",
+                "date",
+                "date_nanos",
+                "double",
+                "geo_point",
+                "geo_shape",
+                "geohash",
+                "geotile",
+                "geohex",
+                "integer",
+                "ip",
+                "keyword",
+                "long",
+                "text",
+                "unsigned_long",
+                "version" },
+            description = "Expression that outputs values to be checked for absence."
+        ) Expression field
+    ) {
+        this(source, field, Literal.TRUE);
+    }
+
+    public Absent(Source source, Expression field, Expression filter) {
+        super(source, field, filter, emptyList());
+    }
+
+    private Absent(StreamInput in) throws IOException {
+        super(in);
+    }
+
+    @Override
+    public String getWriteableName() {
+        return ENTRY.name;
+    }
+
+    @Override
+    protected NodeInfo<Absent> info() {
+        return NodeInfo.create(this, Absent::new, field(), filter());
+    }
+
+    @Override
+    public AggregateFunction withFilter(Expression filter) {
+        return new Absent(source(), field(), filter);
+    }
+
+    @Override
+    public Absent replaceChildren(List<Expression> newChildren) {
+        return new Absent(source(), newChildren.get(0), newChildren.get(1));
+    }
+
+    @Override
+    public DataType dataType() {
+        return DataType.BOOLEAN;
+    }
+
+    @Override
+    public Nullability nullable() {
+        return Nullability.FALSE;
+    }
+
+    @Override
+    protected TypeResolution resolveType() {
+        return isType(field(), dt -> dt.isCounter() == false, sourceText(), DEFAULT, "any type except counter types");
+    }
+
+    @Override
+    public Expression surrogate() {
+        return new Not(source(), new Present(source(), field(), filter()));
+    }
+}

+ 118 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AbsentOverTime.java

@@ -0,0 +1,118 @@
+/*
+ * 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.esql.expression.function.aggregate;
+
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.expression.Literal;
+import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.function.Example;
+import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesTo;
+import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesToLifecycle;
+import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.FunctionType;
+import org.elasticsearch.xpack.esql.expression.function.Param;
+
+import java.io.IOException;
+import java.util.List;
+
+import static java.util.Collections.emptyList;
+
+/**
+ * Similar to {@link Absent}, but it is used to check the absence of values over a time series in the given field.
+ */
+public class AbsentOverTime extends TimeSeriesAggregateFunction {
+    public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
+        Expression.class,
+        "AbsentOverTime",
+        AbsentOverTime::new
+    );
+
+    @FunctionInfo(
+        type = FunctionType.TIME_SERIES_AGGREGATE,
+        returnType = { "boolean" },
+        description = "The absence of a field in the output result over time range.",
+        appliesTo = { @FunctionAppliesTo(lifeCycle = FunctionAppliesToLifecycle.UNAVAILABLE) },
+        note = "Available with the [TS](/reference/query-languages/esql/commands/source-commands.md#esql-ts) command in snapshot builds",
+        examples = { @Example(file = "k8s-timeseries", tag = "absent_over_time") }
+    )
+    public AbsentOverTime(
+        Source source,
+        @Param(
+            name = "field",
+            type = {
+                "aggregate_metric_double",
+                "boolean",
+                "cartesian_point",
+                "cartesian_shape",
+                "date",
+                "date_nanos",
+                "double",
+                "geo_point",
+                "geo_shape",
+                "geohash",
+                "geotile",
+                "geohex",
+                "integer",
+                "ip",
+                "keyword",
+                "long",
+                "text",
+                "unsigned_long",
+                "version" }
+        ) Expression field
+    ) {
+        this(source, field, Literal.TRUE);
+    }
+
+    public AbsentOverTime(Source source, Expression field, Expression filter) {
+        super(source, field, filter, emptyList());
+    }
+
+    private AbsentOverTime(StreamInput in) throws IOException {
+        super(in);
+    }
+
+    @Override
+    public String getWriteableName() {
+        return ENTRY.name;
+    }
+
+    @Override
+    public AbsentOverTime withFilter(Expression filter) {
+        return new AbsentOverTime(source(), field(), filter);
+    }
+
+    @Override
+    protected NodeInfo<AbsentOverTime> info() {
+        return NodeInfo.create(this, AbsentOverTime::new, field(), filter());
+    }
+
+    @Override
+    public AbsentOverTime replaceChildren(List<Expression> newChildren) {
+        return new AbsentOverTime(source(), newChildren.get(0), newChildren.get(1));
+    }
+
+    @Override
+    protected TypeResolution resolveType() {
+        return perTimeSeriesAggregation().resolveType();
+    }
+
+    @Override
+    public DataType dataType() {
+        return perTimeSeriesAggregation().dataType();
+    }
+
+    @Override
+    public Absent perTimeSeriesAggregation() {
+        return new Absent(source(), field(), filter());
+    }
+}

+ 3 - 1
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AggregateWritables.java

@@ -44,7 +44,9 @@ public class AggregateWritables {
             CountDistinctOverTime.ENTRY,
             WeightedAvg.ENTRY,
             Present.ENTRY,
-            PresentOverTime.ENTRY
+            PresentOverTime.ENTRY,
+            Absent.ENTRY,
+            AbsentOverTime.ENTRY
         );
     }
 }

+ 43 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AbsentErrorTests.java

@@ -0,0 +1,43 @@
+/*
+ * 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.esql.expression.function.aggregate;
+
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.function.ErrorsForCasesWithoutExamplesTestCase;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+import org.hamcrest.Matcher;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class AbsentErrorTests extends ErrorsForCasesWithoutExamplesTestCase {
+    @Override
+    protected List<TestCaseSupplier> cases() {
+        return paramsToSuppliers(AbsentTests.parameters());
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new Absent(source, args.get(0));
+    }
+
+    @Override
+    protected Matcher<String> expectedTypeErrorMatcher(List<Set<DataType>> validPerPosition, List<DataType> signature) {
+        assert false : "All checked types must work";
+        return null;
+    }
+
+    @Override
+    protected void assertNumberOfCheckedSignatures(int checked) {
+        assertThat(checked, equalTo(0));
+    }
+}

+ 35 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AbsentOverTimeTests.java

@@ -0,0 +1,35 @@
+/*
+ * 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.esql.expression.function.aggregate;
+
+import com.carrotsearch.randomizedtesting.annotations.Name;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.expression.function.AbstractFunctionTestCase;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+
+import java.util.List;
+import java.util.function.Supplier;
+
+public class AbsentOverTimeTests extends AbstractFunctionTestCase {
+    public AbsentOverTimeTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        return AbsentTests.parameters();
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new AbsentOverTime(source, args.get(0));
+    }
+}

+ 24 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AbsentSerializationTests.java

@@ -0,0 +1,24 @@
+/*
+ * 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.esql.expression.function.aggregate;
+
+import org.elasticsearch.xpack.esql.expression.AbstractExpressionSerializationTests;
+
+import java.io.IOException;
+
+public class AbsentSerializationTests extends AbstractExpressionSerializationTests<Absent> {
+    @Override
+    protected Absent createTestInstance() {
+        return new Absent(randomSource(), randomChild());
+    }
+
+    @Override
+    protected Absent mutateInstance(Absent instance) throws IOException {
+        return new Absent(instance.source(), randomValueOtherThan(instance.field(), AbstractExpressionSerializationTests::randomChild));
+    }
+}

+ 111 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AbsentTests.java

@@ -0,0 +1,111 @@
+/*
+ * 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.esql.expression.function.aggregate;
+
+import com.carrotsearch.randomizedtesting.annotations.Name;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.function.AbstractAggregationTestCase;
+import org.elasticsearch.xpack.esql.expression.function.MultiRowTestCaseSupplier;
+import org.elasticsearch.xpack.esql.expression.function.MultiRowTestCaseSupplier.IncludingAltitude;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class AbsentTests extends AbstractAggregationTestCase {
+    public AbsentTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        ArrayList<TestCaseSupplier> suppliers = new ArrayList<>();
+
+        Stream.of(
+            MultiRowTestCaseSupplier.nullCases(1, 1000),
+            MultiRowTestCaseSupplier.intCases(1, 1000, Integer.MIN_VALUE, Integer.MAX_VALUE, true),
+            MultiRowTestCaseSupplier.longCases(1, 1000, Long.MIN_VALUE, Long.MAX_VALUE, true),
+            MultiRowTestCaseSupplier.ulongCases(1, 1000, BigInteger.ZERO, UNSIGNED_LONG_MAX, true),
+            MultiRowTestCaseSupplier.doubleCases(1, 1000, -Double.MAX_VALUE, Double.MAX_VALUE, true),
+            MultiRowTestCaseSupplier.dateCases(1, 1000),
+            MultiRowTestCaseSupplier.dateNanosCases(1, 1000),
+            MultiRowTestCaseSupplier.booleanCases(1, 1000),
+            MultiRowTestCaseSupplier.ipCases(1, 1000),
+            MultiRowTestCaseSupplier.versionCases(1, 1000),
+            MultiRowTestCaseSupplier.geoPointCases(1, 1000, IncludingAltitude.YES),
+            MultiRowTestCaseSupplier.geoShapeCasesWithoutCircle(1, 1000, IncludingAltitude.YES),
+            MultiRowTestCaseSupplier.cartesianShapeCasesWithoutCircle(1, 1000, IncludingAltitude.YES),
+            MultiRowTestCaseSupplier.geohashCases(1, 1000),
+            MultiRowTestCaseSupplier.geotileCases(1, 1000),
+            MultiRowTestCaseSupplier.geohexCases(1, 1000),
+            MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.KEYWORD),
+            MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.TEXT)
+        ).flatMap(List::stream).map(AbsentTests::makeSupplier).collect(Collectors.toCollection(() -> suppliers));
+
+        // No rows
+        for (var dataType : List.of(
+            DataType.BOOLEAN,
+            DataType.CARTESIAN_POINT,
+            DataType.CARTESIAN_SHAPE,
+            DataType.DATE_NANOS,
+            DataType.DATETIME,
+            DataType.DATE_NANOS,
+            DataType.DOUBLE,
+            DataType.GEO_POINT,
+            DataType.GEO_SHAPE,
+            DataType.INTEGER,
+            DataType.IP,
+            DataType.KEYWORD,
+            DataType.LONG,
+            DataType.TEXT,
+            DataType.UNSIGNED_LONG,
+            DataType.VERSION
+        )) {
+            suppliers.add(
+                new TestCaseSupplier(
+                    "No rows (" + dataType + ")",
+                    List.of(dataType),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(), dataType, "field")),
+                        "Present",
+                        DataType.BOOLEAN,
+                        equalTo(true)
+                    )
+                )
+            );
+        }
+
+        // "No rows" expects 0 here instead of null
+        return parameterSuppliersFromTypedData(randomizeBytesRefsOffset(suppliers));
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new Absent(source, args.getFirst());
+    }
+
+    private static TestCaseSupplier makeSupplier(TestCaseSupplier.TypedDataSupplier fieldSupplier) {
+        return new TestCaseSupplier(fieldSupplier.name(), List.of(fieldSupplier.type()), () -> {
+            TestCaseSupplier.TypedData fieldTypedData = fieldSupplier.get();
+            boolean absent = fieldTypedData.multiRowData().stream().allMatch(Objects::isNull);
+
+            return new TestCaseSupplier.TestCase(List.of(fieldTypedData), "Present", DataType.BOOLEAN, equalTo(absent));
+        });
+    }
+}

+ 2 - 1
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml

@@ -43,6 +43,7 @@ setup:
             - count_distinct_over_time
             - cosine_vector_similarity_function
             - present_over_time
+            - absent_over_time
       reason: "Test that should only be executed on snapshot versions"
 
   - do: {xpack.usage: {}}
@@ -130,7 +131,7 @@ setup:
   - match: {esql.functions.coalesce: $functions_coalesce}
   - gt: {esql.functions.categorize: $functions_categorize}
   # Testing for the entire function set isn't feasible, so we just check that we return the correct count as an approximation.
-  - length: {esql.functions: 176} # check the "sister" test below for a likely update to the same esql.functions length check
+  - length: {esql.functions: 178} # check the "sister" test below for a likely update to the same esql.functions length check
 ---
 "Basic ESQL usage output (telemetry) non-snapshot version":
   - requires: