<?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>Sat, 24 Oct 2026 09:05:11 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Three regions, three Kibanas, one view: Cross-cluster search comes to Elastic Synthetics]]></title>
    <description><![CDATA[Monitors from every connected cluster appear in one Kibana, with journey waterfalls, screenshots, errors and certificates. Nothing is replicated, and each cluster still owns its monitors.]]></description>
    <content:encoded><![CDATA[<p>Until now, three regional deployments meant three browser tabs, separate sets of credentials, and different mental models to reconcile every time someone asked how checkout was doing worldwide.<a href="https://www.elastic.co/docs/explore-analyze/cross-cluster-search"> Cross-cluster search (CCS)</a> for synthetic monitoring ships in Elastic Stack 9.5. Monitors from every connected cluster appear in one Kibana, with status, journey waterfalls, screenshots, errors, and certificates. Nothing is replicated; the central cluster expands the index expression at query time and asks each remote directly, so configuration stays where it was created and each cluster still owns its monitors.</p><p><em>This feature is available in stateful deployments only and isn’t supported on Elastic Cloud Serverless.</em></p><h2>What cross-cluster search for synthetic monitoring adds to Elasticsearch CCS</h2><p>Before 9.5, Synthetics and CCS weren't integrated. Connecting clusters at the Elasticsearch layer gave you raw ping data in Discover but not in the Synthetics UI.Elastic's site reliability engineering (SRE) team <a href="https://www.elastic.co/blog/use-cross-cluster-search-elastic-cloud-observability">wrote about this pattern</a><em>,</em> using CCS to build a single pane of glass across 150+ clusters, but it applied to raw data, not the Synthetics app.</p><p>Monitor definitions are Kibana saved objects, and those objects don't travel across clusters. Wiring CCS at the Elasticsearch layer didn't make remote monitors appear in the Synthetics app. You'd get raw data in Discover, but none of the Synthetics UI experience: no status cards, journey waterfalls, step screenshots, or error drilldowns.</p><p>That's still true for raw CCS on saved objects. What shipped in 9.5 is a native integration built on top of remote cluster search. The app searches remote <code>synthetics-*</code> pings. It marks what's remote and sends you back to the origin Kibana whenever you need to change something.</p><h2>What remote monitors look like in the Synthetics UI</h2><p>Once remote clusters are configured, their monitors appear alongside your local ones throughout the Synthetics UI.</p><h3>Find remote monitors on the Overview page</h3><p>The <strong>Overview</strong> page shows remote monitors inline with local ones, tagged with a <strong>Remote</strong> badge next to the monitor type badge. Two additions help with navigation: a <strong>Remote cluster </strong>filter alongside the existing <strong>Type</strong> / <strong>Location</strong> / <strong>Tags</strong> / <strong>Frequency filters</strong>, and a <strong>Group b</strong>y → <strong>Remote cluster </strong>option. When a remote cluster filter is active, local monitors are hidden and status counts reflect only the filtered set, so you can focus on one deployment at a time.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc0d15a9409ecf14e/6ad88461e0c9dc4a23fd1db9/image1.png" alt="Elastic Synthetics Overview page in Kibana showing remote monitors with Remote badges and a Remote cluster filter" /><h3>Open a remote monitor detail page</h3><p><strong>Monitor detail </strong>pages work the same way for remote monitors as for local ones, including overview, history, errors, journey runs, step details, screenshots, network waterfalls, and duration charts.</p><p>A <strong>Remote monitor</strong> callout shows the cluster name and the originating Kibana URL, and a <strong>View on remote cluster</strong> button takes you directly to the monitor on its home instance.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd0e2ae13d11eec3d/6ad884789e74353d8074561d/image2.png" alt="Remote monitor callout on a Synthetics monitor detail page with the View on remote cluster button" /><h3>See remote TLS certificates on the Certificates page</h3><p>Transport Layer Security (TLS) certificates observed on remote clusters appear on the <strong>Certificates</strong> page, with a tooltip showing which cluster they came from.</p><h2>Can you edit remote monitors from the central Kibana?</h2><p>No, not from the central Kibana. Remote monitors are read-only where you view them because their configuration lives as a saved object in the Kibana where they were defined.</p><p><strong>Action</strong></p><p><strong>Behavior on the central Kibana</strong></p><p>View status, history, errors, journey runs, screenshots, network waterfalls</p><p>Available</p><p>Enable / Disable</p><p>Not available</p><p>Run test manually</p><p>Not available</p><p>Create SLO</p><p>Not available</p><p>Add to dashboard</p><p>Not available</p><p>Edit</p><p>Opens the origin Kibana, when that cluster’s public URL is configured</p><p>Clone</p><p>Opens the origin Kibana, when that cluster’s public URL is configured</p><p>View on remote cluster</p><p>Opens the monitor on the Kibana where it lives</p><p> To manage a remote monitor, click the <strong>View on remote cluster</strong> button on its detail page. It takes you to the monitor on the Kibana where it lives.</p><p>Keeping configuration on the origin cluster is intentional. A single source of truth for monitor definitions avoids the mess of cross-cluster writes.</p><p>One current limitation to note: alerting doesn't cross cluster boundaries yet. A remote monitor that's down shows as down in the federated UI, but it won't fire alerts in the central Kibana. Keep those rules on the cluster that owns the monitor. Federated alerting is on the roadmap.</p><h2>How to set up cross-cluster search for synthetic monitoring</h2><p>There are a few things to put in place.</p><h3>1. Connect the remote clusters in Elasticsearch</h3><p>In <a href="https://www.elastic.co/docs/deploy-manage/remote-clusters"><strong>Stack Management</strong> → <strong>Remote Clusters</strong></a>, add the Elasticsearch clusters that hold Synthetics data. Synthetics doesn't create these connections; it uses the ones already configured at the Elasticsearch level. The <a href="https://www.elastic.co/blog/search-and-replicate-data-between-your-elastic-cloud-and-on-prem-deployments">Search and replicate data between Elastic Cloud and on-prem</a> post covers the Elasticsearch-level setup in detail.</p><h3>2. Set server.publicBaseUrl on every participating Kibana</h3><p>The <strong>View on remote cluster</strong> deep link comes from a <code>kibanaUrl</code> field on ping documents, stamped from <code>server.publicBaseUrl</code>. If that setting is missing on a remote Kibana, you still see the monitor but there's no URL to click. Since kibanaUrl is written at ping time, you need to wait for new check results after setting this config before the link appears.</p><h3>3. Choose which remote clusters Synthetics includes</h3><p>Go to <a href="https://www.elastic.co/docs/solutions/observability/synthetics/configure-settings"><strong>Synthetics</strong> → <strong>Settings</strong> → <strong>Remote Clusters</strong></a>. You need the <strong>All </strong>privilege for <strong>Synthetics and Uptime</strong>. Users with <strong>Read</strong> see the form as read-only.</p><p>Toggle on <strong>Use all remote clusters</strong>, or pick specific ones. Each option shows connected versus disconnected status.</p><h3>4. Grant access to Kibana spaces</h3><p>This is one shared configuration per deployment, not a separate copy per space. Use the <strong>Spaces</strong> field to grant access to specific spaces, or select <strong>All spaces</strong>. On first save, settings are anchored to the space you're in.</p><p><em>Note that clearing the </em><em><strong>Spaces </strong></em><em>field on a subsequent save doesn’t remove access from existing spaces. You must explicitly select the spaces you want to keep.</em></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt21482e1dec686373/6ad884b2744ada25845686ec/image5.png" alt="Synthetics Settings Remote Clusters tab in Kibana with the Use all remote clusters toggle and Spaces access field" /><p>Open the <strong>Monitors</strong> overview, and remote monitors appear alongside your local ones.</p><h2>How the index expression resolves across clusters</h2><p>Nothing is replicated into the central cluster. CCS works by expanding the index expression at query time, which means that the central cluster never stores remote data; it just knows where to ask.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4fdcb5493e7692e7/6ad884cad26d92ccbf9f70a3/image3.png" alt="Cross-cluster search architecture for Elastic Synthetics: UI layer, server route wrapper, local and remote clusters" /><p>On each request, the server figures out which index expressions to query.</p><ul><li><p>If you select <strong>Use all remote clusters</strong>, it builds <code>synthetics-*,*:synthetics-*</code> with no connectivity check needed.</p></li><li><p>If you pick specific clusters, it calls Elasticsearch's <code>cluster.remoteInfo()</code> to check which are currently reachable and only includes those, so a disconnected remote doesn't hang your overview page.</p></li><li><p>The resolved indices are cached per space.</p></li></ul><p>The result is a combined index pattern like the following:</p>synthetics-*,cluster1:synthetics-*,cluster2:synthetics-*<p>The <strong>Overview status</strong> page and the <strong>Certificates</strong> page pick up CCS automatically, since they fan out across all configured clusters. Detail views scope each query to the right cluster. Pings, journeys, screenshots, and network events all resolve against a single remote rather than spreading across everything.</p><h2>What cross-cluster search for Synthetics does not do</h2><p>CCS for Synthetics is a visibility layer, not a management layer. You get a unified read view across clusters; each cluster stays the owner of its own monitors. If you need to create, edit, or disable monitors centrally, you still do that on the Kibana where each monitor lives.</p><p><strong>What</strong></p><p><strong>Where it lives</strong></p><p>Monitor configuration (saved objects)</p><p>Origin cluster only</p><p>Private locations</p><p>Origin cluster only</p><p>Global parameters</p><p>Origin cluster only</p><p>API keys</p><p>Origin cluster only</p><p>Alerting rules</p><p>Origin cluster only</p><p>Ping results</p><p>Federated, visible in the central Kibana</p><p>Journey steps and screenshots</p><p>Federated, visible in the central Kibana</p><p>TLS certificate status</p><p>Federated, visible in the central Kibana</p><p>CCS at the Elasticsearch level is also a prerequisite rather than the whole story. Synthetics needs its own <strong>Remote Clusters</strong> configuration to show remote monitors properly. Dashboards and Discover can still query <code>synthetics-*</code> via CCS for custom visualizations.</p><h2>Start centralized monitoring across your clusters</h2><p>Federated Synthetics ships in 9.5 for stateful deployments. If you run monitors across multiple clusters, go to <strong>Synthetics</strong> → <strong>Settings</strong> → <strong>Remote Clusters</strong> and connect your remotes.</p><p>If a remote monitor appears but the deep link is missing, set <code>server.publicBaseUrl</code> on the origin Kibana and wait for new check results. The <code>kibanaUrl</code> field is stamped on documents written after that config is in place.</p><p>We welcome your feedback in the <a href="https://discuss.elastic.co">Elastic discuss forum</a>.</p><p><strong>Documentation:</strong></p><ul><li><p><a href="https://www.elastic.co/docs/solutions/observability/synthetics/configure-settings">Configure Synthetics settings</a></p></li><li><p><a href="https://www.elastic.co/docs/solutions/observability/synthetics/analyze-data">Analyze data from synthetic monitors</a></p></li></ul><p><strong>Related reading:</strong></p><ul><li><p><a href="https://www.elastic.co/blog/use-cross-cluster-search-elastic-cloud-observability">Use Cross Cluster Search with Elastic Cloud to improve observability:</a> Elastic's SRE team on building a single pane of glass across 150+ clusters</p></li><li><p><a href="https://www.elastic.co/blog/search-and-replicate-data-between-your-elastic-cloud-and-on-prem-deployments">Search and replicate data between Elastic Cloud and on-prem:</a> CCS/cross-cluster replication (CCR) general availability (GA) announcement</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/cross-cluster-search-synthetic-monitoring</link>
    <guid isPermaLink="false">cross-cluster-search-synthetic-monitoring</guid>
    <category><![CDATA[Data Management]]></category>
    <category><![CDATA[APM]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Panagiota Mitsopoulou]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd4a6e07790da1cb0/6ad883deff956c2d46ad8547/image4.png" length="0" type="image/png"/>
    <pubDate>Thu, 22 Oct 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How we moved the OpenTelemetry Java agent to invokedynamic in 70+ pull requests]]></title>
    <description><![CDATA[We did the same migration twice, three years apart, and the second time there were a hundred contributors instead of three. What changes when you rebuild how a Java agent instruments code and nothing in production is allowed to break.]]></description>
    <content:encoded><![CDATA[<p>The OpenTelemetry Java agent now delegates its instrumentation through invokedynamic instead of inlining bytecode into your methods. That took three years and over 70 pull requests, across more than 100 instrumentation modules. In July 2026 I closed the<a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/issues/8999"> issue</a> that had tracked the work since 2023.</p><p>Most users will notice nothing, which is the point, and anything you do notice is a bug. The difference is for maintainers and for anyone writing their own instrumentation. You get classloader isolation, and you can debug instrumentation in an IDE. Writing your own no longer means forking the agent or relying on its internals.</p><p>I did this migration once before, on the<a href="https://www.elastic.co/docs/reference/apm/agents/java"> Elastic APM Java Agent</a>, with a team of three. On paper, the plan the second time was simple:</p><ol><li><p>Take the same approach we had proven in production with the Elastic APM Java Agent.</p></li><li><p>Convince OTel maintainers to adopt it.</p></li><li><p>Migrate most OTel instrumentation.</p></li><li><p>Declare victory and celebrate.</p></li></ol><p>We wrote a <a href="https://www.elastic.co/observability-labs/blog/invokedynamic-opentelemetry-java-agent">blog post</a> when we first started that work. This post is about everything between step two and step four.</p><h2>What invokedynamic is and why it matters for Java agent instrumentation</h2><p>If you’re interested in technical details, I recommend reading <a href="https://www.elastic.co/blog/embracing-invokedynamic-to-tame-class-loaders-in-java-agents">Embracing invokedynamic to tame class loaders in Java agents</a>, the original blog post from Felix Barnsteiner and Rafael Winterhalter (<a href="https://bytebuddy.net/">Byte Buddy</a> author).</p><p>For those who haven’t read it, here’s a short summary. Both the Elastic APM Java Agent and OTel Java instrumentation are built on Byte Buddy, a bytecode manipulation library that lets you modify application bytecode at runtime.</p><p>By default, Byte Buddy provides an <em>inlining</em> strategy; that is, the instrumentation injects bytecode directly into the target method. This is simple, but it trades off some flexibility. All classes referenced by the injected bytecode must be visible to the target class classloader, and we can't debug the injected code with common tools, like integrated development environments (IDEs). Plus, the initial driver was allowing end users to provide their own instrumentation without having to maintain a fork of the instrumentation agent or rely on its internal APIs.</p><p>What we internally call the "indy instrumentation" uses the <code>invokedynamic</code> Java Virtual Machine (JVM) bytecode instruction introduced in Java 7 to delegate instrumentation hooks without inlining the instrumentation code into the original method body.</p><p>Here is how the two instrumentation modes compare across the areas that matter in practice.</p><p>
</p><p><strong>Inlined bytecode</strong></p><p><strong><code>invokedynamic</code></strong><strong> delegation</strong></p><p>Class visibility</p><p>Referenced classes must be visible to the target classloader</p><p>Instrumentation runs in an isolated classloader</p><p>Debugging</p><p>Not possible with standard IDE tooling</p><p>Standard breakpoints and stepping work</p><p>JPMS</p><p>Inherits the instrumented code’s access</p><p>Access must be granted explicitly</p><p>Pre-Java 7 bytecode</p><p>No constraint</p><p>Handled via a generated invokestatic helper</p><p>Classloader instrumentation</p><p>Supported</p><p>Not supported, these modules stay inline</p><p>One major downside of this approach is that it requires modifying and reworking the instrumentation code. Another added challenge with instrumentation agents is that they’re deployed in thousands of production environments, so there’s a lot of pressure to avoid regressions and bugs, since they could cause dramatic crashes or performance regression in the application.</p><p>Although extensive testing is done before any release, the nature of instrumentation is that the end result is highly dependent on the target application. At best, we can validate with sample applications, but those will never cover all the edge cases that a real application and its deployment can have, such as proprietary frameworks and application servers, esoteric JVMs, OSGi, and complex class loading.</p><h2>How we migrated the Elastic APM Java Agent with a team of three</h2><p>At the time, there were only three engineers working on the Elastic APM Java Agent. Although that made it easier to coordinate and make decisions quickly, it also meant that we had very limited capacity to deal with support cases.</p><p>Because it's impossible and too risky to do a "big-bang" migration, we had to perform a gradual transition, moving only a few plugins at a time. One of the first lessons I learned was that even if you have limited capacity in a risk-averse environment, you can still make progress on large and complex changes; you just need to figure out how to apply changes incrementally. Also, doing so provides a natural safety net because the risk is spread across multiple releases.</p><p>A practical tool for this kind of task is what I call a "shopping list" of issues, so you can see the overall progress at a glance. We tracked our first migration here: <a href="https://github.com/elastic/apm-agent-java/issues/1337">#1337</a>.</p><p>Although a few users systematically upgrade to the latest version whenever a new release is available, most upgrade organically over time, and this provides a natural incremental rollout of any new version. There’s a similar adoption pattern for most software and libraries. In rare cases, early adopters reported issues, but we fixed them quickly before the bulk of users upgraded.</p><h3>What Log4Shell told us about the migration</h3><p>However, we were in for a surprise. The <a href="https://en.wikipedia.org/wiki/Log4Shell">Log4Shell</a> critical vulnerability was disclosed in December 2021 (less than three weeks after the publication of our <a href="https://www.elastic.co/blog/embracing-invokedynamic-to-tame-class-loaders-in-java-agents">initial blog post</a>), and it triggered a wave of emergency upgrades across the Java ecosystem. Unfortunately, the Elastic APM Java Agent was impacted, since log4j is used for logging in the agent itself, exploitable under a non-default configuration, as detailed in the <a href="https://discuss.elastic.co/t/apache-log4j2-remote-code-execution-rce-vulnerability-cve-2021-44228-esa-2021-31/291476">security advisory</a>.</p><p>If you were trying to upgrade log4j in your application at that time, you probably remember the chaos and the stress it caused. To me, it seemed like almost everyone was upgrading to the latest version of log4j and publishing new releases of their own software and libraries. As a consequence, the Maven central repository was under extreme load and most builds and releases failed due to timeouts.</p><p>Economists have the concept of <em>natural experiments</em>, where an unexpected anomaly in the real world is used to try out a hypothesis that can't be tested in a controlled environment. We don't have a direct equivalent, but this unintentional massive migration to the newer version of the Elastic APM Java Agent provided us a strong signal that the implementation was stable and without major flaws.</p><p>Retrospectively, this was a very stressful moment for the team. But when we look at <a href="https://www.elastic.co/docs/release-notes/apm/agents/java">the release changelog</a>, we can see that we didn’t have any major issues that would have required more patch releases. As shown in the chart below, except for the 1.28.x releases with log4j updates, the number of patch releases remains very low and the release cadence remains stable:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6b3b2d308dc72a66/6ad6017c7a4b45f0d4dcbd0e/image1.png" alt="Elastic APM Java Agent releases per month, Nov 2018 to Jul 2026, with a spike of 7 patches in Dec 2021" /><h2>Migrating the OpenTelemetry Java agent: what changed at scale</h2><p>As part of Elastic’s contributions to OTel, we identified <code>invokedynamic</code> as something that could benefit OTel Java instrumentation.</p><p>This project includes more contributors and maintainers than the Elastic APM Java Agent did, so it’s naturally more challenging to coordinate and to convince others that it's worth the effort to migrate instrumentation for more than 100 supported technologies. Also, the OTel Java instrumentation is distributed and used by all observability vendors using OTel, so the impact of any regression is much larger.</p><p>The discussion started in July 2023, and the <a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/issues/8999">#8999</a> issue was opened to track the plan and progress of the migration. In the following weeks, my colleague Jonas Kunz wrote the initial proof-of-concept implementation and did most of the low-level work to support invokedynamic instrumentation and classloader isolation in the OTel Java instrumentation.</p><p>We faced some key challenges during this migration, some of which were similar to those with Elastic APM:</p><h3>Migrating more than 100 instrumentation modules</h3><p>The first is to handle a migration over a broad surface. More than 100 instrumentation modules needed to be reworked, as previously it wasn’t possible without a gradual migration and testing path.</p><p>We used multiple strategies and techniques to make this possible, including:</p><ul><li><p>Adding a runtime toggle to opt in to using <code>invokedynamic</code> instrumentation. This allowed local testing as well as continuous testing in continuous integration (CI) for months without impacting existing users.</p></li><li><p>Transforming instrumentation code with Byte Buddy; for example, by setting <code>inline = false</code> on the <code>@Advice</code> annotation or making it compatible with both instrumentation modes through bytecode modification, doing all of that at runtime.</p></li><li><p>Using large language models (LLMs) to help with mechanical code transformation in the final phases of the project.</p></li></ul><p>All of this allowed testing and modifying the code over months with maximum safety. The main bottleneck remains testing and reviewing the changes.</p><h3>Why classloader instrumentation stays inline</h3><p>On a more technical level, some instrumentation modules are incompatible with <code>invokedynamic</code> and need to be kept inline; for example, with instrumentation of classloaders. The process of resolving the <code>invokedynamic</code> call site triggers classloading and thus can lead to a recursive resolution, which isn’t supported.</p><h3>JPMS access when instrumentation moves to another module</h3><p>Another interesting challenge is the compatibility with the Java Platform Module System (JPMS), introduced in Java 9. With inlined instrumentation, the instrumentation code inherits the same access as the instrumented code. When the instrumentation is instead delegated to another class in a different module and classloader, we need to grant access. Fortunately, fixing this was easy since we had already solved this in the Elastic APM Java Agent.</p><h3>Injecting proxy classes into the application classloader</h3><p>With a few instrumentation modules, we have to inject classes directly into the target classloader, when we need to make a class visible to the application code; for example, with the Spring framework. When the instrumentation code is no longer in the same classloader as the application code, we can't easily share a class between the two, because there’s no common classloader to load it from. eWe solved this by introducing proxy classes that are injected into the application classloader but that delegate to the instrumentation classloader, similar to what we have with Java Development Kit (JDK) proxies.</p><h3>Instrumenting libraries compiled before Java 7</h3><p>Last, but not least, my favorite challenge is making it work with Java versions prior to 7. Some libraries and frameworks have been compiled with older Java versions, long before the <code>invokedynamic</code> instruction was introduced. This is quite common in libraries that haven't been released in a long time or aren't maintained anymore. When the JVM validates such bytecode before loading it, it rejects any instruction that doesn’t match the version, even if the JVM supports it. You can solve this by upgrading the version of the application's bytecode before instrumenting it; in other words, we upgrade the bytecode to make it look like it was compiled with Java 7. </p><p>Fortunately, the only tricky and costly operation is to recompute the stack map frames, since newer JVMs expect to know the types of everything stored on the stack. This technique is still used in the <a href="https://github.com/elastic/apm-agent-java/blob/main/apm-agent-core/src/main/java/co/elastic/apm/agent/bci/bytebuddy/PatchBytecodeVersionTo51Transformer.java">Elastic APM Java Agent</a>, but OTel Java instrumentation has since replaced it with a different approach; when a class version doesn't support <code>invokedynamic</code>, it generates a helper class containing the <code>invokedynamic</code> instruction and invokes it via <code>invokestatic</code>, avoiding the need to bump the class version or recompute stack map frames at all (<a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/15258">PR #15258</a>).</p><h2>What’s next for invokedynamic in the OpenTelemetry Java agent</h2><p>OTel Java instrumentation is getting closer to the 3.0 release, in which <code>invokedynamic</code> instrumentation will become enabled by default for all supported modules. There are always aspects that can be improved, but the bulk of the work is done and we can consider this migration complete.</p><p>For example, in the current implementation, there are still parts of the OTel API included in the agent that are being <em>shaded</em>, meaning that they’re renamed at build time to prevent conflicts with another copy of the same library in the application classpath. Thanks to classloader isolation, we should be able to remove this for <code>invokedynamic</code> instrumentation like we did for the Elastic APM Java Agent, but doing so while preserving compatibility with inline instrumentation isn’t trivial. As usual, contributions are welcome, and I’d be happy to help anyone trying to solve this.</p><p>On a personal level, few projects that I've worked on combined this much technical depth with that amount of coordination, including classloader isolation, JPMS access rules, bytecode version tricks, and dozens of project participants, spread across two separate migrations that were three years apart. I’d like to thank the OTel maintainers and contributors, along with my colleagues at Elastic. None of this work would have been possible without them.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/opentelemetry-java-agent-invokedynamic-migration</link>
    <guid isPermaLink="false">opentelemetry-java-agent-invokedynamic-migration</guid>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[APM]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Sylvain Juge]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6ce0643a471ca039/6ad60142bcf6e0d784e57af2/image2.png" length="0" type="image/png"/>
    <pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Control how alerts activate, recover and notify: Kibana Universal Alerting in Elastic Observability]]></title>
    <description><![CDATA[Turn ES|QL queries into alert data using Kibana Universal Alerting, and triage Classic and external alerts alongside Universal alerts in a single view.]]></description>
    <content:encoded><![CDATA[<p>Kibana Universal Alerting is a new option for building and maintaining your alerts and notifications. Whether you start with a guided builder or dive straight into ES|QL, you choose exactly what to query, which data each alert stores, and who gets notified across all of your rules. Every alert is stored as time-series data, allowing you to search, analyze, and triage them all in one place. Six levers, covered below, help you tune each step.</p>
<p>Kibana Universal Alerting will be generally available and on by default in Elastic Cloud Serverless on October 20, 2026, and in Elastic Stack 9.6 for Elastic Cloud Hosted and self-managed deployments. Already have alerts in Kibana? The new system runs alongside your existing Classic Alerting system, so you can adopt what you like at your own pace.</p>
<p>Kibana Classic Alerting continues to handle common scenarios well. Pick a rule type, fill in its parameters, and attach actions as you always have. Classic Alerting is as simple and reliable as ever. Universal Alerting goes further, enabling you to express any ES|QL query as an alerting rule, control flapping with alert and recovery delay, use custom recovery queries, shape alert data to fit your needs, and schedule actions that respond and group alerts across your rules.</p>
<h2 id="whenuniversalalertingfits">When Universal Alerting fits</h2>
<p>It's a good fit when one or more of these is true:</p>
<ol>
<li>Your alert scenarios go beyond what Classic Alerting can offer, e.g., custom severities, workflow-powered actions, alert data control, full control over recovery, and more.</li>
<li>You want the flexibility of building rules using simplified query builders and later "dropping down" to edit and expand the generated ES|QL.</li>
<li>Your alerts come from more than one system, and you want to triage and analyze them in one view.</li>
<li>Fine-grained notification control matters, e.g., who is notified and how often, cross-rule grouping and processing, and more.</li>
<li>Your team wants all the flexibility offered by <a href="https://www.elastic.co/docs/explore-analyze/discover">Discover</a>, <a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL</a>, and <a href="https://www.elastic.co/docs/explore-analyze/workflows">Workflows</a>. Universal Alerting integrates perfectly with and depends on all of these foundational Elastic features.</li>
</ol>
<p>If a Classic rule type already covers your scenario, keep using it. We compare the two systems <a href="https://www.elastic.co/observability-labs/blog/kibana-universal-alerting#one-place-for-all-your-alerts-classic-universal-alerting-and-external">side by side</a> later in this post.</p>
<h2 id="howuniversalalertingworksfromesqlqueryrulestoalertdata">How Universal Alerting works: From ES|QL query rules to alert data</h2>
<p>A Universal Alerting rule runs an ES|QL query on a schedule. Each result is written as a rule event to a dedicated data stream, <code>.rule-events</code>. Four core ideas define the system:</p>
<ul>
<li>Rules are ES|QL queries, so there are no rule types to pick from. Each row the query returns, identified by its group, becomes a rule event, and the columns it returns are stored with the event. The query defines the shape of the alert.</li>
<li>Alerts are data. Rule events live in a regular data stream with a documented schema, whether they came from Kibana alerting rules or an external tool. They can be searched in Discover, charted on dashboards, and used as the input for other rules.</li>
<li>An alert follows one ongoing problem through its lifecycle (pending, active, recovering, inactive) and groups all of its underlying rule events as the alert's history. A rule doesn't trigger a new alert on every run, nor does it update an existing alert document. Rule events can be captured without triggering alerts. These are simply collected as data.</li>
<li>Action policies live outside rules. They match on alert data, group and throttle them, and send them to workflows. Changing who gets notified doesn't mean editing an action per rule.</li>
</ul>
<p>Each of these is a separate component, and each one offers flexibility and controls on its own.</p>
<h2 id="thesreworkflowauthorandshapeonerule">The SRE workflow: Author and shape one rule</h2>
<p>Say you run production hosts across several availability zones. The goal is to alert when a host stays hot, with the severity level and availability zone the on-call engineer needs. A brief spike shouldn't count. The host examples use metrics from the Elastic Agent System integration (<code>system.*</code> fields), and the pod example later uses the Kubernetes integration (<code>kubernetes.*</code> fields). Swap in your own fields as needed.</p>
<h3 id="authoroneesqlrulewrittenthewayyoualreadyquery">Author: One ES|QL rule, written the way you already query</h3>
<p>This rule computes average CPU per host, assigns a severity and returns only the hosts worth a look:</p>
<pre><code>/*
  Average CPU per host, with a severity and a minimum-sample guard.
*/
FROM metrics-*
| WHERE system.cpu.total.norm.pct IS NOT NULL
| STATS
    avg_cpu_pct = ROUND(AVG(system.cpu.total.norm.pct) * 100, 0),
    samples = COUNT(*)
  BY host.name, cloud.availability_zone
    /* A column named severity sets the alert's built-in severity field
       (info, low, medium, high or critical). Hosts below every threshold get
       no severity, and the WHERE below drops them. The other returned
       columns, such as avg_cpu_pct and the zone, are stored on the alert and
       available to action policies and dashboards later. */
| EVAL severity = CASE(avg_cpu_pct &gt; 95, "critical", avg_cpu_pct &gt; 90, "high", avg_cpu_pct &gt; 85, "medium")
| WHERE samples &gt;= 10 AND severity IS NOT NULL
    /* A minimum-sample guard skips hosts that just came up and have only
       reported a few data points. */
| KEEP host.name, cloud.availability_zone, avg_cpu_pct, severity, samples
</code></pre>
<h3 id="shapedecidehowthealertbehavesovertime">Shape: Decide how the alert behaves over time</h3>
<p>The query decides what matches. The rule definition decides what happens as matches come and go. Here's the same rule in YAML, split into a base query, a breach condition, and a recovery condition:</p>
<pre><code>kind: alert

metadata:
  name: host-cpu-saturation
  routing_tags: [infra]                 # links this rule to action policies (lever 5)

schedule:
  every: 1m
  lookback: 5m

query:
  base: |
    FROM metrics-*
    | WHERE system.cpu.total.norm.pct IS NOT NULL
    | STATS
        avg_cpu_pct = ROUND(AVG(system.cpu.total.norm.pct) * 100, 0),
        samples = COUNT(*)
      BY host.name, cloud.availability_zone
    | EVAL severity = CASE(avg_cpu_pct &gt; 95, "critical", avg_cpu_pct &gt; 90, "high", avg_cpu_pct &gt; 85, "medium")
  breach:
    segment: WHERE samples &gt;= 10 AND severity IS NOT NULL

recovery:
  strategy: condition
  segment: WHERE avg_cpu_pct &lt; 70       # recover well below the breach threshold

no_data:
  strategy: keep_last                   # a host that is rebooting keeps its last known status

state_transition:
  pending:
    count: 2                            # alert delay: the alert goes active on the third consecutive breach
  recovering:
    count: 2                            # recovery delay: the alert goes inactive on the third consecutive recovery

grouping:
  fields: [host.name, cloud.availability_zone]   # one alert per host
</code></pre>
<p>Recovery, state transitions, grouping and no-data handling are each tunable, and levers 1 to 4 below cover them. Levers 5 and 6 pick up once the alert is active: who gets notified, and how responders triage it.</p>
<h2 id="sixleversforfinetuningyouralerts">Six levers for fine-tuning your alerts</h2>
<p>Universal Alerting has a control at each stage of the pipeline, listed here from earliest to latest. Use only the ones a scenario calls for.</p>
<h3 id="1thequerydecideexactlywhatcounts">1. The query: Decide exactly what counts</h3>
<p>ES|QL has everything needed to define a condition precisely. <code>WHERE</code> sets the scope, a minimum-sample guard ignores hosts that just came up, <code>CASE</code> assigns severities, and <code>SORT</code> with <code>LIMIT</code> restricts alerts to the worst offenders.</p>
<pre><code>/*
  Alert on only the three hottest pods, not every pod above the line.
*/
FROM metrics-*
| WHERE kubernetes.pod.cpu.usage.node.pct IS NOT NULL
| STATS avg_cpu_pct = ROUND(AVG(kubernetes.pod.cpu.usage.node.pct) * 100, 1) BY kubernetes.pod.name
| SORT avg_cpu_pct DESC
| KEEP kubernetes.pod.name, avg_cpu_pct
| LIMIT 3
    /* LIMIT caps the number of alerts a single run can produce.
       Use it when "the worst few" is the question you care about. */
</code></pre>
<p><code>LIMIT</code> has one side effect to plan for. A pod that drops out of the top three disappears from the results. With <code>no_breach</code> recovery, that counts as recovered, and the alert can activate again if the pod climbs back. If that matters for your rule, pair <code>LIMIT</code> with a recovery delay (lever 3).</p>
<h3 id="2groupingchoosetheunitofanalert">2. Grouping: Choose the unit of an alert</h3>
<p><code>STATS ... BY</code> defines what a single alert is. Group by <code>host.name</code> for one alert per host, or by <code>service.name</code> for one per service. Pick the unit someone can act on: a grouping that's too wide hides problems, and one that's too narrow multiplies them. If a grouping field is missing from some documents, those rows fall into a single null group and share one alert, while the other groups keep their own alerts. Handle missing values explicitly in the query if that matters.</p>
<p>Alerting rules return at most 1,000 rows unless you set a <code>LIMIT</code>. If a single run could produce more groups than that, add an explicit <code>LIMIT</code>.</p>
<h3 id="3alertdelayandrecoverydelaydefinehowanalertstartsandends">3. Alert delay and recovery delay: Define how an alert starts and ends</h3>
<p>Thirty seconds over a threshold isn't always an incident. An instance of recovery isn't always indicative of a real recovery. This kind of flapping is one of the biggest sources of alert noise, and two delay configurations handle it without touching the query.</p>
<p>The alert delay (<code>state_transition.pending</code>) is the number of evaluations an alert spends in the pending state before it goes active. With <code>count: 2</code>, the alert goes active on the third consecutive breach, so a short spike stays pending and never becomes an alert.</p>
<p>The recovery delay (<code>state_transition.recovering</code>) does the same for the recovering state before the alert goes inactive. A signal that bounces around the threshold stays one alert. Each phase accepts a <code>count</code>, a <code>timeframe</code> or both, plus an <code>operator</code> (<code>and</code> or <code>or</code>) that says whether both must be met or just one.</p>
<p>Recovery is a separate setting, and every alert rule must set one (<code>no_breach</code> by default):</p>
<ul>
<li><code>no_breach</code> recovers an alert when its group stops appearing in the breach results.</li>
<li><code>condition</code> recovers it when a different recovery condition (as opposed to alert condition) on the same base query is met, as in the rule above.</li>
<li><code>query</code> recovers it when a separate ES|QL query returns the alert's group. Recovery is per group, so each alert recovers only when the query returns its group.</li>
<li><code>manual</code> never recovers on its own. The alert stays active until someone manually resolves it.</li>
</ul>
<p>A common pattern is to recover well below the breach threshold; for example, breach at 90% CPU and recover under 67%. That way, an alert doesn't close the moment the metric dips under the line. And because <code>query</code> recovery can run its own ES|QL query, it can check different values than the breach does. The example below requires both CPU and load average to be back to normal before the alert closes:</p>
<pre><code># A recovery query that looks at more than the breach query does.
recovery:
  strategy: query
  query: |
    FROM metrics-*
    | STATS
        avg_cpu_pct = ROUND(AVG(system.cpu.total.norm.pct) * 100, 0),
        load_avg_5m = AVG(system.load.5)
      BY host.name, cloud.availability_zone
    | WHERE avg_cpu_pct &lt; 67 AND load_avg_5m &lt; 4
    | KEEP host.name, cloud.availability_zone
</code></pre>
<h3 id="4nodatahandlingdecidewhatsilencemeans">4. No-data handling: Decide what silence means</h3>
<p>When a group with an active alert stops showing up in the breach results, there are two very different explanations. Either the problem cleared up or the data source stopped reporting, and the alert condition alone can't tell them apart. No-data handling lets you decide, per rule, what happens in that moment, so a broken metrics agent doesn't quietly recover a real alert.</p>
<p>Set it with <code>no_data.strategy</code>, or with the no-data setting in the rule builder:</p>
<p>| Option | <code>no_data.strategy</code> | What happens |
| --- | --- | --- |
| Keep last status | <code>keep_last</code> | Holds the last known state. An active alert stays active, and a recovering alert stays recovering. A <code>status:no_data</code> rule event is written to the data stream.|
| Recover | <code>resolve</code> | Closes the alert the first time no data is found, moving it straight to inactive without waiting for the recovery delay. A <code>status:no_data</code> rule event is written to the data stream.|
| Do nothing | <code>ignore</code> | Skips the no-data check. With <code>no_breach</code> recovery, a gap in data looks like a recovery. With <code>condition</code> or <code>query</code> recovery, no event is written and the alert stays active. |</p>
<p>The right choice depends on what silence means for the rule. For infrastructure CPU, use Keep last status: if the agent stops sending data, an active CPU breach stays active until data comes back. For a rule that triggers an alert when a job backlog grows too large, Recover makes sense. Once the queue is empty, the query has nothing to return and the alert closes.</p>
<h3 id="5actionpoliciesroutegroupandthrottle">5. Action policies: Route, group and throttle</h3>
<p>Rules don't carry their own actions. Instead, an action policy selects alerts, bundles them into a payload, controls how often they repeat and dispatches them to a workflow. One action policy can cover alerts from many rules, so changing who gets paged for a hundred rules takes a single edit.</p>
<p>Policy scope selects the alerts an action policy handles, in two parts that can be combined. Routing tags limit the action policy to alerts from rules that carry at least one of those tags. Under Advanced matching, a <a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/kql">KQL</a> expression filters on alert fields such as <code>severity</code>, <code>group_hash</code> or <code>data.*</code>. When both are set, an alert has to pass both.</p>
<p>Routing tags connect an action policy to a rule. Add them in the Routing tags field on the Actions step of the rule form, and select the same tags in the action policy form. From then on, the action policy applies to every rule that carries one of those tags, including rules created later. The rule form's Applied policies panel lists every action policy that matches the rule, so the connection is easy to verify. To target a single rule, give it a routing tag that no other rule uses. Attaching or detaching a rule is then a matter of editing its routing tags, and the action policy itself doesn't change.</p>
<p>Routing tags aren't the same as a rule's regular tags. Regular tags group and filter rules in the rule list. They have no effect on notifications, so adding a regular tag to a rule never attaches an action policy to it.</p>
<p>Two more settings shape the notifications. Notify per controls bundling: one notification per alert, one per group of alerts across rules that share a field value such as the availability zone, or a single digest of everything the action policy matches. Frequency limits repeats, for example to at most once every 30 minutes.</p>
<p>Here are two action policies for the CPU rule, written as the settings chosen in the UI. Both are tied to the rule through its <code>infra</code> routing tag:</p>
<pre><code># Page the on-call engineer only for critical hosts, one page per availability zone.
Name:           infra-oncall-pager
Policy scope:   Routing tags: infra
                Advanced matching (KQL): severity : "critical"
Notify per:     Group, by data.cloud.availability_zone
Frequency:      At most once every 30 minutes
Destination:    pagerduty-infra workflow

# Send the lower severities to the infrastructure channel, batched per zone, at most hourly.
Name:           infra-team-digest
Policy scope:   Routing tags: infra
                Advanced matching (KQL): severity : "high" or severity : "medium"
Notify per:     Group, by data.cloud.availability_zone
Frequency:      At most once every 1 hour
Destination:    slack-infra-team workflow
</code></pre>
<p>An alert can match several action policies, and each one applies its own grouping and frequency. If a whole zone runs hot, the pager gets one grouped page rather than one per host, and the team channel gets an hourly summary of the high and medium hosts.</p>
<p>Action policies also work across rules. Give your CPU, memory and disk rules the same routing tag, select that tag in an action policy and set Notify per to Group by <code>data.cloud.availability_zone</code>. A zone where all three rules fire then sends one page instead of three. Switch Notify per to Digest, and the action policy sends a single notification for everything it matches. To alert on the pattern itself, see <a href="https://www.elastic.co/observability-labs/blog/kibana-universal-alerting#rules-on-top-of-your-alerts">rules on top of your alerts</a>.
Expand commentComment on line R230Resolved
Each destination in these examples is a workflow that calls your connector, such as Slack or PagerDuty. To learn how to build and enable one, see the <a href="https://www.elastic.co/docs/explore-analyze/workflows">Workflows documentation</a>.</p>
<h3 id="6triagecontrolsandmaintenancewindowsquietthingsonpurpose">6. Triage controls and maintenance windows: Quiet things on purpose</h3>
<p>Once an alert is active, a responder can acknowledge it, snooze the series for a set period or resolve it. Suppressed alerts stay out of notification channels but remain in the data. Nothing is deleted, and everything stays auditable. During a planned rollout, for example, snoozing the series keeps it out of notification channels until the snooze expires. Maintenance windows do the same for scheduled work and apply to Universal Alerting and Classic rules alike.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltef955ad52b501fd7/6acf7a53bcf6e058b0e5640c/alerts-actions.png" alt="The Alerts page with an alert details flyout and the Take action menu showing acknowledge, snooze and resolve" /></p>
<h2 id="oneplaceforallyouralertsclassicuniversalandevenexternal">One place for all your alerts: Classic, Universal, and even external</h2>
<p>Few teams have a single source of alerts. Some come from Classic Alerting rules, some from Universal Alerting rules, and some from other monitoring tools. Our new alerts views are built so responders can work from a single queue.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltec51c43df4d2b9ba/6acf7a6a6d1916350f15a8fb/alerts-multisource.png" alt="The Alerts page with Classic, Universal and external alerts" /></p>
<h3 id="alertsfromothertoolsasdata">Alerts from other tools, as data</h3>
<p>Alerts from external monitoring tools land in the same data stream as the alerts Elastic generates, and are searched, routed and triaged the same way. A workflow writes each one with the <code>alerting.create_alert</code> step, and the <code>source</code> field records where it came from. For the on-call engineer, that's one place to check instead of a tab per tool. External alert ingestion is an experimental feature, so expect it to change and improve as we learn how it's used.</p>
<h3 id="universalvsclassicfeaturecomparison">Universal vs. Classic feature comparison</h3>
<p>The two systems share a goal but make different tradeoffs. Classic Alerting favors guided setup for known scenarios, while Universal Alerting favors flexibility and control.</p>
<p>| Feature | Classic | Universal |
| --- | :---: | :---: |
| Prebuilt rule types with guided parameters | ✓ | Partial, more to come |
| Rules written as ES|QL queries | ✓ | ✓ |
| Basic threshold rules | ✓ | ✓ |
| Anomaly detection and APM anomaly rules | ✓ | Coming soon |
| Machine learning job health and transform health rules | ✓ | Coming soon |
| SLO burn rate rules | ✓ | Coming soon |
| Synthetics monitor status rules | ✓ | Coming soon |
| Alert fields defined by the rule's query | | ✓ |
| Alert event historical data, per run |  | ✓ |
| Alert delay (consecutive breaches before an alert goes active) | ✓ | ✓ |
| Recovery delay (consecutive recoveries before an alert auto-recovers) | | ✓ |
| Custom recovery condition or recovery query | | ✓ |
| Actions attached to each rule, sent directly to connectors | ✓ | |
| Reusable action policies shared across many rules, with grouping and throttling | | ✓ |
| Import alerts from external tools (experimental) | | ✓ |
| Triage and analysis in the combined Alerts page | ✓ | ✓ |
| Maintenance windows | ✓ | ✓ |</p>
<p>Universal and Classic alerts appear together on the combined Alerts page, so responders triage everything in one view. Classic Alerting keeps running exactly as it does today. Maintenance windows, connectors, and workflows are shared across both systems.</p>
<p>New alerting capabilities are actively being built in Universal Alerting. Classic rules remain a good fit for the standard scenarios they were designed for, and you can adopt Universal Alerting at your own pace, one rule at a time.</p>
<h2 id="whatuniversalalertingdoesntdoyet">What Universal Alerting doesn't do (yet)</h2>
<p>Universal Alerting doesn't match Classic Alerting feature for feature yet. We'd rather you hear about the gaps now than discover them mid-incident, so here's what to know before moving a rule over.</p>
<ul>
<li>Not every Classic rule type has a Universal equivalent yet. Universal rules are ES|QL queries, written by hand or generated by the rule builder. Threshold-style rules, such as custom, metric, log and index threshold rules, map well to the threshold rule builder, and many other conditions can be written directly in ES|QL. Rule types that rely on results computed elsewhere in the stack, such as anomaly detection, don't have a Universal counterpart yet, so those rules stay on Classic for now. The comparison table above lists them, and the rule library on our roadmap will add ready-made starting points for common patterns.</li>
<li>SLO and synthetics rules need extra work. Their conditions can be written in ES|QL against the SLO and synthetics data, but the multi-window burn rate logic and per-monitor settings of the Classic rules have to be rebuilt by hand. Keep those rules on Classic unless you need the extra control.</li>
<li>Classic rules are not automatically migrated to the Universal system. They keep running unchanged, and moving one to Universal Alerting means rebuilding its condition as an ES|QL rule.</li>
<li>Universal Alerting actions depend on workflows. A Classic rule sends its actions straight to connectors. A Universal rule has no actions of its own: action policies hand alerts to workflows, which are the only destination type, and the workflow then calls Slack, PagerDuty or any other connector. Before a Universal rule can notify anyone, at least one workflow must exist and be enabled.</li>
<li>Workflows require an Enterprise license on Elastic Cloud Hosted and self-managed deployments. Without this license, Universal rules still run and alerts are still written, but action policies cannot deliver to any destination, so no notifications go out. Existing action policies can still be viewed, disabled, snoozed and deleted.</li>
<li>Universal alerts don't provide flapping state configuration. Alert and recovery delays and custom recovery (lever 3) are the way to keep a noisy alert from firing too often.</li>
<li>Field names may differ between the two alert types. A query on a Universal alert field such as <code>group_hash</code> won't match any Classic alerts. Our unified alerts table mostly hides this distinction, but use shared filters (status, severity, rule, tags) in your own combined queries, or query each system separately.</li>
</ul>
<p>Two delivery details apply to every action policy. Notifications are delivered at least once, so in rare cases, such as a restart between dispatch and recording, the same notification can arrive twice. Make workflows idempotent. Notifications are also dispatched on a short polling interval rather than instantly, so don't depend on sub-second paging latency.</p>
<h2 id="understandyouralertingwithesql">Understand your alerting with ES|QL</h2>
<p>An alert and its history are documents in a data stream, so the language that defines a rule can also answer questions about your alerting. From an alert, jump into Discover and pivot to that host's metrics and logs without a separate tool. Both examples below query the <code>.rule-events</code> data stream.</p>
<pre><code>/*
  The ten rules that activated the most alerts in the last 7 days.
  rule.id is the rule's saved object ID, not its name.
*/
FROM .rule-events
| STATS alerts = COUNT_DISTINCT(alert.id) BY rule.id
| SORT alerts DESC
| LIMIT 10
    /* Rules at the top are good candidates for tuning. Adjust a threshold,
       raise the alert delay or change the grouping, then re-run this. */
</code></pre>
<pre><code>/*
  Alert volume per day for one rule, to confirm a change had the effect you wanted.
  rule.id is the rule's saved object ID, not its name.
*/
FROM .rule-events
| WHERE rule.id == "&lt;your-rule-id&gt;" AND type == "alert"
| STATS alerts = COUNT_DISTINCT(alert.id) BY day = BUCKET(@timestamp, 1 day)
| SORT day ASC
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt41c659d5f8a763e7/6acf7a7f0179ead3d02e4711/discover-alerts.png" alt="Discover running an ES|QL query on .rule-events that counts distinct alerts per day" /></p>
<h2 id="rulesontopofyouralerts">Rules on top of your alerts</h2>
<p>Since alerts are data, rules can run over them too. A rule on alert data is an ordinary ES|QL rule that reads <code>.rule-events</code> instead of logs and metrics. That lets it catch a pattern no single alert shows, such as three or more rules firing in the same availability zone. Alerts from Universal rules and from external tools work the same way here, because both land in the same data stream with the same fields. The alerts such a rule produces get the same lifecycle, action policies and triage controls as any other alert.</p>
<p>Signal rules are the building blocks for this. A rule with <code>kind: signal</code> (labeled Events in the UI) records breach and recovery events as data, without triggering alerts or notifying anyone. Use signal rules for conditions that matter mostly in combination, like high CPU, low free disk and elevated I/O wait on the same host. Then write an alert rule that fires when several of those signals line up.</p>
<h2 id="whatwereworkingonnext">What we're working on next</h2>
<h3 id="tryupcomingfeaturesperspace">Try upcoming features per space</h3>
<p>New and upcoming Kibana Universal Alerting features will ship behind an experimental feature flag, so teams can try them before they're enabled for everyone. The flag is the <strong>Universal Alerting: Experimental Features</strong> setting (<code>alerting:v2:experimentalFeatures</code>). It's off by default and set per Kibana space, so you can turn it on in a test or staging space and leave production spaces as they are. Changing it requires a page reload.</p>
<p>The flag gates every upcoming Universal Alerting feature. Everything that ships at GA works without it.</p>
<h3 id="whatscoming">What's coming</h3>
<p>While we aren't committing to any specific dates, this is where we're focused after GA:</p>
<ul>
<li>Sequence rule builder GA: A guided way to build rules that detect events happening in a particular order, without writing the whole query by hand (already available via "experimental features").</li>
<li>More rule builders: Provide more guided ways to build rule queries. (SLO, synthetics, anomaly detection, etc.)</li>
<li>A rule library of ready-made rules to start from and adapt, so a new rule doesn't begin with a blank query.</li>
<li>Alert deletion, for alerts that are no longer needed.</li>
<li>External alert import, generally available, with simpler setup and management.</li>
<li>Bi-directional alert syncing with external systems, so an action taken in one system is reflected in the other.</li>
<li>Further "no data" features.</li>
</ul>
<p>If one of these matters more to you than the rest, tell us. It helps us decide what to build first.</p>
<h2 id="getstarted">Get started</h2>
<p>Universal Alerting is generally available on Elastic Cloud Serverless, and on Elastic Cloud Hosted and self-managed deployments starting with Elastic Stack 9.6. On Elastic Cloud Hosted and self-managed deployments, workflow-based action policies require an Enterprise license, which includes 10,000 workflow executions per month (see <a href="https://www.elastic.co/subscriptions">subscriptions</a>). On Serverless, Elastic manages the alerting system.</p>
<h3 id="giveyourteamaccess">Give your team access</h3>
<p>Access is controlled with Kibana feature privileges, set per role in <strong>Stack Management &gt; Roles</strong>. Universal Alerting adds four, listed under Universal Alerting in the role editor, each with its own <strong>All</strong> or <strong>Read</strong> level:</p>
<p>| Privilege | Read | All |
| --- | --- | --- |
| Rules | View rules | Create, edit and delete rules |
| Alerts | View alerts | Triage alerts |
| Action Policies | View action policies | Create and edit action policies |
| Execution history | View execution history | Same as Read |</p>
<p>Most responders need Alerts: All and Rules: Read. Rule authors need Rules: All, plus Action Policies: All if they also manage notifications. Notifications run through workflows, so anyone who builds or edits them also needs <strong>All</strong> for <strong>Analytics &gt; Workflows</strong>. See <a href="https://www.elastic.co/docs/explore-analyze/workflows/get-started/setup">Set up Workflows</a> for the full list of Workflows privileges.</p>
<p>Privileges control what a user can manage, not what data a rule can read. Each rule runs with the API key of the user who last saved it, and that key determines what data the rule can query. Save your rules as a user who can read the indices they query. For how rules, action policies and workflows are authorized, see the <a href="https://www.elastic.co/docs/explore-analyze/alerting/experimental-alerting-system/authorization">authorization documentation</a>.</p>
<h3 id="createyourfirstrule">Create your first rule</h3>
<p>Open the Universal rules tab, create your first rule and tell us what you find. If something comes up that we didn't cover here, we want to hear about it.</p>
<p>Don't have a deployment yet? Start an <a href="https://cloud.elastic.co/registration">Elastic Cloud free trial</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/kibana-universal-alerting</link>
    <guid isPermaLink="false">kibana-universal-alerting</guid>
    <category><![CDATA[What's New]]></category>
    <category><![CDATA[Incident Management]]></category>
    <dc:creator><![CDATA[Tia Milosevic,Jason Rhodes]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt51b3aa37674303d4/6acf7a1ff87eb7bd53b443e6/header.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 20 Oct 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[YAML meets IConfiguration: Declarative configuration for the OpenTelemetry .NET SDK]]></title>
    <description><![CDATA[You can change sampling without shipping a new build, because the first declarative configuration package parses YAML into a typed model and projects it into the flat keys the OpenTelemetry .NET SDK already reads.]]></description>
    <content:encoded><![CDATA[<p>Soon you will be able to configure the OpenTelemetry (OTel) .NET Client from a YAML file, with one call to <code>UseDeclarativeConfiguration()</code> and no further changes to your instrumentation code.<a href="https://github.com/open-telemetry/opentelemetry-dotnet/pull/7413"> Pull request (PR) #7413</a> added a new <code>OpenTelemetry.Configuration.Declarative</code> package that parses a declarative configuration document into a typed model and then projects it into the <code>IConfiguration</code> that the SDK already reads. Roughly two thirds of its 8,800 lines are tests, including fuzz tests for the parser and for environment variable substitution. The typed model is the part that matters, because a structured, language-neutral document doesn’t fit .NET’s flat and layered configuration system without one.</p><h2>Why OpenTelemetry needs declarative configuration</h2><p>The OTel specification defines three ways to configure an SDK, and each makes a different tradeoff. They differ in their ability to express structure and the need for source access, along with whether changing them means a new build:</p><p><strong>Mechanism</strong></p><p><strong>Expresses structure</strong></p><p><strong>Needs code access</strong></p><p><strong>Needs redeploy to change</strong></p><p>Environment variables</p><p>No, flat key/value only</p><p>No</p><p>No</p><p>Programmatic</p><p>Yes</p><p>Yes</p><p>Yes</p><p>Declarative file</p><p>Yes</p><p>No*</p><p>No</p><p><em>* Needs a package reference and one time registration to opt-in to declarative config. Thereafter, the config file can change and is applied at application launch.</em></p><p>Environment variables are the mechanism that most people encounter first. They’re portable, available everywhere and they ask nothing of your application code. Setting <code>OTEL_SERVICE_NAME</code> or <code>OTEL_EXPORTER_OTLP_ENDPOINT</code> works nicely because those values are single strings. Where they fall down is structural configuration, such as defining a sampler and its related options, or two exporters with different batching behavior. The flat key/value model has no way to express that shape, so you either go without the capability or smuggle structure into a string, and neither option ages well.</p><p>Programmatic configuration sits at the other end. It’s completely expressive, but it asks a lot in return, including access to the source and the ability to change it, in addition to a rebuild and redeploy every time somebody wants to adjust sampling. In many environments, that isn’t a reasonable ask. Production systems and legacy applications share the same constraint, as does anything instrumented automatically without touching code. The people who need to tune the telemetry are frequently not those who can ship a new build.</p><p>Declarative configuration fills that gap. It introduces a standardized, file-based representation of a configuration data model, defined in the <a href="https://opentelemetry.io/docs/specs/otel/configuration/sdk/">Configuration SDK</a> specification. This excerpt from the spec’s <a href="https://github.com/open-telemetry/opentelemetry-configuration/blob/v1.2.0/examples/otel-getting-started.yaml">getting-started example</a> shows the shape. The sampler and the exporter sit inside the tracer provider that they belong to, and a second exporter with its own batching settings would be one more entry under <code>processors</code>:</p>tracer_provider:
  sampler:
    parent_based:
      root:
        always_on:
  processors:
    - batch:
        exporter:
          otlp_http:
            endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://localhost:4318}/v1/traces<p>A document like this offers the expressiveness of programmatic configuration without requiring code changes. And, because the format is language-agnostic, the same shape of document configures a .NET service and a Java one. The model also covers instrumentation alongside SDK components, so its reach isn’t limited to the parts that you would otherwise wire up yourself.</p><p>The specification was <a href="https://opentelemetry.io/blog/2026/stable-declarative-config/">declared stable</a> in March 2026, and the announcement listed .NET among the implementations still in development. The .NET auto-instrumentation project has its own implementation for zero-code setups, but the SDK itself doesn’t support declarative configuration yet. Bringing it into the SDK makes it available to any application or distribution. <a href="https://github.com/open-telemetry/opentelemetry-dotnet/issues/7658">Issue #7658</a> tracks that work. In this post, we cover the early stages of those efforts.</p><h2>Why YAML doesn’t fit .NET’s IConfiguration</h2><p>Reading a YAML file is the easy part. The hard part is that .NET already has a configuration system, and it’s a very good one.</p><p><code>Microsoft.Extensions.Configuration</code> gives every modern .NET application an IConfiguration; that is, a single view composed from an ordered list of sources, typically <code>appsettings.json</code>, an environment-specific overlay, environment variables, command-line arguments, and perhaps a secrets store. When two sources define the same key, the later source wins. The options pattern then binds that view onto typed classes. .NET developers rely on this layering, and they expect any new configuration mechanism to join in.</p><p>Before writing any code, we analyzed how the SDK consumes configuration today. The first finding was encouraging. No SDK component reads environment variables directly. Every <code>OTEL_*</code> value is read through <code>IConfiguration</code>, which is why you can already set <code>OTEL_SERVICE_NAME</code> in <code>appsettings.json</code> and it simply works. Most option types are built from <code>IConfiguration</code> through the SDK's options factory. So a declarative file could plausibly slot in as one more configuration source, and the existing SDK would read it unchanged.</p><p>Projecting a declarative file into <code>IConfiguration</code> would only get part of the way, for five reasons:</p><ul><li><p>YAML is hierarchical, with snake_case keys and nested components. The SDK's options are flat and keyed like environment variables (<code>OTEL_BSP_SCHEDULE_DELAY</code>). Every value in <code>IConfiguration</code> is a string, so <code>value: 3</code> and <code>value: "3"</code>, an integer and a string in the configuration schema, become indistinguishable. An absent property and one explicitly set to <code>null</code> mean different things in the schema, and ordinary configuration access collapses them.</p></li></ul><ul><li><p>Today, a sampler, exporter, or propagator exists because someone wrote <code>SetSampler(new TraceIdRatioBasedSampler(0.5))</code> or <code>AddOtlpExporter()</code>. Where the SDK does turn a string into a component, as with <code>OTEL_TRACES_SAMPLER</code>, it uses a handwritten switch statement that third parties cannot extend. Some spec-defined variables, such as <code>OTEL_PROPAGATORS</code> and the <code>OTEL_*_EXPORTER</code> family, aren’t implemented at all. A document that says "use a <code>parent_based</code> sampler wrapping <code>trace_id_ratio_based</code>" needs a registry that maps names to components, and the SDK has none.</p></li></ul><ul><li><p>The SDK resolves options through <code>IOptionsMonitor&lt;T&gt;</code>, which looks like latent reload support but isn't. That choice was made to fix a dependency-injection lifetime problem. Nothing subscribes to change notifications, and many values are captured into long-lived state at construction. That matters for the longer-term goal of dynamic control, and it means that reload was explicitly out of scope for the first steps.</p></li></ul><ul><li><p>The spec says that when <code>OTEL_CONFIG_FILE</code> is set, "all other environment variables besides those referenced in the configuration file for environment variable substitution MUST be ignored," because "there is no intuitive way to merge the flat environment variable scheme with the structured file configuration scheme in all cases." A .NET developer's instinct is the opposite: the file should be another layer. Because environment variables reach the SDK through <code>IConfiguration</code> in the same key space as <code>appsettings.json</code>, nothing when a value is read records where it came from.</p></li></ul><ul><li><p>We also looked at prior art. The Java SDK is the reference implementation of the specification, and the .NET auto-instrumentation project already ships its own file-based configuration. There were practical questions, too, such as whether the work belonged in the SDK repository or started in contrib, and whether to take a dependency on a YAML library at all.</p></li></ul><p>So the question was: How<em> does a structured, typed, language-neutral document fit into a platform whose configuration is flat, string-typed, and layered, without breaking either model?</em> The question was never <em>How do we parse YAML?</em></p><h2>A typed model behind IConfiguration</h2><p>The analysis produced a broad plan: 24 proposed phases covering foundation work, new option types, a component registry, and reload support. That was too much to land in one go and too much to ask maintainers to agree to in the abstract. The next step was a deliberately minimal proof of concept (PoC) that the maintainers could react to.</p><p>The first cut took the obvious route. A custom <code>IConfigurationProvider</code> walked the YAML and emitted flat <code>OTEL_*</code> keys for the existing SDK to read. It worked, but emitting strings straight from the YAML collapsed absent, <code>null</code>, and present into two states, and the spec requires <code>Parse</code> to distinguish missing from <code>null</code>.</p><p>So the PoC was refined into two stages, and that shape is what <a href="https://github.com/open-telemetry/opentelemetry-dotnet/pull/7413">PR #7413</a> shipped:</p><ol><li><p><strong>Parse into a typed model.</strong> The YAML is parsed into internal records that carry no YAML dependency, with every scalar wrapped in a three-state value. Only the parser touches the YAML library (YamlDotNet), so a future source, such as an Open Agent Management Protocol (OpAMP) push, could build the same model.</p></li><li><p><strong>Project into </strong><strong><code>IConfiguration</code></strong><strong>.</strong> The model is projected into the flat <code>OTEL_*</code> keys that the SDK already understands, through a configuration provider. <code>disabled: true</code> becomes <code>OTEL_SDK_DISABLED=true</code>, and every existing read site picks it up with no changes to the core SDK.</p></li></ol><p>The PR added a new package, <code>OpenTelemetry.Configuration.Declarative</code>, so applications never take on its YAML dependency without opting in. It supported a deliberately small slice of the schema: <code>file_format</code>, <code>disabled</code>, <code>resource.attributes</code>, and <code>resource.attributes_list</code>, plus the spec's environment-variable substitution. Wiring it up takes one call using UseDeclarativeConfiguration on the OpenTelemetryBuilder.</p><p>The following code adds the OTel SDK with declarative configuration and tracing enabled:</p>services.AddOpenTelemetry()
    .UseDeclarativeConfiguration()
    .WithTracing(...);<p>The following basic YAML configuration adds two resource attributes that apply to telemetry data exported from this service:</p>file_format: "1.2"

resource:
  attributes:
    - name: service.name
      value: ${SERVICE_NAME:-my-service}
    - name: service.version
      value: "1.0.0"<p>We called this <em><strong>overlay mode</strong></em>. The YAML source is appended after every source already registered, so the file takes precedence over environment variables and <code>appsettings.json</code>. Keys that the file doesn’t set fall through to whatever was there before, and sources added later still override the file, exactly as .NET developers expect.</p><p>Overlay mode was the right place to start. It needed no changes to the stable core SDK. The file behaved like any other configuration source, so its precedence didn’t need an explanation. Plus, the parser and the model could be reviewed on their own, without a component engine attached, as could  the substitution rules.</p><p>The most important decision in that PR, though, was making the typed model, rather than <code>IConfiguration</code>, the source of truth. Flat keys are one projection of that model, feeding the SDK's existing options pipeline. The design also anticipated a second projection: a schemaless view of each node, the spec's <code>ConfigProperties</code>, for building components. This was necessary because <code>IConfiguration</code> cannot represent absent versus <code>null</code> or polymorphic component selection, nor can it represent arbitrary nesting. Only the first existed in PR #7413, but the second was designed in from the start.</p><p>The PR was also candid about what overlay mode didn’t not do, such as:</p><ul><li><p>Resource attributes were string-only, because typed values cannot survive projection into the <code>OTEL_RESOURCE_ATTRIBUTES</code> string.</p></li><li><p>Sections such as <code>tracer_provider</code> were logged and not applied.</p></li><li><p>The file was read once at start-up.</p></li><li><p>There was no strict mode. Standard <code>IConfiguration</code> ordering applied, so leftover <code>OTEL_*</code> variables still took effect for any setting that the file left out.</p></li></ul><p>The design is now heading somewhere different with that last gap. Take a partially written file, for example, which is the normal case during a migration. It sets no sampler, but the deployment still carries <code>OTEL_TRACES_SAMPLER=always_off</code> from before. Under overlay mode, that leftover variable quietly applies, so two services with identical files can behave differently, which no other OTel SDK would do. The spec's answer is that anyone who needs to combine sources should customize the configuration model, not merge keys.</p><p>With the initial phases landed, the pragmatic choice is to align with the spec and make strict behavior the default: when a document is in use, it’s the source of OTel settings. That closes off the partial-file problem entirely. The .NET-style layering that developers value isn’t being ruled out. We’ll investigate later how .NET might support model customization, with explicit, opt-in merge rules. Either way, the typed model at the center of PR #7413 makes the change possible without starting again.</p><h3>Getting the OTel configuration spec right</h3><p>Configuration is the first thing that an SDK touches and the last thing that anyone suspects when telemetry goes missing, so even small deviations carry risk. Much of the first PR went on details that a casual reading of the spec skips over. Roughly two thirds of its 8,800 lines are tests, including fuzz tests for the parser and for substitution.</p><ul><li><p><strong>Three states, everywhere.</strong> C# nullable annotations cannot distinguish "absent" from "present and <code>null</code>". Union types, which could model this directly, are coming in C# 15 with .NET 11, but they’re still in preview, and the package also has to build for <code>netstandard2.0</code> and .NET Framework. So every model field is a three-state value, and every field is tested in all three states, not only the happy path.</p></li></ul><ul><li><p><strong>YAML 1.2, not YAML 1.1.</strong> The spec requires the YAML 1.2 core schema. YAML 1.1 habits include merge keys (<code>&lt;&lt;: *defaults</code>) and a looser set of Boolean spellings. The package ships its own YAML 1.2 scalar resolver rather than relying on library defaults, and it rejects merge keys outright. Quoting is significant: <code>file_format: "1.1"</code> is a string and is accepted, while <code>file_format: 1.1</code> is a number and is rejected.</p></li></ul><ul><li><p><strong>Substitution happens at the right moment.</strong> Environment-variable substitution runs on the decoded scalar value, after YAML escapes are processed and before types are resolved. That matches the YAML processing model and the Java implementation, in addition to the official configuration validator. So <code>value: ${PORT}</code> may resolve to a number, while <code>value: "${PORT}"</code> is always a string, and substitution can never inject structure into a document.</p></li></ul><ul><li><p><strong>Silence is a bug.</strong> Anything that the package couldn’t honor, such as skipped resource attributes, unapplied sections, or repeated registrations, is reported through the SDK's self-diagnostics rather than quietly dropped. Someone debugging a missing attribute should be able to find out why without reading the source.</p></li></ul><h2>How this work is planned and reviewed upstream</h2><p>This all started with a discussion in the original issue, as well as an analysis of the current configuration landscape within the SDK and the proposed issues. It didn’t start with code. Even now, the design continues to evolve. I track it in a local repository, assisted by AI agents. The repo holds a high-level plan and a decision record in which every significant choice is dated with its context and consequences. It also holds a specification for each phase, written before implementation starts. That level of detail is too much for general consumption, so the high-level phases are published in the public meta issue.</p><p>I've planned the work in small, deliverable units to reduce the review burden and to keep code merging. This approach depends on a close working relationship with Martin Costello, one of the OTel .NET maintainers, who has reviewed each phase as it has landed. His feedback has repeatedly improved the design. After each phase lands, I reevaluate the phases that follow, fold in what review has taught us, and adjust direction where needed, recording the change in the decision record so that the plan never quietly drifts.</p><h2>What’s next for declarative configuration in OpenTelemetry .NET</h2><p>The package hasn’t shipped a release yet, and PR #7413 is the foundation rather than the feature. The plan that came out of it sets out the next steps, some of which are already implemented, including:</p><ul><li><p><strong>A library-neutral value model.</strong> The public <code>ConfigProperties</code> contract for reading any node of the document, distinguishing absent, <code>null</code>, present, and type-mismatched values.</p></li><li><p><strong>Access to the parsed document.</strong> Parse once, and keep the result. Let distributions read it before providers are built, including sections that the SDK doesn’t yet apply.</p></li><li><p><strong>Typed resource attributes.</strong> Integers, Booleans, and arrays reach the <code>Resource</code> with their declared types, rather than through a flat string.</p></li><li><p><strong>Strict mode by default.</strong> Honor the spec's requirement that other <code>OTEL_*</code> settings are ignored when a file is used, and investigate how model customization could bring back .NET-style layering.</p></li><li><p><strong>Components.</strong> A plugin provider abstraction and the built-in samplers, along with the wiring that lets the document itself build providers.</p></li><li><p><strong>Coverage, generation, and </strong><strong><code>Parse</code></strong><strong>/</strong><strong><code>Create</code></strong><strong>.</strong> Extend coverage to the rest of the schema, and generate the model from it. Publish the spec's <code>Parse</code> and <code>Create</code> operations.</p></li></ul><p>Access to the parsed document is a large part of why Elastic is investing in this work. The Elastic Distribution of OpenTelemetry .NET (EDOT .NET) builds on the upstream SDK, and a distribution needs to read the user's configuration before the SDK builds its providers. Building that capability upstream, rather than inside EDOT, means that every distribution and application gets the same behavior.</p><p>Once the core phases are in place, the plan is to ship a public alpha, so users can start experimenting and give feedback on the path to a stable release.</p><p>If you want to follow along or share your feedback, the <a href="https://github.com/open-telemetry/opentelemetry-dotnet/issues/7658">meta issue #7658</a> tracks the planned phases and current progress. The design is still open to change before the first release, and feedback from anyone who configures OTel in production, or builds a distribution on top of it, is exactly what shapes it.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/opentelemetry-dotnet-declarative-configuration</link>
    <guid isPermaLink="false">opentelemetry-dotnet-declarative-configuration</guid>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[APM]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Steve Gordon]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4d1cc892ea638df8/6ad5fcd27963af44c16ce81b/image1.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 19 Oct 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[No log file too small: How Elastic Agent tracks files below the 1 KiB threshold]]></title>
    <description><![CDATA[Elastic Agent 9.5 builds a small log file's identity out of the bytes it already has, then re-links it as it grows and crosses 1024 bytes, so it is never re-ingested.]]></description>
    <content:encoded><![CDATA[<p>Elastic Agent 9.5 reads log files smaller than the 1024-byte fingerprint threshold.
Growing fingerprint identifies each one by hashing the bytes it currently has, and the read offset carries over when the file crosses the threshold, so nothing is re-ingested.
Until 9.5, a file that small had no stable identity, so it was skipped until it grew.</p>
<h2 id="whysmalllogfileswereskipped">Why small log files were skipped</h2>
<p>Filestream needs a stable identifier for every file it reads so that it can track how far it has read from each file.
In earlier versions, we used filesystem metadata such as inode numbers.
This was not enough.
For example, filesystems such as ext4 can reuse an inode number after a file is deleted, leading to a new file being mistaken for the old one.
This is why we <a href="https://www.elastic.co/blog/introducing-filestream-fingerprint-mode">introduced</a> the fingerprint identity and made it the default in 9.0.</p>
<p>Fingerprinting means files are identified by their content, not their metadata.
The first 1024 bytes (by default) are hashed and used as the unique identity of the file.
A registry keeps track of all IDs we encounter beyond the agent's process lifetime.
Restart your machine, change the underlying device, host the files over the network, it doesn't matter; Elastic Agent will remember.</p>
<p>The major downside is that we can't hash a file's first 1024 bytes if it doesn't have this many bytes to begin with.
Imagine a critical Kubernetes pod that panics on startup and its small log output is the reason the panic never reaches your Kibana dashboard.</p>
<p>The main way we've dealt with this until now is through configuration: instead of 1024 bytes, we could hash 512.
Or 256.
Or even less.
However, each step towards reducing the threshold leaves less file content available to distinguish one file from another.
Distinct files are therefore more likely to share the same fingerprint material, making the resulting file identity less stable.
This is where the new growing fingerprint can shine instead.</p>
<h2 id="howgrowingfingerprintsplitsfileidentityintotwophases">How growing fingerprint splits file identity into two phases</h2>
<p>With the introduction of the growing fingerprint mode, filestream splits a file's identity into two phases based on its size.
While a file is smaller than the 1024-byte fingerprint threshold, it's in "growing" mode.
Filestream identifies it with a hash of everything the file contains so far.
When the file grows past the threshold, it gets promoted to a static fingerprint, the one it would have had pre-9.5.</p>
<p>What about file-identity collisions?
Two files with identical contents do share a growing fingerprint, but only for as long as they stay identical: the moment their contents diverge, so do their fingerprints.</p>
<p>Let's take a closer look inside the internals.
Let's start with a simple 13-byte text file:</p>
<pre><code>mkdir -p /tmp/demo
echo 'Hello world!' &gt; /tmp/demo/file.txt
</code></pre>
<p>and start Filebeat, the component inside Elastic Agent that collects log files, with a minimal configuration:</p>
<pre><code>cat &gt;/tmp/demo/filebeat.yml &lt;&lt;YAML
path.home: /tmp/demo

filebeat.inputs:
  - type: filestream
    id: growing-demo
    paths: [ "/tmp/demo/file.txt" ]

output.file:
  path: /tmp/demo/out
logging.level: debug
YAML

filebeat -c /tmp/demo/filebeat.yml
</code></pre>
<h3 id="phase1thegrowingfingerprintofa13bytefile">Phase 1: the growing fingerprint of a 13-byte file</h3>
<p>After a few moments, filestream will pick up our new file.
Digging through the debug logs (vastly reduced here for simplicity), we see:</p>
<pre><code>{"log.level":"debug","log.logger":"input.filestream.prospector","message":"A new file /tmp/demo/file.txt has been found"}
</code></pre>
<p>We can also directly check the registry write-ahead log (WAL) filestream writes to <code>data/registry/filebeat/log.json</code>:</p>
<pre><code>{
  "k": "filestream::growing-demo::fingerprint::f04f9d5ad8c21a115cd207488c6aaadeb4d7db581ea675f48c7f5d23e038c805",
  "v": {
    "cursor": {
      "eof": false,
      "offset": 13
    },
    "meta": {
      "fingerprint_len": 13,
      "identifier_name": "fingerprint",
      "source": "/tmp/demo/file.txt"
    }
  }
}
</code></pre>
<p>The <code>k</code> field is the registry key filestream uses to identify this file state.
Its final segment is the fingerprint hash, which we can reproduce from the file contents:</p>
<pre><code>$ xxd -p /tmp/demo/file.txt | tr -d '\n' | sha256sum
f04f9d5ad8c21a115cd207488c6aaadeb4d7db581ea675f48c7f5d23e038c805  -
</code></pre>
<h3 id="stillinphase1thefingerprintchangesasthefilegrows">Still in phase 1: the fingerprint changes as the file grows</h3>
<p>Let's write more lines to the file:</p>
<pre><code>for i in {1..70}; do echo "Hello $i" &gt;&gt; /tmp/demo/file.txt; done
</code></pre>
<p>Filestream detects the write while the file remains below the threshold:</p>
<pre><code>{"log.logger":"input.filestream.prospector","message":"File /tmp/demo/file.txt has been updated","operation":"write"}
</code></pre>
<p>In the registry, the larger file now has a new fingerprint and a 634-byte <code>fingerprint_len</code>:</p>
<pre><code>{
  "k": "filestream::growing-demo::fingerprint::cb8e361971e7ba91e343d1b5bd8e6560be0bdadbbed2b2f89a6c50099e972d63",
  "v": {
    "cursor": {
      "eof": false,
      "offset": 634
    },
    "meta": {
      "fingerprint_len": 634,
      "identifier_name": "fingerprint",
      "source": "/tmp/demo/file.txt"
    }
  }
}
</code></pre>
<p>The updated key ends in the SHA-256 hash of the hexadecimal fingerprint material:</p>
<pre><code>$ xxd -p /tmp/demo/file.txt | tr -d '\n' | sha256sum
cb8e361971e7ba91e343d1b5bd8e6560be0bdadbbed2b2f89a6c50099e972d63  -
</code></pre>
<h3 id="phase2promotiontoastatic1024bytefingerprint">Phase 2: promotion to a static 1024-byte fingerprint</h3>
<p>Let's now grow the file beyond the 1024-byte threshold:</p>
<pre><code>for i in {1..500}; do echo "More messages $i" &gt;&gt; /tmp/demo/file.txt; done
</code></pre>
<p>Once the file crosses the threshold, filestream reports another write:</p>
<pre><code>{"log.logger":"input.filestream.prospector","message":"File /tmp/demo/file.txt has been updated","operation":"write"}
</code></pre>
<p>The registry key changes one final time as the growing fingerprint is promoted to its static form:</p>
<pre><code>{
  "k": "filestream::growing-demo::fingerprint::c8e252d76d621a02161f92e7a59d1617e3079db666cefd6cfcab106329082d1e",
  "v": {
    "cursor": {
      "eof": false,
      "offset": 9526
    },
    "meta": {
      "identifier_name": "fingerprint",
      "source": "/tmp/demo/file.txt"
    }
  }
}
</code></pre>
<p>The promoted fingerprint hashes the first 1024 bytes.
It is exactly what a pre-9.5 Filebeat would have computed for this file:</p>
<pre><code>$ head -c 1024 /tmp/demo/file.txt | sha256sum
c8e252d76d621a02161f92e7a59d1617e3079db666cefd6cfcab106329082d1e
</code></pre>
<h2 id="howfilestreammatchesagrowingfingerprinttoanexistingfile">How filestream matches a growing fingerprint to an existing file</h2>
<p>During each scan, filestream resolves a file's identity in this order:</p>
<ol>
<li>Look for an exact registry key matching the file's current fingerprint. This part has not changed.</li>
<li>If no key is found, search the growing entries for a shorter fingerprint, trying the same path first. A prefix match means filestream saw the same file earlier, when it contained fewer bytes.</li>
<li>Move the existing registry state to the new key, preserving the read offset.</li>
</ol>
<p>You might have noticed from the registry entries above that we don't store the raw file contents.
That was our initial approach, but it quickly exploded the size of the registry.
Instead, we use the SHA-256 hash as the registry key and store <code>fingerprint_len</code>, which records the number of raw bytes the fingerprint represents.
This reduces the storage required per entry by nearly 100×.</p>
<p>When filestream looks for a prefix match, it first groups the growing fingerprints by length.
It then streams the scanned file's hexadecimal fingerprint material through SHA-256, snapshotting the digest at each group length.
Filestream compares each snapshot only with stored hashes for the same length.
A match means that the stored fingerprint is a prefix of the scanned file.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdf5e8f36fc898105/6aabc22f92a58722afee1031/how-it-works.svg" alt="Diagram of a 700-byte scanned file whose hexadecimal fingerprint material is hashed in one SHA-256 pass. Digest snapshots are compared with registry entries of the same length; the 634-byte entry matches, so its state moves to the new key" /></p>
<h2 id="growingfingerprintbenchmarksmemoryandcpuat10000logfiles">Growing fingerprint benchmarks: memory and CPU at 10,000 log files</h2>
<p>So what does all this extra work cost?
We benchmark Elastic Agent 9.5 ingesting thousands of above-threshold and sub-threshold files.
To make the benchmark fair, we compare the growing fingerprint against the workaround of reducing the fingerprint size to 128 bytes.
Otherwise, we would be comparing doing something against doing nothing and nothing would always win.</p>
<p>The benchmarks run on an r8id.2xlarge EC2 instance (8 vCPUs, Ubuntu 24.04) and report the median of five runs.
Memory numbers are the Elastic Agent's anonymous memory as reported by the kernel via cgroups.</p>
<p>At 10,000 files, the two modes compare as follows.</p>
<p>| Metric | Condition | 128-byte static | Growing | Difference |
|---|---|---|---|---|
| Peak memory | Files above 1 KiB | 1,057 MiB | 1,099 MiB | +4% |
| Peak memory | Files below 1 KiB | 941 MiB | 1,012 MiB | +8% |
| Peak memory | Files crossing the threshold | 968 MiB | 1,016 MiB | +5% |
| Steady-state memory | Files above 1 KiB | 291 MiB | 290 MiB | none |
| Steady-state memory | Files below 1 KiB | 270 MiB | 322 MiB | +52 MiB |
| CPU, one core | Files above 1 KiB, idle | 4.1% | 4.1% | none |
| CPU, one core | Files below 1 KiB, idle | 3.9% | 4.9% | +1.0pp |
| CPU, one core | Files crossing the threshold | 16.8% | 25.5% | +8.7pp |
| Registry size at rest | Files above and below 1 KiB | 8.6 MB | 8.6 MB | none |</p>
<h3 id="memoryuseat1001000and10000logfiles">Memory use at 100, 1,000 and 10,000 log files</h3>
<p>At 100 and 1,000 files, 128-byte static and growing fingerprints differ by only a few MiB.
At 10,000 files the peaks separate by a few percent, but the only lasting difference is for sub-threshold files: above the threshold both modes stabilize within a MiB of each other.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfb3f4bc0bc224b00/6aabc25719b2267b105a8882/benchmark-peak-memory.svg" alt="Grouped bar chart of peak memory at 100, 1,000 and 10,000 log files. At 10,000 files, growing uses 1,099 vs 1,057 MiB above the 1 KiB threshold and 1,012 vs 941 MiB below it" /></p>
<p>The most demanding case is when the fingerprints are still changing.
We created 10,000 sub-threshold files and appended 2,000 lines/s across them until every file crossed the threshold.
That repeatedly exercises the prefix-lookup path and moves registry state to new keys.
The extra memory this costs is released once the files are past the threshold and their harvesters close: both modes settle under 300 MiB, within a few MiB of each other:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltede5d60e559fad77/6aabc26a14e38491ae4eaa40/benchmark-promotion-timeline.svg" alt="Line chart of memory over thirteen minutes while 10,000 files grow past the 1 KiB threshold under sustained writes and then idle. After the harvesters close, the 128-byte static and growing fingerprint modes settle at about 282 and 291 MiB" /></p>
<h3 id="cpuusewithandwithoutwrites">CPU use with and without writes</h3>
<p>The CPU impact is larger: every scan re-hashes a sub-threshold file's full contents instead of a fixed 128-byte prefix.
That costs one extra percentage point of a core with no writes coming in, and almost nine under the changing-fingerprint workload above:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt218b4365aaf1002f/6aabc27e8f2cd72150e5380e/benchmark-cpu.svg" alt="Bar chart of CPU as percent of one core at 10,000 files. Idle scans cost 4.1% for 128-byte static and growing fingerprints on above-threshold files and 3.9% vs 4.9% on sub-threshold files; while files grow past the threshold under sustained writes, the 128-byte static fingerprint uses 16.8% and growing 25.5%" /></p>
<h3 id="whatchangesin96">What changes in 9.6</h3>
<p>We are already working on improving these numbers.
On current main (future 9.6 release), the same benchmarks peak at less than half the memory, and the growing fingerprint's steady-state overhead, in both memory and CPU, is roughly halved.</p>
<h2 id="upgradingandrollingback">Upgrading and rolling back</h2>
<p>Growing mode ships enabled by default in 9.5 through <code>file_identity.fingerprint.growing</code>.
Set it to <code>false</code> to restore the pre-9.5 behavior.
There is no need for a migration on upgrade; existing registry entries keep working and files that were previously too small get picked up on the next scan.</p>
<p>Rolling back is also possible, but you go back to pre-9.5 behaviour: small files are ignored and when they cross the threshold, they get re-ingested.</p>
<h2 id="whatgrowingfingerprintmeansforyourlogfiles">What growing fingerprint means for your log files</h2>
<p>Elastic Agent 9.5 ingests log files smaller than 1024 bytes by default, with no configuration and no waiting for the file to grow.
Shared identities between identical small files resolve themselves as soon as their contents diverge, which a static fingerprint cannot do.
The cost stays small: at realistic node scales the growing fingerprint is indistinguishable from the old workarounds, and at a 10,000-file workload it costs a few percent of memory and single-digit percent of one CPU core.
Remember when upgrading that small files that were invisible before count as new, however long they have been sitting in your paths.
If the trade doesn't work for you, <code>file_identity.fingerprint.growing: false</code> brings back the old behavior.</p>
<h2 id="references">References</h2>
<ul>
<li><a href="https://www.elastic.co/docs/reference/beats/filebeat/file-identity">How to choose file identity for filestream</a> covers all identity options and their trade-offs.</li>
<li><a href="https://www.elastic.co/docs/reference/beats/filebeat/file-identity#file-identity-fingerprint-growing">Growing fingerprint reference</a> documents the setting and the rollback caveat.</li>
<li><a href="https://github.com/elastic/beats/pull/50566">PR #50566</a> is the implementation and <a href="https://github.com/elastic/beats/pull/51784">PR #51784</a> the registry optimization; <a href="https://github.com/elastic/beats/pull/51863">PR #51863</a> and <a href="https://github.com/elastic/beats/pull/51914">PR #51914</a> trimmed per-scan allocations and fingerprint retention in 9.5.2.</li>
<li>Optimizations coming up in 9.6: <a href="https://github.com/elastic/beats/pull/51278">PR #51278</a> parks idle harvesters instead of keeping one goroutine per open file, <a href="https://github.com/elastic/beats/pull/51694">PR #51694</a> scans each file in a single pass, and <a href="https://github.com/elastic/beats/pull/52476">PR #52476</a> normalizes published events in place. <a href="https://github.com/elastic/beats/pull/52103">PR #52103</a>, which replaces the multiline timeout goroutine with a read deadline, is not exercised by these benchmarks.</li>
</ul>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/growing-fingerprint-filestream-file-identity</link>
    <guid isPermaLink="false">growing-fingerprint-filestream-file-identity</guid>
    <category><![CDATA[Logs Analytics]]></category>
    <category><![CDATA[Infrastructure Monitoring]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Orestis Floros]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd520b0634f15c227/6aabc2b1a4ecdf0c30ca91a0/header.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 18 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Telemetry Policy: change OpenTelemetry sampling and log levels at runtime, no restart]]></title>
    <description><![CDATA[Telemetry Policy says what you want to happen and leaves each component to work out how. Change an OpenTelemetry Java agent's trace sampling to 1% and the JVM keeps serving traffic.]]></description>
    <content:encoded><![CDATA[<p>Change the trace sampling rate of a running Java application from 5% to 1% without restarting the JVM. Telemetry Policy states what you want to happen and leaves each SDK, agent or collector to work out how, so a platform team defines it once and applies it everywhere. The screenshots below show a sampling rate of 0.1 set in the Elastic APM UI, and the Java agent logging that it received and applied the value. The rest of this post covers what a policy is and where the limits are. In the next one I'll walk through the first experimental implementation in the OpenTelemetry Java contrib repo.</p>
<h2 id="howtochangeopentelemetryjavaagentsamplingatruntime">How to change OpenTelemetry Java agent sampling at runtime</h2>
<p>Until now, with some proprietary exceptions, configuring that telemetry had to be done at startup of the telemetry-generating component (e.g., SDK, agent, or OpenTelemetry Collector).
Telemetry Policy is the emerging standard that will allow you to reconfigure some telemetry configurations without needing to restart the telemetry-generating component.</p>
<p>For example, let's say you want to:</p>
<ul>
<li>Change the trace sampling percentage to 1% during some special peak periods (eg special sales days), but keep it at 5% at all other times.</li>
<li>Change the log level (e.g., to debug level or back to info) temporarily.</li>
<li>Stop some instrumentations that are active.</li>
</ul>
<p>These are all straightforward requirements.
But applying any of them until now has meant changing the configuration of the component and restarting it.
For a Java application (and applications in most languages) that previously meant also restarting the application, as there was no way to change the agent configuration without restarting the JVM.</p>
<h3 id="whycentralmanagementneedsonepolicyformat">Why central management needs one policy format</h3>
<p>Central management adds another challenge.
A controlling system that requests a change such as "increase log level to debug" must know how to apply that change to every component it controls.
The configuration change for a collector will not look like the change for a Java SDK, even though both are applying the same decision.</p>
<p>Telemetry Policy addresses both problems by separating:</p>
<ol>
<li>what we want to happen</li>
<li>from how a particular SDK, agent, or collector makes it happen.</li>
</ol>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc16be960dd5485b9/6aabc0ab8e00eb71345ca476/setting-sample-rate.png" alt="Using the Elastic APM UI to set the Java agent sampling rate 0.1 (10%)" />
<em>Using the Elastic APM UI to set the Java agent sampling rate 0.1 (10%)</em></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt503d7e688cc22630/6aabc101d1556f22f4728308/logged-sample-rate.png" alt="lastic APM Java agent log statements confirming that the sampling rate value of 0.1 was received and applie" />
<em>Elastic APM Java agent log statements confirming that the sampling rate value of 0.1 was received and applied</em></p>
<h2 id="whatisatelemetrypolicyinopentelemetry">What is a Telemetry Policy in OpenTelemetry?</h2>
<p>A Telemetry Policy is an independent rule describing the behaviour a user wants.</p>
<p>For example:</p>
<pre><code>Sample traces at 5%
</code></pre>
<p>A policy that says to sample traces at 5% does not say which sampler to install, where to add a processor, or how the telemetry pipeline is laid out.
Those are implementation details specific to each component.
The policy only states the intended result.</p>
<p>The draft specification defines a policy as a typed, standalone object.
Each policy has an ID, a name, and one target signal such as traces, logs, or metrics.
The target determines the matching fields and actions that can be used.</p>
<p>Here is an example of that 5% sampling policy:</p>
<pre><code>{
  "id": "sample-spans-5-percent",
  "name": "Sample traces at 5%",
  "trace": {
    "match": [
      {
        "exists": true
      }
    ],
    "keep": {
      "percentage": 5.0
    }
  }
}
</code></pre>
<p>The policy says to match every trace and apply 5% sampling using the policy with ID <code>sample-spans-5-percent</code>.
Each telemetry component that understands that ID will be able to apply the policy.
Any telemetry component that doesn't understand the policy will simply ignore it.</p>
<h3 id="whichsettingscantelemetrypolicychangeatruntime">Which settings can Telemetry Policy change at runtime?</h3>
<p>Policies are not expected to cover every configuration, or even <em>many</em> configurations of each component.
Telemetry Policies are not a general-purpose "reconfigure anything" tool.
Policies are specific to the things that users would most like to change without restarting.
For application agents, our experience at Elastic suggests there are only a dozen or so important policy types that most customers need to be able to change at runtime without restarting the application, including:</p>
<ul>
<li>Changing the trace sampling percentage.</li>
<li>Turning individual instrumentations or all instrumentations off and on.</li>
<li>Turning individual signal exporters (traces, metrics, logs, profiles) off and on.</li>
<li>Changing logging level.</li>
<li>Changing the configuration of a select few instrumentations.</li>
<li>Such as changing which HTTP routes to ignore when creating traces (eg ignore <code>/heartbeat</code>).</li>
<li>Or changing which methods to instrument.</li>
</ul>
<h2 id="howatelemetrypolicyreachesansdkagentorcollector">How a Telemetry Policy reaches an SDK, agent or collector</h2>
<p>The overall telemetry policy pipeline has certain components, and at minimum covers the sequence:</p>
<pre><code>Message -&gt; Provider -&gt; Policy -&gt; Policy aggregator -&gt; Implementer
</code></pre>
<p>A concrete example of changing the trace sampling percentage directed by central management using the <a href="https://github.com/open-telemetry/opamp-spec">Open Agent Management Protocol (OpAMP)</a> helps to understand this flow:</p>
<p>| Stage | What it does | In the 5% sampling example |
| --- | --- | --- |
| Message | Carries the requested change into the component | An OpAMP message specifying a trace sampling percentage of 5%, like the JSON above |
| Provider | Reads policies from an input stream | The OpAMP provider reads the message from the OpAMP data flow |
| Policy | The internal representation of the change | A <code>trace-sampling-percentage-policy</code> with a target value of 5%, which is how the ID <code>sample-spans-5-percent</code> is implemented in the component* |
| Policy aggregator | Combines the policy with others already applied or pending, handling source priority and merges | A pass-through, because there is only one provider |
| Implementer | Applies the change to the running component | Installs a new sampler set to 5% |</p>
<p>Source priority decides which policy wins when two sources disagree. A message from an OpAMP source has higher priority than one from an HTTP source.</p>
<p>The implementer installs a new sampler unless the sampler already running can have its sampling percentage changed at runtime, in which case it updates that one.</p>
<p>*Note that the policy type may become a required part of the message in future.
There is currently no standard mapping from a policy ID to a policy type, so the type must either be included in the message or supplied through an external mapping.</p>
<h3 id="deliveringpoliciesoveropamphttporalocalfile">Delivering policies over OpAMP, HTTP or a local file</h3>
<p>Telemetry Policy does not define a new transport mechanism or protocol.
Policies can be delivered through existing transports:</p>
<ul>
<li>OpAMP.</li>
<li>HTTP.</li>
<li>Local files.</li>
<li>Custom sources.</li>
</ul>
<p>The same trace sampling policy could be read from a local file while testing, supplied over HTTP, or sent from a central management system using OpAMP.
OpAMP is not a requirement for Telemetry Policy; it is a supported provider and is likely to be a common transport for centrally managed systems.</p>
<h2 id="telemetrypolicystatusinopentelemetry">Telemetry Policy status in OpenTelemetry</h2>
<p>Telemetry Policy is currently an accepted developing project. 
The schema, policy types, and implementation details are still being developed before they will become
accepted OpenTelemetry standards.</p>
<h3 id="thefirstexperimentaljavasdkimplementation">The first experimental Java SDK implementation</h3>
<p>The first experimental Java SDK implementation starts with dynamically changing the trace sampling probability.
This is deliberately narrow, providing a simple practical way to test the complete policy flow.</p>
<p>Telemetry Policy separates observability decisions from the internal configuration of the component applying them.
This allows a platform team to define a policy once and apply it consistently in SDKs, agents, and collectors.</p>
<p>In the next blog, I'll walk through the first experimental implementation in the OpenTelemetry Java contrib repo, 
including how it receives policies through OpAMP and changes trace sampling in a running Java application without requiring a restart.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/opentelemetry-java-agent-telemetry-policy</link>
    <guid isPermaLink="false">opentelemetry-java-agent-telemetry-policy</guid>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[APM]]></category>
    <category><![CDATA[What's New]]></category>
    <dc:creator><![CDATA[Jack Shirazi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt06ef200647ccab50/6aabbf6c5ef72dab76c95196/good_oone.png" length="0" type="image/png"/>
    <pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <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 by clicking its name in the <strong>Applications</strong> table of the <strong>Overview</strong> section.</li>
</ol>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdab28204163f3455/6abb84f6512a6db63fe32930/blog-dashboard-list.png" alt="Dashboard list" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2822e60c811e28ba/6abb85168b3d2511b1094140/blog-dashboard-android-overview.png" alt="Android overview dashboard" /></p>
<p>The dashboard is organized into four collapsible sections: <strong>Overview</strong>, <strong>Logs &amp; Spans</strong>, <strong>Crashes</strong>, and <strong>Event timeline</strong>. Together they provide metric panels for an overview of your app's health, performance, and RUM, as well as span and crash tables that can be further explored either in Discover or in the Exception Details dashboard, as explained below.</p>
<h2 id="howtoinspectthedistributedtracingwaterfallinkibana">How to inspect the distributed tracing waterfall in Kibana</h2>
<p>From the Application Overview dashboard, expand the <strong>Logs &amp; Spans</strong> section, 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/blt2f8250fb8e60a9d9/6abb85353afd6e2d0cd0a486/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/blt3a6c29d381a756b7/6abb85a10f460dee82d5a2e8/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 stack traces 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, expand the <strong>Crashes</strong> section. Its table lists crashes grouped by a group ID computed from their stack trace. Click a group ID and select <strong>View crash details</strong> to open the <strong>[Android OTel] Exception Details</strong> dashboard.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbda44451a7b8920c/6abb862823d1933ff95b5445/blog-dashboard-android-overview-crash-list.png" alt="Crash list" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd8761e8c6090f6f9/6abb864123d193502d5b5449/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 stack trace in the <strong>Stack trace</strong> section. Crash events are grouped based on their stack trace, 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/blt05b6afaf44698d7a/6abb8f79d8972418d609ab5e/blog-dashboard-android-exceptions.png" alt="Exception dashboard view" /></p>
<p>For this demo, the stack trace points to the intentional crash in <code>MainActivity</code>. The nearby session events should also include the custom <code>Crash button clicked</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/blt9e4e39ce21c70a2f/6abb8fcde80c0b3a06af017e/1.webp" alt="enter image description here" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltff4bb83b2506b7a1/6abb8682b2d147023c7714e1/blog-dashboard-android-overview-with-session-filter.png" alt="Overview dashboard with session filter" /></p>
<p>The <strong>Event timeline</strong> section on the Application Overview dashboard is also useful here: select a session from the dashboard's top filters, and its <strong>First 100 events</strong> panel shows the sequence of spans and logs in that session chronologically.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb4ff8d5c03fc754f/6abb86990c77f60974eea23d/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>