2 Commits

Author SHA1 Message Date
98d63cc3fd Merge branch 'fix/prometheus-histogram-buckets'
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 13s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Successful in 2m12s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m37s
build-prerelease / Package cortex RPM (push) Successful in 29s
build-prerelease / Test (push) Successful in 6m54s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 13s
2026-08-19 16:35:45 +03:00
a26bd94f13 fix(metrics): export histograms as histograms, not summaries
All checks were successful
CI / Classify changes (push) Successful in 13s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 8s
CI / Clippy (push) Successful in 2m27s
CI / Test (push) Successful in 10m28s
CI / CUDA type-check (push) Successful in 19m44s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
`metrics-exporter-prometheus` renders every `histogram!` as a summary
unless the builder is given buckets, and cortex never gave it any. So
four metrics that are declared, recorded, and labelled correctly were
published as `{quantile="0.95"}` series with **no `_bucket` series at
all**:

    # TYPE cortex_time_to_first_token_seconds summary
    cortex_time_to_first_token_seconds{...,quantile="0.95"} 0

Three consequences, and the third is what surfaced it.

1. `histogram_quantile()` has nothing to read. The fleet dashboard's
   TTFT panel is written the idiomatic way — over
   `..._seconds_bucket` — so it returned "No data" indefinitely while
   cortex served thousands of requests. The panel was right; the
   exporter was not.
2. Summary quantiles are computed per process over a rolling window and
   cannot be aggregated. A p95 averaged across two gateways is not the
   p95 of anything, so the shape would not survive a second gateway
   even where a panel did read it.
3. They decay to `0` when idle, which on a graph is indistinguishable
   from "genuinely instant" — the worst possible failure for a latency
   metric, because it reads as good news.

Buckets are set per metric rather than globally: whole-request latency
runs to minutes on a long agentic turn, prefill is sub-second to
seconds, image generation sits between them, and tokens/sec is not a
duration at all. One shared scale would have left most of them with
every sample in a single bucket, which is a quieter way of having no
data.

Covers `cortex_request_duration_seconds`,
`cortex_time_to_first_token_seconds`, `cortex_images_generation_seconds`
and `cortex_tokens_per_second` — TTFT was merely the one with a panel
pointed at it. Request-latency percentiles were equally unavailable.

No dashboard change: `asset/monitoring/grafana-helexa-fleet.json`
already queries `_bucket` for both TTFT and image-generation latency.

Pinned by a test asserting the exported *shape* — `_bucket` present,
`# TYPE ... histogram`, no `quantile=` series — since the recording
call was never what broke. Verified it fails without the buckets with
the same complaint the dashboard had.

Refs #137

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGhUi88ANnSfvk86DtYc9g
2026-08-19 16:14:44 +03:00

View File

@@ -4,7 +4,7 @@
//! in Prometheus text format.
use anyhow::Result;
use metrics_exporter_prometheus::PrometheusBuilder;
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder};
use std::net::SocketAddr;
/// Install the Prometheus metrics recorder and return a handle.
@@ -12,8 +12,7 @@ use std::net::SocketAddr;
pub fn install(listen: &str) -> Result<()> {
let addr: SocketAddr = listen.parse()?;
PrometheusBuilder::new()
.with_http_listener(addr)
with_buckets(PrometheusBuilder::new().with_http_listener(addr))?
.install()
.map_err(|e| anyhow::anyhow!("failed to install Prometheus exporter: {e}"))?;
@@ -25,13 +24,77 @@ pub fn install(listen: &str) -> Result<()> {
/// Install a recorder for testing (no HTTP listener). Returns a handle
/// that can render the current metrics as Prometheus text.
pub fn install_test_recorder() -> Result<metrics_exporter_prometheus::PrometheusHandle> {
let handle = PrometheusBuilder::new()
let handle = with_buckets(PrometheusBuilder::new())?
.install_recorder()
.map_err(|e| anyhow::anyhow!("failed to install test recorder: {e}"))?;
describe_metrics();
Ok(handle)
}
/// Give every histogram explicit buckets, so it is exported as a
/// Prometheus **histogram** rather than a summary.
///
/// Without this, `metrics-exporter-prometheus` renders every
/// `histogram!` as a summary: `{quantile="0.95"}` series and no
/// `_bucket` series at all. Three things break as a result, and the
/// third is what made it visible.
///
/// 1. `histogram_quantile()` has nothing to read, so any dashboard
/// panel written the idiomatic way returns *No data* forever. The
/// fleet dashboard's TTFT panel did exactly that while cortex was
/// serving thousands of requests.
/// 2. Summary quantiles are computed per process over a rolling
/// window, so they **cannot be aggregated**. Averaging p95 across
/// two gateways is not p95 of anything.
/// 3. They decay: with no samples in the window every quantile reads
/// `0`, which is indistinguishable from "genuinely instant" on a
/// graph.
///
/// Buckets are per-metric because the quantities differ by orders of
/// magnitude — a request lasting minutes and a TTFT of milliseconds
/// have no useful shared scale. Ranges are chosen from observed fleet
/// behaviour rather than round numbers: decode runs for minutes on a
/// long turn, prefill is seconds on a long prompt, and decode
/// throughput sits in the tens of tokens/sec.
fn with_buckets(builder: PrometheusBuilder) -> Result<PrometheusBuilder> {
let seconds_short = &[
0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 30.0, 60.0, 120.0,
];
let seconds_long = &[
0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1200.0,
];
let tokens_per_second = &[
1.0, 2.5, 5.0, 10.0, 15.0, 20.0, 30.0, 40.0, 60.0, 80.0, 120.0, 200.0,
];
builder
// Whole-request latency: a long agentic turn legitimately runs
// for many minutes, so the tail has to reach there.
.set_buckets_for_metric(
Matcher::Full("cortex_request_duration_seconds".into()),
seconds_long,
)?
// Prefill: sub-second for a short prompt, seconds for a long
// one. Anything past a minute is pathological and belongs in
// the overflow bucket.
.set_buckets_for_metric(
Matcher::Full("cortex_time_to_first_token_seconds".into()),
seconds_short,
)?
// Image generation is inherently slower than a text turn and
// scales with resolution and step count.
.set_buckets_for_metric(
Matcher::Full("cortex_images_generation_seconds".into()),
seconds_long,
)?
// Not a duration — decode throughput, tens of tokens/sec on
// this fleet.
.set_buckets_for_metric(
Matcher::Full("cortex_tokens_per_second".into()),
tokens_per_second,
)
.map_err(|e| anyhow::anyhow!("failed to configure histogram buckets: {e}"))
}
fn describe_metrics() {
metrics::describe_histogram!(
"cortex_request_duration_seconds",
@@ -130,3 +193,43 @@ fn describe_metrics() {
"Live prefill throughput per neuron:model, tokens/sec EMA (#137)"
);
}
#[cfg(test)]
mod bucket_tests {
/// Histograms must export as Prometheus **histograms**, with
/// `_bucket` series — not as summaries.
///
/// This asserts the exported *shape*, not the recording call,
/// because the recording was never the problem: cortex measured
/// TTFT correctly for months while the fleet dashboard's panel sat
/// on "No data", since `histogram_quantile()` reads `_bucket`
/// series and a summary has none. A metric that is collected but
/// cannot be queried is indistinguishable from one that was never
/// collected — from the graph, and from the operator's chair.
#[test]
fn histograms_export_buckets_not_summary_quantiles() {
let handle = match super::install_test_recorder() {
Ok(h) => h,
// Another test in this binary owns the global recorder;
// it installs the same buckets, so skipping is honest.
Err(_) => return,
};
metrics::histogram!("cortex_time_to_first_token_seconds", "node" => "n", "model" => "m")
.record(0.42);
let rendered = handle.render();
assert!(
rendered.contains("cortex_time_to_first_token_seconds_bucket"),
"no _bucket series — histogram_quantile() cannot read this:\n{rendered}"
);
assert!(
rendered.contains("# TYPE cortex_time_to_first_token_seconds histogram"),
"exported as the wrong metric type:\n{rendered}"
);
assert!(
!rendered
.contains(r#"cortex_time_to_first_token_seconds{node="n",model="m",quantile="#),
"still exporting summary quantiles, which cannot be aggregated across gateways"
);
}
}