<?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[Jonas Kunz - Elasticsearch Labs]]></title>
    <description><![CDATA[Articles and tutorials from the Search team at Elastic]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Jonas Kunz - Elasticsearch Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1121c0bf0e8a6e65/6a88da6340a1841030ef456f/search-labs-thumbnail.png</url>
      <link>https://www.elastic.co/search-labs/author/jonas-kunz</link>
    </image>
    <link>https://www.elastic.co/search-labs/author/jonas-kunz</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/author/jonas-kunz.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Mon, 28 Sep 2026 12:33:14 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Skip the stateful OTel Collector: Elasticsearch 9.5 natively stores both metric temporalities]]></title>
    <description><![CDATA[Ingest cumulative and delta OpenTelemetry metrics under the same metric name while ES|QL and PromQL queries auto-detect temporality per series, with no new syntax or conversion pipelines required.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch 9.5 natively stores both cumulative and delta OpenTelemetry (OTel) counters and histograms, even when mixed for the same metric name. You ingest via OpenTelemetry Protocol (OTLP) and Elasticsearch preserves the temporality metadata automatically.<a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"> ES|QL TS</a> and<a href="https://www.elastic.co/docs/reference/query-languages/promql"> PromQL</a> queries detect the temporality per series and interpret the data correctly, without new syntax, configuration changes to your OTel SDKs or stateful OTel Collector conversion. Existing queries and downsampled data continue to work as expected.</p><h2>What is metric temporality in OpenTelemetry?</h2><p>Metrics stores usually receive client-side, pre-aggregated metrics. For example, if an application records request response times, it won’t send each individual response time as a single data point to your metrics back end. Instead, the application (or rather the OTel SDK) pre-aggregates those raw response times into counters or histograms. These pre-aggregated values are then exported at a periodic interval, dramatically reducing the number of data points. <em>Temporality</em> is about how this pre-aggregation works. There are two temporality models: <em>cumulative</em> and <em>delta</em>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1829137fe767c17d/6a7edfc40da673bd3357cae8/image6.png" alt="Diagram showing how delta and cumulative temporality represent the same OTel counter metric data points differently" /><h3>Cumulative temporality in OTel metrics</h3><p>With <em>cumulative temporality</em>, each data point represents the totalamount of change in the metric value since the process started. Values monotonically increase, with occasional reset to 0 (for example, when the process restarts).</p><p>Take a counter tracking the total CPU time consumed by a Java Virtual Machine (JVM):</p><p><strong>Timestamp</strong></p><p><strong>Value</strong></p><p><strong>Meaning</strong></p><p>10:01</p><p>12.4s</p><p>12.4s total CPU time since start</p><p>10:02</p><p>13.1s</p><p>13.1s total CPU time since start</p><p>10:03</p><p>13.9s</p><p>13.9s total CPU time since start</p><p>To compute the rate of change between 10:01 and 10:02, we subtract: <code>13.1 - 12.4 = 0.7s</code> of CPU time was consumed in that interval. Dividing by the time range of the interval gives us the <code>rate</code>. This is the default temporality for counters in both Prometheus and OTel.</p><h3>Delta temporality in OTel metrics</h3><p>With <em>delta temporality</em>, each data point represents the change since the last measurement. Values are independent of each other. In other words, after each export, the OTel SDK resets all values for all series.</p><p>The same raw observations from the cumulative example above would look as follows with delta temporality.</p><p><strong>Timestamp</strong></p><p><strong>Value</strong></p><p><strong>Meaning</strong></p><p>10:01</p><p>0.5s</p><p>0.5s of CPU time in this interval</p><p>10:02</p><p>0.7s</p><p>0.7s of CPU time in this interval</p><p>10:03</p><p>0.8s</p><p>0.8s of CPU time in this interval</p><p>To compute the rate or increase, we can use the value directly, without any subtraction.</p><h3>Trade-offs between cumulative and delta OpenTelemetry metrics</h3><p>Both temporalities have practical trade-offs:</p><ul><li><p><strong>Resilience to data loss: </strong>Cumulative counters are self-describing: If you miss an export, the next data point still gives you the correct total. Delta values are incremental, so a lost data point means that the corresponding increase is lost.</p></li><li><p><strong>Metric producer memory footprint: </strong>For cumulative temporality, the OTel SDKs need to keep a state for every series in memory. For delta temporality, the footprint is much lower. There, the SDKs only need to keep track of counters or histograms which changed since the last export. If there are a lot of counters or histograms and many of them don’t increase each period, this difference can be quite substantial.</p></li><li><p><strong>Aggregation across restarts: </strong>Cumulative counters require reset detection logic, which in edge cases can fail: If the metric value decreases, it’s detected as a reset. We assume that the application was restarted and the counter started from 0 again. This can be missed if the first reported counter value after the restart is higher than before the restart. A concrete example:</p></li><ul><li><p>The service consumes 1 second CPU time and restarts.</p></li><li><p>After the restart, the service performs a CPU-intensive task and consumes 2 seconds of CPU time before the metric is exported again.</p></li><li><p>The metric back end just sees 1 followed by 2 as the metric value. It never observes a decrease and therefore misses the reset.</p></li></ul></ul><p>Delta values don't have this problem since each value is independent.</p><p>If you’re using histograms, the trade-offs have an even bigger effect:</p><p><strong>Trade-off</strong></p><p><strong>Cumulative</strong></p><p><strong>Delta</strong></p><p>Histogram size</p><p>Buckets accumulate across exports, consuming more storage</p><p>Buckets reset each export, producing smaller histograms</p><p>Min/max accuracy</p><p>Approximated from buckets for custom time ranges (tracked values represent extremes since process start)</p><p>Exact per-export minimum and maximum values</p><p>Query performance</p><p>Faster: only the first and last value in a time range plus resets are needed</p><p>Slower: all histograms in the queried range must be combined</p><p>OpenTelemetry supports both models and lets you choose per SDK via the <code>OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE</code> environment variable.</p><h2>Why native temporality support eliminates OTel Collector workarounds</h2><p><a href="https://prometheus.io/docs/concepts/metric_types/#counter">Prometheus</a> and most other metrics back ends pick a side: All metrics have to be either cumulative or delta. Elasticsearch previously followed that pattern, too, with native storage of cumulative counters and delta histograms, and workarounds for everything else. Delta counters were stored as gauges, functional but without native counter semantics for rate queries. And cumulative histograms were unsupported.</p><p>One workaround for unsupported temporalities is to configure your metric producers (for example, <a href="https://opentelemetry.io/docs/languages/">OTel SDKs</a>) to produce data with the temporality that your back end supports. In large-scale deployments, this can be a very challenging task. And sometimes this isn’t even possible (for example, if you consume OTLP metrics from third-party services).</p><p>Another workaround is to convert the temporality prior to ingestion. In the OTel Collector, you would typically use the <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/cumulativetodeltaprocessor/README.md">cumulative-to-delta processor</a>, which comes with a big warning sign about <em>statefulness</em>. The conversion is inherently stateful, requiring ordered delivery of metric series to the same collector and persisted state across restarts. In practice, it works, but at scale, it comes with a lot of deployment headaches.</p><p>With Elasticsearch 9.5, you can skip the conversion pipeline entirely. Elasticsearch natively stores and queries metric data with both temporalities. It doesn’t require any stateful conversion required or explicit configuration of your OTel SDKs.</p><h2>Demo: ingesting cumulative and delta OTel metrics side by side</h2><p>To demonstrate the temporality support, we'll reuse a demo setup from our <a href="https://www.elastic.co/search-labs/blog/otel-histogram-metrics-esql">OTel histogram metrics ES|QL blog post</a>: a Java <a href="https://github.com/renaissance-benchmarks/renaissance">Renaissance</a> benchmark instrumented with the <a href="https://opentelemetry.io/docs/zero-code/java/agent/">OTel Java agent</a>. The twist this time: We run two instances of the benchmark, each configured with a different temporality:</p><ul><li><p><strong><code>renaissance-delta</code></strong><strong>: </strong>Exports metrics with delta temporality.</p></li><li><p><strong><code>renaissance-cumulative</code></strong><strong>: </strong>Exports metrics with cumulative temporality.</p></li></ul><p>Both instances report the same metrics under the same service name <code>renaissance</code>, but with different <code>service.instance.id</code> values. Here’s the relevant section of the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-temporality-demo/docker-compose.yml">docker-compose.yml</a> that can be found in <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/elasticsearch-temporality-demo">the companion code</a>:</p>renaissance-delta:
  environment:
    OTEL_SERVICE_NAME: renaissance
    OTEL_RESOURCE_ATTRIBUTES: "service.instance.id=delta-instance"
    OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: delta
    OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: BASE2_EXPONENTIAL_BUCKET_HISTOGRAM

renaissance-cumulative:
  environment:
    OTEL_SERVICE_NAME: renaissance
    OTEL_RESOURCE_ATTRIBUTES: "service.instance.id=cumulative-instance"
    OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: cumulative
    OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: BASE2_EXPONENTIAL_BUCKET_HISTOGRAM<p>To run the demo yourself, you'll also have to fill out the <a href="https://www.elastic.co/docs/reference/opentelemetry/managed-inputs/managed-otlp-endpoint">managed OTLP endpoint URL</a> and the corresponding API key:</p>OTEL_EXPORTER_OTLP_ENDPOINT: https://&lt;cluster-endpoint&gt;
OTEL_EXPORTER_OTLP_HEADERS: "Authorization=ApiKey &lt;base64 api key&gt;"<p>After starting the demo with <code>docker compose up --build</code>, both instances will start reporting metrics to Elasticsearch.</p><h3>Querying OTel counter metrics with ES|QL and PromQL</h3><p>Let's query the first few raw data points of <code>jvm.cpu.time</code> for both instances to see the different temporalities in action:</p><p>This gives us the first five data points for each service instance:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte236c69ee14c534f/6a7ee18f2888399d7307f09f/image2.png" alt="ES|QL query results showing raw cumulative and delta OTel metrics for jvm.cpu.time from two service instances" /><p>The benchmark consumes CPU at a nearly constant rate. This is directly visible based on the delta temporality data: The values are nearly constant between exports. In contrast, the cumulative temporality values grow over time, as they represent the total CPU usage of the benchmark instance.</p><p>Now let's have a look at how to properly query this metric using PromQL:</p>PROMQL sum by (service.instance.id) (rate(jvm.cpu.time))<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf7ed76cee486794e/6a7ee0250e035cf4d8b865a2/image4.png" alt="PromQL rate query showing CPU time per service instance with cumulative and delta OTel metrics overlaid" /><p>The screenshot shows that both benchmark instances consume a nearly constant of 1 to 1.2 number of CPU cores with some variance. This query works because we made our <code>rate</code> implementation respect the temporality: Every time series (so every service instance in our case) stores the temporality as a metric dimension. The <code>rate</code> implementation looks at this dimension and interprets the data accordingly: For delta temporality, values are summed up; for cumulative temporality, a difference computation is done. This all happens automatically in the background, without requiring any changes to your queries.</p><p>We’ve adapted <code>rate</code>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions#increase"><code>increase</code></a>, and <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions#irate"><code>irate</code></a> to work this way. The same applies when using those functions in ES|QL TS queries:</p><p>Because Elasticsearch tracks the temporality as a dimension, you can have multiple series with different temporalities for the same metric, just like in the demo use case. Aggregating across series also works as expected, because at that point <code>rate</code>, <code>increase</code>, or <code>irate</code> already took care of normalizing the data:</p>PROMQL sum(rate(jvm.cpu.time))<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6b8eb2e5ee6199bc/6a7ee06a28883935c907f097/image3.png" alt="PromQL chart showing total CPU time aggregated across both cumulative and delta OTel metrics instances" /><h3>Querying OTel histogram metrics across temporalities</h3><p>Metric temporality applies to histograms in the same way it applies to counters: histogram buckets are effectively a set of counters, each tracking values in a specific range.As in our histogram demo, we use exponential histograms, where bucket boundaries adapt automatically to minimize relative error.</p><p>Due to this similarity, histograms can also be cumulative or delta. Either the counter per bucket is reset after each metric export or the cumulative count carries over between exports.</p><p>Let's query the median major garbage collection (GC) duration for our benchmark instances, which is a histogram metric:</p>PROMQL histogram_quantile(0.5,  sum by (service.instance.id) (increase(jvm.gc.duration{jvm.gc.action=~".*major.*"})))<p>Or the equivalent ES|QL query:</p><p></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb02046819944c5ff/6a7ee0f6dcb4372a2b2d1232/image5.png" alt="Median major GC duration queried across cumulative and delta OpenTelemetry histogram metrics per instance" /><p>Again, both queries will automatically load the temporality per series and interpret the histograms accordingly. In PromQL, this is handled by the <code>increase</code> function. Note that in ES|QL, you don't explicitly call <code>increase</code> on histograms. The <code>TS</code> command automatically handles the temporality-aware merging of histograms when you use aggregation functions, like <code>PERCENTILE</code>, <code>MEDIAN</code>, or <code>AVG</code>.</p><h2>How Elasticsearch stores metric temporality in TSDB</h2><p>Elasticsearch's time series database (TSDB) stores metric temporality in a dedicated dimension field on each document. The <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/time-series#index-time-series-temporality-field"><code>index.time_series.temporality_field</code></a> index setting lets you specify which field carries the temporality information. The field must be a <code>keyword</code> field with <code>time_series_dimension: true</code> and the permissible values <code>"delta"</code> or <code>"cumulative"</code>.</p><p>As soon as this setting is present on a time series index, ES|QL and PromQL will load the corresponding field when performing temporality-dependent aggregations. If the field isn’t present or has no value on a document, we fall back to defaults based on the type of the corresponding metric: counters default to cumulative, and histograms default to delta. This matches the historical behavior and ensures existing queries and existing data continue to work without changes.</p><p>When you ingest metrics via the OTLP endpoint, Elasticsearch automatically adds a <code>temporality</code> dimension field to each document, populated from the <a href="https://opentelemetry.io/docs/specs/otel/metrics/data-model/#temporality">OTLP AggregationTemporality</a> metadata. For custom (neither OTLP nor <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/tsds-ingest-prometheus-remote-write">Prometheus remote write</a>) ingestion, you’ll have to manually set up the <code>index.time_series.temporality_field</code> setting and populate your temporality dimension.</p><p>The temporality is also respected during downsampling: As it’s a dimension, it’s preserved automatically and used to compute the aggregate values.</p><h2>Getting started with mixed-temporality OTel metrics in Elasticsearch</h2><p>With Elasticsearch 9.5, cumulative versus delta is no longer a decision you have to get correct at the start. Ingest both temporalities side by side, even for the same metric name, and let ES|QL and PromQL handle the rest. You can switch between both without having to touch your queries. For more details, see the <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/metric-temporality">metric temporality documentation</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/otel-metrics-cumulative-delta-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/otel-metrics-cumulative-delta-elasticsearch</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Jonas Kunz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfebe6e53bb1ad4e4/6a7eded6b591027803eeca82/image1.png" length="0" type="image/png"/>
    <pubDate>Fri, 14 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[From averages to any percentile: Elasticsearch ships native exponential histogram support in ES|QL]]></title>
    <description><![CDATA[Query any percentile at any time. Elasticsearch natively stores OTel exponential histograms and lets you analyze distributions in ES|QL without fixed buckets or lossy conversions.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch adds native support for OpenTelemetry exponential histograms in ES|QL. Unlike fixed-bucket histograms, exponential histograms dynamically adapt to your data — giving you accurate percentile estimates (median, p99, any percentile you want) at query time with guaranteed error bounds. No more pre-defining buckets, no more lossy conversions. </p><p>Just send your OTel metrics to the <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/tsds-ingest-otlp">Elasticsearch OTLP/HTTP endpoint</a> and they're stored using the new <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/exponential-histogram">exponential_histogram</a> type and queryable immediately. Already have historical data stored in the classic histogram type? A simple ::exponential_histogram cast in your ES|QL queries handles the migration transparently. Already using <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/downsampling-time-series-data-stream">downsampling</a>? Both histogram field types are now fully supported.</p><h2>Histogram metrics</h2><p>When dealing with metrics (in OpenTelemetry or Prometheus, for instance), counters and gauges are the most common metric types. Gauges allow you to monitor values that rise or fall (e.g., CPU utilization). Counters allow you to, well, count things, such as the total number of HTTP requests your service is handling. Counters normally just increase in value, with a few exceptions when they reset, like when a server reboots.</p><p>In the case of counters, you can additionally collect a counter measuring the total sum of your HTTP response times, which allows you to derive the average response time by dividing that sum by the total number of requests. However, average response times provide limited insights into the collected data and the system behavior. The best insights are gained by analyzing the collected metric distribution, e.g., through median and percentile calculations. This is where counters fall short.</p><p>In the past, workarounds have been applied: For example, classic Prometheus-style histograms attempt to capture the distribution using a set of counters. By defining fixed buckets (e.g., one for response times in the range <code>[0s, 1s)</code>, one for <code>[1s, 4s)</code>, and so on) and associating a counter with each, we can at least estimate percentiles broadly. However, the key problem here is that we have to know the distribution of our data up front to properly define these buckets.</p><p>To that end, the OpenTelemetry community has come up with a better solution: exponential histograms. Exponential histograms assign collected values to buckets, just like classic Prometheus-style histograms. The key differentiator is that these buckets vary dynamically based on the collected values. The name "exponential" comes from the fact that the bucket sizes increase exponentially: we use small buckets for small values and wider buckets for larger values. You can find an excellent introduction in the <a href="https://opentelemetry.io/blog/2022/exponential-histograms/">OpenTelemetry exponential histograms introduction</a>.</p><p>Note that in addition to classic histograms, Prometheus also added <a href="https://prometheus.io/docs/specs/native_histograms/">native histograms</a>, which directly map to OTel <a href="https://prometheus.io/docs/specs/native_histograms/#opentelemetry-interoperability">exponential histograms</a>. Native histograms have their own <a href="https://prometheus.io/docs/specs/native_histograms/#promql">PromQL syntax</a>. We are actively working on adding support for that syntax to the <a href="https://www.elastic.co/observability-labs/blog/elasticsearch-supports-promql">Elasticsearch PromQL implementation</a>, so that you can directly query exponential histograms using PromQL.</p><h2>Demo setup</h2><p>Let's start by collecting some histogram metrics to show how they can be stored and analyzed in Elasticsearch using ES|QL.</p><p>We'll focus on a Java JVM metric: garbage collection durations. OpenTelemetry defines the <a href="https://opentelemetry.io/docs/specs/semconv/runtime/jvm-metrics/#metric-jvmgcduration">jvm.gc.duration</a>, which is a histogram-typed metric. The <a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation">OpenTelemetry Java agent</a> natively supports collecting this metric.</p><p>We'll spin up a JVM running a <a href="https://renaissance.dev/">Renaissance benchmark</a> to put it under stress. We'll start that JVM with the vanilla OpenTelemetry Java agent attached and have it send the metrics directly to Elasticsearch.</p><p>You can find the ready-to-run Docker-compose file <a href="https://github.com/JonasKunz/es-histogram-demo">here</a>. You'll just need to insert your <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/tsds-ingest-otlp">Elasticsearch OTLP/HTTP endpoint</a> and API key in the <code>docker-compose.yml</code>:</p>OTEL_EXPORTER_OTLP_ENDPOINT: https://&lt;elasticsearch url&gt;/_otlp
OTEL_EXPORTER_OTLP_HEADERS: "Authorization=ApiKey &lt;base64 API key&gt;"<p>Note that you don't have to use this demo setup. We even encourage you to try it with your own application. Here are the other important OpenTelemetry agent settings the demo already includes, which you should include too if you're bringing your own app:</p>OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: delta
OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: BASE2_EXPONENTIAL_BUCKET_HISTOGRAM
OTEL_INSTRUMENTATION_RUNTIME_TELEMETRY_ENABLED: "true"<p>Let's step through them:</p><ul><li><p><em>Temporality preference</em>: OpenTelemetry supports both cumulative and delta-based histograms. Cumulative means that the histogram is only cleared after an application restart, while delta clears it after each export. At the time of writing, Elasticsearch only supports delta temporality for histograms. We are actively working on supporting cumulative histograms as well.</p></li><li><p><em>Default Histogram Aggregation</em>: By default, OpenTelemetry exports histograms in the Prometheus-style fixed bucket format. Since we want to reap the benefits of exponential histograms, we tell the agent to use them instead.</p></li><li><p><em>Runtime Telemetry enabled</em>: This tells the agent to actually collect the detailed JVM metrics, which include <code>jvm.gc.duration</code>.</p></li></ul><p>Now we are ready to go! We'll let the application run in the background and switch over to Kibana to analyze the GC metric.</p><h2>Querying with ES|QL</h2><p>Now let's open up Kibana and navigate to "Discover". There we'll switch to <a href="https://www.elastic.co/docs/explore-analyze/discover/try-esql">ES|QL mode</a>, and start querying the collected data:</p><p>As a response, we now see the metric panel shown below. If you don't see any data, make sure to double-check the Kibana <a href="https://www.elastic.co/docs/explore-analyze/query-filter/filtering#set-time-filter">time range filter</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt00f1fc071c411284/6a170849acf08862a9be9a98/b863b2e272ac6584ac193661a6c4419abffdd243-729x190.png" alt="ES|QL metric panel showing the total count of jvm.gc.duration samples" /><p>This number represents the total number of garbage collection operations that happened in our test application during the selected time frame.</p><p>Similarly, we can query the total time spent on those garbage collection operations:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc0fc971c91c0f5b9/6a17084b6f7f04323b914792/eda37c5fa244a42258bb452d18f5cbab3ff76eaf-717x190.png" alt="ES|QL metric panel showing the sum of jvm.gc.duration values in the selected time range" /><p>So we have roughly 270k garbage collections, which in total took 713 seconds. Given these two numbers, we can now compute the average if we are still fluent in primary school-level math. Even if not, you can just let ES|QL do that for you:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbfd0b056ac38d0b2/6a17084c60084b3be93c44d5/9fc5d601604b05378beeb4e6e94b613f95fe2fbc-712x188.png" alt="ES|QL metric panel showing the average jvm.gc.duration value" /><p>Now we know that the average garbage collection operation took about 3 milliseconds. However, Java experts might know that there are different kinds of garbage collections happening, which can have significantly different pause times. Fortunately the OpenTelemetry metric comes with attributes, which allow us to slice the data accordingly:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcedc19c4c217bb91/6a17084e14b2708f3ce3c5a4/535d44cdb2ec3ed2ac9afe6b259d7b69c9167bbd-989x476.png" alt="ES|QL bar chart showing the average jvm.gc.duration grouped by jvm.gc.action" /><p>As expected, major garbage collections take a lot more time per collection than minor ones, at least on average. So far, we have done nothing you couldn't also achieve by just using counters. Let's now use histograms to understand the actual distribution of the GC latency. We'll look at the data over time (by grouping using <code>TBUCKET</code>) and focus on the major garbage collections:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1c18e5836bec77a4/6a17084f47d49cc38e2d8937/ce62a4498d5a6e3fcc3bc85ea78f45a18f6e7576-1016x477.png" alt="ES|QL line chart of min, median, p99 and max jvm.gc.duration for major garbage collections" /><p>The graph now shows us the minimum, maximum, median and 99th percentile for major garbage collections. Note that we aren't bound to only querying the median and the 99th percentile. We can query any percentile we'd like to see, as these are estimated at query time from the raw exponential histograms.</p><h2>A note on backwards compatibility</h2><p>So far, we have seen how you can use the new shiny toy in Elasticsearch and ES|QL: exponential histograms. However, since this has just reached general availability (GA) in the 9.4 release, what about your historical data?</p><p>Before exponential histograms were added, Elasticsearch was already capable of storing OpenTelemetry histograms in the <code>histogram</code> field type. To do so, we converted them to a different data structure supported by the <code>histogram</code> field type: <a href="https://github.com/tdunning/t-digest/blob/main/docs/t-digest-paper/histo.pdf">T-Digest</a>. T-Digest provides good accuracy for extreme percentiles (e.g., 99th percentile) at the cost of accuracy for percentiles in the middle of the distribution, such as the median. In contrast, exponential histograms provide a guaranteed upper bound on the relative error for every percentile. As conversions always introduce errors, we are happy to now have native support for exponential histograms, allowing you to collect and analyze your metrics end-to-end without unnecessary conversions.</p><p>But still, what should you do if you have historical data and still want to query it? Thanks to <a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-multi-index#esql-multi-index-union-types">ES|QL union types</a>, the answer is actually easy: You just have to add a <code>::exponential_histogram</code> suffix to the histogram metrics in your queries:</p><p>When this query encounters <code>histogram</code> fields, it will attempt to convert them to exponential histograms. When operating on <code>exponential_histogram</code> fields, the <code>::exponential_histogram</code> cast has no effect. Note that this also works with mixed data sets: if your backing indices use both types, the query will just do the right thing.</p><p>So if you are building queries or dashboards that you expect to run on pre-9.4 ingested data, we recommend that you simply add: <code>::exponential_histogram</code> casts.</p><h2>Wrapping up</h2><p>Native support for OpenTelemetry exponential histograms in Elasticsearch gives you better metric fidelity and more flexible analysis in ES|QL. In this blog post, we have shown you how to easily ingest and analyze your histogram metrics with ES|QL using various aggregations and the impact exponential histograms have.</p><p><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/exponential-histogram">Exponential histograms</a> are <strong>generally available</strong> in Elasticsearch basic starting with the 9.4.0 release. They will be available in Elastic Cloud <a href="https://www.elastic.co/cloud/serverless">Serverless</a> a few weeks after the 9.4.0 release, once <a href="https://www.elastic.co/docs/reference/opentelemetry/motlp">mOTLP</a> (the managed observability OTLP intake) switches to use the Elasticsearch OTLP endpoint. We'll update this blog post and add a note on the Elastic Cloud Serverless release notes when that happens.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/otel-histogram-metrics-esql</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/otel-histogram-metrics-esql</guid>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Jonas Kunz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blteb9d15d1bec33675/6a1708511949f75484e7a985/f44560ece4dcc46e6a01826b597e094169e99691-848x477.png" length="0" type="image/png"/>
    <pubDate>Fri, 08 May 2026 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>