<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title><![CDATA[Miguel Sánchez Gómez - Elastic Observability Labs]]></title>
    <description><![CDATA[Trusted security news & research from the team at Elastic.]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Miguel Sánchez Gómez - Elastic Observability Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltad972c1c27dbefc6/6a88d9782904ea5e8511d473/observability-labs-thumbnail.png</url>
      <link>https://www.elastic.co/observability-labs/author/miguel-sanchez-gomez</link>
    </image>
    <link>https://www.elastic.co/observability-labs/author/miguel-sanchez-gomez</link>
    <atom:link href="https://www.elastic.co/observability-labs/rss/author/miguel-sanchez-gomez.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Thu, 10 Sep 2026 14:44:45 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Correlate logs, metrics, and traces in one ES|QL query]]></title>
    <description><![CDATA[Walk through four investigations, from CPU saturation to pod memory pressure, each answered by a single query across signal types.]]></description>
    <content:encoded><![CDATA[<p>ES|QL can now filter one observability signal by the live result of a query against another.
<a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-in-subquery"><code>WHERE field IN (subquery)</code></a> is in technical preview in Elastic Stack 9.5 and on Elastic Cloud Serverless, and because these subqueries nest, one query can reach across logs, metrics, and traces at the same time.</p>
<p>The gain is in where the intermediate set lives.
When you ask what the saturated hosts logged, the list of saturated hosts is computed and consumed inside Elasticsearch.
Six host names or 500 trace IDs never land in a clipboard or in an AI agent's context window, and the set is recomputed from current data every time the query runs.</p>
<p>That changes the unit of investigation.
You go from "one slow request stalled on a lock" to "most of the slowest requests did," and only the second answer tells you which team to page.
It matters more the further apart your signals are: in many observability stacks, logs, metrics, and traces live in three separate systems, each with its own query language, its own time picker, and its own idea of what a host is, so the same question has to be asked two or three times and the answers joined by hand.</p>
<p>In this post we walk through four investigations, each of which is self-contained.
For every one of them, we set out the scenario that started it, the query that answers it, the table it returns, and a note on the difficulties you run into when you try to get the same answer any other way.</p>
<p>| Pattern | Starts from | Question it answers |
|---|---|---|
| Metrics to logs | CPU saturation | Which error patterns show up only on the saturated hosts? |
| Logs to metrics | Error logs | Do the erroring hosts look any different from the healthy ones? |
| Traces to logs | Slow spans | What did every service log during those specific requests? |
| All three signals | Pod memory pressure | Which log lines sit behind the requests that failed under that pressure? |</p>
<p>The data was collected with <a href="https://www.elastic.co/docs/reference/opentelemetry">OpenTelemetry</a> and lands in the <code>logs-*.otel-*</code>, <code>traces-*.otel-*</code>, and <code>metrics-*.otel-*</code> data streams, where fields keep their <a href="https://opentelemetry.io/docs/specs/semconv/">semantic convention</a> names rather than being rewritten into another schema.
The correlation pattern works just as well on Elastic Agent integrations, though the queries need translating rather than just renaming: ECS carries log severity as the text field <code>log.level</code> instead of a numeric <code>severity_number</code>, the System integration reports CPU as separate <code>system.cpu.*.pct</code> fields instead of one metric with a state dimension, and APM records durations in microseconds.
All of it lands in the same cluster either way, which is the part the subquery depends on.</p>
<p>In <a href="https://www.elastic.co/docs/explore-analyze/discover">Discover</a>, the time picker already applies the range, so the examples below omit an explicit <code>@timestamp</code> filter.
Outside Discover, add a filter by time yourself, either with literal timestamps in the query or with <code>?_tstart</code> and <code>?_tend</code> in the query and values in the <code>params</code> array of your <code>_query</code> request.
Every result below comes from a one hour window over a synthetic fleet of 300 hosts.</p>
<h2 id="metricstologswhatarethesaturatedhostscomplainingabout">Metrics to logs: what are the saturated hosts complaining about?</h2>
<p>An infrastructure alert tells you a handful of hosts in a fleet of a few hundred sat above 90% CPU over the last hour.
That tells you which hosts are hot and nothing about why.
The question worth answering is whether those hosts share a failure mode, or whether they are busy for unrelated reasons and the alert is a coincidence.</p>
<pre><code>FROM logs-*.otel-*
| WHERE severity_number &gt;= 17
  AND resource.attributes.host.name IN (
      TS metrics-hostmetrics.otel-*
      | WHERE attributes.state == "idle"
      | STATS idle = AVG(AVG_OVER_TIME(metrics.system.cpu.utilization))
          BY resource.attributes.host.name
      | WHERE idle &lt; 0.1
      | KEEP resource.attributes.host.name
    )
| STATS errors = COUNT(*), hosts = COUNT_DISTINCT(resource.attributes.host.name)
    BY pattern = CATEGORIZE(body.text), service = resource.attributes.service.name
| SORT errors DESC
</code></pre>
<p>The two halves of the query map onto the two halves of the question.
The subquery works out which hosts were saturated, averaging CPU utilization per host and keeping the ones that averaged under 10% idle, which is another way of saying above 90% busy for the window.
The outer query then works out what those hosts were complaining about, pulling their error logs and using <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/grouping-functions/categorize"><code>CATEGORIZE</code></a> to collapse thousands of individual lines into a handful of error classes.</p>
<p>Two choices in there are worth pausing on.</p>
<p>The subquery uses <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> rather than <code>FROM</code> because a host does not report one CPU number.
It reports a separate time series per CPU state, and per logical core as well if your collector is configured to break them out, so the reduction has to happen in two stages.
<a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions/avg_over_time"><code>AVG_OVER_TIME</code></a> collapses each series to a single value first, and the outer <code>AVG</code> then combines those into one figure per host.</p>
<p>Naming that inner function matters more than it looks.
Write <code>AVG(metrics.system.cpu.utilization)</code> on its own and <code>TS</code> supplies <code>LAST_OVER_TIME</code> for you, averaging each series' final sample rather than its average over the window.
In this dataset that one substitution moves a host from 7% idle to 10% idle, which is the difference between appearing in the results and not.
<a href="https://www.elastic.co/observability-labs/blog/esql-ts-command-querying-metrics">Querying metrics with the TS command</a> goes into the two aggregation phases in more depth.</p>
<p>The log filter tests <code>severity_number</code> (17 is the ERROR floor on the OpenTelemetry scale) rather than the severity text, because the numeric scale is fixed by the spec while the text is whatever the emitting library decided to write.
That is not a hypothetical distinction here: the error logs in this cluster carry four different labels, including <code>SEVERE</code> from a Java service.
Matching on the text alone returns 2,754 of checkout's errors and misses the billing service entirely, while the numeric filter returns all 5,910 of them and keeps billing too.</p>
<p>When you run the query in Discover, the result is a short table of error classes, scoped to the saturated hosts:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9a7ec0258240c9d3/6a85caf327c5cd90465f73f4/metrics-to-logs-result.jpg" alt="Discover ES|QL results showing error log patterns grouped by CATEGORIZE for hosts above 90% CPU" /></p>
<p>The subquery returned six saturated hosts, and on all six the same service is timing out against an upstream dependency and draining its connection pool.
The <code>hosts</code> column is what lets you set the other two rows aside without opening anything: the certificate errors reach only two of the six, and the gateway declines amount to five lines in an hour.
Neither tracks the cohort the way the checkout patterns do.</p>
<p>Having all three signals in one store already removed the exports from this investigation.
The subquery removes the step after that, and closing that last gap matters more than it sounds.
By the time you have read six host names off a chart and typed them into a log search, the set has moved: a host that crossed the threshold a minute ago is missing from your list, and one that has since recovered is still in it.
Here the host list is derived from current data on every run, so re-running the query during an incident gives you the current cohort.</p>
<p>The stale list is only half of it.
A correlation done by hand exists only in the head of the person who did it, so nobody else can check it, save it, or run it again tomorrow.</p>
<h2 id="logstometricsdotheerroringhostslookdifferentfromthehealthyones">Logs to metrics: do the erroring hosts look different from the healthy ones?</h2>
<p>Filtering metrics by a log-derived host set answers the opposite question.
The payments service is throwing errors on some hosts and not others, and you want to know whether resource pressure explains the split before you start reading deploy history.</p>
<p>That is a comparison, so the query needs both cohorts.
<a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fork"><code>FORK</code></a> runs two branches over the same input, and <code>IN</code> and <code>NOT IN</code> against the same log-derived host set divide the fleet between them.</p>
<pre><code>TS metrics-hostmetrics.otel-*
| WHERE attributes.state == "idle"
| STATS idle = AVG(AVG_OVER_TIME(metrics.system.cpu.utilization))
    BY host = resource.attributes.host.name
| EVAL busy = 1 - idle
| FORK
    ( WHERE host IN (
        FROM logs-*.otel-*
        | WHERE severity_number &gt;= 17
          AND resource.attributes.service.name == "payments"
          AND resource.attributes.host.name IS NOT NULL
        | STATS errors = COUNT(*) BY resource.attributes.host.name
        | KEEP resource.attributes.host.name )
      | EVAL cohort = "logging errors" )
    ( WHERE host NOT IN (
        FROM logs-*.otel-*
        | WHERE severity_number &gt;= 17
          AND resource.attributes.service.name == "payments"
          AND resource.attributes.host.name IS NOT NULL
        | STATS errors = COUNT(*) BY resource.attributes.host.name
        | KEEP resource.attributes.host.name )
      | EVAL cohort = "no errors" )
| STATS hosts = COUNT(*), mean_busy = AVG(busy), busiest_host = MAX(busy)
    BY cohort
</code></pre>
<p>The query reads top to bottom as three stages.
The metrics query runs first and reduces the whole fleet to one busy figure per host.
<code>FORK</code> then splits that fleet in two using the same log query in both branches, separating the hosts that appear in it from the hosts that do not.
The final <code>STATS</code> summarizes each group, so both cohorts come back as two rows of one table, measured the same way over the same window.</p>
<p>This query is longer than the others, and three parts of it are less obvious than they look.</p>
<p>The natural way to label the two cohorts would be <code>EVAL cohort = CASE(host IN (...), "erroring", "healthy")</code>, and ES|QL rejects it.
In 9.5 an <code>IN</code> subquery has to be a top-level predicate in a <code>WHERE</code> condition rather than an argument to a scalar function, which is why the split happens at the command level with <code>FORK</code>.</p>
<p>The <code>STATS ... BY</code> inside each subquery looks redundant, since <code>KEEP</code> alone would return the same host names.
It is not: without it, the subquery returns one row per matching log document instead of one row per host, and those rows are all held in memory for the outer query to filter against.
Aggregating first turns millions of rows into a few hundred host names.</p>
<p>The <code>IS NOT NULL</code> filter guards the sharpest edge here, and this dataset is a live example rather than a hypothetical.
<code>NOT IN</code> follows SQL null semantics, so a single null in the subquery result makes the predicate match nothing at all.
Five of the payments error logs in this cluster came through a sidecar that dropped the host name.
Remove that one line from both branches and the query still succeeds, but it returns a single row: the nulls quietly delete the entire 286-host "no errors" cohort, and what is left looks like a perfectly plausible answer to a different question.</p>
<p>Run the query in Discover and you get two rows, one per cohort:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt867c2fae8e2b1116/6a85caf5abdc29f2f11224fc/cohort-comparison.jpg" alt="Discover ES|QL results comparing mean and peak CPU for hosts logging errors against hosts with no errors" /></p>
<p>CPU does not explain the split, and the numbers say so twice.
The 14 erroring hosts run slightly cooler on average than the 286 quiet ones, and the busiest machine among them averaged 50% over the hour while the quiet cohort contains a host that averaged 95%.
Whatever is failing on those 14, they had headroom the entire time, and deploy history is a better place to spend the next ten minutes.</p>
<p>A negative result like this is worth as much as a positive one, and it is usually the one people skip.
Getting it the long way means running the metrics query twice against two hand-built host lists and lining the numbers up afterwards, which is enough friction that the check often just does not happen.
Both cohorts here come from the same log query in the same execution, over identical time windows, so there is nothing to reconcile and no reason not to check.</p>
<h2 id="tracestologswhatdideveryservicelogduringtheslowrequests">Traces to logs: what did every service log during the slow requests?</h2>
<p>A <a href="https://www.elastic.co/observability-labs/blog/slo-burn-rate-analysis-trace-investigation">service level objective (SLO) burn alert</a> fires on checkout latency.
Tracing gives you the slow requests and their spans, and the next question is what the services involved were writing to their logs while those specific requests were in flight.</p>
<p>The trace ID is the join key, and there are far too many of them to move by hand.</p>
<pre><code>FROM logs-*.otel-*
| WHERE trace_id IN (
      FROM traces-*.otel-*
      | WHERE kind == "Server"
        AND resource.attributes.service.name == "checkout"
        AND name == "POST /api/orders"
        AND duration &gt; 2000000000
      | SORT duration DESC
      | LIMIT 500
      | KEEP trace_id
    )
| STATS lines = COUNT(*), traces = COUNT_DISTINCT(trace_id)
    BY pattern = CATEGORIZE(body.text),
       service = resource.attributes.service.name,
       severity_text
| SORT traces DESC
</code></pre>
<p>The subquery answers "which requests were slow."
It looks at the inbound request span for the checkout endpoint rather than the client and internal spans beneath it, then keeps the 500 slowest requests over two seconds.
Durations are recorded in nanoseconds, which is why the threshold has so many zeros.</p>
<p>The outer query answers "what got logged while they were running," gathering every log line that shares one of those trace IDs and grouping them into patterns.</p>
<p>Counting distinct traces per pattern is what makes the output readable.
A log pattern that appears 30,000 times across four traces is one chatty request, while a pattern that shows up in 470 of the 500 slowest traces is a property of being slow.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2d7d39a09bb47dec/6a85caf8f9373d09d496f594/slow-trace-log-patterns.jpg" alt="Discover ES|QL results ranking log patterns by how many of the 500 slowest checkout requests they appear in" /></p>
<p>From the results above, the top row is there by construction and can be set aside: checkout writes one <code>order submitted</code> line per order, so it appears in all 500 traces and says nothing about why these particular 500 were slow.</p>
<p>From the results above, the third row is the answer, and it points at a service nobody was looking at.
A lock wait timeout in inventory, two hops downstream from where the alert fired, shows up in 470 of the 500 slowest requests, and the 947 lines behind those 470 traces mean a good share of them retried more than once.
The payment gateway declines are real failures, and at 19 traces out of 500 they are not what is burning the SLO.</p>
<p>Done by hand, this means opening slow traces one at a time and reading the correlated logs for each, which is tedious at ten traces and nobody's idea of a plan at 500.
When the spans and the logs are held in different systems, every trace you check is a copied ID and a context switch, and the sample size you can afford drops to about three.
Three traces is enough to form a theory and not enough to test one.
Treating the slow requests as a population is what turns "this trace had a lock wait" into "470 of the 500 slowest requests had a lock wait," and that difference decides whether you page the inventory team.</p>
<h2 id="allthreesignalsfrompodmemorypressuretotheloglinesbehindthefailures">All three signals: from pod memory pressure to the log lines behind the failures</h2>
<p><code>IN</code> subqueries nest, so the pattern extends to as many signal types as the question needs.</p>
<p>A node pool starts reporting memory pressure after a rollout.
You want the log lines from the requests that actually failed on the pods under pressure, which means going from metrics to traces to logs without stopping in between.</p>
<pre><code>FROM logs-*.otel-*
| WHERE trace_id IN (
      FROM traces-*.otel-*
      | WHERE kind == "Server"
        AND status.code == "Error"
        AND resource.attributes.k8s.pod.uid IN (
            TS metrics-kubeletstats.otel-*
            | STATS peak = MAX(MAX_OVER_TIME(metrics.k8s.pod.memory_limit_utilization))
                BY resource.attributes.k8s.pod.uid
            | WHERE peak &gt; 0.95
            | KEEP resource.attributes.k8s.pod.uid
          )
      | STATS failures = COUNT(*) BY trace_id
      | SORT failures DESC
      | LIMIT 1000
      | KEEP trace_id
    )
| STATS lines = COUNT(*), traces = COUNT_DISTINCT(trace_id)
    BY pattern = CATEGORIZE(body.text), service = resource.attributes.service.name
| SORT traces DESC
</code></pre>
<p>Reading the query inside out, you can see each layer answering one part of the question.
The innermost subquery identifies the pods whose memory peaked above 95% of their limit, using <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions/max_over_time"><code>MAX_OVER_TIME</code></a> to take each pod's peak rather than its average.
The middle one narrows to the requests that actually failed on those pods and reduces them to at most 1,000 trace IDs.
The outer query then collects the logs for those traces from every service that took part, including services running on pods that were entirely healthy, and that last part turns out to be where the answer is.</p>
<p>The pods are matched on their UID rather than their name, because names repeat across namespaces and restarts.
The query assumes Kubernetes metadata reaches your spans, which is what the collector's <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/k8sattributesprocessor"><code>k8sattributes</code> processor</a> is for; if it does not, the host or container ID works the same way.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3aa0104b29390bd2/6a85cafa07829040c5321766/three-signal-result.jpg" alt="Discover ES|QL results tracing Kubernetes pod memory pressure through failed spans to the log patterns behind them" /></p>
<p>From the results above, the first row sits at exactly 1,000 because that is the subquery's <code>LIMIT</code>, so it describes the size of the sample rather than the size of the incident.
Every trace in that sample carries the cart deadline line, which is the symptom you already knew about when you started.</p>
<p>Look at the second row instead.
<code>product-catalog</code> is rejecting oversized payloads across 946 of the same 1,000 traces, and it never appeared in the pod subquery at all: its pods peaked at 75% of their memory limit, well under the 95% threshold.
The rollout started sending larger payloads, which would account for both cart's memory climb and the failures.
Checkout's retry budget gives out in about half of them, which is how the failure became visible to users.</p>
<p>Filtering logs directly by the pressured pods would have shown you the cart line and hidden the product-catalog one, which is to say it would have confirmed the symptom and buried the cause.
Doing it without subqueries means three queries and two hand-built lists, and the second list is a thousand trace IDs.
That is usually the point at which people stop after the first hop and go with the cart theory.
The reason the second hop is cheap here is that all three signals sit in the same store behind the same query language, so widening from pods to traces to every service in the trace is a clause, not a project.</p>
<h2 id="whydoesqlsubqueriesmatterforaiagents">Why do ES|QL subqueries matter for AI agents?</h2>
<p>Keeping the intermediate set inside the cluster is convenient for a person and close to essential for an agent querying on your behalf.</p>
<p>Split across two tool calls, the intermediate result has to travel.
A list of 500 trace IDs comes back in a tool response and occupies the model's context, and the agent then has to rewrite every one of them into the next query.
That costs tokens on every hop, and it is where truncation and transcription errors come from.
With a subquery, the intermediate set stays inside Elasticsearch and the agent only ever sees the final table.</p>
<p>The problem compounds when the signals are spread across systems.
An agent then needs credentials, a client, and a working knowledge of the query language for each one, plus the judgment to join results that use different names for the same host.
One store and one query language reduce that to a single skill the agent has to be good at.</p>
<p>One ES|QL string is also a complete description of the correlation, which makes the investigation reproducible: an agent can put the query in its summary, and a human can paste it into Discover and get the same logic evaluated against current data.
A two-call sequence with a hardcoded host list in the middle gives you neither.
The one thing to watch is that an agent calling the <code>_query</code> API has to filter <code>@timestamp</code> itself, since nothing is binding a time picker.</p>
<h2 id="fourthingstoknowbeforewritingesqlinsubqueries">Four things to know before writing ES|QL IN subqueries</h2>
<p><code>IN</code> subqueries are in technical preview in Elastic Stack 9.5 and on Elastic Cloud Serverless, while <code>TS</code> and <code>FORK</code> have been generally available since 9.4.
<code>CATEGORIZE</code> has been generally available since 9.1 and requires a <a href="https://www.elastic.co/subscriptions">Platinum license</a>; every query above works without it if you group by an existing field instead.</p>
<p>Four things are worth knowing before you write your own:</p>
<ul>
<li>In 9.5 the subquery returns exactly one column, which is what the trailing <code>KEEP</code> does in each example.</li>
<li>Aggregate the subquery down to distinct values with <code>STATS ... BY</code> before returning them.
Its result is materialized for the outer query to filter against, so handing back a few hundred host names instead of a few million rows is both faster and safer.</li>
<li>Filter nulls out of any <code>NOT IN</code> subquery, because SQL null semantics mean one null makes the predicate match nothing.</li>
<li>Subqueries are non-correlated.
They run independently and cannot reference columns from the outer query, so this is a set filter rather than a row-by-row join.
Reach for <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/lookup-join"><code>LOOKUP JOIN</code></a> when you need per-row enrichment, which we covered in <a href="https://www.elastic.co/observability-labs/blog/elastic-esql-join-observability">ES|QL joins for richer observability</a>.</li>
</ul>
<h2 id="tryesqlsignalcorrelationonyourowndata">Try ES|QL signal correlation on your own data</h2>
<p>The pattern under all four examples is the same.
You start with a set you can describe in one signal and a question you can only answer in another, and the subquery carries that set across the boundary for you.</p>
<p>The syntax is the smaller part of what makes that work.
It works because logs, metrics, and traces sit in <a href="https://www.elastic.co/observability-labs/blog/elasticsearch-columnar-metrics-engine-30x-faster-prometheus">one store behind one query engine</a>, under field names they kept on the way in, so crossing from one signal to another is a clause in a query rather than an integration to build and maintain.
Where that is not true, the same four investigations turn into a sequence of exports, translations, and manual joins, and that costs more than slower answers.
It quietly shrinks the number of questions anyone is willing to ask, and the negative results are the first to go.</p>
<p>Each pattern here replaces two or three queries with one, and no host list or trace ID list has to move between them.
Fewer steps mean fewer places to be wrong, and a correlation you can save as a single string and hand to someone else.</p>
<p>To try it:</p>
<ol>
<li>Open an <a href="https://cloud.elastic.co/serverless-registration?onboarding_token=observability">Observability project on Elastic Cloud Serverless</a>, or upgrade to Elastic Stack 9.5.</li>
<li>Send data with the <a href="https://www.elastic.co/docs/reference/opentelemetry">Elastic Distributions of OpenTelemetry</a>, or point an existing collector at Elasticsearch.</li>
<li>In <strong>Discover</strong>, switch to ES|QL and start from the metrics to logs query above, swapping in your own data streams and thresholds.</li>
<li>Read the <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-in-subquery"><code>IN</code> subquery reference</a> for the full set of commands you can use inside a subquery.</li>
</ol>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/esql-subqueries-correlate-logs-metrics-traces</link>
    <guid isPermaLink="false">esql-subqueries-correlate-logs-metrics-traces</guid>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[Metrics]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Vinay Chandrasekhar,Miguel Sánchez Gómez]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt746d25b6fd650a97/6a85cafe4710c625d2d3cb3d/header.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 14 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[From CrashLoopBackOff to OOMKilled with PromQL in Elasticsearch and Kibana]]></title>
    <description><![CDATA[Use PromQL in Elasticsearch and Kibana to move from a CrashLoopBackOff alert to OOMKilled, memory versus the limit, and a verified fix.]]></description>
    <content:encoded><![CDATA[<p>A <code>CrashLoopBackOff</code> alert on <code>checkout-api</code> is paging you.
With <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/promql">PromQL</a> in Elasticsearch and Kibana, you can move from that alert to <code>OOMKilled</code>, prove the container is hitting its memory limit (not the node), raise the limit, and watch the alert recover.
If you are new to PromQL in Elastic, start with <a href="https://www.elastic.co/observability-labs/blog/elasticsearch-supports-promql">Elasticsearch supports PromQL</a>, <a href="https://www.elastic.co/observability-labs/blog/promql-queries-run-in-kibana">PromQL queries in Kibana</a>, and <a href="https://www.elastic.co/observability-labs/blog/promql-investigate-kubernetes-infrastructure">Investigate Kubernetes infrastructure with PromQL</a>.</p>
<h2 id="whatyouneed">What you need</h2>
<ul>
<li>An <strong>Observability</strong> <a href="https://www.elastic.co/docs/solutions/observability/get-started">serverless project</a>, or an Elastic Cloud Hosted or self-managed stack at <strong>version 9.4 or later</strong>.
PromQL is <strong>generally available</strong> in Elastic Cloud Serverless and Elastic Stack 9.5, and available as a <strong>technical preview</strong> in Elastic Stack 9.4.</li>
<li>Kubernetes state and container memory metrics in Elasticsearch.</li>
</ul>
<h2 id="whatisthealerttellingus">What is the alert telling us?</h2>
<p>This is the alert that opened the investigation:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd2f013a3a12a2919/6a7f19f35967e55ed15dd6b9/active-alert.png" alt="Active checkout API CrashLoopBackOff alert in Kibana" /></p>
<p>It comes from this waiting-reason query:</p>
<pre><code>PROMQL
  max by (namespace, pod, container) (
    max_over_time(
      kube_pod_container_status_waiting_reason{
        namespace="checkout",
        pod=~"checkout-api-.*",
        container="api",
        reason="CrashLoopBackOff"
      }[2m]
    )
  ) == 1
</code></pre>
<p>A result of <code>1</code> means Kubernetes is delaying another start because the container has failed repeatedly.
That is the correct paging signal here because the checkout path has a single replica: when that replica restarts, requests fail.</p>
<p>The <code>max_over_time(...[2m])</code> range keeps the alert tied to recent samples.
Without it, the last observed value of <code>1</code> can outlive the pod, and the rule keeps matching after that pod is gone.</p>
<p>That PromQL query ran every minute over a two-minute window and created an alert after one matching run.</p>
<h2 id="whydidthelastcontainerstop">Why did the last container stop?</h2>
<p>The alert shows what Kubernetes is doing now.
It does not show how the previous container ended:</p>
<pre><code>PROMQL
  max by (namespace, pod, container) (
    kube_pod_container_status_last_terminated_reason{
      namespace="checkout",
      pod=~"checkout-api-.*",
      container="api",
      reason="OOMKilled"
    }
  ) == 1
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1b7cd5d1c73d9d32/6a7f19f6e02fac5a485d69a3/last-termination-oom.png" alt="PromQL result showing OOMKilled as the last termination reason for checkout-api-8655769b49-vwddl" /></p>
<p>A result of <code>1</code> for the same namespace, pod, and container means the last recorded exit was out of memory.
Kube-state-metrics keeps that last reason as a gauge, so the value can stay visible after recovery.
It points the investigation at memory; it does not prove that every restart in the window was an OOM kill.</p>
<h2 id="isthefailurerepeating">Is the failure repeating?</h2>
<p>A single restart can still be transient.
The restart counter shows whether the failure keeps happening:</p>
<pre><code>PROMQL
  max by (namespace, pod, container) (
    increase(
      kube_pod_container_status_restarts_total{
        namespace="checkout",
        pod=~"checkout-api-.*",
        container="api"
      }[10m]
    )
  )
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte798ed61aa455a3b/6a7f19f9bdcff037f7c4329f/restart-history.png" alt="PromQL chart showing repeated checkout API container restarts during the incident" /></p>
<p><code>increase()</code> shows how much the restart counter rose over the selected range.
Repeated increases during the incident window explain why Kubernetes entered backoff.</p>
<h2 id="howcloseismemorytothelimit">How close is memory to the limit?</h2>
<p>We need to know how close the container is to its memory limit, and whether that gap collapses right before each restart.
This deployment allows only 128MiB, so the next query divides working-set memory by that configured limit:</p>
<pre><code>PROMQL
  max by (namespace, pod, container) (
    container_memory_working_set_bytes{
      namespace="checkout",
      pod=~"checkout-api-.*",
      container="api",
      image!=""
    }
  )
  /
  max by (namespace, pod, container) (
    container_spec_memory_limit_bytes{
      namespace="checkout",
      pod=~"checkout-api-.*",
      container="api",
      image!=""
    }
  )
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfba66d8eb586ec9a/6a7f19fc33fa8af83e202b78/memory-limit-percent.png" alt="Checkout API memory repeatedly climbing toward its 128MiB container limit before OOMKilled restarts" /></p>
<p>The chart shows a repeating sawtooth: memory approaches 90% of the limit, drops when the process stops, and climbs again after each restart.</p>
<p>Working set is the better signal here than total usage.
Total usage includes reclaimable file cache, so it can sit near the limit without a kill.
Working set is closer to the memory that triggers OOMKilled for this workload.</p>
<h2 id="isthenodeundermemorypressure">Is the node under memory pressure?</h2>
<p><code>OOMKilled</code> can mean the container hit its own limit, or the node ran low on memory and Kubernetes started reclaiming.
To separate those cases, first find which node runs the pod, then check whether that node (or any peer) reported <code>MemoryPressure</code>.</p>
<pre><code>PROMQL
  max by (namespace, pod, node) (
    kube_pod_info{
      namespace="checkout",
      pod=~"checkout-api-.*"
    }
  ) == 1
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt831b6ef6ee5cb750/6a7f19ffe02fac7f585d69a7/pod-node.png" alt="PromQL result mapping the checkout API pod to ip-10-0-2-18.ec2.internal" /></p>
<p>The pod sits on <code>ip-10-0-2-18.ec2.internal</code>.
That is the node whose <code>MemoryPressure</code> result matters most for this incident:</p>
<pre><code>PROMQL
  max by (node) (
    max_over_time(
      kube_node_status_condition{
        condition="MemoryPressure",
        status="true"
      }[30m]
    )
  )
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltec63d50e8f11cd0f/6a7f1a023ce8e24429cf57a1/node-memory-pressure.png" alt="PromQL result showing no Kubernetes MemoryPressure on the cluster nodes" /></p>
<p>Every node returns <code>0</code>, including <code>ip-10-0-2-18.ec2.internal</code>.
So the host was not under node-wide memory pressure.
The kill came from the container limit itself.</p>
<h2 id="doesraisingthelimitclearthealert">Does raising the limit clear the alert?</h2>
<p><code>checkout-api</code> was healthy, then began building an in-memory cache that grows to 200MiB in 10MiB steps.
The container only allows 128MiB, so the process is killed with <code>OOMKilled</code> before that cache is fully allocated.</p>
<p>We will raise the memory limit to 512MiB so the 200MiB cache fits with room for the runtime, then check whether <code>CrashLoopBackOff</code> clears:</p>
<pre><code>kubectl set resources deployment/checkout-api -n checkout --limits=memory=512Mi
</code></pre>
<p>The same waiting-reason query then stops matching.
<code>CrashLoopBackOff</code> drops off:</p>
<pre><code>PROMQL
  max by (namespace, pod, container) (
    max_over_time(
      kube_pod_container_status_waiting_reason{
        namespace="checkout",
        pod=~"checkout-api-.*",
        container="api",
        reason="CrashLoopBackOff"
      }[2m]
    )
  ) == 1
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1d0d9f8d705b70eb/6a7f1a0473d9bde46629df4f/waiting-reason-cleared.png" alt="PromQL result showing CrashLoopBackOff clearing after the memory limit increase" /></p>
<p>And the alert that started this investigation? Gone.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte501026dbe06098d/6a7f1a0805b7b57f9d18bd3d/recovery.png" alt="Recovered checkout API CrashLoopBackOff alert in Kibana" /></p>
<p>That is how you detect and investigate a Kubernetes CrashLoopBackOff with PromQL: from the firing alert, through <code>OOMKilled</code> and the limit mismatch, to a recovered alert.
Elasticsearch holds the metrics; Kibana runs the same PromQL queries you already know from Prometheus.</p>
<h2 id="tryit">Try it</h2>
<ol>
<li>Open an <a href="https://cloud.elastic.co/serverless-registration">Observability project on Elastic Cloud Serverless</a>, or use Elastic Stack 9.4 or later.</li>
<li>In the ES|QL editor in Kibana, run the waiting-reason query against a workload you care about.</li>
<li>Follow the same path from that alert to termination reason, restarts, memory versus the limit, and recovery.</li>
</ol>
<p>For more PromQL in Elastic, see <a href="https://www.elastic.co/observability-labs/blog/elasticsearch-supports-promql">Elasticsearch supports PromQL</a>, <a href="https://www.elastic.co/observability-labs/blog/promql-queries-run-in-kibana">PromQL queries in Kibana</a>, and <a href="https://www.elastic.co/observability-labs/blog/promql-investigate-kubernetes-infrastructure">Investigate Kubernetes infrastructure with PromQL</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/promql-kubernetes-oomkilled-crashloopbackoff</link>
    <guid isPermaLink="false">promql-kubernetes-oomkilled-crashloopbackoff</guid>
    <category><![CDATA[Kubernetes]]></category>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[Metrics]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Miguel Sánchez Gómez]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt256fcae0e6b1797d/6a7f1a0bfc63ab76c464d06c/header.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Four clicks from alert to root cause: how Elastic Observability links APM services to Kubernetes infrastructure]]></title>
    <description><![CDATA[Check service dependencies and compare per-pod CPU, memory and network trends on the Infrastructure tab to find which instance is causing trouble, all without leaving the alert investigation.]]></description>
    <content:encoded><![CDATA[<p>Elastic Observability links your <a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">OTel-instrumented services</a> to the Kubernetes hosts, containers, and pods they run on.
The <a href="https://www.elastic.co/docs/solutions/observability/apm/infrastructure">Infrastructure tab</a> in APM puts per-instance CPU, memory and network trends a few clicks away, so when a service degrades you can spot which pod lines up with when the problem started, all from inside the investigation.
This walkthrough follows a latency alert on a recommendation service from notification to the problematic pod in four steps.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte83736c1f42a7f15/6a7f02af2f00b296c3efe6ef/step-04-check-infra-metric-trends.gif" alt="Correlating service latency with per-pod infrastructure metrics" /></p>
<h2 id="availability">Availability</h2>
<p>This is available in Elastic Observability serverless today and is coming to Elastic Cloud Hosted and self-managed deployments in 9.5.</p>
<h2 id="prerequisitesforlinkingapmservicestokubernetesinfrastructure">Prerequisites for linking APM services to Kubernetes infrastructure</h2>
<p>You need application traces and Kubernetes infrastructure metrics in the same Elastic Observability project.</p>
<ul>
<li><strong>Application instrumentation:</strong> EDOT-instrumented services sending traces via the EDOT Collector or an <a href="https://www.elastic.co/docs/solutions/observability/get-started/opentelemetry/use-cases/upstream-collector">upstream OpenTelemetry Collector</a> with both the <a href="https://www.elastic.co/docs/reference/edot-collector/components/elasticapmconnector"><code>elasticapm</code> connector</a> and the <a href="https://www.elastic.co/docs/reference/edot-collector/components/elasticapmprocessor"><code>elasticapm</code> processor</a>. The EDOT Collector includes both by default; for a custom upstream pipeline, see the <a href="https://www.elastic.co/docs/solutions/observability/get-started/opentelemetry/use-cases/upstream-collector">upstream collector setup</a>.</li>
<li><strong>Kubernetes observation:</strong> the cluster observed via OpenTelemetry with host and Kubernetes metrics from the EDOT Collector. See the <a href="https://www.elastic.co/docs/solutions/observability/get-started/opentelemetry/quickstart/serverless/k8s">Kubernetes quickstarts</a> and <a href="https://www.elastic.co/docs/solutions/observability/get-started/opentelemetry/use-cases/kubernetes">Kubernetes observability with EDOT</a> for setup.</li>
<li><strong>Backend:</strong> Observability serverless today, or Elastic Stack 9.5 on Elastic Cloud Hosted and self-managed when 9.5 releases.</li>
</ul>
<h2 id="apmalerttriagefromnotificationtoproblematicpod">APM alert triage: from notification to problematic pod</h2>
<h3 id="step1confirmtheservicedegradationontheapmalertdetailpage">Step 1: Confirm the service degradation on the APM alert detail page</h3>
<p>The redesigned <a href="https://www.elastic.co/docs/solutions/observability/apm/create-apm-rules-alerts">alert detail page</a> in Elastic Observability shows the impacted service, environment, endpoint and RED metrics in one view.
Open it from the alert notification.</p>
<p>You can clearly see which service is impacted, which environment it runs in, what endpoint is being affected and easily look for correlations in their RED metrics.
In this case, we can immediately rule out a spike in traffic as the throughput is clearly stable.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9ee2e802b0b09fc9/6a7f02b34c4bfbf920ccd0fd/step-01-alert-detail.gif" alt="Alert showing high transaction latency on the recommendation service" /></p>
<h3 id="step2ruleoutservicedependencieswiththeembeddedservicemap">Step 2: Rule out service dependencies with the embedded service map</h3>
<p>The newly embedded <a href="https://www.elastic.co/docs/solutions/observability/apm/service-map">service map</a> preview on the alert detail page shows the health and RED metrics of every dependent service, so you can rule out upstream causes without navigating away.
In this case, we have been able to quickly rule out problems with other services causing the symptom with the symptomatic service:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1711cc95500177fb/6a7f02b7448e4e15195c0268/step-02-check-dependencies.gif" alt="Service map showing healthy dependent services" /></p>
<h3 id="step3reviewkubernetesinfrastructuremetricsperpodcontainerandhost">Step 3: Review Kubernetes infrastructure metrics per pod, container and host</h3>
<p>After ruling out service dependencies, open the service's updated <a href="https://www.elastic.co/docs/solutions/observability/apm/infrastructure"><strong>Infrastructure</strong> tab</a> in Elastic Observability to check for infrastructure-level patterns.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta5526498737af6af/6a7f02ba227b1cf310598174/step-03-view-service-check-infra.gif" alt="Infrastructure tab showing average metrics per instance for the symptomatic service" /></p>
<h3 id="step4compareperinstancemetrictrendstofindtherootcause">Step 4: Compare per-instance metric trends to find the root cause</h3>
<p>The <a href="https://www.elastic.co/docs/solutions/observability/apm/infrastructure">Infrastructure tab</a> shows the average metric values over the specified time period.
To really understand whether there is a problem with the infrastructure, we need to <strong>compare the pod, container and host metrics over time</strong>.
This allows us to easily spot differences between different entities that may correlate with when the service started showing symptoms.
In our example, we can clearly see a difference between some of the metrics between the pods that correlates with when the service symptoms began.
So we know there is something going on with the infrastructure that needs investigating:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte83736c1f42a7f15/6a7f02af2f00b296c3efe6ef/step-04-check-infra-metric-trends.gif" alt="Infrastructure metric trends correlating latency with a change in CPU or network" /></p>
<h2 id="summaryfromapmalerttorootcauseinfourclicks">Summary: from APM alert to root cause in four clicks</h2>
<p>In just a few clicks from an alert in Elastic Observability, you can rule out healthy dependent services without leaving the alert detail page, then compare per-pod infrastructure metrics to see which instance correlates with when the symptoms started.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/apm-kubernetes-infrastructure-metrics-analysis</link>
    <guid isPermaLink="false">apm-kubernetes-infrastructure-metrics-analysis</guid>
    <category><![CDATA[APM]]></category>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[Kubernetes]]></category>
    <dc:creator><![CDATA[Roshan Gonsalkorale,Miguel Sánchez Gómez]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta9d1c405e47bfbf5/6a7f02beeab5be600a20a278/header.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Investigate Kubernetes infrastructure issues with PromQL in Elasticsearch & Kibana]]></title>
    <description><![CDATA[Walkthrough of a Kubernetes fleet-wide CPU investigation in Elastic Observability, from cluster to namespace to the noisy pod, using PromQL in Elasticsearch and Kibana.]]></description>
    <content:encoded><![CDATA[<p><a href="https://www.elastic.co/observability-labs/blog/elasticsearch-supports-promql">Elasticsearch now supports PromQL natively</a>, and <a href="https://www.elastic.co/observability-labs/blog/promql-queries-run-in-kibana">you can run PromQL queries in Kibana</a> through the <code>PROMQL</code> source command in ES|QL.
That means you can use PromQL to query your Kubernetes metrics stored in Elasticsearch. You can run those queries directly in Discover, Dashboards or alerting rules.</p>
<p>When <strong>cluster CPU spikes</strong> and you need to find <strong>which workload</strong> is responsible, narrow from <strong>fleet</strong> to <strong>namespace</strong> to <strong>pod</strong>, one step at a time.</p>
<h2 id="whatyouneed">What you need</h2>
<ul>
<li>An <strong>Observability</strong> <a href="https://www.elastic.co/docs/solutions/observability/get-started">serverless project</a> or a self-managed or Elastic Cloud Hosted stack at <strong>version 9.4 or later</strong>, where <strong>PromQL</strong> is available as a <strong>preview</strong> query language for metrics.</li>
<li><strong>Kubernetes</strong> metrics flowing into Elasticsearch. For this exercise we have considered <strong>OpenTelemetry</strong> data.</li>
<li>One or more clusters with workloads running so <code>group by</code> queries have something to compare.</li>
</ul>
<h2 id="thescenario">The scenario</h2>
<p>You manage a fleet of Kubernetes clusters:</p>
<p>| Cluster | Region | Role |
|---------|--------|------|
| <code>prod-us-east-1</code> | US East | Production: services, ML training |
| <code>prod-eu-west-1</code> | EU West | Production: regional web tier, cache |
| <code>staging-us-east-1</code> | US East | Staging: QA, integration tests |
| <code>dev-sandbox</code> | US East | Developer sandbox |</p>
<p>The production cluster in US East runs a mix of services and ML training jobs across several namespaces.</p>
<p>An <strong>alert</strong> fires: <strong>cluster-wide CPU is elevated</strong>, but only one team is complaining about slower response times.</p>
<p>You are triaging <strong>which cluster</strong>, then <strong>which namespace</strong>, then <strong>which pod</strong>.</p>
<p>You are not after a full root-cause proof in one query, but enough to <strong>name the suspect</strong> and hand off.</p>
<h2 id="yourdata">Your data</h2>
<p>The OpenTelemetry Collector's <strong>Kubelet Stats Receiver</strong> populates data streams like <code>metrics-kubeletstatsreceiver.otel-default</code>.
Metrics follow the <code>k8s.*</code> naming convention (for example <code>k8s.pod.cpu.usage</code>) and labels like <code>k8s.cluster.name</code> or <code>k8s.namespace.name</code> let you slice by cluster, namespace, or pod.</p>
<p>To verify the data is there, open <strong>Discover</strong>, switch to ES|QL mode, run <strong><code>TS metrics-*</code></strong>, and scope the query with <strong><code>WHERE data_stream.dataset == "kubeletstatsreceiver.otel"</code></strong>.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt78109b18cefd6655/6a7f19dbe3a219121f99f8b2/discover-ts-metrics-k8s.png" alt="Discover: kubernetes metrics from OpenTelemetry" /></p>
<h2 id="investigationfindthenoisyneighbor">Investigation: find the noisy neighbor</h2>
<h3 id="step1whichclusterishot">Step 1: Which cluster is hot?</h3>
<p>When you manage multiple clusters, start at the fleet level.</p>
<pre><code>PROMQL sum by (k8s.cluster.name) (k8s.pod.cpu.usage)
</code></pre>
<p>This groups total pod CPU by cluster.</p>
<p><code>prod-us-east-1</code> immediately stands out: total pod CPU is <strong>an order of magnitude higher</strong> than the other clusters.</p>
<p>The EU production cluster, staging, and dev-sandbox are all quiet.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1ccaed4498c9d723/6a7f19de96b5a62c2c87b873/promql-fleet-cpu-by-cluster.png" alt="Fleet-level PromQL chart showing prod-us-east-1 as the outlier" /></p>
<p>Now you know <strong>where</strong> the problem is, time to zoom in.</p>
<h3 id="step2overallcpuinthehotcluster">Step 2: Overall CPU in the hot cluster</h3>
<p>Filter to <code>prod-us-east-1</code> and look at total CPU:</p>
<pre><code>PROMQL sum(k8s.pod.cpu.usage{k8s.cluster.name="prod-us-east-1"})
</code></pre>
<p>This gives you the <strong>cluster-wide pod CPU footprint</strong> over time.</p>
<p>If the total is climbing or spiking, something changed, but you don't yet know <strong>what</strong>.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt14eca383e549700a/6a7f19e24c4bfb7d30ccd8ec/promql-hot-cluster.png" alt="Overall CPU in prod-us-east-1 showing a clear spike" /></p>
<h3 id="step3breakdownbynamespace">Step 3: Break down by namespace</h3>
<p>The fastest way to isolate <strong>which team</strong> is responsible: group by namespace.</p>
<pre><code>PROMQL sum by (k8s.namespace.name) (k8s.pod.cpu.usage{k8s.cluster.name="prod-us-east-1"})
</code></pre>
<p>Set the <strong>time picker</strong> in Kibana to cover your incident window.</p>
<p><code>ml-training</code> dominates at <strong>~2.0 cores</strong> while every other namespace stays well below <strong>0.2 cores</strong>.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte0eef1cef1efae0c/6a7f19e5448e4e068c5c0b56/promql-group-by-noisy-neighbor.png" alt="Grouped PromQL chart showing ml-training as the dominant series" /></p>
<h3 id="step4drilldowntothepod">Step 4: Drill down to the pod</h3>
<p>Now that you know the namespace, identify the specific pod:</p>
<pre><code>PROMQL sum by (k8s.pod.name) (k8s.pod.cpu.usage{k8s.cluster.name="prod-us-east-1", k8s.namespace.name="ml-training"})
</code></pre>
<p>That ranks pods in the namespace by total CPU.</p>
<p>The chart should make the outlier obvious.</p>
<p>Pod <code>model-train-v2-run-47-d9j67</code> is consuming the full <strong>2.0 cores</strong>.
It is a training job saturating its allocation.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc9b5a73325ae96ab/6a7f19e85967e564495dd6b5/promql-drilldown-pod.png" alt="Pod drill-down showing model-train-v2-run-47-d9j67 as the CPU consumer" /></p>
<h3 id="step5checkresourceutilizationratios">Step 5: Check resource utilization ratios</h3>
<p>Raw CPU cores tell you <strong>how much</strong>.
Utilization ratios tell you <strong>how close to limits</strong>.</p>
<p>A pod hitting 100% of its CPU limit is being throttled, and it is both the noisy neighbor <strong>and</strong> a victim of its own limits.</p>
<pre><code>PROMQL sum by (k8s.namespace.name) (k8s.container.cpu_limit_utilization{k8s.cluster.name="prod-us-east-1"})
</code></pre>
<p><code>ml-training</code> shows <strong>~100% CPU limit utilization</strong> (pegged at the 2-core limit), while the other namespaces stay under 20%.</p>
<p>This confirms the training job is <strong>saturating its allocation</strong> and likely causing scheduling pressure on the shared node.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt64374adda3576ad3/6a7f19ebea068d317bf0a2bb/promql-cpu-utilization.png" alt="CPU limit utilization by namespace — ml-training pegged near 100%" /></p>
<h2 id="whathappensnext">What happens next</h2>
<p>The PromQL query <strong>named the suspect</strong>: the training job <code>model-train-v2-run-47</code> in <code>ml-training</code>.</p>
<p>From here:</p>
<ul>
<li><strong>Logs</strong>: Filter by the pod name in Discover to see what the training job was doing and whether it logged errors or warnings.</li>
<li><strong>Kube events</strong>: Check for OOMKilled, throttling, or eviction events in the same time window.</li>
<li><strong>Resource policies</strong>: Review whether the training job's requests and limits match its actual usage. A large gap between request and limit lets a pod burst past what the scheduler planned for. Consider <code>ResourceQuota</code> or <code>LimitRange</code> on the namespace.</li>
</ul>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/promql-investigate-kubernetes-infrastructure</link>
    <guid isPermaLink="false">promql-investigate-kubernetes-infrastructure</guid>
    <category><![CDATA[Kubernetes]]></category>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[Metrics]]></category>
    <dc:creator><![CDATA[Miguel Sánchez Gómez]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3651d463b7cb4316/6a7f19eebdcff04042c4329b/cover.png" length="0" type="image/png"/>
    <pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Your PromQL queries now run in Kibana!]]></title>
    <description><![CDATA[With PromQL now natively supported in Kibana, write and execute PromQL for analyzing metrics in Discover, in Dashboards visualizations, in alerting rules and wherever else ES|QL is supported. PromQL is currently available in Tech Preview for common metrics analytics use cases.]]></description>
    <content:encoded><![CDATA[<p>Since its initial development in 2012 alongside Prometheus, PromQL has been a cornerstone of time-series monitoring for over a decade.
While Kibana already comprehensively supports time-series analysis via the ES|QL TS command, we are thrilled to introduce native PromQL support for common metrics analytics use cases.
For teams already fluent in PromQL, this support means a near-zero learning curve and significantly easier onboarding directly into the Elastic ecosystem.</p>
<h2 id="runningpromqlqueriesinkibana">Running PromQL queries in Kibana</h2>
<p>In the ES|QL editor in Kibana, enter the <code>PROMQL</code> command, and type your PromQL in that block.
<code>PROMQL</code> marks that segment so Elasticsearch parses it as PromQL inside the wider ES|QL request Kibana sends.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt09b0a036963d9786/6a7f1a0fb6b7341e46e491b8/promql-first-look.png" alt="Discover in ES|QL mode with a PROMQL query in the bar" /></p>
<h2 id="whatyoucanquery">What you can query</h2>
<p>Here are a few patterns to get started.</p>
<p><strong>Raw metric</strong></p>
<pre><code>PROMQL container.cpu.usage
</code></pre>
<p><strong>Average across all containers</strong></p>
<pre><code>PROMQL avg(container.cpu.usage)
</code></pre>
<p><strong><code>rate()</code> on a counter</strong></p>
<pre><code>PROMQL rate(docker.network.inbound.bytes)
</code></pre>
<p><strong>Aggregated rate</strong></p>
<pre><code>PROMQL sum(rate(docker.network.inbound.bytes))
</code></pre>
<p><strong>Group by a label</strong></p>
<pre><code>PROMQL sum by (agent.id) (rate(docker.network.inbound.bytes))
</code></pre>
<p>You may notice that none of these examples include <code>start</code>, <code>end</code>, <code>step</code>, or a lookback window on every <code>rate()</code>.
Those parameters are optional: the time picker and Kibana defaults handle most of it for you.</p>
<p>Optionally, you can include the data stream name using the <code>index=</code> parameter.
For example: <code>PROMQL index=metrics-docker.cpu-default container.cpu.usage</code>.
Adding the parameter helps narrow down the scope of what data the query scans.</p>
<p>The current release of PromQL tech preview has over 80% query coverage benchmarked against top Grafana dashboards.
Advanced modifiers and specific functions are in consideration for future releases.</p>
<h2 id="findyourstreamsandmetricnames">Find your streams and metric names</h2>
<p>If you have existing PromQL queries, you can use them directly in the <code>PROMQL</code> command without changes.
If you are writing a query from scratch and need to find the exact field names, run <code>TS metrics-*</code> in Discover to see every metrics data stream.
Each metric appears as a small chart so you can tell at a glance what is active.
Hover over a metric and click the "View details" action to see the field name and the data stream it belongs to.</p>
<p>For a deeper walkthrough, see <a href="https://www.elastic.co/docs/solutions/observability/infra-and-hosts/discover-metrics">Explore metrics data with Discover in Kibana</a>.</p>
<h2 id="timepickerandquerytimehandling">Time picker and query time handling</h2>
<p>The time picker in Kibana sets the time window for the query.
Dashboard panels and Alerting rules work the same way using their own time range, so you do not need to write <code>start=</code> or <code>end=</code> in the query itself.</p>
<p>Step is the gap between two consecutive data points on the chart.
A smaller step means more data points across the same span.
If you do not set <code>step=</code> or <code>buckets=</code>, the default is <code>buckets=100</code>.
You can set <code>step=</code> to a fixed width such as <code>1m</code>, or set <code>buckets=</code> to a different target maximum number of data points.</p>
<h2 id="discoveranddashboards">Discover and Dashboards</h2>
<p>In Discover, switch to ES|QL mode and run your <code>PROMQL</code> query so you can see how the metric behaves over the range you pick, as a time-series chart.
When you want to save that visualization, choose "Save visualization to dashboard" and add it to a new or existing dashboard.</p>
<p>Or go to Dashboards directly: add a panel, choose ES|QL, and write your <code>PROMQL</code> query.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt224342ac307c9dc8/6a7f1a1242a117add795c2ed/dashboard-promql.png" alt="Dashboard: ES|QL visualization with PromQL" /></p>
<h2 id="alerting">Alerting</h2>
<p>You can create alert rules using PromQL.
Go to Alerts, open Manage rules, and create a rule.
Search for Elasticsearch query and select it.
Choose ES|QL as the query type.</p>
<p>Write your <code>PROMQL</code> query, but assign the metric to a variable so you can use it in a <code>WHERE</code> clause for the alert condition:</p>
<pre><code>PROMQL metric_value=(sum by (agent.id) (rate(docker.network.inbound.bytes)))
| WHERE metric_value &gt;= 500
</code></pre>
<p>Select <code>@timestamp</code> for the time field and continue defining the rest of the rule configuration.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt50854477d5962a3c/6a7f1a15ea068d9643f0a2bf/alert-rule-promql.png" alt="Alert rule: Elasticsearch query with a PROMQL condition" /></p>
<h2 id="tryit">Try it</h2>
<ol>
<li>Open an <a href="https://cloud.elastic.co/serverless-registration">Observability project on Elastic Cloud Serverless</a>, or use Elastic Stack 9.4.</li>
<li>Write your query: in the ES|QL editor in Kibana, run your PromQL via <code>PROMQL</code>.
You can also go to Dashboards, add a panel, choose ES|QL, and write the query there.</li>
<li>If you are writing from scratch and need to find metric names, run <code>TS metrics-*</code> in Discover (see "Find your streams and metric names" above).</li>
<li>Check the results and adapt the query if needed.</li>
</ol>
<p>PromQL support in Elasticsearch and Kibana will continue to evolve.
Follow the Observability Labs feed for follow-up posts as coverage and ergonomics improve.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/promql-queries-run-in-kibana</link>
    <guid isPermaLink="false">promql-queries-run-in-kibana</guid>
    <category><![CDATA[Metrics]]></category>
    <dc:creator><![CDATA[Miguel Sánchez Gómez,Vinay Chandrasekhar,Felix Barnsteiner]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt486d62547cd575db/6a7f1a1842a117335495c2f1/cover.png" length="0" type="image/png"/>
    <pubDate>Wed, 15 Apr 2026 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>