<?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[What's New - 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[What's New - 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/blog/category/whats-new</link>
    </image>
    <link>https://www.elastic.co/observability-labs/blog/category/whats-new</link>
    <atom:link href="https://www.elastic.co/observability-labs/rss/category/whats-new.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Wed, 16 Sep 2026 09:03:21 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Native OTLP metrics ingestion on Elastic Cloud Hosted]]></title>
    <description><![CDATA[Send an exponential OpenTelemetry histogram and Elasticsearch keeps the scale and buckets you sent. All four type and temporality combinations work now, and your SDK and Collector config stay exactly as they are.]]></description>
    <content:encoded><![CDATA[<p>Upgrade an Elastic Cloud Hosted (ECH) deployment to Elastic Stack 9.5.3 or later and Elasticsearch stores every OpenTelemetry histogram you send it. Cumulative histograms are included, and exponential buckets keep the scale and offset you sent, which covers all four combinations of histogram type and temporality. The <a href="https://www.elastic.co/docs/reference/opentelemetry/managed-inputs/managed-otlp-endpoint">Elastic Cloud Managed OTLP Endpoint</a> picks the ingestion path per deployment, so nothing changes in your SDK or Collector config. Older deployments keep working through the Elasticsearch bulk API. Four runnable <code>curl</code> examples follow, one per histogram case.</p>
<h2 id="whatistheelasticcloudmanagedotlpendpoint">What is the Elastic Cloud Managed OTLP Endpoint?</h2>
<p>Managed inputs are ingestion frontends that Elastic operates on your behalf.
You point a shipper at an Elastic-owned endpoint, authenticate with an API key, and Elastic takes care of receiving, buffering, and indexing the data into your deployment.
There is no gateway to size, patch, or monitor, and no backend credentials to distribute to edge agents.</p>
<p>The Managed OTLP Endpoint is the managed input for OpenTelemetry data.
It accepts standard OTLP over HTTP and gRPC from any OpenTelemetry SDK or Collector distribution, buffers it durably, and indexes it into your Elasticsearch deployment using the OpenTelemetry data model.
It is generally available on both Elastic Cloud Serverless and ECH.
For an overview across logs, traces, and metrics, read <a href="https://www.elastic.co/observability-labs/blog/elastic-managed-otlp-endpoint-ga-elastic-cloud-hosted">Now GA: Managed OTLP Endpoint on Elastic Cloud Hosted</a>.</p>
<p>For metrics, clients send OTLP/HTTP requests to the <code>/v1/metrics</code> signal path and authenticate with an Elasticsearch API key that has the <code>event:write</code> privilege for the <code>apm</code> application:</p>
<pre><code>POST https://&lt;managed-otlp-endpoint&gt;/v1/metrics
Authorization: ApiKey &lt;encoded-api-key&gt;
</code></pre>
<p>The <code>/v1/metrics</code> client-facing path never changes.
This post focuses on what happens after the request is accepted, during the last hop between the managed pipeline and Elasticsearch.</p>
<h2 id="twowaystoingestopentelemetrymetricsthebulkapiandthenativeotlpendpoint">Two ways to ingest OpenTelemetry metrics: the bulk API and the native OTLP endpoint</h2>
<p>Elasticsearch offers two ways to receive OpenTelemetry metrics.</p>
<p>The <strong>bulk API</strong> (<code>/_bulk</code>) is the general-purpose document ingestion API.
To use it for OTLP metrics, something upstream must convert each OTLP data point into a JSON document that follows the OpenTelemetry mapping mode, group the documents by target data stream, serialize them as newline-delimited JSON, and send them in bulk requests.
In the OpenTelemetry Collector ecosystem, this conversion is handled by the Elasticsearch exporter, and until recently, it was the only option.</p>
<p>The <strong>native OTLP endpoint</strong> (<code>/_otlp/v1/metrics</code>) accepts OTLP/HTTP Protobuf directly.
Elasticsearch decodes the payload, builds the documents itself, and indexes them into time series data streams.
Because Elasticsearch sees the original OTLP structure, it can perform these steps directly:</p>
<ul>
<li>It reads the aggregation temporality of every data point and stores it as a dimension</li>
<li>It hashes the shared resource attributes once for all data points in a resource</li>
<li>It maps exponential histograms to the <code>exponential_histogram</code> field type without an intermediate T-Digest conversion</li>
</ul>
<p>The following diagram shows the two lanes.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt411743a98069ab16/6aa9324ea812980378e3d7e9/two-paths.png" alt="Clients send to a single entry point, /v1/metrics on the Managed OTLP Endpoint. Behind it the service forks by deployment version: the bulk API path converts OTLP into JSON documents for /_bulk, the native path forwards OTLP Protobuf to /_otlp/v1/metrics" /></p>
<p>The two paths differ in three ways that matter for users:</p>
<ul>
<li><strong>Work distribution.</strong> On the bulk path, the managed pipeline converts and serializes documents, then Elasticsearch parses them again. On the native path, the pipeline forwards batched Protobuf and Elasticsearch builds documents once.</li>
<li><strong>Temporality.</strong> The Elasticsearch exporter used by the managed bulk path does not support cumulative histograms and drops those points. The native path preserves temporality and accepts both delta and cumulative histograms.</li>
<li><strong>Histogram fidelity.</strong> The bulk path converts every histogram to a T-Digest style representation. The native path stores exponential histograms natively and converts explicit-boundary histograms into the same exponential representation.</li>
</ul>
<p>Do not confuse the two paths with the client-facing path.
Clients always send to <code>/v1/metrics</code> on the Managed OTLP Endpoint.
The service chooses between <code>/_bulk</code> and <code>/_otlp/v1/metrics</code> behind it.</p>
<h2 id="nativeopentelemetrymetricssupportbyelasticstackversion">Native OpenTelemetry metrics support by Elastic Stack version</h2>
<p>Native OTLP metrics support in Elasticsearch arrived in several steps.</p>
<ol>
<li><strong>Elastic Stack 9.2</strong> introduced the Elasticsearch OTLP/HTTP metrics endpoint as a technical preview.</li>
<li><strong>Elastic Stack 9.3</strong> added the <code>exponential_histogram</code> field type as a technical preview.</li>
<li><strong>Elastic Stack 9.4</strong> made <code>exponential_histogram</code> generally available and the default mapping for OTLP histograms received on the native endpoint.</li>
<li><strong>Elastic Stack 9.5</strong> added metric temporality support as a generally available feature. The native endpoint now stores the temporality of every data point, which is what makes cumulative histograms possible. The same release added OTLP/HTTP endpoints for logs and traces as a technical preview.</li>
<li><strong>Elastic Stack 9.5.3</strong> is the current minimum version for native OTLP metrics routing through the Managed OTLP Endpoint on ECH. The routing floor was raised from 9.2.0 because earlier versions can fail native OTLP requests when audit request-body logging processes Protobuf payloads. The 9.5.3 floor includes the required fixes.</li>
</ol>
<p>The Elasticsearch Prometheus remote write endpoint arrived in Elastic Stack 9.4 and follows the same capability-gating model, but it is out of scope for this post.</p>
<p>Keep two things apart when reading version numbers in this post.
The first four milestones are capabilities of Elasticsearch itself, available to anyone who calls <code>/_otlp/v1/metrics</code> directly.
The fifth is a routing decision of the Managed OTLP Endpoint, which is stricter than "the endpoint exists" because the managed service has to work reliably for every tenant.</p>
<h2 id="howelasticcloudhostedroutesopentelemetrymetrics">How Elastic Cloud Hosted routes OpenTelemetry metrics</h2>
<p>On Serverless, Elastic operates and upgrades the backend, so the Managed OTLP Endpoint always uses the native path and users never see a version matrix.</p>
<p>ECH is different.
Users pick and control their Elastic Stack version, and the Managed OTLP Endpoint supports deployments on any version from 9.0 onward.
A single multi-tenant service therefore receives metrics for deployments that have no native endpoint at all, deployments that have one but predate the fixes the service relies on, and deployments that are fully ready.
It cannot enable the native path unconditionally.</p>
<h3 id="howthemanagedotlpendpointroutesametricsrequest">How the Managed OTLP Endpoint routes a metrics request</h3>
<p>The following diagram shows what happens to a metrics request for an ECH deployment.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf804b169dfac4995/6aa9326135eddc32b211d938/version-aware-routing.png" alt="Sequence of a metrics request through the Managed OTLP Endpoint: authenticate, resolve the target deployment and its Elasticsearch version, derive capability flags, buffer the request, then select the OTLP/HTTP exporter or the bulk API exporter at indexing time" /></p>
<ol>
<li>The endpoint authenticates the API key and resolves the target ECH deployment.</li>
<li>It looks up the deployment's Elasticsearch version from Elastic's own control plane, not from anything the client sends.</li>
<li>It derives a small set of capability flags for the target, such as "native OTLP metrics supported", and attaches them to the request as metadata.</li>
<li>The request is buffered durably together with its metadata.</li>
<li>At the indexing stage, the consumer reads the flags and hands the batch to one of two exporters: the OTLP/HTTP exporter, which forwards Protobuf to <code>/_otlp/v1/metrics</code>, or the Elasticsearch exporter, which converts the batch into documents and posts them to <code>/_bulk</code>.</li>
</ol>
<p>Because the flags are derived inside Elastic's trust boundary, a client cannot opt a deployment into a path its version does not support.
Elastic can also pin specific deployments to the bulk API for compatibility reasons, so the version rule below describes the normal case rather than a guarantee.</p>
<h3 id="whichingestionpathdoesmydeploymentuse">Which ingestion path does my deployment use?</h3>
<p>| ECH Elastic Stack version | Path the Managed OTLP Endpoint normally uses |
| ------------------------- | ------------------------------------------- |
| 9.0 to 9.5.2              | Bulk API (<code>/_bulk</code>)                          |
| 9.5.3 or later            | Native OTLP endpoint (<code>/_otlp/v1/metrics</code>)   |</p>
<p><strong>No client-side change is required.</strong>
If you already send OTLP metrics to the Managed OTLP Endpoint on ECH, your traffic moves to the native path when you upgrade the deployment to 9.5.3 or later.</p>
<h2 id="whatchangeswhenopentelemetrymetricsmovetothenativepath">What changes when OpenTelemetry metrics move to the native path</h2>
<p>Client configuration and data stream routing remain unchanged.
Histogram representation and temporality handling change: the native path uses <code>exponential_histogram</code> by default and records temporality as a dimension.
Review dashboards, alerts, and queries that depend on the previous histogram representation or aggregate cumulative snapshots.
Queries spanning historical <code>histogram</code> fields and newer <code>exponential_histogram</code> fields may need an explicit <code>::exponential_histogram</code> cast, as described in <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-histogram-fields#query-historical-data-alongside-new-data">querying historical data alongside new data</a>.</p>
<p>The distribution of work across the ingestion chain also changes.
On the bulk path, the managed pipeline converts every data point into a JSON document, serializes it, and Elasticsearch parses that JSON back into a document and hashes the dimensions of each one to compute its time-series identifier.
On the native path, Elasticsearch decodes a compact Protobuf payload once, and because it sees the OTLP resource and scope structure, it can <a href="https://github.com/elastic/elasticsearch/pull/134982">hash the resource attributes once</a> and reuse that partial hash for every data point that shares the resource, instead of re-hashing the full dimension set per document.
The <a href="https://github.com/elastic/elasticsearch/pull/133057">pull request that introduced the endpoint</a> names both effects, the binary encoding and the reused partial hashes, as the reasons the native endpoint is more efficient than bulk ingestion of the same data.</p>
<p>There are also capabilities you gain compared with the Elasticsearch exporter currently used by the managed bulk path.
The native endpoint records the aggregation temporality of each data point, stores histograms in the <code>exponential_histogram</code> field type, and accepts cumulative histograms.
These are differences between the two managed ingestion implementations, not inherent restrictions of the Bulk API.
Custom bulk clients can index exponential histogram documents and <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/metric-temporality#configure-temporality">configure a temporality dimension</a>.
The rest of this post is about those differences.</p>
<h2 id="opentelemetryhistogramtypesandtemporalityexplained">OpenTelemetry histogram types and temporality explained</h2>
<p>The most visible difference between the two paths is what happens to histograms, so the rest of this post is a tour through every histogram case.</p>
<p>OpenTelemetry defines two histogram data types.
<code>Histogram</code> uses explicit bucket boundaries chosen by the producer.
<code>ExponentialHistogram</code> uses exponentially spaced buckets controlled by a <code>scale</code> parameter, so the SDK adapts resolution to the data.</p>
<p>Each type can use one of two temporalities.
With <strong>delta</strong> temporality, every data point covers one collection interval.
With <strong>cumulative</strong> temporality, every data point covers everything since a fixed start time.</p>
<p>That gives four combinations, and the two ingestion paths treat them differently.</p>
<p>| Path                             | Explicit, delta      | Explicit, cumulative | Exponential, delta | Exponential, cumulative |
| -------------------------------- | -------------------- | -------------------- | ------------------ | ----------------------- |
| Bulk API (ECH 9.0 to 9.5.2)      | Supported, converted | Dropped              | Supported, converted | Dropped               |
| Native (ECH 9.5.3 or later)      | Supported, converted | Supported, converted | Supported          | Supported               |</p>
<p>"Converted" means the metric is accepted but stored with a different bucket representation than the one on the wire.
On the native path, that representation is the <code>exponential_histogram</code> field type, which is the default mapping for OTLP histograms since 9.4.
Cumulative histograms on the native path require that default; if a cluster overrides <code>xpack.otel_data.histogram_field_type</code> to <code>histogram</code>, cumulative histograms are not supported.
If Elastic has pinned a deployment to the bulk API for compatibility, the bulk row applies regardless of version.</p>
<p>Each of the four examples below is a complete <code>curl</code> request you can run against your own deployment.
The JSON encoding of OTLP/HTTP is verbose but self-documenting, which makes it a good learning tool.
Production shippers use Protobuf (<code>application/x-protobuf</code>) instead.</p>
<h3 id="prerequisitesyourendpointurlandapikey">Prerequisites: your endpoint URL and API key</h3>
<p>In the Elastic Cloud Console, find your deployment under <strong>Hosted deployments</strong> and select <strong>Manage</strong>.
In <strong>Application endpoints, cluster and component IDs</strong>, select <strong>Managed OTLP</strong> and copy the public endpoint.</p>
<p>Then open the <strong>API keys</strong> management page in Kibana and create an API key with the <code>event:write</code> privilege for the <code>apm</code> application.
Use the <strong>Encoded</strong> value of the key.
It is already base64-encoded in the <code>id:api_key</code> format that the <code>Authorization</code> header expects.</p>
<pre><code>export MANAGED_OTLP_URL="https://your-endpoint.elastic-cloud.com"
export ELASTIC_API_KEY="your-encoded-api-key"

# Nanosecond-precision timestamps: T0 = 2 min ago, T1 = 1 min ago, T2 = now
T0="$(python3 -c 'import time; print(time.time_ns() - 120_000_000_000)')"
T1="$(python3 -c 'import time; print(time.time_ns() -  60_000_000_000)')"
T2="$(python3 -c 'import time; print(time.time_ns())')"
</code></pre>
<p>All four examples report the same metric, <code>http.server.request.duration</code> in seconds, for the same service.
Each has a distinct <code>example.case</code> resource attribute so the examples form separate time series and can be queried independently.
The explicit and exponential examples describe different latency distributions; each cumulative example contains two snapshots whose difference matches its delta counterpart.
Run the timestamp setup again before repeating the examples to avoid resending points with the same dimensions and timestamp.</p>
<h3 id="explicithistogramwithdeltatemporalitycase1">Explicit histogram with delta temporality (case 1)</h3>
<p>This is the case that works on every supported ECH version, so it is the right place to learn the shape of an OTLP histogram.</p>
<p>A histogram distributes individual measurements across buckets.
For an explicit histogram, you choose the bucket boundaries in advance. For example: 5 ms, 10 ms, and 25 ms, and the SDK counts how many requests fell into each bucket.
Each data point carries the bucket counts, the total count, and the sum, which is enough to estimate percentiles at query time.</p>
<p>Percentiles are what you want for latency.
An average of 50 ms hides the fact that 1% of requests take 5 seconds, while a histogram shows p50, p95, and p99 side by side.</p>
<pre><code>curl --fail-with-body --silent --show-error \
  -H "Authorization: ApiKey ${ELASTIC_API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary '{
    "resourceMetrics": [
      {
        "resource": {
          "attributes": [
            {"key": "service.name", "value": {"stringValue": "my-service"}},
            {"key": "example.case", "value": {"stringValue": "explicit-delta"}}
          ]
        },
        "scopeMetrics": [
          {
            "scope": {"name": "http"},
            "metrics": [
              {
                "name": "http.server.request.duration",
                "unit": "s",
                "histogram": {
                  "aggregationTemporality": 1,
                  "dataPoints": [
                    {
                      "startTimeUnixNano": "'"${T1}"'",
                      "timeUnixNano":      "'"${T2}"'",
                      "count": 100,
                      "sum": 12.5,
                      "explicitBounds": [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5],
                      "bucketCounts":   [2,     5,    10,    20,   30,  18,   10,  4,   1,  0],
                      "attributes": [
                        {"key": "http.request.method",       "value": {"stringValue": "GET"}},
                        {"key": "http.response.status_code", "value": {"intValue": 200}}
                      ]
                    }
                  ]
                }
              }
            ]
          }
        ]
      }
    ]
  }' \
  "${MANAGED_OTLP_URL}/v1/metrics"
</code></pre>
<p><code>aggregationTemporality: 1</code> means delta.
The data point covers the one-minute window from <code>startTimeUnixNano</code> to <code>timeUnixNano</code> and nothing before it.</p>
<p><code>explicitBounds</code> lists nine upper boundaries, so <code>bucketCounts</code> has ten entries.
The last entry is the overflow bucket for values above 2.5 seconds.</p>
<p>A successful response is HTTP 200 with an empty or absent <code>partialSuccess</code> object, meaning the managed endpoint durably accepted the data for processing.
It does not confirm that Elasticsearch indexed the points: unsupported cumulative histograms can still be dropped on the managed bulk path, and indexing can fail downstream.
Use the queries below to verify ingestion. See <a href="https://www.elastic.co/docs/reference/opentelemetry/managed-inputs/managed-otlp-endpoint#indexing-errors-and-the-failure-store">indexing errors and the failure store</a> for troubleshooting.</p>
<p><strong>How Elastic stores it.</strong>
Both paths accept this payload, and both convert it.
On the bulk path, the Elasticsearch exporter turns the buckets into a T-Digest style <code>histogram</code> field.
On the native path, Elasticsearch converts the explicit boundaries into an <code>exponential_histogram</code>.
In both cases percentile precision is bounded by the boundaries you chose on the producer side, because the conversion cannot recover detail that the original buckets did not have.</p>
<h3 id="explicithistogramwithcumulativetemporalitycase2">Explicit histogram with cumulative temporality (case 2)</h3>
<p>This case uses the same metric and bucket boundaries as case 1, with cumulative counts and a fixed start time.
This is the case that separates the two paths most sharply.</p>
<p>Cumulative temporality is the default in most OpenTelemetry SDKs and the model Prometheus users know.
Every data point reports the totals since the process started, so the counts only grow and a consumer computes the rate over a window by subtracting two points.
It is resilient to lost data points, because the next point still carries the full state.</p>
<pre><code>curl --fail-with-body --silent --show-error \
  -H "Authorization: ApiKey ${ELASTIC_API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary '{
    "resourceMetrics": [
      {
        "resource": {
          "attributes": [
            {"key": "service.name", "value": {"stringValue": "my-service"}},
            {"key": "example.case", "value": {"stringValue": "explicit-cumulative"}}
          ]
        },
        "scopeMetrics": [
          {
            "scope": {"name": "http"},
            "metrics": [
              {
                "name": "http.server.request.duration",
                "unit": "s",
                "histogram": {
                  "aggregationTemporality": 2,
                  "dataPoints": [
                    {
                      "startTimeUnixNano": "'"${T0}"'",
                      "timeUnixNano":      "'"${T1}"'",
                      "count": 150,
                      "sum": 18.5,
                      "explicitBounds": [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5],
                      "bucketCounts":   [3,     7,    15,    30,   45,  27,   15,  6,   2,  0],
                      "attributes": [
                        {"key": "http.request.method",       "value": {"stringValue": "GET"}},
                        {"key": "http.response.status_code", "value": {"intValue": 200}}
                      ]
                    },
                    {
                      "startTimeUnixNano": "'"${T0}"'",
                      "timeUnixNano":      "'"${T2}"'",
                      "count": 250,
                      "sum": 31.0,
                      "explicitBounds": [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5],
                      "bucketCounts":   [5,     12,   25,    50,   75,  45,   25,  10,  3,  0],
                      "attributes": [
                        {"key": "http.request.method",       "value": {"stringValue": "GET"}},
                        {"key": "http.response.status_code", "value": {"intValue": 200}}
                      ]
                    }
                  ]
                }
              }
            ]
          }
        ]
      }
    ]
  }' \
  "${MANAGED_OTLP_URL}/v1/metrics"
</code></pre>
<p><code>aggregationTemporality: 2</code> means cumulative.
Both points retain <code>T0</code> as the fixed start of the series.
The first reports 150 requests through <code>T1</code>; the second reports 250 through <code>T2</code>.
Subtracting their counts, sums, and corresponding buckets gives the 100-request distribution for <code>T1</code> to <code>T2</code> shown in case 1.</p>
<p><strong>How Elastic stores it.</strong>
On the native path Elasticsearch stores the point as an <code>exponential_histogram</code> and records <code>cumulative</code> in the temporality dimension.
Time series functions in ES|QL use that dimension to compute per-interval rates from consecutive cumulative points.</p>
<p>On the bulk path this data point is dropped.
The managed bulk path's Elasticsearch exporter does not support cumulative histograms; treating those buckets as delta would produce incorrect interval distributions.
If your ECH deployment is on 9.0 to 9.5.2, configure your SDK or Collector to export histograms with delta temporality, or put a <code>cumulativetodelta</code> processor in front of the export.</p>
<h3 id="exponentialhistogramwithdeltatemporalitycase3">Exponential histogram with delta temporality (case 3)</h3>
<p>Now switch the data type while keeping delta temporality.</p>
<p>An exponential histogram removes the need to pick boundaries.
Its buckets are exponentially spaced and controlled by a single <code>scale</code> parameter, and the SDK lowers the scale automatically when the data range grows.
That is why exponential histograms are the recommended default for latency SLOs where p99 and p99.9 accuracy matters: the resolution is relative to the value, so the tail is captured as precisely as the body.</p>
<pre><code>curl --fail-with-body --silent --show-error \
  -H "Authorization: ApiKey ${ELASTIC_API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary '{
    "resourceMetrics": [
      {
        "resource": {
          "attributes": [
            {"key": "service.name", "value": {"stringValue": "my-service"}},
            {"key": "example.case", "value": {"stringValue": "exponential-delta"}}
          ]
        },
        "scopeMetrics": [
          {
            "scope": {"name": "http"},
            "metrics": [
              {
                "name": "http.server.request.duration",
                "unit": "s",
                "exponentialHistogram": {
                  "aggregationTemporality": 1,
                  "dataPoints": [
                    {
                      "startTimeUnixNano": "'"${T1}"'",
                      "timeUnixNano":      "'"${T2}"'",
                      "count": 100,
                      "sum": 62.2,
                      "scale": 3,
                      "zeroCount": 0,
                      "positive": {
                        "offset": -10,
                        "bucketCounts": [2, 5, 10, 20, 30, 18, 10, 4, 1]
                      },
                      "attributes": [
                        {"key": "http.request.method",       "value": {"stringValue": "GET"}},
                        {"key": "http.response.status_code", "value": {"intValue": 200}}
                      ]
                    }
                  ]
                }
              }
            ]
          }
        ]
      }
    ]
  }' \
  "${MANAGED_OTLP_URL}/v1/metrics"
</code></pre>
<p>Reading the bucket layout takes a moment the first time.</p>
<p>The base of the histogram is <code>2^(2^-scale)</code>.
With <code>scale: 3</code> that is about 1.09, so each bucket is 9% wider than the previous one.</p>
<p>Bucket <code>i</code> in <code>bucketCounts</code> covers the range <code>(base^(offset+i), base^(offset+i+1)]</code>, lower bound excluded and upper bound included.
With <code>offset: -10</code> the first bucket starts at about 0.42 seconds and the ninth ends at about 0.92 seconds.</p>
<p><code>zeroCount</code> counts measurements that are exactly zero or within the zero threshold, and a <code>negative</code> bucket range exists for negative values, which latency never uses.</p>
<p><strong>How Elastic stores it.</strong>
On the native path Elasticsearch stores the scale, offset, and counts as an <code>exponential_histogram</code> without conversion.
This is the highest-fidelity case: what you measured is what you query.</p>
<p>On the bulk path the Elasticsearch exporter converts the exponential buckets into a T-Digest style <code>histogram</code>.
The data point is accepted and percentiles remain usable, but the original scale and bucket layout are not preserved.</p>
<h3 id="exponentialhistogramwithcumulativetemporalitycase4">Exponential histogram with cumulative temporality (case 4)</h3>
<p>The last case combines the adaptive bucket layout of case 3 with the cumulative semantics of case 2.
An SDK configured for exponential histogram aggregation and cumulative export temporality produces this shape.
The <a href="https://opentelemetry.io/docs/specs/otel/metrics/sdk/#default-aggregation">OpenTelemetry SDK default aggregation</a> for histogram instruments is explicit buckets, so selecting exponential aggregation is a separate configuration choice.</p>
<pre><code>curl --fail-with-body --silent --show-error \
  -H "Authorization: ApiKey ${ELASTIC_API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary '{
    "resourceMetrics": [
      {
        "resource": {
          "attributes": [
            {"key": "service.name", "value": {"stringValue": "my-service"}},
            {"key": "example.case", "value": {"stringValue": "exponential-cumulative"}}
          ]
        },
        "scopeMetrics": [
          {
            "scope": {"name": "http"},
            "metrics": [
              {
                "name": "http.server.request.duration",
                "unit": "s",
                "exponentialHistogram": {
                  "aggregationTemporality": 2,
                  "dataPoints": [
                    {
                      "startTimeUnixNano": "'"${T0}"'",
                      "timeUnixNano":      "'"${T1}"'",
                      "count": 150,
                      "sum": 93.3,
                      "scale": 3,
                      "zeroCount": 0,
                      "positive": {
                        "offset": -10,
                        "bucketCounts": [3, 7, 15, 30, 45, 27, 15, 6, 2]
                      },
                      "attributes": [
                        {"key": "http.request.method",       "value": {"stringValue": "GET"}},
                        {"key": "http.response.status_code", "value": {"intValue": 200}}
                      ]
                    },
                    {
                      "startTimeUnixNano": "'"${T0}"'",
                      "timeUnixNano":      "'"${T2}"'",
                      "count": 250,
                      "sum": 155.5,
                      "scale": 3,
                      "zeroCount": 0,
                      "positive": {
                        "offset": -10,
                        "bucketCounts": [5, 12, 25, 50, 75, 45, 25, 10, 3]
                      },
                      "attributes": [
                        {"key": "http.request.method",       "value": {"stringValue": "GET"}},
                        {"key": "http.response.status_code", "value": {"intValue": 200}}
                      ]
                    }
                  ]
                }
              }
            ]
          }
        ]
      }
    ]
  }' \
  "${MANAGED_OTLP_URL}/v1/metrics"
</code></pre>
<p><strong>How Elastic stores it.</strong>
On the native path this is stored exactly like case 3, as an <code>exponential_histogram</code>, plus the <code>cumulative</code> temporality dimension.
Elasticsearch can then downsample and compute rates over the series correctly.
The two points share the same start time and bucket layout; their difference is the delta distribution in case 3.</p>
<p>On the bulk path the data point is dropped, for the same reason as case 2.</p>
<blockquote>
  <p><strong>Requires Elastic Stack 9.5.3 or later on ECH.</strong>
  On ECH 9.0 to 9.5.2, cases 2 and 4 are dropped.
  Emit delta histograms until you upgrade.</p>
</blockquote>
<h2 id="queryopentelemetryhistogrampercentileswithesql">Query OpenTelemetry histogram percentiles with ES|QL</h2>
<p>Sending is only half of the showcase.
Open Discover in Kibana, switch to ES|QL mode, and first confirm that the documents arrived:</p>
<pre><code>FROM metrics-*.otel-*
| EVAL duration = metrics.http.server.request.duration::exponential_histogram
| WHERE service.name == "my-service" AND duration IS NOT NULL
| KEEP @timestamp, resource.attributes.example.case, metrics.http.server.request.duration, attributes.http.request.method
| SORT @timestamp DESC
| LIMIT 10
</code></pre>
<p>On a native-path deployment the histogram field shows the scale, the bucket indices, and the counts you sent.
On a bulk-path deployment you see the converted <code>values</code> and <code>counts</code> arrays instead, which is the visible trace of the conversion.</p>
<p>Then ask the question the histogram was collected for, the request latency percentiles:</p>
<pre><code>TS metrics-*.otel-*
| WHERE service.name == "my-service" AND @timestamp &gt;= NOW() - 5 minutes
| STATS p50 = PERCENTILE(metrics.http.server.request.duration::exponential_histogram, 50),
        p95 = PERCENTILE(metrics.http.server.request.duration::exponential_histogram, 95),
        p99 = PERCENTILE(metrics.http.server.request.duration::exponential_histogram, 99)
    BY resource.attributes.example.case, attributes.http.request.method, TBUCKET(5 minutes)
</code></pre>
<p><code>TS</code> merges histograms per time series and respects their temporality before computing percentiles.
For cumulative series, it accounts for the change between consecutive snapshots instead of counting their overlapping observations repeatedly.
<code>FROM</code> is useful for inspecting individual documents, but it ignores temporality when aggregating them.
See <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/metric-temporality#how-temporality-affects-queries">how temporality affects queries</a>.</p>
<p>Grouping by <code>resource.attributes.example.case</code> keeps the four examples separate.
The explicit examples place the median in the 0.05 to 0.1 second bucket; the exponential examples place it around 0.6 seconds.
These are different input distributions, so their percentile results should differ. The cumulative examples describe the same final one-minute distribution as their corresponding delta examples.
Refer to the <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/aggregation-functions">ES|QL aggregation functions</a> reference for the full list of functions that accept exponential histogram fields.</p>
<h2 id="summaryopentelemetryhistogramsupportonelasticcloudhosted">Summary: OpenTelemetry histogram support on Elastic Cloud Hosted</h2>
<ul>
<li>ECH deployments on Elastic Stack 9.5.3 or later receive OTLP metrics through the native Elasticsearch endpoint, with no client change.</li>
<li>Those deployments accept all four histogram cases, and exponential histograms are stored without conversion.</li>
<li>ECH deployments on 9.0 to 9.5.2 continue through the bulk API and should emit delta histograms.</li>
<li>Serverless deployments always use the native path.</li>
</ul>
<p>Clients keep sending to the same <code>/v1/metrics</code> path on the Managed OTLP Endpoint in every case.</p>
<h2 id="learnmoreaboutopentelemetrymetricsonelastic">Learn more about OpenTelemetry metrics on Elastic</h2>
<ul>
<li><a href="https://www.elastic.co/docs/reference/opentelemetry/managed-inputs/managed-otlp-endpoint">Elastic Cloud Managed OTLP Endpoint documentation</a></li>
<li><a href="https://www.elastic.co/docs/manage-data/ingest/otlp-endpoint">Elasticsearch OTLP/HTTP endpoint documentation</a></li>
<li><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/exponential-histogram">Exponential histogram field type</a></li>
<li><a href="https://www.elastic.co/observability-labs/blog/elastic-managed-otlp-endpoint-ga-elastic-cloud-hosted">Now GA: Managed OTLP Endpoint on Elastic Cloud Hosted</a></li>
</ul>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/opentelemetry-histograms-elastic-cloud-hosted</link>
    <guid isPermaLink="false">opentelemetry-histograms-elastic-cloud-hosted</guid>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[Metrics]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Maurizio Branca]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt77ab0ff8549edba9/6aa7dd83afd9a8c1a8120ed9/header.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 15 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[LLM tracing in Elastic APM: prompts, responses, and token counts in the span view]]></title>
    <description><![CDATA[In a twenty-call agentic trace, you can see which span is using the most tokens and read the prompt that caused it. Both live in Elastic APM, so there is no second tool to run.]]></description>
    <content:encoded><![CDATA[<p>Elastic APM now does LLM tracing in the trace view. The GenAI tab in the span flyout has the whole conversation, so you can read the system prompt, the user messages and the model response, and copy any of them. Every GenAI span row in the waterfall shows input and output token counts, so in an agentic trace with twenty LLM calls you can find the span using the most tokens without opening any of them. Your LLM calls are now in the same waterfall as your database queries and HTTP spans.</p>
<p>Both features follow the <a href="https://github.com/open-telemetry/semantic-conventions-genai/tree/main/docs/gen-ai">OTel GenAI semantic conventions</a> and work with any OTel-instrumented provider. If your framework already emits OTel GenAI span attributes, there is nothing to change.</p>
<h2 id="howotelgenaispansarestructured">How OTel GenAI spans are structured</h2>
<p>A GenAI span stores everything as span attributes. A typical chat span includes:</p>
<ul>
<li><code>gen_ai.provider.name</code>: the provider (<code>openai</code>, <code>anthropic</code>, <code>aws.bedrock</code>, etc.); <code>gen_ai.system</code> is supported as a fallback for older instrumentation.</li>
<li><code>gen_ai.operation.name</code>: the operation type (<code>chat</code>, <code>embeddings</code>, etc.).</li>
<li><code>gen_ai.request.model</code>: the model being called.</li>
<li><code>gen_ai.usage.input_tokens</code>: tokens consumed by the prompt.</li>
<li><code>gen_ai.usage.output_tokens</code>: tokens generated in the response.</li>
<li><code>gen_ai.input.messages</code>, <code>gen_ai.output.messages</code>: conversation messages.</li>
<li><code>gen_ai.system_instructions</code>: the system prompt.</li>
</ul>
<p>Both features read from these attributes:</p>
<p>| Feature | What it shows | Where it appears | Attributes it reads |
| --- | --- | --- | --- |
| <strong>GenAI tab</strong> | Details (operation type, request model, provider, input and output token counts, response model, response ID) and Conversation (system prompt, user messages, model response) | Span flyout in the APM trace view, and the span flyout in Discover | Appears with any <code>gen_ai.*</code> attribute. Conversation needs <code>gen_ai.system_instructions</code>, <code>gen_ai.input.messages</code>, and <code>gen_ai.output.messages</code> |
| <strong>Token count badges</strong> | Input and output token counts for each GenAI span | Every GenAI span row in the trace waterfall | <code>gen_ai.usage.input_tokens</code>, <code>gen_ai.usage.output_tokens</code> |</p>
<h2 id="howtoreadllmpromptsandresponsesinthegenaitab">How to read LLM prompts and responses in the GenAI tab</h2>
<p>When any <code>gen_ai.*</code> attribute is present on a span, the span flyout shows a dedicated <strong>GenAI</strong> tab next to <strong>Metadata</strong>.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltaeedbb89fd9c6871/6a968cd2144a15728fde3efd/genai-tab-span-details.png" alt="GenAI tab in span details flyout" /></p>
<p>The Details section shows model metadata from the span attributes: operation type, request model, provider, input and output token counts, response model, and response ID. The Conversation section shows the full exchange, populated from <code>gen_ai.system_instructions</code> (system prompt), <code>gen_ai.input.messages</code> (user messages), and <code>gen_ai.output.messages</code> (model response), each with a copy button so you can pull the exact prompt or response out of the trace without scraping text from a formatted table.</p>
<p>All raw span attributes remain accessible on the <strong>Metadata</strong> tab.</p>
<p>The <strong>GenAI</strong> tab is also available in the span flyout in <strong>Discover</strong>, so you can inspect LLM prompts and responses directly alongside your log and trace data without switching to the APM view.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd1ccc4e30c48f8ed/6a968ced36a7416fdf27288b/genai-tab-discover.png" alt="GenAI tab in Discover span flyout" /></p>
<h2 id="whatinstrumentationdoesllmtracingrequire">What instrumentation does LLM tracing require?</h2>
<p>No Kibana-side configuration is needed. The GenAI tab appears automatically when any <code>gen_ai.*</code> attribute is present on a span. Full Conversation support requires the <a href="https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-spans.md">OTel GenAI v1.37.0 span-attribute model</a>: <code>gen_ai.input.messages</code>, <code>gen_ai.output.messages</code>, and <code>gen_ai.system_instructions</code>.</p>
<p>Frameworks that emit the older span-events model (<code>gen_ai.user.message</code>, <code>gen_ai.assistant.message</code>, <code>gen_ai.choice</code>) will show the Details metadata section but will not populate the Conversation section. For a current list of compatible instrumentations, see the <a href="https://github.com/open-telemetry/opentelemetry-python-genai/#released-instrumentations">OTel GenAI semantic conventions</a>.</p>
<p>To verify, open the span in Discover, check that <code>gen_ai.input.messages</code> and <code>gen_ai.output.messages</code> are present, and confirm the Conversation section renders.</p>
<p>If your application already sends APM data to Elastic from a GenAI workload, open any GenAI span in the trace view and check for the GenAI tab.</p>
<h2 id="llmtokenusageinthetracewaterfall">LLM token usage in the trace waterfall</h2>
<p>Token count badges now appear on each GenAI span row in the waterfall, so you can scan the full trace without drilling in. In agentic traces with ten or twenty LLM calls, this lets you identify which span is driving token consumption before opening any span.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2d47691c65ef8799/6a968d055f9db7338e560d85/genai-waterfall-tokens.png" alt="Trace waterfall with token counts on GenAI spans" /></p>
<p>Each row shows input and output token count badges sourced from <code>gen_ai.usage.input_tokens</code> and <code>gen_ai.usage.output_tokens</code>. The row label is the span name, which instrumentation frameworks typically set to something like <code>chat gpt-4o-mini</code>.</p>
<p>Waterfall-level token counts are most useful in agentic traces where a chain of LLM calls uses different models or the same model with varying context sizes.</p>
<h2 id="whichllmprovidersdoeselasticapmsupportforgenaitracing">Which LLM providers does Elastic APM support for GenAI tracing?</h2>
<p>Elastic APM's LLM tracing works with any OTel-instrumented provider: the GenAI tab and waterfall token counts use the same OTel attribute schema regardless of which provider your application uses. Provider is read from <code>gen_ai.provider.name</code>, falling back to <code>gen_ai.system</code> for older instrumentation.</p>
<p>The <a href="https://github.com/open-telemetry/semantic-conventions-genai/tree/main/docs/gen-ai">OTel GenAI semantic conventions</a> that enable this provider detection are currently in a <code>Development</code> lifecycle. Check the <a href="https://github.com/open-telemetry/semantic-conventions-genai/releases">release notes</a> before upgrading instrumentation.</p>
<h2 id="howtoenablellmtracinginelasticapm">How to enable LLM tracing in Elastic APM</h2>
<blockquote>
  <p><strong>Availability:</strong> Both features are available as a Technical Preview on Elastic Serverless and will be available as a Technical Preview in Elastic Stack 9.6.</p>
</blockquote>
<p>To try these features:</p>
<ol>
<li>Instrument your GenAI application with an OTel SDK that follows the <a href="https://github.com/open-telemetry/semantic-conventions-genai/tree/main/docs/gen-ai">OTel GenAI semantic conventions</a> (v1.37.0 or later for full Conversation support).</li>
<li>Send traces to <a href="https://www.elastic.co/observability">Elastic Observability</a> using OTLP, the Elastic APM agent, or an EDOT SDK.</li>
<li>Open the <strong>APM</strong> section in Kibana, navigate to a service that makes LLM calls, and open the trace waterfall for any transaction.</li>
</ol>
<p>The GenAI tab appears on any span with at least one <code>gen_ai.*</code> attribute set; token count badges appear when <code>gen_ai.usage.input_tokens</code> or <code>gen_ai.usage.output_tokens</code> are present.</p>
<p>If you don't have a GenAI application to test with, the <a href="https://github.com/jennypavlova/otel-genai-chat-app">otel-genai-chat-app</a> repository is a minimal OpenAI chat app pre-instrumented with EDOT. Set <code>OPENAI_API_KEY</code> and follow the EDOT commands in the <a href="https://github.com/jennypavlova/otel-genai-chat-app#otel-genai-chat-app">README</a> to send traces to Elastic and see both features in action.</p>
<h2 id="whatsnextforllmobservabilityinelasticapm">What's next for LLM observability in Elastic APM</h2>
<p>We're exploring cost estimation per span (estimated spend based on model pricing and token counts, surfaced in the waterfall) and tool call rendering (structured display of tool/function call inputs and outputs for agentic spans).</p>
<p>If you are building GenAI applications and want early access or to share feedback, reach out through the <a href="https://discuss.elastic.co/c/observability">Elastic community forums</a> or open an issue in the <a href="https://github.com/elastic/kibana/issues">kibana repository</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/llm-tracing-elastic-apm-genai-spans</link>
    <guid isPermaLink="false">llm-tracing-elastic-apm-genai-spans</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[APM]]></category>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Jenny Pavlova,Miriam Aparicio,Costas Pipilas]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7b5da648bcacf4aa/6a968bfc5c312610fa43eee4/header.png" length="0" type="image/png"/>
    <pubDate>Tue, 01 Sep 2026 15:22:01 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[OpenTelemetry Java extensions: customize traces without forking the agent]]></title>
    <description><![CDATA[One JAR, loaded at startup by the OpenTelemetry Java agent, lets you filter health checks, rename spans, add resource attributes, and control sampling with no application code changes.]]></description>
    <content:encoded><![CDATA[<p>You've just set up auto-instrumentation on a Java application. Without any code changes, traces start flowing to your observability platform.
After a few minutes, you realize health check endpoints are flooding your trace view, and transaction names reflect generic framework patterns rather than your domain operations.</p>
<p>Forking the agent would fix this, but then you own every upstream merge.
You could also use manual instrumentation for complete control, but that requires code changes and ongoing upkeep.
OpenTelemetry Java extensions give you a cleaner path: a separate JAR the agent loads at startup, giving you precise control over what gets captured and exported, without touching agent or application code.</p>
<p>For example, the following challenges are very common:</p>
<ul>
<li>Health check probes are flooding your trace view.</li>
<li>Span names reflect generic framework patterns rather than your domain operations.</li>
<li>Some span names or attributes have high cardinality creating noise in your traces.</li>
<li>Spans are missing attributes relevant to your business logic.</li>
<li>Baggage headers are propagating to downstream services when they shouldn't.</li>
<li>Resource attributes that describe your deployment are not automatically captured because they rely on custom environment variables.</li>
</ul>
<p>Some of those can be solved through configuration, or by using an intermediate OpenTelemetry Collector for processing.
However, this also might add complexity to the telemetry pipeline, and you might prefer to solve this at the source, where the data is captured.</p>
<h2 id="whatareopentelemetryjavaextensions">What are OpenTelemetry Java extensions</h2>
<p>An extension is a JAR file the agent loads at startup. It hooks into the agent's extension points through Java's Service Provider Interface (SPI) mechanism, the same mechanism the agent uses internally.</p>
<p>The extension mechanism works identically with the upstream OpenTelemetry Java agent and with <a href="https://github.com/elastic/elastic-otel-java">Elastic's OpenTelemetry distribution</a>. You write the extension once and it works with either.</p>
<p>For reference, the <a href="https://opentelemetry.io/docs/zero-code/java/agent/extensions/">upstream extension documentation</a> provides an exhaustive overview of extension points and a few examples.</p>
<p>This post does not aim to provide a complete reference, but focuses on simple use cases you're likely to reach for in production: renaming spans, filtering noisy traces, or propagating context that the agent doesn't cover in your environment.</p>
<p>Extensions also let you modify and extend the agent instrumentation itself. That goes beyond what this post covers. Here are two starting points:</p>
<ul>
<li><a href="https://opentelemetry.io/docs/zero-code/java/agent/extensions/#instrumentercustomizerprovider">Modify instrumentation using instrumenter customizers</a>.</li>
<li><a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/examples/extension/src/main/java/com/example/javaagent/instrumentation/DemoServlet3InstrumentationModule.java">Modify or add instrumentation using the instrumentation module</a>.</li>
</ul>
<h2 id="settingupanopentelemetryjavaextensionproject">Setting up an OpenTelemetry Java extension project</h2>
<p>An extension is a standard Java Gradle project with two requirements: the output must be a shadow JAR (a fat JAR with all extension dependencies bundled), and OpenTelemetry dependencies must be declared <code>compileOnly</code> so you don't bundle the SDK itself.</p>
<p>The shadow JAR requirement exists because the agent loads the extension in its own classloader. If you declare a dependency as <code>implementation</code>, it gets bundled and may conflict with the version already in the agent. Using <code>compileOnly</code> keeps those JARs out of the extension JAR entirely.</p>
<p>Here is a minimal <code>build.gradle.kts</code> for a simple extension that does not customize instrumentation and thus relies only on the OpenTelemetry SDK/API.</p>
<pre><code>plugins {
  id("java")
  id("com.gradleup.shadow")
}

repositories {
  mavenCentral()
}

java {
  toolchain {
    languageVersion.set(JavaLanguageVersion.of(8))
  }
}

dependencies {
  // Use BOM to manage OpenTelemetry dependency versions
  compileOnly(platform("io.opentelemetry:opentelemetry-bom:1.64.0"))
  // OpenTelemetry SDK autoconfiguration SPI (provided by agent)
  compileOnly("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi")
  // OpenTelemetry SDK
  compileOnly("io.opentelemetry:opentelemetry-sdk")
  // Annotation processor for automatic SPI registration
  compileOnly("com.google.auto.service:auto-service:1.1.1")
  annotationProcessor("com.google.auto.service:auto-service:1.1.1")
}

tasks.assemble {
  dependsOn(tasks.shadowJar)
}
</code></pre>
<p>Check <a href="https://central.sonatype.com/artifact/io.opentelemetry/opentelemetry-bom/">Maven Central</a> for the latest version of the BOM before starting.</p>
<p>Extensions only depend at compile-time on the OpenTelemetry SDK and the autoconfiguration SPI. The agent provides the rest of the SDK and instrumentation implementation at runtime.</p>
<h2 id="loadingopentelemetryjavaextensionsatruntime">Loading OpenTelemetry Java extensions at runtime</h2>
<p>To load an OpenTelemetry Java extension at runtime, you can use the <code>otel.javaagent.extensions</code> system property or <code>OTEL_JAVAAGENT_EXTENSIONS</code> environment variable. The value is a comma-separated list of paths to extension JARs:</p>
<pre><code>java -Dotel.javaagent.extensions=/path/to/my-extension.jar -javaagent:/path/to/opentelemetry-javaagent.jar -jar myapp.jar
</code></pre>
<p>The upstream OpenTelemetry Java agent also lets you <a href="https://opentelemetry.io/docs/zero-code/java/agent/extensions/#embedding-extensions-in-the-agent">embed extensions directly into the agent JAR</a> to simplify deployment.</p>
<h2 id="filteringandrenamingspanswithopentelemetryjavaextensions">Filtering and renaming spans with OpenTelemetry Java extensions</h2>
<p>You can modify spans in two ways:</p>
<ul>
<li>Using a <code>SpanProcessor</code> that is called synchronously when the span starts or ends.</li>
<li>Using a <code>SpanExporter</code> that is called asynchronously when the span is exported.</li>
</ul>
<h3 id="renamespanswithaspanprocessor">Rename spans with a SpanProcessor</h3>
<p><code>SpanProcessor.onStart</code> receives a <code>ReadWriteSpan</code>, which means you can call <code>span.updateName()</code> before the span is exported. This is the right hook for renaming based on attributes that are available at span start.</p>
<pre><code>public class OperationRenamingSpanProcessor implements SpanProcessor {

  @Override
  public void onStart(Context parentContext, ReadWriteSpan span) {
    String operation = span.getAttribute(AttributeKey.stringKey("app.operation"));
    if (operation != null) {
      span.updateName(operation);
    }
  }

  @Override
  public boolean isStartRequired() { return true; }

  @Override
  public void onEnd(ReadableSpan span) {}

  @Override
  public boolean isEndRequired() { return false; }

  @Override
  public CompletableResultCode shutdown() { return CompletableResultCode.ofSuccess(); }

  @Override
  public CompletableResultCode forceFlush() { return CompletableResultCode.ofSuccess(); }
}
</code></pre>
<p>Register the SpanProcessor via <code>AutoConfigurationCustomizerProvider</code>, composing it with whatever processor you have already configured:</p>
<pre><code>@AutoService(AutoConfigurationCustomizerProvider.class)
public class RenamingCustomizerProvider implements AutoConfigurationCustomizerProvider {

  @Override
  public void customize(AutoConfigurationCustomizer customizer) {
    customizer.addTracerProviderCustomizer(this::configureSdkTracerProvider);
  }

  private SdkTracerProviderBuilder configureSdkTracerProvider(
      SdkTracerProviderBuilder tracerProvider, ConfigProperties config) {
    return tracerProvider.addSpanProcessor(new OperationRenamingSpanProcessor());
  }

}
</code></pre>
<p>The <a href="https://github.com/elastic/elastic-otel-java/tree/main/examples/extensions/modify-span">modify-span EDOT Java extension example</a> provides a complete implementation.</p>
<h3 id="filterspanswithaspanexporter">Filter spans with a SpanExporter</h3>
<p>A <code>SpanExporter</code> wrapper lets you modify or drop spans before they leave the process. This works well for known noisy endpoints like health checks.</p>
<pre><code>public class FilteringSpanExporter implements SpanExporter {

  private final SpanExporter delegate;

  public FilteringSpanExporter(SpanExporter delegate) {
    this.delegate = delegate;
  }

  @Override
  public CompletableResultCode export(Collection&lt;SpanData&gt; spans) {
    List&lt;SpanData&gt; filtered = new ArrayList&lt;&gt;();
    for (SpanData span : spans) {
      if (!"GET /health".equals(span.getName())) {
        filtered.add(span);
      }
    }
    return delegate.export(filtered);
  }

  @Override
  public CompletableResultCode flush() { return delegate.flush(); }

  @Override
  public CompletableResultCode shutdown() { return delegate.shutdown(); }
}
</code></pre>
<p>Register the FilteringSpanExporter via <code>addSpanExporterCustomizer</code>:</p>
<pre><code>customizer.addSpanExporterCustomizer((existing, config) -&gt; new FilteringSpanExporter(existing));
</code></pre>
<p>The <a href="https://github.com/elastic/elastic-otel-java/tree/main/examples/extensions/modify-span">modify-span EDOT Java extension example</a> provides a complete implementation.</p>
<p>The approach has two limitations:</p>
<ul>
<li>This won't discard any child span that may have been created, for example, if the healthcheck calls the database.</li>
<li>Spans filtered at the exporter have already passed through the full processor pipeline and occupied buffer space in the batch processor.</li>
</ul>
<p>If you're dropping a large fraction of your traffic at this stage, a custom <code>Sampler</code> (shown below) is more efficient because it drops spans before any processing happens and also filters out child spans.
Also, when using <a href="https://opentelemetry.io/docs/zero-code/java/agent/declarative-configuration/">declarative configuration</a>, the rule-based sampler lets you implement filtering on rules using only configuration.</p>
<h2 id="addingcustomresourceattributeswitharesourceprovider">Adding custom resource attributes with a ResourceProvider</h2>
<p>Resource attributes describe what's running: the service name, its version, the host. A <code>ResourceProvider</code> lets you attach additional attributes that the agent doesn't know about, such as deployment metadata your platform injects through environment variables.</p>
<p>The example below uses environment variables, but it could also be a configuration file, a cloud metadata service, or any other source available to the agent at startup.</p>
<p>Because the SDK initialization is synchronous, when querying an external service like a metadata endpoint, this can make the agent (and thus the application) startup slower.
If possible, prefer checking environment variables and local config first before calling an external service.</p>
<pre><code>@AutoService(ResourceProvider.class)
public class DeploymentResourceProvider implements ResourceProvider {

  @Override
  public Resource createResource(ConfigProperties config) {
    AttributesBuilder attributes = Attributes.builder();

    String region = System.getenv("DEPLOY_REGION");
    if (region != null) {
      attributes.put(AttributeKey.stringKey("deployment.region"), region);
    }

    String buildVersion = System.getenv("BUILD_VERSION");
    if (buildVersion != null) {
      attributes.put(AttributeKey.stringKey("build.version"), buildVersion);
    }

    return Resource.create(attributes.build());
  }
}
</code></pre>
<p>Attributes from a <code>ResourceProvider</code> merge with the agent's own resource. When two providers supply the same key, the one with the higher <code>order()</code> value wins. The agent's built-in providers use order 0, so overriding <code>order()</code> to return a positive integer gives your provider priority.</p>
<p>The <a href="https://github.com/elastic/elastic-otel-java/tree/main/examples/extensions/resource-attribute">resource-attribute EDOT Java extension example</a> provides a complete implementation.</p>
<h2 id="customsamplinginopentelemetryjava">Custom sampling in OpenTelemetry Java</h2>
<p>When filtering at the exporter is too late or too expensive, implement a <code>Sampler</code> directly. The sampler runs before any span processing, so dropped spans never touch the batch buffer.</p>
<p>However, the sampling decision can only rely on attributes that are provided when the span starts. For example, the status code of an HTTP response can't be used as it is only available when the span ends.</p>
<p>The key detail: wrap the existing sampler rather than replacing it. That way, your logic composes with whatever you configured, and parent-based decisions from an upstream service are still respected.</p>
<pre><code>public class HealthCheckSampler implements Sampler {

  private final Sampler delegate;

  public HealthCheckSampler(Sampler delegate) {
    this.delegate = delegate;
  }

  @Override
  public SamplingResult shouldSample(
      Context parentContext,
      String traceId,
      String name,
      SpanKind spanKind,
      Attributes attributes,
      List&lt;LinkData&gt; parentLinks) {
    if (spanKind == SpanKind.SERVER &amp;&amp; name.contains("health")) {
      return SamplingResult.create(SamplingDecision.DROP);
    }
    return delegate.shouldSample(parentContext, traceId, name, spanKind, attributes, parentLinks);
  }

  @Override
  public String getDescription() {
    return "HealthCheckSampler{" + delegate.getDescription() + "}";
  }
}
</code></pre>
<p>Register the HealthCheckSampler via <code>addSamplerCustomizer</code>, which gives you both the existing sampler and the resolved config:</p>
<pre><code>customizer.addSamplerCustomizer((existing, config) -&gt; new HealthCheckSampler(existing));
</code></pre>
<h2 id="communityextensionsinopentelemetryjavacontrib">Community extensions in opentelemetry-java-contrib</h2>
<p>The <a href="https://github.com/open-telemetry/opentelemetry-java-contrib">opentelemetry-java-contrib</a> repository contains several community-maintained extensions.</p>
<p>Some of them are already included in the OpenTelemetry Java agent (and inherited in the Elastic distribution), but are opt-in:</p>
<ul>
<li><a href="https://github.com/open-telemetry/opentelemetry-java-contrib/tree/main/azure-resources">azure-resources</a></li>
<li><a href="https://github.com/open-telemetry/opentelemetry-java-contrib/tree/main/aws-resources">aws-resources</a></li>
<li><a href="https://github.com/open-telemetry/opentelemetry-java-contrib/tree/main/gcp-resources">gcp-resources</a></li>
<li><a href="https://github.com/open-telemetry/opentelemetry-java-contrib/tree/main/cloudfoundry-resources">cloudfoundry-resources</a></li>
<li><a href="https://github.com/open-telemetry/opentelemetry-java-contrib/tree/main/baggage-processor">baggage-processor</a></li>
</ul>
<p>Most Elastic distribution <a href="https://www.elastic.co/docs/reference/opentelemetry/edot-sdks/java/features">features</a> exist as extensions in the contrib repository, so you can use them with the upstream agent in a vendor-neutral way.</p>
<ul>
<li><a href="https://github.com/open-telemetry/opentelemetry-java-contrib/tree/main/inferred-spans">inferred-spans</a></li>
<li><a href="https://github.com/open-telemetry/opentelemetry-java-contrib/tree/main/span-stacktrace">span-stacktrace</a></li>
</ul>
<h2 id="furtherreadingandextensionexamples">Further reading and extension examples</h2>
<p>The <a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/examples/extension">upstream extension examples</a> cover additional extension points not shown here, including custom propagators, ID generators, and ignored-type configurers.</p>
<p>The <a href="https://github.com/elastic/elastic-otel-java/tree/main/examples/baggage">Elastic baggage example</a> shows the filtering propagator for baggage running end-to-end with a two-service application, it also demonstrates custom instrumentation to add baggage without modifying the application code.</p>
<p>This post covered the project setup and the patterns most likely to come up in production. Both links above go deeper: the upstream examples add extension points not covered here, and the baggage example shows a complete two-service implementation you can run locally.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/opentelemetry-java-extensions</link>
    <guid isPermaLink="false">opentelemetry-java-extensions</guid>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[Kubernetes]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Sylvain Juge]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2b379be7ce7ba2c1/6a8ea21cbf814594cbd284ec/header.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 20 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elastic Agent now runs as an OpenTelemetry Collector: Less memory overhead, zero config changes]]></title>
    <description><![CDATA[Elastic Agent 9.3 sends logs, metrics and traces through one OTel Collector pipeline, running Beats integrations alongside native OTel sources in a single Fleet-managed agent.]]></description>
    <content:encoded><![CDATA[<p>Elastic Agent 9.3 uses less memory and accepts data from any OpenTelemetry-compatible (OTel-compatible) source out of the box.
Under the hood, the old Beats subprocess architecture has been replaced by a single OTel-native pipeline for logs, metrics, and traces, built on the Elastic Distribution of OpenTelemetry (EDOT) Collector.
Your existing integrations, dashboards, Fleet policies, alerting rules, and ingest pipelines all work without changes.</p>
<h2 id="whatchangedinelasticagent93anativeotelcollectorunderthehood">What changed in Elastic Agent 9.3: A native OTel Collector under the hood</h2>
<p>Previously, Elastic Agent acted as a supervisor process, spinning up Beats-based subprocesses, such as Filebeat or Metricbeat.
From 9.3 onward, that architecture has been replaced.
Elastic Agent itself is now built on the EDOT Collector, turning it into a first-class OTel Collector under the hood while preserving its original functionality.</p>
<p>Key benefits of this architectural shift include:</p>
<ul>
<li><strong>Reduced footprint:</strong> Fewer subprocesses mean significantly less memory overhead and a simpler deployment model. In future releases, this footprint will be even further reduced.</li>
<li><strong>Unified telemetry pipeline:</strong> Logs and metrics flow through a single, standards-based OTel pipeline, as do traces.</li>
<li><strong>Ecosystem interoperability:</strong> Elastic Agent can now receive data from any OTel-compatible source out of the box. It can also be configured to emit to OTel-compatible destinations.</li>
<li><strong>Aligned with the OTel ecosystem:</strong> As the OTel ecosystem matures with new receivers, processors, and exporters, Elastic Agent deployments gain access to those capabilities automatically.</li>
</ul>
<p>When you deploy or update Elastic Agent from version 9.3 onward, you're deploying an OpenTelemetry Collector.
EDOT is the technology foundation; Elastic Agent is the product.</p>
<h2 id="howexistingbeatsconfigurationsruninsidetheotelcollectorpipeline">How existing Beats configurations run inside the OTel Collector pipeline</h2>
<p>Elastic has introduced Beats Receivers, which are Beat inputs and processors that execute natively inside the new OTel Collector pipeline.
For your teams and customers, this means:</p>
<ul>
<li>Existing <code>elastic-agent.yml</code> configurations require no modification.</li>
<li>Fleet-managed agents automatically translate policy configurations into OTel format internally.</li>
<li>All integrations, dashboards, ingest pipelines, and alerting rules continue to function exactly as before.</li>
<li>Data written via Beats Receivers lands in the same data streams as always.</li>
</ul>
<p>Upgrading to 9.3 is transparent because it uses the same inputs and produces the same outputs.</p>
<h2 id="runningbeatsandotelcollectorpipelinesinoneelasticagent">Running Beats and OTel Collector pipelines in one Elastic Agent</h2>
<p>The new Elastic Agent is a collector capable of simultaneously running traditional Beats-based collections alongside native OTel pipelines, all in a single deployment.
One agent policy can collect Elastic Common Schema–schematized (ECS-schematized) data via Beats Receivers and ingest native OpenTelemetry Protocol (OTLP) data from OTel-instrumented applications and infrastructure.
This same agent policy can apply OTel processing stages across all telemetry before export.</p>
<p>OTel integrations from Elastic's catalog can be added to any agent policy.
When native OTel data is ingested, Elastic automatically installs the relevant dashboards and alerts, in addition to necessary content packs, without any manual setup.</p>
<h2 id="whatstherelationshipbetweenelasticagentandedot">What's the relationship between Elastic Agent and EDOT?</h2>
<p>You may be familiar with EDOT, the Elastic Distribution of OpenTelemetry Collector, as a stand-alone product.
With this architectural change, EDOT is the technology foundation that now powers Elastic Agent, not a separate product that users need to track or deploy independently.</p>
<p>Going forward, Elastic Agent is the supported, Fleet-manageable, fully featured product.
A stand-alone deployment remains available for specific niche scenarios (environments where the full version of Elastic Agent cannot be installed), but it isn't the recommended path for the vast majority of users.</p>
<h2 id="elasticagentdeploymentoptionsfleetmanagedvsstandalone">Elastic Agent deployment options: Fleet-managed vs. stand-alone</h2>
<p>|                            | <strong>Fleet-managed Elastic Agent</strong> | <strong>Stand-alone Elastic Agent</strong>                                                                           |
| :------------------------- | :-----------------------------: | :-----------------------------------------------------------------------------------------------------: |
| Fleet lifecycle management | Yes                             | Can enroll into Fleet in-field without reinstallation                                                   |
| Beats Receivers            | Yes                             | Yes                                                                                                     |
| Elastic Defend             | Yes                             | No                                                                                                      |
| Cloud Security             | Yes                             | No                                                                                                      |
| Profiler support           | Yes                             | No                                                                                                      |
| OTel-native pipeline       | Yes                             | Yes                                                                                                     |
| Best for                   | Most deployments                | Environments where full Elastic Agent cannot be installed or management is handled by other tools       |</p>
<h2 id="doineedtochangeanythingwhenupgradingtoelasticagent93">Do I need to change anything when upgrading to Elastic Agent 9.3?</h2>
<p>For users running Elastic Agent today, upgrading to 9.3 requires no changes to configurations or integrations, and no changes to workflows.
For customers evaluating OTel adoption, Elastic Agent now provides a fully supported, production-ready OTel Collector with Fleet management and rich integrations, along with Elastic's full support matrix, and none of this requires a separate OTel deployment.</p>
<p>With Elastic Agent 9.3, Elastic's data collection is fully OpenTelemetry-native.
Elastic Agent is now an OpenTelemetry Collector.
Everything you have today still works, and you also get all the capabilities of OTel.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/opentelemetry-collector-elastic-agent</link>
    <guid isPermaLink="false">opentelemetry-collector-elastic-agent</guid>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[Agentic Observability]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Nima Rezainia]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt36d2c1da5195912a/6a859a7218249c7a3818ec86/header.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 17 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <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[vLLM Prometheus metrics for self-hosted LLM tuning: TTFT, KV Cache, and GPU Utilization]]></title>
    <description><![CDATA[Tuning a self-hosted vLLM inference using its Prometheus metrics in Elastic Observability — TTFT, KV cache, prefix caching and DCGM GPU counters]]></description>
    <content:encoded><![CDATA[<p>Somewhere in your company there is a team that cannot use Claude, GPT, or Gemini — not because they don't want to, but because their data isn't allowed to leave a jurisdiction, a network boundary, or due to a contract. Claims files. Patient notes, source code under an export-control regime, etc.</p>
<p>That team still wants a model. So the request lands on an SRE's desk, and it sounds deceptively small: <em>"Can you stand up an open-weight model for the claims team? Sixty people. It has to run on our hardware."</em> They aren't even allowed to use a neocloud. There is a cost associated with this, but we won't explore that part. Just the part that covers running the model and observing the configuration.</p>
<p>Standing it up is the easy half. Four manifests and an afternoon, and you have a model answering questions. The hard part arrives a week later, when someone says <em>"it feels slow"</em> and you realize you have no idea whether the deployment is configured well, badly, or catastrophically — and no obvious way to find out.</p>
<p>This guide shows you how Elastic Observability can help you analyze the metrics from the configuration. It walks through tuning a real vLLM deployment using the metrics vLLM already emits. vLLM exposes these on a <code>/metrics</code> endpoint in <strong>Prometheus exposition format</strong> — no instrumentation, no sidecar, no code change — which is why every query in this guide starts from a Prometheus scrape. The goal: turn "it feels slow" into a specific, defensible decision.</p>
<h3 id="testenvironmentvllmonakubernetesclusterusingnvidiaa10gwithdcgmexporterandprometheusmetrics">Test environment: vLLM on a Kubernetes cluster using NVIDIA A10G with dcgm-exporter and Prometheus metrics</h3>
<p>Every figure in this guide was measured on the following stack — one replica, one GPU, no autoscaling.</p>
<ul>
<li><strong>Workload</strong> — Kubernetes-native load generator, scaled from 8 to 32 concurrent requests.</li>
<li><strong>Model</strong> — <code>Qwen/Qwen2.5-3B-Instruct</code>, bf16, <code>--max-model-len 4096</code></li>
<li><strong>Engine</strong> — vLLM <code>v0.23.0</code>, OpenAI-compatible server, Prometheus <code>/metrics</code> on <code>:8000</code></li>
<li><strong>GPU</strong> — NVIDIA A10G, 24 GB — an AWS <code>g5.xlarge</code></li>
<li><strong>Cluster</strong> — Amazon EKS 1.30, tainted GPU node pool with <code>minSize: 0</code></li>
<li><strong>Telemetry</strong> — Prometheus scraping every 15s, plus <code>dcgm-exporter</code> on <code>:9400</code>, shipped via <code>remote_write</code></li>
<li><strong>Analysis</strong> — Elastic Observability, queried with ES|QL and PromQL</li>
<li><strong>Measured</strong> — 2026-07-27</li>
</ul>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd14cf1e2fc1644c5/6a859ab0d6cf297af1bafe8a/arch-measurement-stack.png" alt="Architecture of the measurement stack: a load generator driving a vLLM pod running Qwen on a tainted NVIDIA A10G node in Amazon EKS, with dcgm-exporter as a DaemonSet on the same node and a Prometheus pod scraping both and remote-writing to Elastic Observability" /></p>
<h2 id="whyisithardforansretoselfhostandtuneanopenweightllm">Why is it hard for an SRE to self-host and tune an open-weight LLM?</h2>
<p><strong>The difficulty is not the deployment, it's the tuning which has no feedback loop.</strong> vLLM starts, serves, and reports success whether it's configured brilliantly or wastefully. Nothing tells you which.</p>
<p>When loading up the model, your manifest would have this configuration:</p>
<pre><code>      containers:
        - name: vllm
          image: vllm/vllm-openai:v0.23.0  # pin an exact release — metric names shift between versions
          args:
            - "--model=Qwen/Qwen2.5-3B-Instruct"
            - "--max-model-len=4096"         # cap context → predictable KV-cache size
            # A10G has native bf16 — do NOT add --dtype=half (T4-only).
          ports:
            - name: http
              containerPort: 8000            # OpenAI API + /metrics
</code></pre>
<p>But you can run into specific issues, such as:
Hugging Face downloads take minutes. If your cluster expects a server to start in 30 seconds, it will assume the app is dead and kill it mid-download, putting you in an infinite crash loop.</p>
<p>Or you could have a hardware mismatch, and potentially degrade your model’s speed or precision because hardware architectures vary</p>
<p>or a bevy of other issues.</p>
<p>Once the model is finally running, optimizing performance is complete guesswork because default metrics don't tell you if you're being efficient.</p>
<p>You could use <code>nvidia-smi</code>, but this only understands raw hardware state, not application software logic.</p>
<p>Now that you have it running, a few hours to maybe even a day in, the team says "it feels slow." You are, functionally, tuning blind.</p>
<p><strong>How do you tune a self-hosted vLLM deployment?</strong></p>
<p>You're not an inference engineer, you own forty other services besides this one, and you don't have a forward-deployed engineer from a model vendor on call. But tuning an LLM server turns out to need exactly one skill you already have: <strong>reading telemetry and reasoning about saturation.</strong> The only missing piece is telemetry that exists and means something.</p>
<p>It does. vLLM emits a rich Prometheus endpoint out of the box — latency decomposed by inference phase, cache hit rates, batch occupancy, token accounting, completion outcomes. Almost nobody looks at it. The rest of this guide is how to read it.</p>
<hr />
<h2 id="definingtheworkloadsixtyusersshortpromptsstreamingresponses">Defining the workload: sixty users, short prompts, streaming responses</h2>
<p>With the slowness detected and reported, you gather the usage profile of the users. Their usage pattern is as follows:</p>
<ul>
<li><strong>~60 users, but not concurrent.</strong> Realistic peak is <strong>8–12 simultaneous in-flight requests</strong>; sustained is lower.</li>
<li><strong>Short prompts, long answers.</strong> The user pastes a paragraph and asks for a structured summary. Prompts run ~50 tokens; useful answers run 500–1,000.</li>
<li><strong>Interactive, streaming UI.</strong> Perceived speed is dominated by <strong>time to first token (TTFT)</strong>, not total time — the same psychology as a chat interface.</li>
<li><strong>Heavy prompt reuse.</strong> Every request carries the same system prompt and the same policy-language boilerplate.</li>
</ul>
<p>From that, you write down actual service objectives — the step most self-hosted LLM projects skip:</p>
<ul>
<li><strong>TTFT p95 &lt; 300 ms</strong> </li>
<li><strong>inter-token latency &lt; 50 ms</strong> (≥ 20 tokens/sec, faster than reading speed) </li>
<li><strong>zero queueing at 12 concurrent</strong> </li>
<li><strong>error + abort rate &lt; 0.5%</strong></li>
</ul>
<p>Those four numbers are the point of everything that follows. Without them, "it feels slow" has no answer. With them, every metric below passes or fails a stated bar.</p>
<hr />
<h2 id="howdoyougetprometheusmetricsoutofvllmandthegpu">How do you get Prometheus metrics out of vLLM and the GPU?</h2>
<p><strong>There are two sources, and you need both.</strong> </p>
<ul>
<li>vLLM reports on itself — latency by phase, cache hit rates, batch occupancy, token counts — on <code>/metrics</code> at its serving port, with no adapter and no instrumentation work. </li>
<li>The GPU reports separately, through NVIDIA's <code>dcgm-exporter</code> on <code>:9400</code> (NVIDIA Data Center GPU Manager (DCGM) is a suite of tools and libraries designed to comprehensively manage, monitor, and diagnose enterprise-grade NVIDIA GPUs in clusters and data centers). vLLM tells you what the <em>engine</em> thinks is happening; DCGM tells you what the <em>card</em> is actually doing. Step 6 is built entirely on the gap between those two answers.</li>
</ul>
<p>On this EKS cluster that means three things running side by side:</p>
<ul>
<li><strong>vLLM</strong> as a plain Deployment on the tainted <code>g5.xlarge</code> GPU node pool. For one model on one card, a Deployment and a Service is the whole architecture.</li>
<li><strong><code>dcgm-exporter</code></strong> as a DaemonSet, pinned to the same GPU nodes.</li>
<li><strong>A Prometheus server</strong> on a CPU node, scraping both endpoints every 15 seconds.</li>
</ul>
<p>Nothing here is AWS-specific. The production version is the same manifests on an on-prem cluster with L40S or H100 nodes — which is the point of doing this on Kubernetes rather than on a vendor's platform.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd5929cba73afdd08/6a859ab3078290bd30320cc1/arch-vllm-kubernetes.png" alt="vLLM and DCGM exporter running on a tainted GPU node pool in Kubernetes, scraped by Prometheus" /></p>
<h3 id="whataboutkserveandllmd">What about KServe and llm-d?</h3>
<p><strong>Neither was run for this guide, and neither changes where the metrics come from.</strong> KServe and llm-d sit <em>on top of</em> vLLM rather than replacing it — vLLM is still the engine, so <code>/metrics</code> is still the source of every number here. Each adds its own layer on top (KServe: autoscaler and revision metrics; llm-d: router and cache-routing metrics), but the inference telemetry underneath is identical.</p>
<p>What they change is <em>when</em> you need them — and each promotion is triggered by a metric you're already collecting:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte3690f26168dfe1f/6a859ab5982926339a582e1a/scaling-ladder-kserve-llmd.png" alt="The scaling ladder from a plain vLLM Deployment to KServe to llm-d, with the metric that triggers each promotion" /></p>
<hr />
<h2 id="howdoyoushipthevllmmetricsanddcgmmetricstoobservability">How do you ship the vLLM metrics and DCGM metrics to Observability</h2>
<p><strong>A Prometheus scraping inside the cluster only holds hours of data — the metrics have to reach a store you can still query next week.</strong> There are two paths for that, and they are not equivalent.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt757fb6a3447dfa4d/6a859ab8f61d6e3bac9c209b/pipeline-metrics-to-backend.png" alt="Two paths for shipping vLLM and DCGM metrics off the cluster: an OpenTelemetry Collector over OTLP, or a Prometheus server using native remote_write" /></p>
<p><strong>Path A — OpenTelemetry Collector.</strong> Puts inference metrics into the same pipeline as your traces and logs. One collector, one auth path, one mental model. The cost is that Prometheus metrics sent through OTLP get normalized: the stored schema ends up neither purely Prometheus nor purely OTel, and metric names shift.</p>
<p><strong>Path B — native Prometheus <code>remote_write</code>.</strong> Stands up a small Prometheus that scrapes both endpoints and pushes to a backend speaking the remote-write protocol. Names and labels land untouched, <code>_sum</code> / <code>_count</code> / <code>_bucket</code> histogram parts stay intact, and existing queries keep working.</p>
<p><strong>For a tuning exercise, choose Path B.</strong> That's what produced every number in this guide. The reason is narrow but decisive: tuning means comparing against the vLLM documentation and the vLLM community, and both speak in exact metric names. When your chart says <code>vllm:kv_cache_usage_perc</code>, you can search for it.</p>
<p>The deployment is two YAML files — a Prometheus Deployment with two scrape jobs and a <code>remote_write</code> block, plus a Secret holding the backend credential. In this build the destination was an Elastic Serverless project, which exposes a Prometheus remote-write endpoint and lands data in a time-series data stream, <code>metrics-vllm.prometheus-inference</code>.</p>
<p>Two things cost me real time. If your backend has a separate ingest host for OTLP versus its main API, remote-write usually lives on the <strong>main API host</strong>, not the ingest one — pointing at the wrong one returns a 404 that looks like a path error. And the credential needs <strong>index-write privileges</strong>, not just ingest authentication; a key that works fine for OTLP can authenticate successfully and then 403 on every sample. Check <code>prometheus_remote_storage_samples_failed_total</code> on the Prometheus itself before looking anywhere else.</p>
<h3 id="howdoyouconfirmvllmmetricslandedinelastic">How do you confirm vLLM metrics landed in Elastic?</h3>
<p>Once the pipeline is up, look at the field list. Roughly <strong>127 metric series</strong> arrive from a single vLLM pod plus DCGM:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc8fad7c5f6812920/6a859abb682666f68a1ea1af/metrics-landed-discover.png" alt="vLLM and DCGM metric fields arriving in the observability backend, with per-metric sparklines" /></p>
<p>This screen is more useful than it looks. Scanning the field list is how you confirm your vLLM version's exact metric names — <strong>they do shift between major vLLM releases</strong>, and a dashboard built against the wrong names fails silently by returning nothing rather than erroring.</p>
<h3 id="twogotchaswhenqueryingvllmmetrics">Two gotchas when querying vLLM metrics</h3>
<p>Both produce results that look like "the metrics aren't working" when the pipeline is perfectly healthy.</p>
<p><strong>vLLM metric names contain a colon</strong> (<code>vllm:num_requests_running</code>), so they need escaping in most query languages. More insidiously, if you filter by metric <em>name</em> across several metrics and then aggregate only one of them, you get rows back — full of nulls, with no error. Each Prometheus metric lands in its own field, so <strong>naming the field is the filter</strong>; you don't need the name predicate at all.</p>
<p><strong>Counters need rate functions, gauges don't.</strong> <code>vllm:generation_tokens_total</code> is cumulative and monotonic — taking a max of it gives the pod's lifetime total, not its throughput. Gauges like <code>vllm:num_requests_running</code>, <code>vllm:num_requests_waiting</code> and <code>vllm:kv_cache_usage_perc</code> are instantaneous and want max or average. Mixing these up produces charts that are wrong but plausible, which is considerably worse than charts that are empty.</p>
<hr />
<h2 id="whichvllmprometheusmetricsactuallymatter">Which vLLM Prometheus metrics actually matter?</h2>
<p>A reference for the metrics used in this guide, what each tells you, and the condition worth watching. Names are as vLLM emits them; the <code>DCGM_FI_*</code> series come from <code>dcgm-exporter</code>.</p>
<p>| Metric | Type | What it tells you | Watch for |
|---|---|---|---|
| <code>vllm:time_to_first_token_seconds</code> | Histogram | TTFT — how long before the first token streams | p95 above your interactive bar (300 ms here) |
| <code>vllm:inter_token_latency_seconds</code> | Histogram | Streaming speed after the first token | Above ~50 ms is slower than reading speed |
| <code>vllm:e2e_request_latency_seconds</code> | Histogram | Total request time | Rising while TTFT is flat = decode or workload change |
| <code>vllm:request_queue_time_seconds</code> | Histogram | Time waiting for admission | <strong>Earliest saturation signal</strong> — any sustained rise |
| <code>vllm:request_prefill_time_seconds</code> | Histogram | Time processing the prompt | Dominant share = prefill-bound workload |
| <code>vllm:request_decode_time_seconds</code> | Histogram | Time generating tokens | Dominant share = memory-bandwidth-bound |
| <code>vllm:num_requests_running</code> | Gauge | Requests currently being decoded | Batch occupancy |
| <code>vllm:num_requests_waiting</code> | Gauge | Requests queued for admission | Sustained non-zero = add a replica |
| <code>vllm:kv_cache_usage_perc</code> | Gauge | Occupancy of the KV block pool — <strong>not VRAM</strong> | Autoscaling trigger (~60%) |
| <code>vllm:prompt_tokens_total</code> + <code>vllm:prompt_tokens_cached_total</code> | Counters | Prefix-cache hit rate | A drop means routing scattered your prefixes |
| <code>vllm:generation_tokens_total</code> | Counter | Output throughput in tokens/sec | Headline throughput number |
| <code>vllm:request_prompt_tokens</code> + <code>vllm:request_generation_tokens</code> | Histograms | Per-request token sizes; their ratio is the workload's shape | A moving ratio means the workload changed character |
| <code>vllm:iteration_tokens_total</code> | Histogram | Tokens advanced per forward pass | Near 1.0 with concurrency = batching broken |
| <code>vllm:request_success_total{finished_reason}</code> | Counter | Completion outcomes | <code>error</code>/<code>abort</code> = SLO; <code>length</code> share = truncation |
| <code>http_requests_total{status}</code> | Counter | Server-level requests | Catches 4xx and malformed requests <code>vllm:*</code> never sees |
| <code>DCGM_FI_DEV_FB_USED</code> / <code>_FB_FREE</code> | Gauge | Physical VRAM | Capacity planning only — never alert on it |
| <code>DCGM_FI_DEV_GPU_UTIL</code> | Gauge | "A kernel is resident" | <strong>Not</strong> a measure of useful work |
| <code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE</code> | Gauge | Tensor-core activity | Low here + high DRAM = memory-bound |
| <code>DCGM_FI_PROF_DRAM_ACTIVE</code> | Gauge | Memory-bandwidth activity | High = the bottleneck is bandwidth |
| <code>DCGM_FI_DEV_POWER_USAGE</code> | Gauge | Watts drawn | Pairs with throughput for tokens-per-watt |</p>
<hr />
<h2 id="step1wheredoesvllmlatencygottftprefillanddecodedecomposed">Step 1: Where does vLLM latency go? TTFT, prefill, and decode decomposed</h2>
<p><strong>Decompose total latency into queue, prefill, and decode before optimizing anything.</strong> vLLM reports all three separately, and they have completely different fixes. This is the single most valuable chart in the setup.</p>
<p>The query averages each phase's cumulative time by request count in the same window — in Prometheus terms, <code>rate(vllm:request_prefill_time_seconds_sum) / rate(vllm:e2e_request_latency_seconds_count)</code>, and the same for queue and decode:</p>
<pre><code>TS metrics-vllm.prometheus-inference
| WHERE @timestamp &gt; NOW() - 30 minutes
| STATS reqs = SUM(RATE(`metrics.vllm:e2e_request_latency_seconds_count`)),
        q_s  = SUM(RATE(`metrics.vllm:request_queue_time_seconds_sum`)),
        pf_s = SUM(RATE(`metrics.vllm:request_prefill_time_seconds_sum`)),
        dc_s = SUM(RATE(`metrics.vllm:request_decode_time_seconds_sum`))
    BY minute = BUCKET(@timestamp, 1 minute)
| EVAL queue_ms   = ROUND(q_s  / reqs * 1000, 2),
       prefill_ms = ROUND(pf_s / reqs * 1000, 1),
       decode_ms  = ROUND(dc_s / reqs * 1000, 1)
| KEEP minute, queue_ms, prefill_ms, decode_ms
| SORT minute ASC
</code></pre>
<p>At 8 concurrent requests on the A10G:</p>
<pre><code>minute    | queue_ms | prefill_ms | decode_ms | e2e_ms  | ttft_ms | inter_token_ms
22:55:00  | 0.01     | 37.98      | 1664.69   | 1715.18 | 50.81   | 16.23
22:56:00  | 0.01     | 40.99      | 1670.44   | 1723.78 | 53.50   | 16.20
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9369d6f7665848b2/6a859abe9bf994282509fb28/latency-decomposition.png" alt="Latency decomposition over time — queue, prefill, decode, e2e, TTFT and inter-token latency" /></p>
<p><strong>Read it against the objectives:</strong></p>
<ul>
<li><strong>TTFT is 51 ms against a 300 ms target.</strong> Passing, with almost 6× headroom. Perceived responsiveness is not the problem, whatever was said in the meeting.</li>
<li><strong>Inter-token latency is 16 ms — about 62 tokens/sec</strong> against a 50 ms / 20 tok-s bar. Text arrives roughly three times faster than a person reads it.</li>
<li><strong>Queue time is 0.01 ms.</strong> Nothing is waiting for admission; the engine has capacity to spare at this concurrency.</li>
<li><strong>Decode is 1,665 ms against 38 ms of prefill — 97% of the time is decode.</strong></li>
</ul>
<p>That last line is the finding. <strong>Every optimization aimed at prefill is worthless for this workload.</strong> Chunked prefill, prompt compression, a faster attention kernel for long contexts — all real techniques, all irrelevant when prefill is 2% of the time. Decode is memory-bandwidth-bound, so the levers that would actually move it are <strong>quantization, tensor parallelism across two cards, or a smaller model</strong>. A single chart eliminated the wrong shopping list.</p>
<p><strong>Watch <code>vllm:request_queue_time_seconds</code> specifically.</strong> It is the earliest saturation signal in the entire vLLM metric set — queue time climbs <em>before</em> <code>vllm:num_requests_waiting</code> becomes visibly non-zero, because a request can wait milliseconds for admission without ever registering as queued at scrape time. If you alert on one thing from this section, alert on queue time crossing a small absolute threshold.</p>
<hr />
<h2 id="step2isvllmusingthegpuefficientlykvcacheprefixcachingandbatchoccupancy">Step 2: Is vLLM using the GPU efficiently? KV cache, prefix caching, and batch occupancy</h2>
<p><strong>Four metrics answer this: prefix-cache hit rate, tokens per iteration, KV-cache occupancy, and running-vs-waiting requests.</strong> Latency tells you the experience is good; these tell you whether you're overpaying for it.</p>
<pre><code>TS metrics-vllm.prometheus-inference
| WHERE @timestamp &gt; NOW() - 30 minutes
| STATS ptok    = SUM(RATE(`metrics.vllm:prompt_tokens_total`)),
        cached  = SUM(RATE(`metrics.vllm:prompt_tokens_cached_total`)),
        gen     = SUM(RATE(`metrics.vllm:generation_tokens_total`)),
        it_s    = SUM(RATE(`metrics.vllm:iteration_tokens_total_sum`)),
        it_c    = SUM(RATE(`metrics.vllm:iteration_tokens_total_count`)),
        running = MAX(`metrics.vllm:num_requests_running`),
        waiting = MAX(`metrics.vllm:num_requests_waiting`),
        kv      = MAX(`metrics.vllm:kv_cache_usage_perc`)
    BY minute = BUCKET(@timestamp, 1 minute)
| EVAL prefix_cache_hit_pct = ROUND(cached / ptok * 100, 1),
       tokens_per_iteration = ROUND(it_s / it_c, 2),
       gen_tokens_per_sec   = ROUND(gen, 1),
       kv_cache_pct         = ROUND(kv * 100, 3)
| KEEP minute, prefix_cache_hit_pct, tokens_per_iteration,
       gen_tokens_per_sec, running, waiting, kv_cache_pct
| SORT minute ASC
</code></pre>
<pre><code>minute   | prefix_cache_hit_pct | tokens_per_iteration | gen_tok/s | running | waiting | kv_cache_pct
22:55:00 | 32.5                 | 10.36                | 479.1     | 8.0     | 0.0     | 0.255
22:59:00 | 32.3                 | 10.34                | 480.0     | 8.0     | 0.0     | 0.121
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9c70c464d265fbcd/6a859ac127c5cd723f5f68b4/efficiency-kv-cache.png" alt="Efficiency panel — prefix cache hit rate, tokens per iteration, throughput, running/waiting, KV cache occupancy" /></p>
<p><strong>A 32% prefix-cache hit rate is a third of all prefill work simply not done.</strong> The claims team's requests share a system prompt and policy boilerplate, and vLLM's automatic prefix caching recognizes that. This is a direct argument for <em>raising</em> prompt standardization: the more the application puts shared context in a consistent leading position, the higher this climbs and the cheaper every request gets. It is also the number that will crater the day a naive round-robin load balancer sits in front of two replicas — precisely the condition that justifies llm-d's cache-aware routing.</p>
<p><strong><code>tokens_per_iteration ≈ 10.3</code> with 8 concurrent requests is continuous batching working correctly.</strong> Each forward pass through the model advances about ten sequences at once. If this sat near 1.0 with multiple requests in flight, batching would be broken and you'd be paying full model-forward cost per token per user. This metric proves you're getting vLLM's core value.</p>
<h3 id="whatdoesvllmkv_cache_usage_percactuallymeasure">What does <code>vllm:kv_cache_usage_perc</code> actually measure?</h3>
<p><strong><code>vllm:kv_cache_usage_perc</code> reports occupancy of vLLM's pre-allocated KV block pool — not physical GPU memory.</strong> At startup, vLLM reserves a fraction of VRAM (governed by <code>--gpu-memory-utilization</code>, default 0.9) and carves a KV block pool out of that reservation. This gauge reports how full <em>that pool</em> is.</p>
<p>That's why it read <strong>0.25%</strong> here. Eight concurrent requests holding ~150 tokens each barely touch an A10G's block budget. The pool is large, and correctly so. Push the same server to 32 concurrent requests with 512–1,024 token generations and it moves — to about <strong>2.8%</strong>. Still small.</p>
<p>The instinct is to read a number that low as "the cache is broken" or "I've massively over-provisioned." Both are wrong. <strong>Treating this gauge as a VRAM proxy is the most common self-hosted vLLM configuration error I see.</strong> Step 6 shows exactly how far apart the two are.</p>
<hr />
<h2 id="step3whatshapeisyourvllminferenceworkload">Step 3: What shape is your vLLM inference workload?</h2>
<p><strong>Confirm the workload is what you think it is before tuning anything.</strong> This is the panel that explains a latency "regression" that isn't your fault.</p>
<pre><code>minute   | requests_per_min | avg_prompt_tokens | avg_generated_tokens | avg_max_tokens | gen_to_prompt_ratio
22:55:00 | 282.2            | 49.6              | 103.6                | 103.6          | 2.09
23:01:00 | 276.0            | 50.6              | 103.0                | 103.0          | 2.04
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt23956fea686f8579/6a859ac59829261f8f582e20/workload-shape.png" alt="Workload shape — request rate, prompt and generation token averages, generation-to-prompt ratio" /></p>
<p><strong>The generation-to-prompt ratio is 2.04 — this workload writes twice as much as it reads.</strong> That single ratio <em>is</em> the explanation for Step 1's 97%-decode finding, and it holds for any summarize-and-draft use case. If the team later adds a long-document RAG feature, prompts jump to thousands of tokens, the ratio inverts, the workload becomes prefill-bound, and the correct tuning changes completely. <strong>Watching this ratio is how you learn your workload changed character before someone files a ticket.</strong></p>
<p>Now the column that should bother you: <strong><code>avg_generated_tokens</code> equals <code>avg_max_tokens</code> exactly.</strong> Every request is stopping because it hit its token ceiling, not because the model finished its thought. The screenshot shows the same pattern holding as generation lengths scale to ~685 tokens against a ~777 ceiling.</p>
<p>In a load test that's an artifact of the generator. <strong>In production, that number is users getting cut off mid-sentence</strong> — and it is invisible in every latency metric you have. Which brings us to the metric that catches it.</p>
<hr />
<h2 id="step4arevllmrequestsactuallysucceedingcheckingfinished_reason">Step 4: Are vLLM requests actually succeeding? Checking finished_reason</h2>
<p><strong>Break <code>vllm:request_success_total</code> down by its <code>finished_reason</code> label.</strong> This is the closest thing self-hosted inference has to an application-level SLI, and it catches a failure mode no latency chart can.</p>
<pre><code>TS metrics-vllm.prometheus-inference
| WHERE @timestamp &gt; NOW() - 30 minutes
| STATS completions_per_min = ROUND(SUM(RATE(`metrics.vllm:request_success_total`)) * 60, 2)
    BY minute = BUCKET(@timestamp, 1 minute), finish_reason = labels.finished_reason
| SORT minute ASC, finish_reason
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd26d2ce94404b523/6a859ac818249ce7a418ecaa/health-finish-reason.png" alt="Completion outcomes broken down by finish reason — stop, length, abort, error, repetition" /></p>
<p>Five outcomes, each meaning something different operationally:</p>
<p>| <code>finished_reason</code> | What it means | What to do about it |
|---|---|---|
| <code>stop</code> | The model finished naturally | This is the number you want large |
| <code>length</code> | Truncated at <code>max_tokens</code> | High share means users are cut off — raise the ceiling, or shorten the ask |
| <code>abort</code> | The client disconnected first | Users giving up, or a proxy timeout shorter than your generations |
| <code>error</code> | The engine failed | Your hard SLO signal. Should be flat zero |
| <code>repetition</code> | Degenerate looping output | A sampling-parameter problem, not an infrastructure one |</p>
<p>Under the small load generator the split was <strong>100% <code>length</code></strong> — expected, since it requested a fixed ceiling. In the screenshot, at a mixed load, <code>stop</code> and <code>length</code> run side by side at roughly 51 and 32 completions/min. That mix is the healthy shape: most requests finishing on their own, a minority hitting the ceiling.</p>
<p><strong>The lesson generalizes.</strong> <code>error</code> and <code>abort</code> are what you page on. But the <strong><code>stop</code>-to-<code>length</code> ratio is what you review weekly</strong>, because drift toward <code>length</code> means answers are being truncated and no latency dashboard on earth will tell you.</p>
<p>One blind spot to close: <strong><code>vllm:*</code> metrics only count requests the engine accepted.</strong> Malformed JSON, 4xx, auth failures and dropped connections never reach it. Those live in <code>http_requests_total</code> with <code>status</code> and <code>handler</code> labels — worth a panel beside this one, because "the model is broken" reports frequently turn out to be the gateway in front of it.</p>
<hr />
<h2 id="step5whatnvidiadcgmmetricssaythegpuisactuallydoing">Step 5: What NVIDIA DCGM metrics say the GPU is actually doing</h2>
<p><strong>Use NVIDIA DCGM as an independent witness to vLLM's own account.</strong> Everything so far is the engine describing itself; DCGM describes the silicon.</p>
<pre><code>FROM metrics-vllm.prometheus-inference
| WHERE @timestamp &gt; NOW() - 30 minutes
| STATS gpu_util_pct    = MAX(`metrics.DCGM_FI_DEV_GPU_UTIL`),
        mem_bw_util_pct = MAX(`metrics.DCGM_FI_DEV_MEM_COPY_UTIL`),
        vram_used_mib   = MAX(`metrics.DCGM_FI_DEV_FB_USED`),
        vram_free_mib   = MIN(`metrics.DCGM_FI_DEV_FB_FREE`),
        power_w         = ROUND(MAX(`metrics.DCGM_FI_DEV_POWER_USAGE`), 1),
        temp_c          = MAX(`metrics.DCGM_FI_DEV_GPU_TEMP`)
    BY minute = BUCKET(@timestamp, 1 minute)
| SORT minute ASC
</code></pre>
<p>→ <strong>100% GPU util · 21,483 MiB used / 1,352 MiB free · 240 W · 73 °C</strong></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6aa395617cbe369d/6a859acb43c0b731cd2efb39/dcgm-hardware.png" alt="DCGM GPU hardware panel — utilization, VRAM used and free, power draw, temperature, SM clock" /></p>
<p>100% utilization and 94% VRAM. Under the conventional reading, this card is maxed out and it's time to ask for more hardware. That reading is wrong.</p>
<h3 id="whyisgpuutilizationamisleadingmetricforllminference">Why is GPU utilization a misleading metric for LLM inference?</h3>
<p><strong><code>DCGM_FI_DEV_GPU_UTIL</code> means "a kernel is resident on the device," not "the device is doing useful work."</strong> It reads 100% for a perfectly-tuned server and 100% for a badly-tuned one, so it cannot distinguish them. The DCGM profiling counters can:</p>
<pre><code>gr_engine_active 99.8%  ·  tensor_active 16.6%  ·  dram_active 80.4%
</code></pre>
<p><strong>Read those three together.</strong> The GPU's compute engine is busy essentially all the time — but its <strong>tensor cores, the units that do the actual matrix math, are active only 16.6%</strong>, while <strong>DRAM is active 80.4%</strong>. The card is not computing. It is <strong>waiting on memory.</strong></p>
<p>This is independent, hardware-level confirmation of what Step 1 inferred purely from timings: decode is memory-bandwidth-bound. Two entirely different instruments, two different layers of the stack, one conclusion — the difference between a hypothesis and a finding.</p>
<p>It also permanently retires GPU utilization as a capacity metric for LLM inference. <strong>If your GPU capacity planning rests on <code>DCGM_FI_DEV_GPU_UTIL</code> — and most does — it rests on nothing.</strong></p>
<hr />
<h2 id="step6vllmkvcachevsgpuvramandwhytheydisagree">Step 6: vLLM KV cache vs GPU VRAM, and why they disagree</h2>
<p><strong>Put <code>vllm:kv_cache_usage_perc</code> and physical VRAM usage on one 0–100% axis.</strong> They describe the same GPU memory, they sit at opposite ends of the chart, and both are correct.</p>
<pre><code>minute   | running | gen_tok_s | kv_cache_pct | vram_used_pct | gpu_util | dram_active_pct | tensor_active_pct | tokens_per_watt
00:05:00 | 31      | 1627.5    | 2.82         | 94.1          | 100.0    | 80.4            | 16.6              | 6.80
</code></pre>
<p><strong>KV cache at 2.8%. VRAM at 94.1%.</strong></p>
<p>vLLM pre-allocates a large fraction of VRAM at startup — governed by <code>--gpu-memory-utilization</code>, default 0.9 — and carves its KV block pool out of that reservation. <code>vllm:kv_cache_usage_perc</code> reports occupancy <em>of the pool</em>. DCGM reports what the <strong>driver</strong> sees, which is the whole reservation, whether or not it's holding anything.</p>
<p>The operational consequences are precise, and they're the practical payoff of the entire exercise:</p>
<ul>
<li><strong>Autoscale on <code>vllm:kv_cache_usage_perc</code> and <code>vllm:num_requests_waiting</code>.</strong> These describe admission capacity — whether the engine can take another request right now.</li>
<li><strong>Capacity-plan on VRAM.</strong> This describes physical space — whether a second model could ever fit on this card. (It can't. 1.3 GB free.)</li>
<li><strong>Never alert on VRAM.</strong> It will page you at 3 a.m. for a healthy, mostly-idle server, every single night, forever.</li>
</ul>
<p>And <strong><code>tokens_per_watt</code> — generated tokens divided by power draw, 6.8 here — is a genuine cost-efficiency metric.</strong> It's comparable across GPU models, batch settings and quantization levels in a way that neither latency nor utilization is. When you go back to Finance for card number two, this is the number that makes the argument: <em>at 32 concurrent we sustain 1,627 tokens/sec at 240 watts, and here's what that becomes on an L40S.</em></p>
<hr />
<h2 id="vllmtuningdecisionswhatthesredoeswiththeseprometheusmetrics">vLLM tuning decisions: what the SRE does with these Prometheus metrics</h2>
<p>Six steps, thirty minutes, one server. The verdict against the stated objectives:</p>
<p>| Objective | Measured | Verdict |
|---|---|---|
| TTFT p95 &lt; 300 ms | <strong>51 ms</strong> | Pass, 6× headroom |
| Inter-token &lt; 50 ms | <strong>16 ms</strong> (≈62 tok/s) | Pass |
| Zero queueing at 12 concurrent | <strong><code>queue_ms</code> 0.01, <code>waiting</code> 0</strong> at 8; still 0 at 32 | Pass, large margin |
| Error + abort &lt; 0.5% | <strong>0%</strong> | Pass |</p>
<p><strong>The configuration is correct for this department, and the department is over-provisioned rather than under-provisioned.</strong> That's a defensible, evidence-backed answer to "it feels slow" — and it redirects the investigation to the app, the gateway, or the prompt, which is where the problem actually is.</p>
<p>The concrete follow-ups, each tied to a metric rather than a hunch:</p>
<ol>
<li><strong>Stop optimizing prefill.</strong> — <code>vllm:request_decode_time_seconds</code> vs <code>vllm:request_prefill_time_seconds</code>. Decode is 97% of the time against prefill's 2%, confirmed twice. Chunked prefill and prompt compression are off the table for this workload.</li>
<li><strong>If more throughput is needed, quantize before buying hardware.</strong> — <code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE</code> (16.6%) vs <code>DCGM_FI_PROF_DRAM_ACTIVE</code> (80.4%). The bottleneck is memory bandwidth, not compute, so an FP8 or AWQ build of the same model is the highest-leverage single change: it moves fewer bytes per token, which is exactly the constrained resource.</li>
<li><strong>Raise the client-side <code>max_tokens</code> ceiling.</strong> — <code>vllm:request_success_total{finished_reason}</code>. Every request finishing on <code>length</code> rather than <code>stop</code> is a user getting cut off mid-answer. This is the one finding that's a live user-experience defect. You need to increase the prompt max_token limit.</li>
<li><strong>Standardize the prompt prefix.</strong> — <code>vllm:prompt_tokens_cached_total</code> over <code>vllm:prompt_tokens_total</code>, 32% today. But it should be better (more like 70%) More shared boilerplate in a consistent leading position raises it, and it's free.</li>
<li><strong>Set the autoscaling trigger now, before it's needed.</strong> — <code>vllm:kv_cache_usage_perc</code> and <code>vllm:num_requests_waiting</code>. Scale when the first crosses ~60% or the second stays above zero. Do <em>not</em> scale on <code>DCGM_FI_DEV_GPU_UTIL</code> — it's pinned at 100% regardless.</li>
<li><strong>Alert on queue time, not on VRAM.</strong> — <code>vllm:request_queue_time_seconds</code> is the earliest true saturation signal; <code>DCGM_FI_DEV_FB_USED</code> is a constant that looks like an emergency.</li>
<li><strong>Revisit when the workload changes shape.</strong> — <code>vllm:request_generation_tokens</code> over <code>vllm:request_prompt_tokens</code>, 2.04 today. When the RAG feature ships that ratio inverts, the workload becomes prefill-bound, and half of this analysis needs redoing. The chart tells you the day it happens.</li>
</ol>
<p>Notice that most of the metrics are looking at the vLLM metrics not the GPU metrics in helping optimize. These are still within the limit of a single service, but when you get <code>vllm:num_requests_waiting</code> to persistently non-zero, then you need to run KServe or you can use KEDA and HPA autoscaling. But you get the metrics to help you determine or allow KServe to scale. So you can see that understanding these metrics are crucial to tuning the inference service. </p>
<p>Elastic Observability can provide this to you.</p>
<hr />
<h2 id="whyselfhostedllmtuningisansreproblemnotanmlproblem">Why self-hosted LLM tuning is an SRE problem, not an ML problem</h2>
<p><strong>Self-hosting an open-weight model is not primarily an ML problem. It is a capacity and saturation problem</strong> — something SREs have been extremely good at for twenty years. The blocker was never skill. It was that the telemetry sat unexamined on a <code>/metrics</code> endpoint nobody scraped, in a schema nobody had mapped to the questions they actually had.</p>
<p>Once it's collected, the reasoning is familiar work in unfamiliar clothes:</p>
<ul>
<li>Decompose latency by phase before optimizing anything (queue / prefill / decode).</li>
<li>Distinguish the logical resource from the physical one (KV block pool ≠ VRAM), and know which each metric describes.</li>
<li>Never trust a single-source utilization number — corroborate the engine's account with the hardware's.</li>
<li>Tie every knob to a metric and every metric to a stated objective, so tuning converges instead of wandering.</li>
</ul>
<p>For a team that isn't allowed to send its data anywhere, that difference — between running a model and <em>operating</em> one — is the whole ballgame. The department gets a capability it's otherwise locked out of, and the SRE gets to answer questions about it with numbers.</p>
<hr />
<h2 id="frequentlyaskedquestions">Frequently asked questions</h2>
<p><strong>What does <code>vllm:kv_cache_usage_perc</code> measure?</strong>
It measures occupancy of vLLM's pre-allocated KV block pool, not physical GPU memory. vLLM reserves a fraction of VRAM at startup (<code>--gpu-memory-utilization</code>, default 0.9) and carves the KV pool from that reservation. In this deployment it read 2.8% while DCGM reported 94.1% VRAM used on the same card at the same moment. Use it as an autoscaling signal; use VRAM for capacity planning.</p>
<p><strong>Why is my vLLM deployment decode-bound?</strong>
Because the workload generates more tokens than it reads. Compare <code>vllm:request_decode_time_seconds</code> against <code>vllm:request_prefill_time_seconds</code>, and check the generation-to-prompt token ratio. In this deployment the ratio was 2.04 — twice as many output tokens as input — which produced 1,665 ms of decode against 38 ms of prefill. Decode is memory-bandwidth-bound, so quantization, tensor parallelism, or a smaller model help; prefill optimizations do not.</p>
<p><strong>Should I autoscale vLLM on GPU utilization?</strong>
No. <code>DCGM_FI_DEV_GPU_UTIL</code> means a kernel is resident on the device, not that the device is doing useful work — it reads 100% for both a well-tuned and a badly-tuned server. Autoscale on <code>vllm:kv_cache_usage_perc</code> (around 60%) or on <code>vllm:num_requests_waiting</code> staying above zero, since those describe whether the engine can admit another request.</p>
<p><strong>Why does my GPU show 100% utilization when it isn't fully used?</strong>
Because GPU utilization only reports kernel residency. Check the DCGM profiling counters instead: in this deployment <code>DCGM_FI_PROF_GR_ENGINE_ACTIVE</code> was 99.8% while <code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE</code> was only 16.6% and <code>DCGM_FI_PROF_DRAM_ACTIVE</code> was 80.4%. That combination means the GPU is waiting on memory bandwidth rather than computing.</p>
<p><strong>How do I get vLLM metrics into Prometheus?</strong>
vLLM already exposes Prometheus exposition format on <code>/metrics</code> at its serving port — no adapter or instrumentation needed. Point a Prometheus scrape job at the vLLM Service, add a second job for <code>dcgm-exporter</code> on <code>:9400</code>, and use <code>remote_write</code> to ship to long-term storage. Sending through an OpenTelemetry Collector also works but normalizes the metric names, which makes them harder to match against vLLM documentation.</p>
<p><strong>What TTFT should I target for an interactive LLM application?</strong>
For a streaming chat-style interface, a p95 time-to-first-token under 300 ms feels immediate, and inter-token latency under 50 ms (about 20 tokens/sec) outpaces reading speed. This deployment measured 51 ms TTFT and 16 ms inter-token latency on a 3B model on a single NVIDIA A10G, leaving roughly 6× headroom.</p>
<p><strong>Why are all my vLLM requests finishing with <code>length</code>?</strong>
Because they're hitting the <code>max_tokens</code> ceiling instead of the model choosing to stop. Break <code>vllm:request_success_total</code> down by its <code>finished_reason</code> label: a high <code>length</code> share means answers are being truncated mid-sentence. This is invisible in every latency metric, so review the <code>stop</code>-to-<code>length</code> ratio regularly and raise the client-side ceiling if it drifts.</p>
<p><strong>When should I move from a plain vLLM Deployment to KServe or llm-d?</strong>
Move to KServe when <code>vllm:num_requests_waiting</code> is persistently non-zero at peak and you need replicas to appear without human intervention. Move to llm-d when your prefix-cache hit rate collapses across replicas — a sign that load balancing scattered conversations that shared a prefix — or when prefill time starts stealing measurably from decode.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/tune-vllm-prometheus-metrics-elastic</link>
    <guid isPermaLink="false">tune-vllm-prometheus-metrics-elastic</guid>
    <category><![CDATA[Kubernetes]]></category>
    <category><![CDATA[Metrics]]></category>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Bahubali Shetti]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt33f0247e8d048208/6a859acebc5bb37efef81125/header-vllm-tuning.png" length="0" type="image/png"/>
    <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Android application monitoring with OpenTelemetry: distributed tracing from tap to backend]]></title>
    <description><![CDATA[EDOT Android adds mobile APM to your Android app with one Gradle dependency: crash reporting, session tracking and distributed tracing visible in Kibana.]]></description>
    <content:encoded><![CDATA[<p>People are handling more and more matters on their smartphones through mobile apps, both privately and professionally. With thousands or even millions of users, ensuring great performance and reliability is a key challenge for mobile app teams and the backend services they depend on. Understanding real user impact, crash patterns, and the root causes of slow response times is fundamental to managing mobile app quality.</p>
<p>The challenge deepens when something goes wrong. A crash on the device, a slow screen, or an error response might originate in the Android app itself, in a backend service, or somewhere in the network path between them. Debugging these problems without a connected, E2E view from the mobile client to the backend is time-consuming and frustrating. And without a standard instrumentation format, mobile teams often end up maintaining separate tooling that doesn't integrate with what the backend and infrastructure teams already use.</p>
<p><a href="https://opentelemetry.io/">OpenTelemetry</a> offers a way out: a unified, open-standard instrumentation model that works across platforms and languages, backed by a large community. The Elastic Distribution of OpenTelemetry Android, or EDOT Android, is an APM agent for native Android applications built on top of OpenTelemetry. It gives Android teams a practical path to observe mobile app behavior in Elastic, providing them with distributed tracing, crash reporting, session tracking, disk buffering, and automatic instrumentation, with as little code as possible while staying grounded in open standards.</p>
<p>To see what it all looks like, we will instrument a demo Android weather application end to end. You will run Elasticsearch, Kibana, and the Elastic Agent locally. The Elastic Agent provides the OTLP endpoint that receives telemetry from the Android app and backend. You will then generate distributed traces, custom spans, logs, and Android crashes from the app, and explore the results in Kibana using the Android OpenTelemetry dashboards.</p>
<p>This article focuses on a hands-on experiment to explore the E2E experience of observing Android apps with Elastic, using the EDOT Android agent. For more specific details on the EDOT Android agent, such as a list of supported features and a setup guide for your own Android project, take a look at <a href="https://www.elastic.co/docs/reference/opentelemetry/edot-sdks/android">EDOT Android docs</a>.</p>
<h2 id="settingupedotandroidwithelasticsearchandkibana">Setting up EDOT Android with Elasticsearch and Kibana</h2>
<p>We will use the <a href="https://github.com/elastic/android-agent-demo">EDOT Android demo application</a>. The demo is intentionally small but covers the main workflows you need when evaluating mobile observability with Elastic.</p>
<p>The demo has two main components: an <strong>Android app</strong>, and a <strong>Spring Boot backend</strong>. Additionally, you'll need an <strong>Elastic Stack</strong> environment up and running; we'll explain more about how to get one later in this guide.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd2e9cece7b3e7427/6a85cd5c18249cdfba18f809/blog-demo-project-components.png" alt="Demo app components" /></p>
<h3 id="prerequisites">Prerequisites</h3>
<ul>
<li>Java 17 or higher.</li>
<li><a href="https://www.docker.com/">Docker</a>.</li>
<li><a href="https://developer.android.com/studio">Android Studio</a>.</li>
<li>An <a href="https://developer.android.com/studio/run/emulator">Android emulator</a>.</li>
<li>On Windows, use <a href="https://learn.microsoft.com/en-us/windows/wsl/install">Windows Subsystem for Linux (WSL)</a> to run the demo scripts.</li>
</ul>
<h3 id="step1clonethedemoappsrepository">Step 1: Clone the demo app's repository</h3>
<p>We'll start by cloning the <a href="https://github.com/elastic/android-agent-demo">EDOT Android demo application</a>:</p>
<pre><code>git clone git@github.com:elastic/android-agent-demo.git
</code></pre>
<h3 id="step2starttheelasticstack">Step 2: Start the Elastic Stack</h3>
<p>The demo uses <a href="https://github.com/elastic/start-local/">start-local</a> to run Elasticsearch, Kibana, and the <a href="https://www.elastic.co/docs/reference/fleet/elastic-agent-as-otel-collector">Elastic Agent</a> with a single command. In this setup, the Elastic Agent provides the OTLP endpoint that receives telemetry from the application and backend. Run this from the directory where you want the local Elastic files to be created:</p>
<pre><code>curl -fsSL https://elastic.co/start-local | sh -s -- --edot
</code></pre>
<p>For more information on this step, take a look at the <a href="https://github.com/elastic/android-agent-demo#step-1-setting-up-elasticsearch-kibana-and-the-elastic-agent">demo app's instructions</a>.</p>
<h3 id="step3startthelocalbackend">Step 3: Start the local backend</h3>
<p>The demo backend is a Spring Boot service instrumented with the <a href="https://github.com/elastic/elastic-otel-java/">EDOT Java agent</a>. It handles the app's weather requests and calls the <a href="https://open-meteo.com/">Open-Meteo</a> public API for weather data.</p>
<pre><code>./backend-manager start
</code></pre>
<p>For more information on managing the backend service, take a look at the <a href="https://github.com/elastic/android-agent-demo#step-2-launching-the-backend-service">demo app's instructions</a>.</p>
<h3 id="step4launchtheandroidapplication">Step 4: Launch the Android application</h3>
<p>Use <a href="https://developer.android.com/studio/intro">Android Studio</a> to open up the <a href="https://github.com/elastic/android-agent-demo">EDOT Android demo application</a> repo and run the application in your emulator. More info on how to run Android apps from Android Studio <a href="https://developer.android.com/studio/run">here</a>.</p>
<h2 id="generatingdistributedtraceserrorsandcrashesfromanandroidapp">Generating distributed traces, errors and crashes from an Android app</h2>
<p>The Android app has two screens: a city selector and a weather display screen that shows the current weather for the selected city on the previous screen. It includes two intentional failure paths: the first one is reached by selecting <strong>New York</strong>, which causes the backend to reject the request (the demo backend only supports European cities), and tapping the floating crash button intentionally crashes the app so you can review crash reporting in Kibana after relaunch. We'll take a look at those use cases in more detail below.</p>
<h3 id="tracingasuccessfulrequestendtoend">Tracing a successful request end to end</h3>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb358eb3f6e58dbf0/6a85cd5f43c0b77b1e2f066c/blog-android-app-selecting-paris.png" alt="Selecting Paris" /></p>
<p>In the EDOT Android demo app, selecting "Paris" as the city triggers a successful backend request on the second screen, for which a span will be automatically generated using EDOT Android's <a href="https://www.elastic.co/docs/reference/opentelemetry/edot-sdks/android/automatic-instrumentation#okhttp">OkHttp auto-instrumentation</a>, which supports all OkHttp-generated HTTP requests and tools using it, such as Retrofit. Aside from the Android HTTP span, the successful city request continues e2e and creates a backend HTTP client span to Open-Meteo.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0ed067b86aa2fba2/6a85cd61eaf24566fea49f99/blog-trace-waterfall-view.png" alt="Trace waterfall" /></p>
<h3 id="howbackenderrorsappearintheandroiddistributedtrace">How backend errors appear in the Android distributed trace</h3>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5f4b20a6e5ab1e55/6a85cd65abdc29c09f122542/blog-android-app-selecting-new-york.png" alt="Selecting New York" /></p>
<p>The demo backend only supports European cities, so selecting "New York" causes it to fail, which in turn automatically creates an error associated with our Android app's HTTP span. This is done automatically. We'll see later how to find and inspect these issues from Kibana.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte19bebcdcb1c6b55/6a85cd67abdc295b7d12254a/blog-error-trace-waterfall-view.png" alt="Error trace waterfall" /></p>
<h3 id="howedotandroidcapturesandreportsappcrashes">How EDOT Android captures and reports app crashes</h3>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2075b1b23db04111/6a85cd6b33f244a7ec49f557/blog-android-app-selecting-crash.png" alt="Application crash" /></p>
<p>The crash button creates a crash event that appears in Kibana after the app is reopened. This event contains session information that will help us narrow down its root cause from Kibana, as we'll see later.</p>
<p>Note: EDOT Android automatically attaches Android session context to spans and logs. That means that any span or log created before the crash can be reviewed together with the crash event and nearby spans from the same session, giving you a complete picture of what the user was doing. This even applies to <a href="https://www.elastic.co/docs/reference/opentelemetry/edot-sdks/android/manual-instrumentation">manually created spans and logs</a>.</p>
<h2 id="visualizingandroidapplicationmonitoringdatainkibana">Visualizing Android application monitoring data in Kibana</h2>
<p>To see the whole story from our Android app in a single place, we'll install Kibana's <a href="https://www.elastic.co/docs/reference/integrations/otel_android_dashboards">Android OpenTelemetry Assets</a> package by following the steps below.</p>
<ol>
<li>In Kibana, search for "Android OpenTelemetry Assets" in the <a href="https://www.elastic.co/docs/explore-analyze/find-and-organize/find-apps-and-objects">global search field</a>.</li>
<li>Open it and click <strong>Install</strong> to add the Android dashboards to your Kibana instance.</li>
</ol>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte095f0fc9464311c/6a85cd6d5c2790e893f59b59/blog-content-pack-search.png" alt="Searching content pack" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1c2ec15025a351d8/6a85cd70682666f73c1eac55/blog-content-pack-install.png" alt="Installing content pack" /></p>
<h3 id="exploringandroidapplicationmonitoringdashboardsinkibana">Exploring Android application monitoring dashboards in Kibana</h3>
<p>Once the content package is installed, open the [Android OTel] Application Overview dashboard:</p>
<ol>
<li>In Kibana, search for Dashboards in the <a href="https://www.elastic.co/docs/explore-analyze/find-and-organize/find-apps-and-objects">global search field</a> or in Kibana's menu.</li>
<li>In Dashboards, search for Android OTel and open the "[Android OTel] Application Overview" dashboard.</li>
<li>Select your application from the Applications panel at the top of the dashboard.</li>
</ol>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt01e558c291c033e4/6a85cd735c2790eef2f59b5d/blog-dashboard-list.png" alt="Dashboard list" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7cd63db3287bbe1c/6a85cd7618249c7e3918f817/blog-dashboard-android-overview.png" alt="Android overview dashboard" /></p>
<p>The dashboard provides a set of metric panels for an overview of your app's health, performance, and RUM, as well as a set of panels that can be further explored either in Discover or the Exception dashboard, as explained below.</p>
<h2 id="howtoinspectthedistributedtracingwaterfallinkibana">How to inspect the distributed tracing waterfall in Kibana</h2>
<p>From the Application Overview dashboard, go to one of the span tables (either <strong>All spans</strong> or <strong>Failed spans</strong>) and click its <strong>Explore in Discover</strong> button.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt583214104943a381/6a85cd7811893c26cea7abc6/blog-dashboard-android-overview-explore-spans.png" alt="Explore spans" /></p>
<p>In Discover, click the expand icon on the left side of any span row to open its details panel. The trace waterfall UI appears inside, showing the full span hierarchy and timing for that trace. You can expand the waterfall to full screen and drill down from there.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdfb4a277fa1ca84a/6a85cd7b0782902c153217be/blog-discover-span-dialog-open.png" alt="Discover open span dialog" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt763fe1ec0c488c93/6a85cd7d80984c6d40669020/blog-discover-span-dialog-view.png" alt="Discover span dialog" /></p>
<h3 id="analyzingfailedspansandbackenderrorsinkibana">Analyzing failed spans and backend errors in Kibana</h3>
<p>While you can find all kinds of spans in the dashboard's <strong>All spans</strong> panel, you can narrow them down to failed ones only by exploring the <strong>Failed spans</strong> panel instead.</p>
<p>For the New York path, find a failed span and expand it. The trace waterfall highlights the backend error, and the exception details show the intentional backend rule that only supports European cities.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd3515f83ee7c1ce3/6a85cd8018249c1b6b18f81b/blog-dashboard-android-overview-explore-failed-spans.png" alt="Explore failed spans" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8075553525ac7f75/6a85cd834710c61975d3cbb6/blog-discover-failed-span-dialog-open.png" alt="Discover failed span dialog open" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte5dcfeb3df0e6c34/6a85cd86f5f1a06eb82ec95f/blog-discover-failed-span-dialog.png" alt="Discover failed span dialog" /></p>
<h2 id="reviewingcrashdetailsandstacktracesintheexceptiondashboard">Reviewing crash details and stacktraces in the exception dashboard</h2>
<p>Crash reporting is provided by the <a href="https://www.elastic.co/docs/reference/opentelemetry/edot-sdks/android/automatic-instrumentation#crash-reporting">crash automatic instrumentation plugin</a>. When an unhandled exception crashes the app, EDOT Android stores the crash event on disk. The event is exported the next time the app starts. Disk buffering ensures the crash event is not lost even if the network was unavailable at the time of the crash.</p>
<p>In the Application Overview dashboard, scroll to the <strong>Crashes</strong> section. You will see crash groups listed by a computed stacktrace group ID. Select a group and click <strong>View crash details</strong> to open the Exception Details dashboard.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5facd8c29dd053d6/6a85cd8927c5cdb9ee5f7444/blog-dashboard-android-overview-crash-list.png" alt="Crash list" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0be9ec5b582ea0be/6a85cd8c9d2b71fe05f939fc/blog-dashboard-android-overview-crash-view-details.png" alt="View crash details" /></p>
<p>The Exception Details dashboard shows a set of metrics to better understand the impact of the selected crash, as well as its full stacktrace. Crash events are grouped based on their stacktrace, which helps ensure that the same crash is counted and aggregated in this dashboard to better understand a single crash's impact on your application.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt36c7aefc29888240/6a85cd8f1aa1e11536ff8dab/blog-dashboard-android-exceptions.png" alt="Exception dashboard view" /></p>
<p>For this demo, the stacktrace points to the intentional crash in <code>MainActivity</code>. The nearby session events should also include the custom <code>Crash button click</code> log created just before the crash, which helps explain how the crash was triggered. We'll take a look at how to inspect a session to get an idea of the user's journey within your application that led them to a crash.</p>
<h2 id="usingsessionstounderstanduserflowinedotandroid">Using sessions to understand user flow in EDOT Android</h2>
<p>Mobile troubleshooting often starts with a single bad outcome (a crash, an error, a slow UX), but the useful question is what happened before that outcome. EDOT Android helps answer that by attaching <code>session.id</code> to every span and log emitted by the application, even for manually created ones.</p>
<p>A session is meant to cover a single user interaction with your application. A new one is created when there is no previous active session or when the previous session has expired. Sessions expire after 30 minutes of inactivity. If the app stays active, a session can last up to 4 hours.</p>
<p>This lets you query all the telemetry from a single session and review it in order. After finding a crash group, drill into one affected session from the <strong>Top affected sessions</strong> panel and review the event timeline. You can see the custom logs, app startup spans, HTTP request spans, and crash data together in one investigation path.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4afad0b2945a3c77/6a85cd92f61d6ea4129c2b6d/blog-dashboard-android-exceptions-view-session-details.png" alt="Exception dashboard view session details" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt239ba5bb7095356b/6a85cd9418249c055218f81f/blog-dashboard-android-overview-with-session-filter.png" alt="Overview dashboard with session filter" /></p>
<p>The <strong>Event timeline</strong> panel on the Application Overview dashboard is also useful here: select a session from the dashboard's top filters, and the timeline shows the full sequence of spans and logs in that session chronologically.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt13d994d2109d0253/6a85cd978c2944d923b8909f/blog-dashboard-android-overview-event-timeline.png" alt="Event timeline with session filter" /></p>
<h2 id="whatedotandroidaddsasamobileapmforproductionapps">What EDOT Android adds as a mobile APM for production apps</h2>
<p>The demo uses a local stack and simple code, but the same agent features apply to production apps.</p>
<p><strong>Disk buffering</strong> stores telemetry locally before export. This reduces data loss when the device has poor connectivity or the app is temporarily offline.</p>
<p><strong>Automatic instrumentation</strong> creates telemetry for supported targets without adding code around every call. Today that includes OkHttp, crash reporting, and an adapter for <a href="https://github.com/open-telemetry/opentelemetry-android">OpenTelemetry Android</a> instrumentation.</p>
<p><strong>Manual instrumentation</strong> lets you add spans, logs, and metrics for app-specific workflows. This is useful for screen loading times, checkout flows, login steps, feature usage, or any area where framework-level telemetry alone is not enough.</p>
<p><strong>Central configuration</strong> can remotely adjust selected EDOT Android behavior through Kibana when the OpAMP endpoint is configured. At the time of writing, central configuration for EDOT Android is in preview and supports settings such as recording and session sample rate.</p>
<p><strong>Distributed tracing</strong> connects Android app requests to backend service spans so you can trace the full path of any user action, from the tap on the screen to the database query on the server. EDOT Android ensures that your application's telemetry timestamps are in sync with the <a href="https://en.wikipedia.org/wiki/Coordinated_Universal_Time">coordinated universal time</a>. This ensures a proper trace waterfall hierarchy later on in Kibana, where different components are properly coordinated in time.</p>
<h2 id="cleanupthedemo">Clean up the demo</h2>
<p>When you are finished, stop the backend in case you're planning to restart it later, or uninstall it otherwise:</p>
<pre><code>./backend-manager stop
# ./backend-manager uninstall
</code></pre>
<p>Then stop or uninstall the local Elastic Stack:</p>
<pre><code>cd elastic-start-local
./stop.sh
# ./uninstall.sh
</code></pre>
<h2 id="gettingstartedwithedotandroidinyourownapp">Getting started with EDOT Android in your own app</h2>
<p>EDOT Android gives native Android teams an OpenTelemetry-based path for mobile APM in Elastic. With a small Gradle setup and one early initialization call, you get automatic HTTP spans, crash reporting, session tracking, and direct access to the OpenTelemetry SDK for custom telemetry, and you can see it all tied together in Kibana's Android dashboards.</p>
<p>Observability is a crucial part of modern mobile development. Crashes, slow screens, and backend errors all impact real users, and the sooner you can identify root causes across the full request path, from the device to the database, the better. The demo app is a good first step because it exercises the complete workflow without requiring a production deployment. After that, the same setup model applies to your own app with production endpoints, API key authentication, and custom spans and logs tailored to your use cases.</p>
<p>Developer resources:</p>
<ul>
<li><a href="https://github.com/elastic/android-agent-demo">EDOT Android demo application</a></li>
<li><a href="https://www.elastic.co/docs/reference/opentelemetry/edot-sdks/android">EDOT Android documentation</a></li>
<li><a href="https://www.elastic.co/docs/reference/opentelemetry/edot-sdks/android/getting-started">EDOT Android getting started guide</a></li>
<li><a href="https://www.elastic.co/docs/reference/opentelemetry/edot-sdks/android/automatic-instrumentation">EDOT Android automatic instrumentation</a></li>
<li><a href="https://www.elastic.co/docs/reference/opentelemetry/edot-sdks/android/manual-instrumentation">EDOT Android manual instrumentation</a></li>
<li><a href="https://www.elastic.co/docs/reference/opentelemetry/edot-sdks/android/configuration">EDOT Android configuration</a></li>
<li><a href="https://www.elastic.co/docs/troubleshoot/ingest/opentelemetry/edot-sdks/android">EDOT Android troubleshooting</a> </li>
<li><a href="https://www.elastic.co/docs/reference/integrations/otel_android_dashboards">Android OpenTelemetry Assets dashboard docs</a></li>
</ul>
<p>Don't have an Elastic Cloud account yet? <a href="https://cloud.elastic.co/registration">Sign up for Elastic Cloud</a> and try out mobile observability with EDOT Android as described in this guide. We'd love to hear about your experience gaining visibility into your Android application stack with Elastic.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/distributed-tracing-android-mobile-apm-opentelemetry</link>
    <guid isPermaLink="false">distributed-tracing-android-mobile-apm-opentelemetry</guid>
    <category><![CDATA[APM]]></category>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Cesar Munoz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9a224bedfc4c93ec/6a85cd9a682666089e1eac5f/header-image.png" length="0" type="image/png"/>
    <pubDate>Wed, 12 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[CrashLoopBackOff to root cause in seconds: automating the 20-minute Kubernetes investigation with Elastic Observability]]></title>
    <description><![CDATA[Elastic's Kubernetes Experience fires alongside the CrashLoopBackOff alert and delivers a root-cause hypothesis with evidence before you even open it.]]></description>
    <content:encoded><![CDATA[<p>It's the middle of your on-call rotation and your phone buzzes. <code>CrashLoopBackOff</code>. A pod is stuck in a restart cycle, and now the clock is running.</p>
<p>If you've been an SRE for any length of time, you know what usually comes next. You acknowledge the page, open your observability tool, and start the <em>process</em>: pull up the cluster dashboard, find the namespace, find the pod, check restart counts, pivot to logs, check whether an upstream dependency is degraded, compare against last week, and slowly assemble a picture from a dozen tabs. It works, but it's a process, and the process is where the minutes go.</p>
<p>Elastic's new Kubernetes Experience changes the starting point. When the CrashLoopBackOff alert fires, an <strong>Investigation Workflow runs automatically alongside it</strong>. By the time you open the alert, the evidence has already been gathered and a root-cause hypothesis is waiting for you. Instead of a blank dashboard, you open the alert to an answer. Or at minimum, a strong starting point that tells you exactly where to look next.</p>
<p>This post walks through a typical CrashLoopBackOff scenario end to end. The sections that follow break down what Elastic's UI shows at each step and why it saves you time.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt295cd19d441aab2c/6a7f04823ce8e25835cf502e/workflow-root-cause-summary.png" alt="Elastic Workflows synthesize step" /></p>
<h2 id="themanualcrashloopbackoffinvestigationsixstepseverysreruns">The manual CrashLoopBackOff investigation: six steps every SRE runs</h2>
<p>Here's the shape of a normal CrashLoopBackOff investigation, minus Elastic's workflow:</p>
<ol>
<li><strong>You get paged.</strong> Something restarted too many times.</li>
<li><strong>You orient.</strong> Which pod? Which namespace? Which deployment owns it?</li>
<li><strong>You characterize.</strong> How many restarts? What was the last termination reason — OOMKilled, a failed liveness probe, a bad exit code?</li>
<li><strong>You classify.</strong> Is this a memory problem, a config problem, a dependency problem, a scheduling problem?</li>
<li><strong>You corroborate.</strong> Pull Kubernetes events, read the pod logs, check whether an upstream service started erroring first, compare current behavior against a healthy baseline.</li>
<li><strong>You conclude.</strong> Only now can you form a hypothesis and act.</li>
</ol>
<p>Every one of those steps is a query, a click, or a context switch. None of them is hard on its own. Together, on a bad night, they're twenty minutes you don't have and they're twenty minutes of <em>the same steps you ran during the last incident, and the one before that</em>.</p>
<p>The insight behind Elastic's Kubernetes Experience is simple: <strong>that sequence is deterministic enough to automate.</strong> So Elastic automated it.</p>
<hr />
<h2 id="alerttorootcauseinminutes">Alert to Root cause in minutes</h2>
<p>Elastic's Kubernetes integration ships pre-built alert rule templates for states that are wrong by definition; no baseline or warmup required. A pod in CrashLoopBackOff is <em>always</em> a problem, so the rule fires the moment the restart count crosses your configured threshold within a rolling window.</p>
<p>Here's the alert firing in this scenario, both the <code>[Kubernetes OTel] Pod CrashLoopBackOff</code> and <code>OOMKilled containers</code> rules light up for the <code>recommendation</code> group in the <code>otel-demo</code> namespace:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8969107211092a4b/6a7f04852f00b25aefefe7e1/crashloopbackoff-alert-list.png" alt="Elastic Alerts page" /></p>
<p>The alert itself is defined by an ES|QL query, so it's transparent and tunable; you can read exactly what triggers it and adjust the threshold to match your environment. And its <strong>action</strong> is what makes the rest of this post possible: the rule runs the <code>K8s CrashLoopBackOff Investigation (OTel)</code> workflow <em>per alert</em>, the instant a new alert fires. (For a refresher on the alert library and how these templates work, see <a href="https://www.elastic.co/observability-labs/blog/kubernetes-dashboards-alerts-anomaly-detection#alert-rules-that-fire-on-day-one">Part 1</a>.)</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc30b604aa6b6931a/6a7f0488e88c65ccfb00b2b6/alert-config.png" alt="CrashLoopBackOff alert rule configuration in Elastic" /></p>
<p>In this scenario the rule fires on the <code>recommendation</code> pod (<code>recommendation-788dc88c6c-56w74</code>) in the <code>otel-demo</code> namespace. When you open the alert, Elastic's AI Agent is already summarizing what happened; no rule-details spelunking required:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte1d391aa18ae2850/6a7f048b73d9bd94f229d790/alert-detail-ai-summary.png" alt="CrashLoopBackOff alert detail" /></p>
<p>But the alert firing is only half the story. Attached to it is a <strong>Kubernetes Investigation Workflow</strong> (technical preview), a directed graph of steps that triggers the instant the alert does. While your phone is still buzzing, the workflow is already querying your cluster, branching on what it finds, and synthesizing an answer.</p>
<hr />
<h2 id="insidetheinvestigationwhattheworkflowactuallydoes">Inside the investigation: what the workflow actually does</h2>
<p>The workflow mirrors the exact sequence an experienced SRE would run by hand, except it runs in seconds and writes nothing down that it can't back up with evidence. For our CrashLoopBackOff scenario, here's the path it takes.</p>
<h3 id="step1whatdoestheworkflowcheckfirstonthecrashingpod">Step 1: What does the workflow check first on the crashing pod?</h3>
<p>The workflow queries Kubernetes metrics for the restart count, the last termination reason, and utilization against the pod's declared limits.</p>
<p><strong>Result:</strong> last termination reason <code>OOMKilled</code>, restart count <code>7</code>. Memory utilization data happened to be unavailable at query time, but the <code>OOMKilled</code> reason is definitive: the container is being killed by the kernel for exceeding its memory limit on each startup, then immediately restarting.</p>
<p>The <code>OOMKilled</code> termination reason determines everything that follows. The workflow branches down the <strong>memory-investigation path</strong> rather than the log-investigation path it would take for a non-memory crash.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt17a7d46eeae29ded/6a7f048f227b1c51c7598272/workflow-step-characterize-oomkilled.png" alt="Elastic Workflows execution" /></p>
<h3 id="step2howdoestheworkflowtellamemoryleakfromaloadspike">Step 2: How does the workflow tell a memory leak from a load spike?</h3>
<p>The ML anomaly check is the step that separates a good investigation from a fast-but-wrong one. <code>OOMKilled</code> does <strong>not</strong> automatically mean "memory leak." Rather than recompute memory trends from scratch, the workflow queries the ML anomaly index for an active <code>k8s_pod_memory_growth</code> anomaly on this pod.</p>
<p><strong>Result:</strong> no anomaly. The memory spike is flagged as <strong>load-driven, not a suspected leak.</strong> The ML baseline — established over the preceding days — didn't see the slow, creeping growth trajectory that characterizes a leak. It saw a jump consistent with real traffic.</p>
<p>Distinguishing a load-driven memory spike from a genuine leak would take a human several minutes and a good deal of judgment. The workflow reaches it because Part 1's <a href="https://www.elastic.co/observability-labs/blog/kubernetes-dashboards-alerts-anomaly-detection#anomaly-detection-jobs-with-ml-baselines">anomaly detection jobs</a> were already learning the workload's baseline in the background.</p>
<h3 id="step3isthefailurespreadingtootherkubernetesservices">Step 3: Is the failure spreading to other Kubernetes services?</h3>
<p>A crashing pod is often a <em>symptom</em>, not a cause, and it can also <em>cause</em> problems downstream. So the workflow enumerates the pod's dependencies from APM <code>service_destination</code> aggregates and compares the current error rate and latency against baseline. An AI classification step decides whether the failure is spreading.</p>
<p><strong>Result:</strong> the sole direct caller is the <code>frontend</code> service, which is absorbing the impact with just a <strong>0.14% error rate</strong> against the recommendation service  and no other service exceeds its degradation threshold relative to baseline. The <strong>blast radius is isolated to the recommendation service</strong>; there's no significant downstream cascade. The problem is local to this pod.</p>
<h3 id="step4didarecentchangeinthenamespacecausethecrashloop">Step 4: Did a recent change in the namespace cause the crash loop?</h3>
<p>Finally, the workflow scans the namespace event log. It finds a continuous <code>Pulled → Created → Started → Killing → BackOff</code> cycle running from roughly <strong>18:51 to 18:54 UTC</strong>, the textbook signature of an active crash loop at the time the alert fired. Nothing changed operationally; this is a steady-state resource problem.</p>
<h3 id="whatthecrashloopbackoffrootcausehypothesislookslike">What the CrashLoopBackOff root-cause hypothesis looks like</h3>
<p>When you open the alert, this is what greets you:</p>
<pre><code>ROOT CAUSE HYPOTHESIS (confidence: high)

The recommendation service pod (recommendation-788dc88c6c-56w74) is in a
crash-loop caused by repeated OOMKilled terminations. The pod has restarted
7 times and Kubernetes events confirm a continuous BackOff/restart cycle
since at least 18:51 UTC. Memory utilization data was unavailable at query
time, but the OOMKilled termination reason is definitive: the container is
exceeding its configured memory limit on each startup, being killed by the
kernel, and immediately restarting. No memory leak was detected by ML
anomaly analysis, indicating the memory pressure is load-driven — the
container's memory limit is simply insufficient for the current request
volume. The frontend service (the sole direct caller) is absorbing the
impact with a 0.14% error rate on the recommendation service itself, but
no significant downstream cascade is observed.

EVIDENCE
- Pod restarted 7 times; last termination reason: OOMKilled — container is
  consistently exceeding its memory limit
- ML memory anomaly check: no anomaly found; memory spike assessed as
  load-driven, not a leak
- Blast radius is isolated to the recommendation service; no other service
  exceeds degradation thresholds relative to baseline
- Continuous BackOff events from 18:51–18:54 UTC confirm active crash-loop
  at alert time

PROBABLE CAUSE: The recommendation container's memory limit is too low for
current traffic load, causing repeated OOMKilled terminations and a
crash-loop backoff.

RECOMMENDED NEXT STEPS
1. Immediately increase the memory limit (and request) for the
   recommendation container in its Deployment spec to provide headroom
   above the observed peak usage, then redeploy to break the crash-loop.
2. Profile the recommendation service under representative load to
   determine the actual memory working set and set a right-sized limit
   with a safe buffer (e.g., 20–30% above peak observed).
3. Add a Kubernetes HorizontalPodAutoscaler or VPA policy for the
   recommendation service so memory resources scale with traffic rather
   than requiring manual intervention.
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt295cd19d441aab2c/6a7f04823ce8e25835cf502e/workflow-root-cause-summary.png" alt="Elastic Workflows synthesize step" /></p>
<p>Read that again from the perspective of the on-call engineer. You were paged. You opened the alert. And the alert didn't hand you a pile of logs and a dashboard; it handed you a <strong>calibrated root-cause hypothesis with the evidence attached and the next actions spelled out.</strong> The six manual steps from the "old way" are done. Your job is now to <em>decide</em>, not to <em>dig</em>.</p>
<p>That's the time savings: not shaving a few seconds off each query, but removing the entire investigative scavenger hunt from the critical path.</p>
<hr />
<h2 id="howdoeselasticobservabilityavoidmisdiagnosingoomkilledasamemoryleak">How does Elastic Observability avoid misdiagnosing OOMKilled as a memory leak?</h2>
<p>Speed is worthless if the answer is wrong, so it's worth noting <em>how</em> the workflow avoids the classic misdiagnoses. It encodes the reasoning an experienced SRE applies instinctively:</p>
<ul>
<li><strong><code>OOMKilled</code> is not automatically a leak.</strong> It compares against a 7-day baseline before ever claiming one. Here, that check is what turned "the app is leaking memory" into the correct "the limit is undersized for real load."</li>
<li><strong>Co-symptoms are not causes.</strong> It explicitly checks whether the upstream degraded <em>first</em> before blaming or clearing it.</li>
<li><strong>Absence of evidence is not evidence.</strong> If a query returns zero rows, it reports "no data available" rather than inventing a failure mode.</li>
<li><strong>It's honest about confidence.</strong> The hypothesis is labeled <code>high</code>, <code>medium</code>, or <code>low</code>. When two failure modes fit the evidence, the workflow names both and says which it believes is causal and why. Manufacturing false confidence is treated as a failure of the investigation itself.</li>
</ul>
<p>This is the same diagnostic protocol encoded in Elastic's <code>observability-k8s-investigation</code> Skill,  a failure-mode taxonomy covering 16 distinct Kubernetes failure patterns, from OOMKilled and CPU throttling through scheduling and networking issues. (More on that in <a href="https://www.elastic.co/observability-labs/blog/ai-powered-kubernetes-observability-elastic-mcp#observability-skill-for-kubernetes-investigations">Part 2</a>.)</p>
<hr />
<h2 id="stillwanttodoublecheckdashboardsdiscoverandapm">Still want to double-check? Dashboards, Discover, and APM</h2>
<p>The workflow gives you the answer. But a good root-cause tool should also make it trivial to <em>verify</em> that answer because sometimes you want to see it with your own eyes, and sometimes the workflow returns <code>medium</code> confidence and you need to close the gap yourself. Everything the workflow reasoned over is available to you directly.</p>
<p><strong>Dashboards — confirm the restart cascade visually.</strong> The Kubernetes dashboards are built for drill-down. Start at the cluster <strong>Overview</strong>, where "top namespaces by container restarts" surfaces the problem at a glance. Click into the flagged namespace, then the pod driving the restarts. The <strong>Pods</strong> view flags container restarts on the <code>recommendation</code> pod and plots memory against requests and limits over time — you'll see the working set pressing against the limit exactly as the workflow described. It's roughly four clicks from cluster to container.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta7c8b428c0dcd661/6a7f04926693f80ed8663bc1/dashboard-overview.png" alt="Kubernetes OTel Overview dashboard" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltad1fe324b28c4f88/6a7f049696b5a694cd87b0c1/dashboard-overview-part2.png" alt="Kubernetes OTel Overview dashboard, Namespaces section" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf90ad0e667f86b70/6a7f0499ea068d7e4af09ae3/dashboard-pod-detail-restarts.png" alt="Kubernetes Pods dashboard in Elastic" /></p>
<p><strong>Discover — read the raw evidence.</strong> The pod detail dashboard links directly to correlated pod logs and events in Discover. Here you can confirm the <code>OOMKilled</code> events and the restart cadence in the raw log and event stream, and run your own ES|QL queries if you want to slice the data differently.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt870c24a37ac11428/6a7f049cc2e914ef5d016836/discover-backoff-events.png" alt="Discover in Elastic running an ES|QL query over Kubernetes events" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbfa295a9478e4883/6a7f04a0ead8ecac6bbaa485/discover-oomkilled-utilization.png" alt="Discover in Elastic running a TS ES|QL query" /></p>
<p><strong>APM — verify the blast radius is really contained.</strong> The workflow found the impact isolated to the recommendation service, with <code>frontend</code> (the sole caller) absorbing it at a 0.14% error rate. You can confirm that independently in the APM UI: open the service map, check the caller's latency and error rate over the incident window, and compare against the weekly baseline yourself.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt06e97005715181a0/6a7f04a3448e4e1e6e5c035e/apm-frontend-blast-radius.gif" alt="Animated walkthrough of the APM UI" /></p>
<p>The point isn't that you <em>have</em> to do any of this; it's that the workflow's conclusion is fully auditable. Fast when you trust it, transparent when you want to check.</p>
<hr />
<h2 id="thesameinvestigationfromyouridethemcpapp">The same investigation from your IDE: the MCP App</h2>
<p>Not every investigation starts from a Kibana alert. Sometimes a developer just asks, "why is this service crashing?" from their editor. Elastic's <strong>Observability MCP App</strong> (technical preview) exposes the same telemetry (and the same investigation workflow) as AI-callable tools that render interactive views <strong>inline in your chat or IDE</strong>, no context switch to Kibana required.</p>
<p>For our CrashLoopBackOff scenario, the flow looks like this from an MCP-compatible client such as Claude Desktop or VS Code:</p>
<p><strong>"What's broken?"</strong> → the cluster health rollup returns an overall health badge, degraded services, top memory consumers, and a Kubernetes breakdown (CPU, memory, restarts, nodes) in one inline view. Here it flags a critical cluster with <code>payment</code>, <code>cart</code>, <code>frontend</code>, and <code>frontend-proxy</code> degraded.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta1fe86e1a29f6b1b/6a7f04a6e02fac45915d620d/mcp-app-cluster-health.png" alt="Elastic Observability MCP App rendering a cluster health" /></p>
<p><strong>"Is anything anomalous in the recommendation pod?"</strong> → the memory analysis view confirms the spike is load-driven, not a leak — the crashing <code>788dc88c6c</code> pods report <em>null</em> memory (they die too fast to emit a sample) while the healthy pods sit flat at 45MB, exactly the ML result the workflow used.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta4f90704b7c542f6/6a7f04aac2cc09230824918e/mcp-app-memory-analysis.png" alt="Elastic Observability MCP App rendering an inline memory analysis for the recommendation pods" /></p>
<p><strong>"Why is the recommendation service crashing?"</strong> → the agent returns the <strong>same structured root-cause reasoning</strong> you'd see on the alert, rendered inline: memory limit set below what the container needs to boot, the ReplicaSet has been intermittently OOMing for weeks, and a concrete mitigation (roll back, then fix the limit), all without leaving the editor.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3817791e4172bef8/6a7f04ad3cab1c477a0e44b4/mcp-app-root-cause.png" alt="Elastic Observability MCP App rendering the inline root-cause answer" /></p>
<p>Same evidence, same root cause, delivered wherever you happen to be working. (For the full set of MCP App views and architecture, see <a href="https://www.elastic.co/observability-labs/blog/ai-powered-kubernetes-observability-elastic-mcp#observability-mcp-app-that-renders-where-you-work">Part 2</a>.)</p>
<hr />
<h2 id="frompagedtodecidedskippingthekubernetesinvestigationentirely">From paged to decided: skipping the Kubernetes investigation entirely</h2>
<p>The change here is not "a better dashboard." It's a shift in <em>what you do when you get paged.</em></p>
<p>Before, the alert was the <strong>start</strong> of the investigation. You were notified, and then you went and found the answer. Now, the alert arrives <strong>with</strong> the investigation already run: evidence gathered, dead ends eliminated, a calibrated hypothesis, and next steps in hand. You go straight from "notified" to "deciding," and you keep the full trail of dashboards, Discover, and APM for whenever you want to verify or dig deeper.</p>
<p>For a single incident that's a few minutes saved. Across a quarter of on-call rotations, across every engineer who no longer re-runs the same six steps at 3 a.m., it's real time back and a lot less alert fatigue.</p>
<hr />
<h2 id="tryityourself">Try it yourself</h2>
<p>You don't need a production incident to see this. The <strong>OpenTelemetry Astronomy Shop</strong> demo environment ships with a feature-flag service that lets you trigger failure scenarios on demand. Enable a cart/checkout failure, watch the restart cascade unfold, and the CrashLoopBackOff alert rule fires with the investigation workflow running right behind it.</p>
<p>To get set up:</p>
<ol>
<li><strong>Install the Kubernetes integration</strong> — dashboards are available immediately. (<a href="https://www.elastic.co/observability-labs/blog/kubernetes-dashboards-alerts-anomaly-detection#getting-started">Part 1: Getting started</a>)</li>
<li><strong>Deploy data collection</strong> via the EDOT Collector (OpenTelemetry) or standalone Elastic Agent, both Helm-based.</li>
<li><strong>Enable the alert rule templates</strong> in Observability &gt; Alerts, including CrashLoopBackOff, and connect your notification channel.</li>
<li><strong>Let the ML modules warm up</strong> for 24–48 hours so anomaly baselines are ready when you need them.</li>
<li><strong>Enable the Investigation Workflow</strong> (technical preview) — import the Kubernetes Crashloop Investigation Workflow from the Workflows page and configure it to trigger on the alert. (<a href="https://www.elastic.co/observability-labs/blog/ai-powered-kubernetes-observability-elastic-mcp#getting-started">Part 2: Getting started</a>)</li>
<li><strong>Install the MCP App</strong> (technical preview) on your favorite agentic client to bring investigations into your IDE.</li>
</ol>
<hr />
<p><em>Running Kubernetes on Elastic today? Tell us which investigation steps you still repeat by hand on every incident, and which remediations you'd trust a workflow to propose. Join the <a href="https://discuss.elastic.co/c/observability">Elastic Community Discussion</a>.</em></p>
<p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/crashloopbackoff-root-cause-kubernetes</link>
    <guid isPermaLink="false">crashloopbackoff-root-cause-kubernetes</guid>
    <category><![CDATA[Kubernetes]]></category>
    <category><![CDATA[Metrics]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Bahubali Shetti]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt774f48dbd750660d/6a7f04b042a11784a095bb3e/crashloopbackoff-scenario-poster.png" length="0" type="image/png"/>
    <pubDate>Fri, 31 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[You have the IP, you want the hostname: building a lookup processor for OpenTelemetry]]></title>
    <description><![CDATA[Look up any value from YAML, CSV or DNS inside the OpenTelemetry Collector or wire in your own source through a processor Elastic built and shipped to Collector Contrib.]]></description>
    <content:encoded><![CDATA[<p>Enrichment is one of those tasks that sounds trivial until you try to do it inside a telemetry pipeline. You have a <code>user.id</code> on a log record and you want the <code>user.name</code>. You have a <code>client.ip</code> and you want the hostname behind it. Until now, the OpenTelemetry Collector had no general way to do this kind of lookup.</p>
<p>The closest option today is to hand-code the mapping in the transform processor:</p>
<pre><code># otel.yml
processors:
  transform:
    log_statements:
      - context: log
        statements:
          - set(attributes["user.name"], "Alice") where attributes["user.id"] == "user001"
          - set(attributes["user.name"], "Bob") where attributes["user.id"] == "user002"
          - set(attributes["user.name"], "Carol") where attributes["user.id"] == "user003"
          # ...and one more line for every user
</code></pre>
<p>That works for a handful of entries, but it does not scale beyond 10 to 20 items. Every new mapping means another statement, the lookup data lives in the same file as your pipeline config, and there is no way to point at reference data that already exists. It proves the need is real, but it only covers the simplest, smallest cases.</p>
<p>If you run the Collector and you have been reaching for the transform processor, a sidecar script, or a downstream ingest pipeline just to add reference data, this component is for you.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt19206d54078356b9/6a7f18df227b1c6a875989e5/cover.png" alt="Raw telemetry flowing through the lookup processor into enriched telemetry" /></p>
<h2 id="whytheopentelemetrycollectorneededalookupprocessor">Why the OpenTelemetry Collector needed a lookup processor</h2>
<p>The Collector was already good at a couple of enrichment patterns. The <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/transformprocessor">transform processor</a> reshapes and derives data from what is already on a record. The <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/k8sattributesprocessor">k8sattributes</a> and <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/resourcedetectionprocessor">resourcedetection</a> processors attach system and environment metadata, like Kubernetes pod details or cloud host information.</p>
<p>What it could not do was look up related data by a key you already have. Three patterns in particular had no home:</p>
<ul>
<li><strong>File-based lookups</strong> from static reference data in JSON, YAML, or CSV</li>
<li><strong>HTTP and API-based enrichment</strong> from an external service</li>
<li><strong>DNS lookups</strong>, such as resolving an IP to a hostname</li>
</ul>
<p>These are everyday tasks in other data collectors and transformation tools. Mapping an internal service ID to a friendly name, attaching business metadata by customer ID, or resolving an IP all fall into this category. Without a native component, teams built brittle workarounds or pushed the work downstream where it is harder to reuse.</p>
<p>That gap is what the new <strong>lookup processor</strong> closes. Elastic's Data Processing team proposed it, the community accepted it, and it was built in partnership with Grafana, thanks to Sam DeHaan (<a href="https://github.com/dehaansa">GH: dehaansa</a>). The lookup processor takes a value from your telemetry, uses <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/pkg/ottl">OTTL</a> to build a lookup key, queries a source such as a YAML file or DNS, and writes the result back as new attributes.</p>
<h2 id="howtheopentelemetrylookupprocessorisdesigned">How the OpenTelemetry lookup processor is designed</h2>
<p>The design grew out of repeated requests from the community for richer enrichment. A few examples that fed into it:</p>
<ul>
<li><a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/40526">Enrich attributes based on key matching from YAML or CSV definition (#40526)</a></li>
<li><a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/40936">Enrich telemetry with resource metadata from an inventory datasource (#40936)</a></li>
<li><a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/29627">Generic resource detector (#29627)</a></li>
<li><a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/18526">Alert Manager receiver and exporter (#18526)</a></li>
<li><a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/20888">gRPC processor / connector (#20888)</a></li>
</ul>
<p>Rather than build a one-off component for each request, the goal was a single processor flexible enough to cover them. Four ideas shaped it:</p>
<ul>
<li><strong>Multiple lookups per processor</strong>, so one instance can enrich several attributes in a single pass.</li>
<li><strong>Caching</strong>, so external sources like DNS do not get queried for the same key over and over.</li>
<li><strong>OTTL for key extraction</strong>, so you get a full expression language for pulling the lookup key off a record, including converters.</li>
<li><strong>Extensible sources</strong>, so you are not limited to the built-in set. You can register a custom source for your own data.</li>
</ul>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc4ad99e402d4f0b6/6a7f18e2e02fac31ae5d6981/lookup-sources.png" alt="One lookup processor with pluggable sources: YAML, CSV, and DNS available today, HTTP and custom sources on the roadmap" /></p>
<p>Extensible sources matter most for the processor's long-term value. A source is a small, well-defined interface, so the processor is a foundation for enrichment rather than a fixed list of features. YAML and CSV cover static reference data today, DNS covers dynamic resolution, and the same interface leaves the door open for HTTP APIs, key-value stores, or anything specific to your environment.</p>
<h2 id="howthelookupprocessorevaluateskeyswithottl">How the lookup processor evaluates keys with OTTL</h2>
<p>Whatever source you configure, the processor runs the same three steps for every record.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4ca88a68dddd0e5f/6a7f18e5b6b734e1ace49198/lookup-processor-flow.png" alt="The lookup processor evaluates an OTTL key, queries a source, and writes the result back as attributes" /></p>
<p>First, it evaluates an OTTL expression to produce a lookup key from the record. Second, it hands that key to the configured source and gets a value back. Third, it writes the value to the attributes you name, on the record or on its parent resource. When a key has no match, the processor writes a configurable default so downstream queries stay predictable.</p>
<p>The next two sections walk through the two sources available today.</p>
<h2 id="filebasedlookupswithyamlintheopentelemetrycollector">File-based lookups with YAML in the OpenTelemetry Collector</h2>
<p>The most common case is static reference data. You keep a mapping file next to the Collector and enrich records against it. Here the processor reads a YAML file and adds <code>user.name</code> to each log based on its <code>user.id</code>.</p>
<pre><code># otel.yml
processors:
  lookup:
    source:
      type: yaml
      path: /etc/otel/mappings.yaml
    lookups:
      - key: log.attributes["user.id"]
        attributes:
          - destination: user.name
            default: "Unknown User"
</code></pre>
<p>The mapping file is a plain set of key-value pairs:</p>
<pre><code># /etc/otel/mappings.yaml
user001: "Alice"
user002: "Bob"
</code></pre>
<p>The <code>key</code> field is an OTTL value expression, so <code>log.attributes["user.id"]</code> reads the <code>user.id</code> attribute off the log record. The <code>destination</code> is where the looked-up value lands, and <code>default</code> is what gets written when the key is missing from the file.</p>
<p>Given this incoming log record:</p>
<pre><code>{
  "body": "User logged in",
  "attributes": {
    "user.id": "user001",
    "http.method": "POST"
  }
}
</code></pre>
<p>The processor looks up <code>user001</code>, finds <code>Alice</code>, and produces:</p>
<pre><code>{
  "body": "User logged in",
  "attributes": {
    "user.id": "user001",
    "user.name": "Alice",
    "http.method": "POST"
  }
}
</code></pre>
<p>The original attributes stay intact and the enriched value is added alongside them. The CSV source works the same way for teams that keep reference data in spreadsheets or exports rather than YAML.</p>
<h2 id="dnslookupenrichmentinsidetheopentelemetrycollector">DNS lookup enrichment inside the OpenTelemetry Collector</h2>
<p>Static files are one thing, but some enrichment requires live, changing data. Resolving an IP address to a hostname is the classic example, and it is the first dynamic source the processor supports. Instead of a file, you point it at a DNS server.</p>
<pre><code># otel.yml
processors:
  lookup:
    source:
      type: dns
    lookups:
      - key: log.attributes["client.ip"]
        attributes:
          - destination: client.hostname
            default: "Not found"
</code></pre>
<p>The shape of the config is identical to the YAML example. Only the source changed. The processor pulls <code>client.ip</code> off the record, asks a DNS resolver to reverse-resolve it, and writes the hostname back.</p>
<p>Given this incoming record:</p>
<pre><code>{
  "body": "User logged in",
  "attributes": {
    "client.ip": "8.8.8.8",
    "http.method": "POST"
  }
}
</code></pre>
<p>The DNS source resolves <code>8.8.8.8</code> and produces:</p>
<pre><code>{
  "body": "User logged in",
  "attributes": {
    "client.ip": "8.8.8.8",
    "client.hostname": "dns.google",
    "http.method": "POST"
  }
}
</code></pre>
<p>Because DNS queries hit an external system, this is where caching earns its place. The processor keeps results in an in-memory cache so a stream of records sharing the same IP does not turn into a stream of identical DNS queries. That keeps latency down and avoids hammering your resolver.</p>
<h2 id="writingacustomlookupsource">Writing a custom lookup source</h2>
<p>The built-in sources cover common cases, but the real design goal was extensibility. A source is a small contract: you implement a lookup function that takes a string key and returns a value. The processor takes care of OTTL key evaluation, caching, defaults, and writing attributes, so a custom source only has to answer the question "what value goes with this key?"</p>
<p>That means if you already run an internal metadata API, a Redis cache, or a custom database, you can wire it in as a source and reuse everything else the processor provides. HTTP-based sources and key-value stores are natural fits, and they are on the roadmap precisely because the interface makes them straightforward to add.</p>
<h2 id="howelasticplanstousethislookupprocessor">How Elastic plans to use this lookup processor</h2>
<p>Elastic builds its Collector distributions on OpenTelemetry Collector Contrib. The plan is to include lookup processor so the enrichment work teams do today with brittle workarounds can run inside the pipeline instead of downstream.</p>
<p>Two patterns stand out. The first is reference-data enrichment: turning an internal service ID into a friendly name, or attaching metadata such as a team or customer by ID, so telemetry arrives already labeled for search and correlation. The second is DNS resolution: turning a <code>client.ip</code> into a hostname before the data lands, which matters for network and security telemetry where you want the name rather than the raw address.</p>
<p>Doing this in the Collector keeps reference data close to where telemetry is processed and avoids duplicating the logic in separate ingest steps. As the source interface grows to cover HTTP APIs and key-value stores, the same processor can back richer enrichment without changing how pipelines are configured.</p>
<h2 id="lookupprocessorroadmapandhowtocontribute">Lookup processor roadmap and how to contribute</h2>
<p>The main implementation of the lookup processor has merged upstream into OpenTelemetry Collector Contrib. The <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/45340">core processor and YAML source landed first</a>, and the DNS source followed as the first dynamic lookup.</p>
<p>There is a healthy backlog of work ahead, and contributions are welcome:</p>
<ul>
<li><strong>An HTTP lookup source</strong> for enrichment from external APIs.</li>
<li><strong>More DNS capabilities</strong>, including A and AAAA queries and support for multiple DNS servers.</li>
<li><strong>Component telemetry</strong>, so you can observe cache hit and miss rates, lookup latency, and error rates.</li>
<li><strong>Performance improvements</strong> as real-world usage grows.</li>
</ul>
<p>If you want to try it, add the processor to a Collector build that includes Contrib, point a YAML source at a mapping file, and enrich a real record. Then open an issue or pull request on the <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib">OpenTelemetry Collector Contrib</a> repository. The component is community-owned, and the more sources and feedback it gets, the more useful it becomes.</p>
<p>To go deeper, read the <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/lookupprocessor">lookup processor README</a> and the <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/41816">enrichment tracking issue</a>. For more on how Elastic builds on OpenTelemetry, browse the <a href="https://www.elastic.co/observability-labs/blog/tag/opentelemetry">OpenTelemetry articles on Elastic Observability Labs</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/opentelemetry-collector-lookup-processor</link>
    <guid isPermaLink="false">opentelemetry-collector-lookup-processor</guid>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Vihas Makwana]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte6f2a87fd8059793/6a7f18e8ead8ecb74abaac32/header.png" length="0" type="image/png"/>
    <pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Three clicks from alert to error log: breaking down RED metrics by any span attribute in Elastic Observability]]></title>
    <description><![CDATA[See which pod, deployment or version is driving a RED metrics change by breaking down span attributes in Discover, then trace a failing span to the error log behind it.]]></description>
    <content:encoded><![CDATA[<p>Elastic Observability now lets you break down <a href="https://www.elastic.co/docs/solutions/observability/apm/metrics">RED metrics</a> in Discover by any span attribute on your traces. Split by pod, deployment, service version or any custom dimension to see which values moved the metric. From there, you can open a failing span's trace waterfall and follow it through to the linked error log in a few clicks, no query needed.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1cf0a6474d8514d8/6a7f1a2d96b5a66aff87b881/metric-drivers-2.gif" alt="Breaking down RED metrics by span attribute in Discover" /></p>
<p><strong>Availability</strong></p>
<p>This is available in serverless today and is coming to Elastic Cloud Hosted and self-managed deployments in 9.5.</p>
<h2 id="whatyouneedforredmetricsbreakdowninelasticobservability">What you need for RED metrics breakdown in Elastic Observability</h2>
<p>You need trace data from a service instrumented with any method <a href="https://www.elastic.co/docs/solutions/observability/apm/ingest">Elastic APM supports</a>.</p>
<ul>
<li><strong>Application instrumentation:</strong> one of the following:</li>
<li><strong><a href="https://www.elastic.co/docs/solutions/observability/apm/apm-agents">Elastic APM agents</a></strong> for Java, .NET, Node.js, Python, PHP, Ruby, Go, and other supported languages.</li>
<li><strong><a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">OpenTelemetry SDKs</a></strong> sending OTLP via Elastic Agent or an <a href="https://www.elastic.co/docs/solutions/observability/get-started/opentelemetry/use-cases/upstream-collector">upstream OpenTelemetry Collector</a> with the <a href="https://www.elastic.co/docs/reference/edot-collector/components/elasticapmconnector"><code>elasticapm</code> connector</a> under <strong>connectors</strong> (not processors).</li>
<li><strong>Useful attributes:</strong> breakdown works best when spans include the dimensions you want to compare (<code>k8s.pod.name</code>, <code>k8s.deployment.name</code>, <code>service.version</code>, and others). You can also declare custom attributes on spans: add <a href="https://www.elastic.co/docs/solutions/observability/apm/metadata">labels</a> to transactions and spans with Elastic APM agents, or set <a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry/attributes">OpenTelemetry attributes</a> on spans and resources with OpenTelemetry SDKs. Those custom fields work as breakdown dimensions too.</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="howtogofromaredmetricsalerttotherootcauseerrorlog">How to go from a RED metrics alert to the root-cause error log</h2>
<h3 id="step1reviewredmetricsonthealertdetailpage">Step 1: Review RED metrics on the alert detail page</h3>
<p>When you receive a notification for a RED metric threshold breach, if you open the <a href="https://www.elastic.co/docs/solutions/observability/apm/create-apm-rules-alerts">alert detail page</a>, you can review the symptoms for the impacted service on one page.</p>
<p>In our example, failed transactions have clearly increased for the cart service, so we want to understand what is driving that RED metric change:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd60e8db89d43d27f/6a7f1a31eab5be222b20aaf4/metric-drivers-1.gif" alt="Alert detail showing RED symptoms for the cart service" /></p>
<h3 id="step2breakdownredmetricsbyspanattributesindiscover">Step 2: Break down RED metrics by span attributes in Discover</h3>
<p>To investigate why a RED metric changed, open <strong>Traces in Discover</strong> and use the new <strong>breakdown</strong> feature to split RED metrics by any attribute on your spans.
In our example, we're going to check Kubernetes attributes and service version, but you could break down by any span attribute you send (e.g. <code>cloud.region</code>, <code>cloud.availability_zone</code>, or <code>container.id</code>).</p>
<p>Each breakdown shows which attribute values moved the metric, so you can see whether the problem is isolated to one pod, deployment, version, or whatever dimension you split on.</p>
<p>In our example, error rate clusters on a single Kubernetes deployment, which points the investigation at a release. We will break down by <code>service.version</code> to validate our hypothesis:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1cf0a6474d8514d8/6a7f1a2d96b5a66aff87b881/metric-drivers-2.gif" alt="Breaking down RED metrics by span attribute in Discover" /></p>
<h3 id="step3openthetracewaterfallandreadthelinkederrorlog">Step 3: Open the trace waterfall and read the linked error log</h3>
<p>Once trace breakdown has identified a specific service version as the likely cause, we can filter by that <code>service.version</code> and look at sample failing spans to see if they explain why the version is causing failures.</p>
<p>Open the trace waterfall for one failing span and follow through to the linked error log.</p>
<p>In our example, the error log points to bad configuration that could be causing the issue. Either way, we have narrowed the investigation to a solid hypothesis we can act on in just a few clicks:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9ef23df1d803c870/6a7f1a356c6eac6b6ef14598/metric-drivers-3.gif" alt="Trace waterfall and error log for a sample failing span" /></p>
<h2 id="fromredmetricsalerttoerrorloginelasticobservability">From RED metrics alert to error log in Elastic Observability</h2>
<p>From a RED metric alert, you can review the symptomatic service, break down <strong>Traces</strong> in Discover by any attribute on your spans, and open a failing span's trace waterfall to reach the error log in just a few clicks.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/red-metrics-trace-breakdown-discover</link>
    <guid isPermaLink="false">red-metrics-trace-breakdown-discover</guid>
    <category><![CDATA[APM]]></category>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Roshan Gonsalkorale,Irene Blanco Fabregat]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte55244369ae6789c/6a7f1a3896b5a6329d87b885/header.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How Elastic cut OpenTelemetry tail sampling memory by 65% with disk-backed trace storage]]></title>
    <description><![CDATA[Elastic contributed two features upstream to the OTel Collector's tail sampling processor. The span-ingest strategy lets sampling decisions happen earlier, and Pebble tail storage moves trace buffering to disk. It costs more CPU, but operators can raise decision_wait and num_traces without OOM kills.]]></description>
    <content:encoded><![CDATA[<p>Elastic contributed two upstream improvements to the OpenTelemetry Collector's tail sampling processor (<a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor"><code>tailsamplingprocessor</code></a>) that cut memory usage by up to 65%.
<code>sampling_strategy: span-ingest</code> lets sampling decisions happen at ingest time, releasing traces before <code>decision_wait</code> elapses.
<a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/extension/tailstorage/pebbletailstorageextension"><code>pebbletailstorageextension</code></a> moves trace buffering to a Pebble LSM database on disk, so storage scales with disk capacity instead of RAM. That means operators can increase <code>decision_wait</code> and <code>num_traces</code> without OOM kills. The cost is roughly 2x CPU.</p>
<h2 id="whatistailsampling">What is Tail Sampling?</h2>
<p>Distributed tracing is useful for debugging, but at production scale it comes with processing overhead and storage costs, at which point sampling becomes a natural way to maintain the value of tracing while keeping costs under control. Tail-based sampling, or tail sampling, is a technique that makes a sampling decision conditionally at a later stage, so that high-value traces like errors or slow transactions are more likely to be sampled. The opposite is head sampling, which makes the decision at the start of a trace, before any such information is available.</p>
<h2 id="howdoesthetailsamplingprocessorwork">How does the tail sampling processor work?</h2>
<p>The tail sampling processor buffers 100% of incoming traces (or spans, used interchangeably), then forwards the sampled subset after applying the sampling policies.
Buffering is a major source of memory usage, and it scales proportionally to the volume of spans, a well known pain point in the community.</p>
<p>Memory usage is bounded by configuration parameters like <code>decision_wait</code> and <code>num_traces</code>.
Setting <code>decision_wait</code> to 1 minute means a sampling decision is made for a trace after 1 minute, during which all spans for that trace are expected to have arrived.
If a trace is slower than 1 minute, the decision is made with some spans missing.</p>
<p>As a side note, scaling out the tail sampling setup involves using the <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/exporter/loadbalancingexporter"><code>loadbalancingexporter</code></a> to satisfy the requirement that all spans for a trace must be routed to the same collector.
This introduces some operational complexity and potentially data loss during collector restarts.
But this post focuses on the memory usage of a single tail sampling processor instance, regardless of horizontal scaling.</p>
<h2 id="whydoestailsamplingcausememorypressure">Why does tail sampling cause memory pressure?</h2>
<p>These parameters introduce a tradeoff between data loss and memory usage, and they require assumptions about the shape of traces: how slow they can be, how many spans they contain, how large each span is. These assumptions can become stale as instrumentation evolves.</p>
<p>How much data loss is acceptable to limit memory usage, and can the tradeoff be improved? The following two contributions aim to give operators more flexibility.</p>
<h2 id="howspaningestreducestailsamplingmemorybyreleasingspansearly">How span-ingest reduces tail sampling memory by releasing spans early</h2>
<p><code>sampling_strategy</code> is a new configuration option added to the tail sampling processor in <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/releases/tag/v0.149.0"><code>v0.149.0</code></a>.</p>
<p><code>sampling_strategy</code> defaults to <code>trace-complete</code>, which matches the original behavior: sampling policies are only evaluated when <code>decision_wait</code> has elapsed, at which point the trace is considered complete.
(There is a similar config, <code>decision_wait_after_root_received</code>, for optimization, but it is excluded from this discussion for simplicity.)
This means all spans are buffered in memory for roughly <code>decision_wait</code> before being released, regardless of whether a decision could have been made earlier.
For example, health check spans that should always be dropped are still held in memory until policy evaluation time.</p>
<p>Alternatively, <code>sampling_strategy</code> can be set to <code>span-ingest</code>, where spans are evaluated individually at ingest time.
This allows terminal decisions, specifically <code>drop</code> or <code>sampled</code>, to be made earlier, freeing memory by dropping or exporting all spans buffered so far for that trace before <code>decision_wait</code> elapses.
In the health check example, a policy can be configured to drop the entire trace as soon as the root span belongs to a health check.
It is worth noting that an <code>unsampled</code> decision, unlike an explicit <code>drop</code>, is not terminal, as it can be overruled by a <code>sampled</code> or <code>drop</code> decision from another span in the same trace, so <code>unsampled</code> traces cannot be released early.</p>
<p>Switching from <code>trace-complete</code> to <code>span-ingest</code> will require policy adjustments, as policies can no longer assume all spans are available at evaluation time.
Moreover, not all policy types are supported with the <code>span-ingest</code> strategy.</p>
<h2 id="diskbackedtailsamplingstoragewithpebble">Disk-backed tail sampling storage with Pebble</h2>
<p>Even with <code>span-ingest</code>, all spans are still buffered in memory.
As <code>decision_wait</code> is increased to accommodate slow traces and <code>num_traces</code> is increased to limit data loss, the collector will eventually hit its memory limit and get OOM killed, resulting in further data loss.</p>
<p>What if traces were buffered on disk instead, where there is an order of magnitude more capacity?
The main drawback is performance: disk throughput and latency, even with SSDs, are at least an order of magnitude slower than memory, so disk writes need to be efficient.
For this reason, <a href="https://github.com/cockroachdb/pebble"><code>Pebble</code></a>, an LSM database, was chosen as the storage backend for its fast write performance.
Read performance is less of a concern, as reads only happen for the sampled subset of traces when <code>sampling_strategy</code> is set to <code>span-ingest</code>.</p>
<p>The implementation introduces a <code>TailStorage</code> interface for trace storage operations, and a new <code>tail_storage</code> option in <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/releases/tag/v0.150.0"><code>v0.150.0</code></a> (behind feature gate <code>processor.tailsamplingprocessor.tailstorageextension</code>) to configure the storage backend.
The default in-memory behavior is unchanged, but it is now possible to swap in a different storage backend, like the new <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/extension/tailstorage/pebbletailstorageextension"><code>pebbletailstorageextension</code></a> contributed to Collector Contrib.</p>
<h2 id="tailsamplingmemorybenchmarkstracecompletevsspaningestwithpebble">Tail sampling memory benchmarks: trace-complete vs span-ingest with Pebble</h2>
<h3 id="benchmarksetupopentelemetrydemowithfanoutcollectors">Benchmark setup: OpenTelemetry Demo with fan-out collectors</h3>
<p>The following benchmarks were produced by running <a href="https://github.com/open-telemetry/opentelemetry-demo"><code>OpenTelemetry Demo</code></a> with increased load against a pipe collector, which receives all spans and fans them out to two identical collectors under observation (<code>CUO-A</code> and <code>CUO-B</code>), differing only in their tail sampling configuration.
Measurements include pipe collector throughput, spans received, spans sent (sampled), CPU usage, and memory usage.</p>
<h3 id="benchmarksetupdiagram">Benchmark setup diagram</h3>
<pre><code>                      demo ns
     +----------------------------------------+
     |  opentelemetry-demo                     |
     |    loadgenerator (locust)               |
     |    services: frontend, cart, ...        |
     |    demo-collector                       |
     +----------------------------------------+
                          |  OTLP/gRPC
                          v
                     chamber ns
     +----------------------------------------+
     |             pipe-collector             |
     |         receive once, fan out          |
     |      exporters: [otlp/a, otlp/b]       |
     +----------------------------------------+
              | OTLP                  | OTLP
              v                       v
     +----------------+      +----------------+
     |     CUO-A      |      |     CUO-B      |
     | tail_sampling  |      | tail_sampling  |
     |   (config A)   |      |   (config B)   |
     +----------------+      +----------------+
</code></pre>
<h3 id="tailsamplingprocessorconfigurations">Tail sampling processor configurations</h3>
<h4 id="cuoa">CUO-A</h4>
<pre><code>config:
  processors:
    tail_sampling:
      sampling_strategy: trace-complete
      decision_wait: 5m
      num_traces: 5000000
      block_on_overflow: true
      decision_cache:
        sampled_cache_size: 10000
        non_sampled_cache_size: 200000
      policies:
        - name: root_1pct
          type: and
          and:
            and_sub_policy:
              - name: root_span_only
                type: ottl_condition
                ottl_condition:
                  error_mode: ignore
                  span:
                    - "IsRootSpan()"
              - name: root_probabilistic
                type: probabilistic
                probabilistic:
                  sampling_percentage: 1.0
</code></pre>
<h4 id="cuob">CUO-B</h4>
<p><code>CUO-B</code> uses the same tail sampling processor configuration as <code>CUO-A</code>, except it sets <code>sampling_strategy: span-ingest</code> and <code>tail_storage: pebble_tail_storage/main</code>, along with its corresponding <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/extension/tailstorage/pebbletailstorageextension"><code>pebbletailstorageextension</code></a> configuration.</p>
<pre><code>extensions:
  pebble_tail_storage/main:
    directory: /var/lib/otelcol/pebble
</code></pre>
<h3 id="memorycpuandthroughputresults">Memory, CPU and throughput results</h3>
<p>The following tables compare trace-complete (CUO-A) against span-ingest with Pebble disk storage (CUO-B) across memory, CPU and throughput.
The process RSS, Go heap allocation, and per-process CPU measurements come from OpenTelemetry Collector internal process and runtime metrics, while container working set and container CPU come from Kubernetes cgroup metrics scraped by kubelet/cAdvisor.</p>
<h4 id="memorypeakoverthewindow">Memory (peak over the window)</h4>
<p>| Metric | cuo-a | cuo-b | Δ (B vs A) |
| --- | ---: | ---: | ---: |
| process RSS | 916.4 MiB | 442.7 MiB | -51.7% |
| Go heap alloc | 699.3 MiB | 241.7 MiB | -65.4% |
| container working set | 763.0 MiB | 282.9 MiB | -62.9% |</p>
<h4 id="cputotaloverthewindow">CPU (total over the window)</h4>
<p>| Metric | cuo-a | cuo-b | Δ (B vs A) |
| --- | ---: | ---: | ---: |
| per-process CPU | 11.9 core-s | 22.7 core-s | +90.7% |
| container CPU | 11.9 core-s | 22.7 core-s | +90.1% |</p>
<h4 id="throughputtotaloverthewindow">Throughput (total over the window)</h4>
<p>| Metric | cuo-a | cuo-b | Δ (B vs A) |
| --- | ---: | ---: | ---: |
| spans received | 257,804 | 257,804 | 0.0% |
| spans sent | 2,477 | 2,477 | 0.0% |</p>
<h4 id="tailsampling">Tail sampling</h4>
<p>| Metric | cuo-a | cuo-b | Δ (B vs A) |
| --- | ---: | ---: | ---: |
| traces in memory peak | 29,284 | 29,245 | -0.1% |
| traces sampled by root_1pct policy | 496 | 496 | 0.0% |</p>
<ul>
<li><code>cuo-a</code> = <code>trace-complete</code>, <code>cuo-b</code> = <code>span-ingest-pebble</code></li>
<li>Window: 15m 39s (<code>t+0:00</code> start, <code>t+10:06</code> drain start, <code>t+15:39</code> drain end)</li>
</ul>
<p>The results show a significant memory reduction when using <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/extension/tailstorage/pebbletailstorageextension"><code>pebbletailstorageextension</code></a> with <code>span-ingest</code>, at the cost of increased CPU usage from event serialization and database overhead.</p>
<h2 id="whatsnextforopentelemetrytailsampling">What's next for OpenTelemetry tail sampling</h2>
<p>Both <code>sampling_strategy</code> and <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/extension/tailstorage/pebbletailstorageextension"><code>pebbletailstorageextension</code></a> are still in their early stages at the time of writing.
Feedback and contributions are welcome in the OpenTelemetry Collector Contrib repo.
Stay tuned for more improvements.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/tail-sampling-memory-opentelemetry</link>
    <guid isPermaLink="false">tail-sampling-memory-opentelemetry</guid>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[APM]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Carson Ip]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blteb738eca25e4e5c5/6a7f1b746693f828d066439f/header.png" length="0" type="image/png"/>
    <pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Prometheus metrics in Elastic Observability: your PromQL runs unchanged]]></title>
    <description><![CDATA[Point Prometheus from your Kubernetes cluster at Elastic Observability with one config block. PromQL runs unchanged, keep your PromQL no cardinality billing.]]></description>
    <content:encoded><![CDATA[<p>It is 2:14 AM and an alert fires on your Kubernetes cluster. You open Grafana for the memory graph, then Loki for the container logs, then your APM tool to check whether the upstream service was already degrading. Three tabs and eleven minutes later you have a hypothesis, and the label you needed to confirm it was dropped last quarter to keep the metrics bill down.</p>
<p>Elasticsearch now stores Prometheus metrics in the same columnar backend as your logs and traces, at 3.75 bytes per datapoint, with no custom-metric penalty. The graph, the logs, and the traces answer to one query language.</p>
<p>Using an existing Kubernetes cluster (AWS EKS in this example): point Prometheus at
Elastic with one config block, see every metric render in Discover with no dashboard to
build, run most of your existing PromQL unchanged, find where ES|QL takes you past what
PromQL can express, and finish by reading your logs with the same query language.</p>
<p>Nothing about your collection changes. Your scrape configs, relabeling rules, and service discovery carry over as-is. Most of your PromQL comes with you too — see the coverage note in Step 5 for the gaps.</p>
<h2 id="step1getaprometheusendpointandanapikeyfromelasticobservability">Step 1: get a Prometheus endpoint and an API key from Elastic Observability</h2>
<p>You need Elastic Cloud Serverless or Elastic Cloud Hosted. Both expose the Prometheus endpoints with no configuration.</p>
<p><strong>Serverless</strong> is the fastest start. Sign in at <a href="https://cloud.elastic.co">cloud.elastic.co</a> and create an Observability project. There is nothing to size and nothing to provision.</p>
<p><strong>Elastic Cloud Hosted</strong> works the same way for everything in this post, and is the right choice when you need a specific stack version, a specific region topology, or the deployment-level controls that come with a managed cluster.</p>
<h3 id="findtheprometheusendpointandcreateanapikeyintheui">Find the Prometheus endpoint and create an API key in the UI</h3>
<p>For Prometheus endpoint and the API key go to Kibana, click <strong>Add data</strong> in the left nav.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt93ddb01d86974daf/6a859a888c29449904b8857d/add-data-page.png" alt="Add data page in Elastic Observability" /></p>
<p>For Prometheus, scroll to <strong>Connect directly to the endpoint</strong> at the bottom and select the <strong>Prometheus</strong> tab.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltca384e5a9f888d4c/6a859a8b33f244fbc549ea21/prometheus-endpoint.png" alt="The &quot;Connect directly to the endpoint&quot; panel with the Prometheus tab selected" /></p>
<p>Two things to copy:</p>
<p><strong>The endpoint.</strong> It looks like <code>https://my-observability-project-xxx.ingest.us-west-2.aws.elastic.cloud</code>. Note the <code>.ingest.</code> host. This is not the same host as your Elasticsearch search endpoint or your Kibana URL.</p>
<p><strong>The API key.</strong> Click <strong>Create key</strong>. Copy the value before you close the dialog, because it is not retrievable afterward. </p>
<p>If you want to scope it by hand instead, <strong>Open in API keys</strong> takes you to the full editor, and the minimum privilege for metrics ingest is:</p>
<pre><code>{
  "ingest": {
    "indices": [
      {
        "names": ["metrics-*"],
        "privileges": ["auto_configure", "create_doc"]
      }
    ]
  }
}
</code></pre>
<p>Keep both values. Every remaining step uses them.</p>
<h2 id="step2makesureprometheusisscrapingyourkubernetescluster">Step 2: make sure Prometheus is scraping your Kubernetes cluster</h2>
<p>Prometheus should already be scraping your cluster.</p>
<p>If it is not, the shortest path is the <code>kube-prometheus-stack</code> Helm chart, which installs the Prometheus Operator, Prometheus itself, <code>kube-state-metrics</code>, and <code>node-exporter</code> in one command:</p>
<pre><code>helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

helm install prometheus prometheus-community/kube-prometheus-stack \
  --namespace monitoring --create-namespace
</code></pre>
<p>That gives you the metrics you see for the rest of this post: </p>
<ul>
<li>cAdvisor container metrics (<code>container_cpu_usage_seconds_total</code>, <code>container_memory_working_set_bytes</code>)</li>
<li>kube-state-metrics cluster objects (<code>kube_deployment_spec_replicas</code>, <code>kube_daemonset_status_number_ready</code>).</li>
</ul>
<h2 id="step3configureprometheusremotewritetotheprometheusendpointinstep1">Step 3: configure Prometheus remote write to the Prometheus endpoint in Step 1</h2>
<p>Elasticsearch implements the Prometheus Remote Write protocol natively. There is no adapter, no sidecar, and no translation layer. You add one block and the data flows on the next scrape interval.</p>
<h3 id="ifyouruntheprometheusoperator">If you run the Prometheus Operator</h3>
<p>The Operator does not read a <code>prometheus.yml</code> you write by hand. It generates one from the <code>Prometheus</code> custom resource, and <code>authorization.credentials</code> there is a reference to a Kubernetes Secret, not an inline value. Create the secret first:</p>
<pre><code>kubectl create secret generic elastic-prometheus \
  --namespace monitoring \
  --from-literal=api_key='YOUR_API_KEY'
</code></pre>
<p>Then reference it from <code>values.yaml</code>:</p>
<pre><code>prometheus:
  prometheusSpec:
    remoteWrite:
      - url: "https://my-observability-project-xxxx.ingest.us-west-2.aws.elastic.cloud:443/api/v1/write"
        authorization:
          type: ApiKey
          credentials:
            name: elastic-prometheus
            key: YOUR_API_KEY
</code></pre>
<p>And apply it:</p>
<pre><code>helm upgrade prometheus prometheus-community/kube-prometheus-stack \
  --namespace monitoring \
  -f values.yaml
</code></pre>
<h3 id="configureprometheusremotewritewithprometheusyml">Configure Prometheus remote write with prometheus.yml</h3>
<p>Same thing, in <code>prometheus.yml</code>:</p>
<pre><code>remote_write:
  - url: "https://YOUR_ES_ENDPOINT/_prometheus/metrics/node/eks/api/v1/write"
    authorization:
      type: ApiKey
      credentials: YOUR_API_KEY
</code></pre>
<h3 id="ifyourungrafanaalloyussthefollowingconfiguration">If you run Grafana Alloy uss the following configuration</h3>
<pre><code>prometheus.remote_write "elasticsearch" {
  endpoint {
    url = "https://YOUR_ES_ENDPOINT/_prometheus/metrics/node/eks/api/v1/write"
    headers = {"Authorization" = "ApiKey YOUR_API_KEY"}
  }
}
</code></pre>
<h3 id="howtheremotewriteurlmapstoelasticsearchdatastreams">How the remote write URL maps to Elasticsearch data streams</h3>
<p>You do not name an index anywhere. There is no index field in the payload and no data stream in your <code>remote_write</code> config. Elasticsearch derives the target data stream from the write path and creates it on the first sample. The two path segments after <code>/metrics/</code> are the dataset and the namespace:</p>
<p>| URL | Data stream |
|---|---|
| <code>/_prometheus/api/v1/write</code> | <code>metrics-generic.prometheus-default</code> |
| <code>/_prometheus/metrics/{dataset}/api/v1/write</code> | <code>metrics-{dataset}.prometheus-default</code> |
| <code>/_prometheus/metrics/{dataset}/{namespace}/api/v1/write</code> | <code>metrics-{dataset}.prometheus-{namespace}</code> |</p>
<p>The examples above use <code>/metrics/node/eks/</code>, which writes to <code>metrics-node.prometheus-eks</code>. That is the data stream you will see in Discover in the next step. Use dataset and namespace to separate production from staging, or to give each cluster and each team a data stream with its own retention and downsampling policy.</p>
<p>If you would rather keep a bare <code>/api/v1/write</code> URL, you can route per time series instead: attach <code>data_stream_dataset</code> and <code>data_stream_namespace</code> labels to the series, and they take precedence over the URL path. These two are control fields, so they route the document without being stored in its <code>labels</code> object.</p>
<p>Elasticsearch installs the index template for <code>metrics-*.prometheus-*</code> itself. You do not create templates or mappings.</p>
<h3 id="whatprometheusmetricslooklikeinelasticobservability">What Prometheus metrics look like in Elastic Observability</h3>
<p>Every Prometheus sample becomes a document. Labels become keyword fields that serve as time series dimensions. The value goes under <code>metrics.&lt;metric_name&gt;</code>:</p>
<pre><code>{
  "@timestamp": "2026-07-02T10:30:00.000Z",
  "data_stream": {
    "type": "metrics",
    "dataset": "node.prometheus",
    "namespace": "eks"
  },
  "labels": {
    "__name__": "container_memory_working_set_bytes",
    "pod": "checkout-7d9f6c4b8-x2kqp",
    "namespace": "oteldemo",
    "container": "checkout",
    "node": "ip-10-0-3-14.us-west-2.compute.internal"
  },
  "metrics": {
    "container_memory_working_set_bytes": 36700160
  }
}
</code></pre>
<p><strong>The one gotcha to know now.</strong> Elasticsearch infers whether a metric is a counter or a gauge from its name. Names ending in <code>_total</code>, <code>_sum</code>, <code>_count</code>, or <code>_bucket</code> are counters. Everything else is a gauge. That inference is correct for <code>container_cpu_usage_seconds_total</code> and correct for <code>container_memory_working_set_bytes</code>. It is wrong for any metric in your estate that does not follow Prometheus naming convention, and a misclassified metric gets rejected by the function that should accept it: <code>RATE(my_metric::counter)</code> works on counters only. Step 6 shows how to override the inference.</p>
<p><strong>Current limits.</strong> Remote Write v1 only. Classic histograms and summaries are supported through their <code>_bucket</code>, <code>_sum</code>, and <code>_count</code> series, each mapped to the right metric type. Native (sparse) histograms and exemplars arrive with Remote Write v2, which is on the roadmap. Staleness markers are not stored or respected, and non-finite values (NaN, Infinity) are dropped silently. See the <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/tsds-ingest-prometheus-remote-write">Prometheus remote write ingest docs</a> for the full list.</p>
<h2 id="step4verifyprometheusmetricsareflowingintoelasticsearch">Step 4: verify Prometheus metrics are flowing into Elasticsearch</h2>
<p>Do not go build a dashboard. Confirm the pipeline first, and Elastic makes that a single command.</p>
<p>In Kibana, open <strong>Discover</strong>, switch the query editor to ES|QL, and type the name of the data stream you just wrote to:</p>
<pre><code>TS metrics-node.prometheus-eks
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6656243127573f59/6a859a8e43c0b719e12efb15/discover-prometheus-metrics.png" alt="ES|QL query &quot;TS metrics-node.prometheus-eks&quot;" /></p>
<p>That is the whole query. Discover reads the data stream, finds every metric in it, and renders each one as its own time series chart. Fifty metrics, fifty charts, no dashboard to build and no query to write per metric. It reads the metric type and charts each one correctly: gauges as averages, counters as rates, histograms as p95 distributions.</p>
<p>You can also get here without typing anything. <strong>Observability</strong> → <strong>Streams</strong> lists every data stream in the cluster. A <strong>Time series</strong> badge means it is a time series data stream. Click <strong>View in Discover</strong> and the <code>TS</code> query is filled in for you.</p>
<p>This is your ingest health check. Three things to look for.</p>
<ul>
<li><strong>Data is flowing.</strong> Recent, continuous values. Not gaps, and not a line that stops an hour ago.</li>
<li><strong>Values are plausible.</strong> Memory in the tens of megabytes for a small container. CPU as a fraction of a core. Network bytes tracking real traffic.</li>
<li><strong>Coverage is what you expected.</strong> If <code>kube_pod_container_status_restarts_total</code> is missing, your kube-state-metrics scrape config is wrong, and you want to know that now rather than when you are building an alert on it.</li>
</ul>
<p>Widen the time picker before you conclude anything is broken. A 15-minute window over a quiet period makes healthy data look flat.</p>
<p>To list what actually has data rather than what the mapping declares:</p>
<pre><code>TS metrics-node.prometheus-eks | METRICS_INFO | SORT metric_name
</code></pre>
<p>The <strong>No dimensions selected</strong> control above the charts lets you break every chart out by a label: select <code>pod</code> and each chart splits into one series per pod.</p>
<h2 id="step5runpromqlqueriesonprometheusmetricsinelasticobservability">Step 5: run PromQL queries on Prometheus metrics in Elastic Observability</h2>
<p>If your team writes PromQL, keep writing PromQL. <code>PROMQL</code> is a source command in ES|QL, alongside <code>FROM</code> and <code>TS</code>, and it runs anywhere ES|QL runs: Discover, dashboard panels, and alert rules.</p>
<p>It does not run a separate engine. It parses the expression, resolves each function to its ES|QL equivalent, and builds a <code>TS</code> execution plan, so your PromQL gets the same vectorized, parallel execution as native ES|QL.</p>
<p><strong>Current limits.</strong> <code>PROMQL</code> is generally available on Elastic Cloud Serverless and a tech preview on Elastic Stack 9.4, benchmarked at over 80% query coverage against popular Grafana OSS dashboards. The gaps worth knowing before you paste a dashboard in: <code>histogram_quantile</code> is not yet implemented, which matters most because it is how nearly every latency dashboard computes p95; group modifiers (<code>on(...) group_left(...)</code>) and the set operators <code>or</code>, <code>and</code>, and <code>unless</code> are unsupported; and <code>predict_linear</code>, <code>label_join</code>, and <code>label_replace</code> are not yet available. Time buckets also align to fixed calendar boundaries rather than the query start time, so short ranges or large steps can differ slightly from Prometheus. See the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/promql"><code>PROMQL</code> command reference</a> for the current list.</p>
<h3 id="cpuperpodcpuratewithpromql">CPU: per-pod CPU rate with PromQL</h3>
<p>The per-second CPU rate across containers, grouped by pod. This is the first thing you look at when something is hot:</p>
<pre><code>PROMQL sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))
</code></pre>
<p>Broken out by namespace instead, to find which team is burning the cluster:</p>
<pre><code>PROMQL sum by (namespace) (rate(container_cpu_usage_seconds_total[5m]))
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd77344beab424b2b/6a859a914710c6851ad3c0bd/promql-cpu.png" alt="The PromQL CPU query" /></p>
<p>Note what came back: a <code>pod</code> column, a <code>step</code> column, and the value column named after the expression itself. That is a normal ES|QL table, which is the whole point and the thing Step 6 builds on.</p>
<h3 id="memoryworkingsetandrsswithpromql">Memory: working set and RSS with PromQL</h3>
<p>Working set is the number that matters for OOM risk. It is what the kernel counts against the limit, and it is not the same as total allocated memory:</p>
<pre><code>PROMQL sum by (pod) (container_memory_working_set_bytes)
</code></pre>
<p>Resident set, for comparison, when you are trying to tell a real leak from page cache:</p>
<pre><code>PROMQL sum by (pod) (container_memory_rss)
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta775eef49c896a49/6a859a94f5f1a066672ebf42/promql-memory.png" alt="The PromQL memory working set query" /></p>
<h3 id="networkreceiveratebypodwithpromql">Network: receive rate by pod with PromQL</h3>
<pre><code>PROMQL sum by (pod) (rate(container_network_receive_bytes_total[5m]))
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt860489ec46eaef6f/6a859a97342d69db5b21a5a4/promql-network.png" alt="PromQL network receive rate by pod running in Discover" /></p>
<h3 id="clusterobjectsreplicacountswithpromql">Cluster objects: replica counts with PromQL</h3>
<p>Deployments that are not running the replica count they declare:</p>
<pre><code>PROMQL kube_deployment_spec_replicas
</code></pre>
<p>One nicety worth calling out: in Prometheus every query needs an explicit <code>start</code>, <code>end</code>, and <code>step</code>. In Kibana you drop all three. The date picker supplies the range and Kibana derives the step, which is why every query above is a single line.</p>
<h3 id="buildakibanadashboardfrompromqlqueries">Build a Kibana dashboard from PromQL queries</h3>
<p>Every query above is a dashboard panel. In Discover, click <strong>Save</strong>, or go to <strong>Dashboards</strong> → <strong>Create</strong> → <strong>Add panel</strong> → <strong>ES|QL</strong> and paste the query in. The date picker drives <code>start</code>, <code>end</code>, and <code>step</code>, so a panel written once works at every zoom level.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdfd4713140505956/6a859a9b27c5cd313e5f68a8/prometheus-dashboard.png" alt="Prometheus Metrics for B10 Cluster" /></p>
<p>Four panels, four one-line PromQL queries, no translation step. If you are coming from Grafana, this is the same dashboard you already have, rebuilt in about five minutes. If you would rather not rebuild it at all, keep Grafana and point its existing Prometheus datasource at Elasticsearch. That is covered at the end of the post.</p>
<h2 id="step6queryprometheusmetricswithesqlbeyondwhatpromqlcanexpress">Step 6: query Prometheus metrics with ES|QL, beyond what PromQL can express</h2>
<p>The <code>PROMQL</code> command in ES|QL compiles to <code>TS</code>. These two queries are equivalent:</p>
<pre><code>PROMQL sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))
</code></pre>
<pre><code>TS metrics-node.prometheus-eks
| WHERE TRANGE(1h)
| STATS SUM(RATE(metrics.container_cpu_usage_seconds_total, 5m)) BY labels.pod, TBUCKET(1m)
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt07ecc21afdaad511/6a859a9e43c0b7c8ee2efb1f/ESQL-CPU-usage-pod.png" alt="The TS form of the CPU query running in the Kibana ES|QL editor in Discover: SUM(RATE(container_cpu_usage_seconds_total, 5m)) by pod, per-pod CPU rate bars across kube-system pods, 176 results in 11ms" /></p>
<p>The second form is where metrics stop being a separate world from logs and traces —
and where the real joins happen.</p>
<h3 id="topnandfiltering">Top-N and filtering</h3>
<p>A <code>TS</code> query returns a normal ES|QL table, so <code>SORT</code>, <code>LIMIT</code>, and <code>WHERE</code> all work downstream. Top-N needs no special function, and no <code>topk</code>. The ten pods using the most memory are a <code>SORT</code> and a <code>LIMIT</code>:</p>
<pre><code>TS metrics-node.prometheus-eks
| WHERE `labels.pod` IS NOT NULL
| STATS mem = SUM(metrics.container_memory_working_set_bytes) BY `labels.pod`
| SORT mem DESC
| LIMIT 10
</code></pre>
<p>Filtering is the same move, a <code>WHERE</code> on the aggregated column. Only the pods over 50 MB:</p>
<pre><code>TS metrics-node.prometheus-eks
| WHERE `labels.pod` IS NOT NULL
| STATS mem = SUM(metrics.container_memory_working_set_bytes) BY `labels.pod`
| WHERE mem &gt; 50000000
| SORT mem DESC
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte74a13a2d1124d56/6a859aa19a32f11279a7d507/pods-over-50ms.png" alt="The top-N memory query running in the Kibana ES|QL editor in Discover" /></p>
<p>You actually can pipe the results of a PROMQL query and post-process with regular ES|QL.</p>
<h3 id="memoryusageagainsttherequest">Memory usage against the request</h3>
<p>A useful question during a memory scare: which pods are using more than they requested, and by how much. That combines two metrics, and ES|QL expresses it in a single pass with filtered aggregations, one <code>MAX</code> per metric:</p>
<pre><code>TS metrics-node.prometheus-eks
| WHERE `labels.pod` IS NOT NULL AND `labels.namespace` IS NOT NULL
| STATS
    used_memory      = MAX(metrics.container_memory_working_set_bytes),
    requested_memory = MAX(metrics.kube_pod_container_resource_requests)
                       WHERE `labels.resource` == "memory"
  BY `labels.pod`, `labels.namespace`, time_bucket = TBUCKET(5 minute)
| EVAL pct_of_request = 100 * used_memory / requested_memory
| WHERE pct_of_request &gt; 80
| SORT pct_of_request DESC
| LIMIT 100
</code></pre>
<p>The <code>WHERE</code> attached to <code>requested_memory</code> is a filtered aggregation: <code>kube_pod_container_resource_requests</code> carries both CPU and memory under a <code>labels.resource</code> dimension, and the filter keeps only the memory rows, so the ratio is memory over memory. Each metric lives in its own documents; the shared <code>BY</code> key lands both aggregates on one row.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt08d2c5ef4cbb4d6f/6a859aa4e2447a2b478b08d1/ESQL-pod-memory-and-requests.png" alt="memory-vs-request query in the Kibana ES|QL" /></p>
<h2 id="step7queryprometheusmetricsandlogstogetherwithesql">Step 7: query Prometheus metrics and logs together with ES|QL</h2>
<p>Over the last few sections we used ES|QL and PromQL to explore metrics. ES|QL reads logs too. Here is a quick query against the OpenTelemetry demo running on this cluster:</p>
<pre><code>FROM logs-*
| WHERE TRANGE(30m) AND kubernetes.pod.name == "checkout-9656cbd88-fsr9v"
| STATS events = COUNT(*) BY log.level, TBUCKET(1m)
| SORT events DESC
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1a436e9d7fcaabb7/6a859aa79a32f17f35a7d511/logs-correlation.png" alt="ESQL Logs query" /></p>
<p>Same editor, same date picker you used for metrics. Only the source command changed, from <code>TS</code> to <code>FROM</code>, and now you are reading log volume by level per minute. One query language across metrics and logs, no tab switch and no second tool.</p>
<h2 id="whatsrunninginelasticobservabilitynow">What's running in Elastic Observability now</h2>
<p>Prometheus is writing to Elastic with one config block. Every metric renders in Discover with no dashboard built. Most of your existing PromQL runs unchanged, and ES|QL takes you further: top-N, filtering, and cross-metric ratios you can pipe into the rest of the language. Metrics and logs answer to the same query in the same window.</p>
<p>You did not drop a single label to get here, and you are not being billed for cardinality.</p>
<h2 id="nextstepsgrafanadashboardmigrationandopentelemetry">Next steps: Grafana, dashboard migration, and OpenTelemetry</h2>
<ol>
<li><strong>Keep Grafana if you want it.</strong> Elasticsearch exposes a Prometheus-compatible read API at <code>&lt;endpoint&gt;/_prometheus</code>. Point Grafana's existing Prometheus datasource at it, set <code>httpMethod: GET</code> on the datasource, and your PromQL dashboards keep working. Keep Grafana, replace Mimir.</li>
<li><strong>Migrate your Grafana dashboards.</strong> When you are ready to move off Grafana rather than point it at Elasticsearch, the <a href="https://github.com/elastic/observability-migration-platform">Observability Migration Platform</a> translates Grafana dashboards, panels, and alert rules into Kibana-native equivalents. It is a source-agnostic CLI (<code>obs-migrate</code>) that converts what it can and flags what needs a human, with a migration report showing what translated cleanly and where semantic gaps remain, so nothing is silently dropped. The <a href="https://www.elastic.co/observability-labs/blog/migrate-datadog-grafana-dashboards-alerts-to-kibana">walkthrough</a> covers a Grafana and Datadog migration end to end.</li>
<li><strong>Want to add OpenTelemetry?</strong> Read Part 2 if you are also running OpenTelemetry, or if you would rather collect with an OTel Collector than with Prometheus. Both land in the same store and the same queries read across both.</li>
</ol>
<p><strong>Start a free trial:</strong> <a href="https://cloud.elastic.co/registration">cloud.elastic.co/registration</a>
<strong>Docs:</strong> <a href="https://www.elastic.co/docs/solutions/observability">elastic.co/docs/solutions/observability</a></p>
<h2 id="frequentlyaskedquestions">Frequently asked questions</h2>
<p><strong>How do I send Prometheus metrics to Elasticsearch?</strong>
Add a <code>remote_write</code> block to your Prometheus configuration pointing to
<code>https://&lt;YOUR_ES_ENDPOINT&gt;/_prometheus/metrics/{dataset}/{namespace}/api/v1/write</code>
with an <code>Authorization: ApiKey &lt;YOUR_KEY&gt;</code> header. Elasticsearch implements the
Prometheus Remote Write v1 protocol natively — no adapter or sidecar required.</p>
<p><strong>Can I run existing PromQL queries in Elasticsearch?</strong>
Most of them, yes. ES|QL includes a <code>PROMQL</code> source command that accepts standard PromQL
expressions, benchmarked at over 80% coverage against popular Grafana OSS dashboards. It
is generally available on Elastic Cloud Serverless and a tech preview on Elastic Stack
9.4. <code>histogram_quantile</code>, <code>predict_linear</code>, <code>label_join</code>, and <code>label_replace</code> are not
yet implemented, and group modifiers and the set operators <code>or</code>, <code>and</code>, and <code>unless</code> are
unsupported.</p>
<p><strong>Does Elasticsearch charge based on Prometheus metric cardinality?</strong>
No. Elasticsearch stores Prometheus metrics at 3.75 bytes per datapoint with no
cardinality-based billing and no custom-metric penalty, regardless of how many unique
label combinations your metrics produce.</p>
<p><strong>How do I route Prometheus metrics to different data streams in Elasticsearch?</strong>
The two path segments after <code>/metrics/</code> in the Remote Write URL set the dataset and
namespace. <code>/metrics/node/eks/api/v1/write</code> writes to <code>metrics-node.prometheus-eks</code>.
You can also route per time series using <code>data_stream_dataset</code> and
<code>data_stream_namespace</code> labels, which take precedence over the URL path.</p>
<p><strong>What Prometheus metric types does Elasticsearch support?</strong>
Elasticsearch supports Remote Write v1, including counters, gauges, classic histograms,
and summaries. Counter vs. gauge classification is inferred from the metric name: names
ending in <code>_total</code>, <code>_sum</code>, <code>_count</code>, or <code>_bucket</code> are counters; everything else is a
gauge. Native histograms and exemplars require Remote Write v2, which is on the roadmap.</p>
<p><strong>Can I query Prometheus metrics and logs together in Elasticsearch?</strong>
Yes. ES|QL reads both time series metrics and logs in the same query editor with the
same date picker. Switch from <code>TS metrics-node.prometheus-eks</code> to <code>FROM logs-*</code> — same
syntax, same window, no second tool.</p>
<h2 id="relatedreading">Related reading</h2>
<ul>
<li><a href="https://www.elastic.co/observability-labs/blog/prometheus-metrics-elasticsearch-faster-cheaper-datadog">Elasticsearch: best-in-class for logs, now best-in-class for metrics</a></li>
<li><a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch">Ship Prometheus metrics to Elasticsearch with Remote Write</a></li>
<li><a href="https://www.elastic.co/observability-labs/blog/elasticsearch-supports-promql">Query Prometheus metrics in Elasticsearch with native PromQL support</a></li>
<li><a href="https://www.elastic.co/observability-labs/blog/esql-ts-command-querying-metrics">Don't leave metrics on the table: query them with the ES|QL TS command</a></li>
<li><a href="https://www.elastic.co/observability-labs/blog/query-prometheus-metrics-grafana-elasticsearch">Elasticsearch as a backend for Grafana</a></li>
<li><a href="https://www.elastic.co/search-labs/blog/elasticsearch-metrics-columnar-engine">How we rebuilt Elasticsearch as a columnar metrics engine</a></li>
</ul>
<p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/prometheus-metrics-elasticsearch-getting-started</link>
    <guid isPermaLink="false">prometheus-metrics-elasticsearch-getting-started</guid>
    <category><![CDATA[Metrics]]></category>
    <category><![CDATA[Kubernetes]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Bahubali Shetti]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4e9f1c7205265bf7/6a859aab18249c724c18ec9c/header.png" length="0" type="image/png"/>
    <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>