Compare commits
2 Commits
0fe7aa38b0
...
98d63cc3fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
98d63cc3fd
|
|||
|
a26bd94f13
|
@@ -4,7 +4,7 @@
|
|||||||
//! in Prometheus text format.
|
//! in Prometheus text format.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use metrics_exporter_prometheus::PrometheusBuilder;
|
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
/// Install the Prometheus metrics recorder and return a handle.
|
/// Install the Prometheus metrics recorder and return a handle.
|
||||||
@@ -12,8 +12,7 @@ use std::net::SocketAddr;
|
|||||||
pub fn install(listen: &str) -> Result<()> {
|
pub fn install(listen: &str) -> Result<()> {
|
||||||
let addr: SocketAddr = listen.parse()?;
|
let addr: SocketAddr = listen.parse()?;
|
||||||
|
|
||||||
PrometheusBuilder::new()
|
with_buckets(PrometheusBuilder::new().with_http_listener(addr))?
|
||||||
.with_http_listener(addr)
|
|
||||||
.install()
|
.install()
|
||||||
.map_err(|e| anyhow::anyhow!("failed to install Prometheus exporter: {e}"))?;
|
.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
|
/// Install a recorder for testing (no HTTP listener). Returns a handle
|
||||||
/// that can render the current metrics as Prometheus text.
|
/// that can render the current metrics as Prometheus text.
|
||||||
pub fn install_test_recorder() -> Result<metrics_exporter_prometheus::PrometheusHandle> {
|
pub fn install_test_recorder() -> Result<metrics_exporter_prometheus::PrometheusHandle> {
|
||||||
let handle = PrometheusBuilder::new()
|
let handle = with_buckets(PrometheusBuilder::new())?
|
||||||
.install_recorder()
|
.install_recorder()
|
||||||
.map_err(|e| anyhow::anyhow!("failed to install test recorder: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to install test recorder: {e}"))?;
|
||||||
describe_metrics();
|
describe_metrics();
|
||||||
Ok(handle)
|
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() {
|
fn describe_metrics() {
|
||||||
metrics::describe_histogram!(
|
metrics::describe_histogram!(
|
||||||
"cortex_request_duration_seconds",
|
"cortex_request_duration_seconds",
|
||||||
@@ -130,3 +193,43 @@ fn describe_metrics() {
|
|||||||
"Live prefill throughput per neuron:model, tokens/sec EMA (#137)"
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user