The metrics engine rebuilt how time series are stored. This essay is about how they are asked: why ordinary aggregation gives confidently wrong answers on metrics, the TS command that fixes the order of operations, and the moment Elasticsearch started speaking PromQL, the native language of the world's dashboards, against its own columns.
Here is a question that looks trivial: how many requests per second is my service handling? Three hosts export request_count, a counter, the total since each process started. Run the obvious query and watch it lie:
The failure isn't a bug; it's a grammar error. A counter's value is cumulative, so its meaning lives in the differences within one series, and FROM … | STATS knows nothing of series: it sees a bag of rows and adds them. Any aggregation that crosses series boundaries before doing per-series math produces a number, promptly, with no error message, that means nothing.
The TS command changes the order of operations. Under it, every STATS runs in two phases: an inner function collapses each individual time series within each time bucket (this is where RATE, AVG_OVER_TIME, LAST_OVER_TIME live), and only then does an outer function aggregate across series. Compose a query and watch the phases work:
SUM(RATE(…)) is fleet throughput; MAX(MAX_OVER_TIME(…)) is worst-case anywhere; AVG(LAST_OVER_TIME(…)) is what TS quietly does when you write a bare AVG(cpu), because every series should count once per bucket, not once per sample. Note the discipline the engine enforces: RATE demands a field mapped as a counter and rejects gauges: the type system knows the grammar of time even when the query author doesn't.Every one of those inner functions works over a window of samples, and the window is where real dashboards quietly break. Metrics arrive at a fixed cadence, say every 30 seconds. Now ask for 10-second buckets:
RATE(request_count, 5m), the exact counterpart of PromQL's rate(x[5m]), which lets every evaluation reach back for enough samples no matter how fine the buckets.Section 3 settled what a window means. This section is about what one costs, because a dashboard never asks for a single window: it asks for a five-minute window recomputed every thirty seconds, thousands of overlapping positions per panel. The naive plan re-reads every sample inside every position, so each point is read W / s times, once for every window that covers it. The engine's plan is different: fold each arriving sample into a per-bucket partial state, then answer any window position by merging the partials it spans. Each point contributes to partial state exactly once; the window slides forward without ever replaying history, and the smooth trend line comes out at roughly the cost of one pass. Scrub the window and watch the two plans pay:
The other half of the execution story is what happens across cores. RATE is embarrassingly parallel across series and strictly ordered within one: shuffle the points inside a single counter and its resets become invisible. So the engine partitions the work by slicing the ordered _tsid range by prefix: each thread takes a contiguous run of whole series, and no series is ever split into a scrambled bag of points.
_tsid range by prefix: every series lands whole, in order, on exactly one thread.Prefix partitioning plus bucket interpolation at the boundaries preserve exactly the two things a counter's meaning lives in, the resets and the boundary deltas, while making fewer passes over the data. (What a reset is, and why it must be absorbed rather than summed, is the metrics engine.) Section 3 was the semantics; this is the economics: the window means what it always meant, the engine just stopped paying for it per position.
Then there is the question of the dashboards that already exist. They speak PromQL, and rewriting them is the migration cost nobody pays. So Elasticsearch learned the language instead, natively, against the same columns. The same question, both tongues:
The door swings both ways. A Prometheus-compatible HTTP API lives at /_prometheus/api/v1/…: remote write in, instant and range queries out, plus metric metadata. Grafana configures Elasticsearch as an ordinary Prometheus data source, and existing dashboards render. And inside ES|QL, the PROMQL source command embeds a PromQL expression as the head of a pipe, so its result can flow on through WHERE, LOOKUP JOIN, anything: two grammars, one query. Honestly labeled: both arrived as a technical preview in 9.4 and went GA in 9.5, where more than 80% of a corpus of real-world PromQL queries runs unmodified. The gaps are known and listed: vector matching with on()/group_left, set operators like and/unless, topk, and a handful of functions. How that engine work was done is the clock problem.
One storage engine underneath, three ways in: TS when you think in pipes, PROMQL when the expression already exists, the HTTP API when the client was built for Prometheus. The stored bytes never care which language asked. How those bytes got so small is the metrics engine; the machinery every query rides is ES|QL: one language for the whole machine.