Add histogram_quantile() PromQL query for percentiles
The PromQL layer only had a fixed-width histogram() (per-bucket counts), with no way to ask for p50/p95/p99. Adds histogram_quantile(p, metric[range]), interpolating over each series's retained raw sample values (linear interpolation between closest ranks) rather than building a separate pre-aggregated bucketed-histogram representation — exact for the data actually kept. An optional bracketed range narrows the computation to a trailing window, mirroring rate()'s range selector. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
4a0fe92ce8
commit
dbe2ea9961
+103
-1
@@ -8,6 +8,7 @@ pub enum AggOp {
|
|||||||
Rate,
|
Rate,
|
||||||
Histogram,
|
Histogram,
|
||||||
Gauge,
|
Gauge,
|
||||||
|
Quantile,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -23,6 +24,9 @@ pub struct PromqlQuery {
|
|||||||
pub matchers: Vec<LabelMatcher>,
|
pub matchers: Vec<LabelMatcher>,
|
||||||
pub range: Option<Duration>,
|
pub range: Option<Duration>,
|
||||||
pub bucket_width: Option<f64>,
|
pub bucket_width: Option<f64>,
|
||||||
|
/// The `p` in `histogram_quantile(p, metric[range])`, e.g. `0.95` for p95. Only set when
|
||||||
|
/// `agg` is `Some(AggOp::Quantile)`.
|
||||||
|
pub quantile: Option<f64>,
|
||||||
pub inner_rate: bool,
|
pub inner_rate: bool,
|
||||||
pub by_labels: Vec<String>,
|
pub by_labels: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -51,6 +55,13 @@ impl PromqlQuery {
|
|||||||
"histogram() requires a bucket width, e.g. histogram(my_metric[10])".to_string(),
|
"histogram() requires a bucket width, e.g. histogram(my_metric[10])".to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if query.agg == Some(AggOp::Quantile) && query.quantile.is_none() {
|
||||||
|
return Err(
|
||||||
|
"histogram_quantile() requires a quantile between 0 and 1 and a metric, e.g. \
|
||||||
|
histogram_quantile(0.95, latency_ms)"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(query)
|
Ok(query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,11 +236,42 @@ impl<'a> Parser<'a> {
|
|||||||
matchers,
|
matchers,
|
||||||
range,
|
range,
|
||||||
bucket_width,
|
bucket_width,
|
||||||
|
quantile: None,
|
||||||
inner_rate: false,
|
inner_rate: false,
|
||||||
by_labels: Vec::new(),
|
by_labels: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses a decimal number (e.g. `0`, `1`, `0.95`) — used for `histogram_quantile(p, ...)`'s
|
||||||
|
/// `p` argument, which isn't an integer like the range-selector widths elsewhere in this grammar.
|
||||||
|
fn parse_float(&mut self) -> Result<f64, String> {
|
||||||
|
self.skip_whitespace();
|
||||||
|
let rest = self.remaining();
|
||||||
|
let mut end = 0;
|
||||||
|
let mut seen_digit = false;
|
||||||
|
let mut seen_dot = false;
|
||||||
|
for (idx, c) in rest.char_indices() {
|
||||||
|
if c.is_ascii_digit() {
|
||||||
|
seen_digit = true;
|
||||||
|
end = idx + c.len_utf8();
|
||||||
|
} else if c == '.' && !seen_dot {
|
||||||
|
seen_dot = true;
|
||||||
|
end = idx + c.len_utf8();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seen_digit {
|
||||||
|
return Err("expected a number".to_string());
|
||||||
|
}
|
||||||
|
let text = &rest[..end];
|
||||||
|
let value: f64 = text
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("'{text}' is not a valid number"))?;
|
||||||
|
self.pos += end;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_by_clause(&mut self) -> Result<Vec<String>, String> {
|
fn parse_by_clause(&mut self) -> Result<Vec<String>, String> {
|
||||||
self.skip_whitespace();
|
self.skip_whitespace();
|
||||||
let checkpoint = self.pos;
|
let checkpoint = self.pos;
|
||||||
@@ -276,6 +318,7 @@ impl<'a> Parser<'a> {
|
|||||||
"rate" => Some(AggOp::Rate),
|
"rate" => Some(AggOp::Rate),
|
||||||
"histogram" => Some(AggOp::Histogram),
|
"histogram" => Some(AggOp::Histogram),
|
||||||
"gauge" => Some(AggOp::Gauge),
|
"gauge" => Some(AggOp::Gauge),
|
||||||
|
"histogram_quantile" => Some(AggOp::Quantile),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
self.skip_whitespace();
|
self.skip_whitespace();
|
||||||
@@ -288,7 +331,20 @@ impl<'a> Parser<'a> {
|
|||||||
if agg.is_some() && self.peek() == Some('(') {
|
if agg.is_some() && self.peek() == Some('(') {
|
||||||
self.expect_char('(')?;
|
self.expect_char('(')?;
|
||||||
self.skip_whitespace();
|
self.skip_whitespace();
|
||||||
let mut inner = if matches!(agg, Some(AggOp::Sum) | Some(AggOp::Avg)) {
|
let mut inner = if agg == Some(AggOp::Quantile) {
|
||||||
|
let quantile = self.parse_float()?;
|
||||||
|
if !(0.0..=1.0).contains(&quantile) {
|
||||||
|
return Err(format!(
|
||||||
|
"histogram_quantile() quantile must be between 0 and 1, got {quantile}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.skip_whitespace();
|
||||||
|
self.expect_char(',')?;
|
||||||
|
self.skip_whitespace();
|
||||||
|
let mut vector = self.parse_vector_selector()?;
|
||||||
|
vector.quantile = Some(quantile);
|
||||||
|
vector
|
||||||
|
} else if matches!(agg, Some(AggOp::Sum) | Some(AggOp::Avg)) {
|
||||||
let inner_checkpoint = self.pos;
|
let inner_checkpoint = self.pos;
|
||||||
let took_inner_rate = self.parse_identifier().is_ok_and(|id| id == "rate");
|
let took_inner_rate = self.parse_identifier().is_ok_and(|id| id == "rate");
|
||||||
if took_inner_rate {
|
if took_inner_rate {
|
||||||
@@ -591,4 +647,50 @@ mod tests {
|
|||||||
let err = PromqlQuery::parse("sum by (path) (requests) by (method)").unwrap_err();
|
let err = PromqlQuery::parse("sum by (path) (requests) by (method)").unwrap_err();
|
||||||
assert!(err.contains("only appear once"));
|
assert!(err.contains("only appear once"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_histogram_quantile() {
|
||||||
|
let query = PromqlQuery::parse("histogram_quantile(0.95, latency_ms)").unwrap();
|
||||||
|
assert_eq!(query.agg, Some(AggOp::Quantile));
|
||||||
|
assert_eq!(query.metric_name, "latency_ms");
|
||||||
|
assert_eq!(query.quantile, Some(0.95));
|
||||||
|
assert_eq!(query.range, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_histogram_quantile_with_range_and_matchers() {
|
||||||
|
let query =
|
||||||
|
PromqlQuery::parse(r#"histogram_quantile(0.5, latency_ms{path="/a"}[5m])"#).unwrap();
|
||||||
|
assert_eq!(query.quantile, Some(0.5));
|
||||||
|
assert_eq!(query.range, Some(Duration::from_secs(300)));
|
||||||
|
assert_eq!(
|
||||||
|
query.matchers,
|
||||||
|
vec![LabelMatcher {
|
||||||
|
name: "path".to_string(),
|
||||||
|
value: "/a".to_string()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_histogram_quantile_with_by_clause() {
|
||||||
|
let query = PromqlQuery::parse("histogram_quantile(0.99, latency_ms) by (path)").unwrap();
|
||||||
|
assert_eq!(query.by_labels, vec!["path".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_histogram_quantile_missing_comma() {
|
||||||
|
assert!(PromqlQuery::parse("histogram_quantile(0.95 latency_ms)").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_histogram_quantile_out_of_range() {
|
||||||
|
let err = PromqlQuery::parse("histogram_quantile(1.5, latency_ms)").unwrap_err();
|
||||||
|
assert!(err.contains("between 0 and 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_histogram_quantile_without_number() {
|
||||||
|
assert!(PromqlQuery::parse("histogram_quantile(latency_ms)").is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1287,10 +1287,76 @@ impl EngineSnapshot {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
Some(AggOp::Quantile) => {
|
||||||
|
let quantile = parsed
|
||||||
|
.quantile
|
||||||
|
.expect("histogram_quantile() always carries a quantile");
|
||||||
|
// The bracketed range, if any, narrows each series to its trailing window
|
||||||
|
// (mirroring rate()'s range selector) rather than the full panel window —
|
||||||
|
// interpolating over whatever raw values are retained for that series, since
|
||||||
|
// this engine doesn't keep a separate pre-aggregated bucketed-histogram
|
||||||
|
// representation to compute quantiles from.
|
||||||
|
let range_ms = parsed.range.map(|d| d.as_millis() as i64);
|
||||||
|
let last_ts = series_points
|
||||||
|
.values()
|
||||||
|
.flat_map(|points| points.iter().map(|(ts, _)| *ts))
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let mut groups: BTreeMap<BTreeMap<String, String>, Vec<f64>> = BTreeMap::new();
|
||||||
|
for (labels, points) in &series_points {
|
||||||
|
let group_key = project_labels(labels, &parsed.by_labels);
|
||||||
|
let window_start = match range_ms {
|
||||||
|
Some(range_ms) => {
|
||||||
|
let window_end = points.last().map(|(ts, _)| *ts).unwrap_or(0);
|
||||||
|
Some(window_end - range_ms)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
groups.entry(group_key).or_default().extend(
|
||||||
|
points
|
||||||
|
.iter()
|
||||||
|
.filter(|(ts, _)| window_start.is_none_or(|start| *ts >= start))
|
||||||
|
.map(|(_, value)| *value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
groups
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(labels, mut values)| {
|
||||||
|
let quantile_value = interpolated_quantile(&mut values, quantile)?;
|
||||||
|
Some(PromqlSeriesDto {
|
||||||
|
labels,
|
||||||
|
points: vec![(last_ts, quantile_value)],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Linear interpolation between closest ranks over the raw retained sample values (the "R-7"
|
||||||
|
/// method, matching numpy/Excel's default `PERCENTILE` behavior) — exact for the data actually
|
||||||
|
/// kept, unlike a bucketed-histogram approximation.
|
||||||
|
fn interpolated_quantile(values: &mut [f64], quantile: f64) -> Option<f64> {
|
||||||
|
if values.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
values.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
if values.len() == 1 {
|
||||||
|
return Some(values[0]);
|
||||||
|
}
|
||||||
|
let rank = quantile * (values.len() - 1) as f64;
|
||||||
|
let lower = rank.floor() as usize;
|
||||||
|
let upper = rank.ceil() as usize;
|
||||||
|
if lower == upper {
|
||||||
|
return Some(values[lower]);
|
||||||
|
}
|
||||||
|
let fraction = rank - lower as f64;
|
||||||
|
Some(values[lower] + (values[upper] - values[lower]) * fraction)
|
||||||
|
}
|
||||||
|
|
||||||
// --- sort: sort-helper index maintenance ---
|
// --- sort: sort-helper index maintenance ---
|
||||||
impl EngineSnapshot {
|
impl EngineSnapshot {
|
||||||
pub(super) fn build_sort_helper_index(
|
pub(super) fn build_sort_helper_index(
|
||||||
|
|||||||
@@ -1724,6 +1724,67 @@ fn rate_and_sum_over_occurrence_count_metrics_are_unaffected_by_the_semantics_gu
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn histogram_quantile_interpolates_over_retained_sample_values() {
|
||||||
|
let mut index = EngineSnapshot::default();
|
||||||
|
declare_gauge_metric_with_value(&mut index);
|
||||||
|
for (sequence, depth) in [(0u64, 1), (1, 2), (2, 3), (3, 4), (4, 5)] {
|
||||||
|
ingest_log(&mut index, sequence, &format!("queue depth={depth}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = PromqlQuery::parse("histogram_quantile(0.5, queue_depth)").unwrap();
|
||||||
|
let series = index.evaluate_promql(&parsed, None, None).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(series.len(), 1);
|
||||||
|
assert_eq!(series[0].points.len(), 1);
|
||||||
|
assert_eq!(series[0].points[0].1, 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn histogram_quantile_p0_and_p1_return_the_min_and_max() {
|
||||||
|
let mut index = EngineSnapshot::default();
|
||||||
|
declare_gauge_metric_with_value(&mut index);
|
||||||
|
for (sequence, depth) in [(0u64, 10), (1, 20), (2, 30)] {
|
||||||
|
ingest_log(&mut index, sequence, &format!("queue depth={depth}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let min_parsed = PromqlQuery::parse("histogram_quantile(0, queue_depth)").unwrap();
|
||||||
|
let max_parsed = PromqlQuery::parse("histogram_quantile(1, queue_depth)").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
index.evaluate_promql(&min_parsed, None, None).unwrap()[0].points[0].1,
|
||||||
|
10.0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
index.evaluate_promql(&max_parsed, None, None).unwrap()[0].points[0].1,
|
||||||
|
30.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn histogram_quantile_range_narrows_to_the_trailing_window() {
|
||||||
|
let mut index = EngineSnapshot::default();
|
||||||
|
declare_gauge_metric_with_value(&mut index);
|
||||||
|
// Values far outside the trailing 2s window should not affect the result.
|
||||||
|
ingest_log(&mut index, 0, "queue depth=1000");
|
||||||
|
ingest_log(&mut index, 5000, "queue depth=10");
|
||||||
|
ingest_log(&mut index, 6000, "queue depth=20");
|
||||||
|
|
||||||
|
let parsed = PromqlQuery::parse("histogram_quantile(0.5, queue_depth[2s])").unwrap();
|
||||||
|
let series = index.evaluate_promql(&parsed, None, None).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(series[0].points[0].1, 15.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn histogram_quantile_on_metric_with_no_data_returns_no_series() {
|
||||||
|
let mut index = EngineSnapshot::default();
|
||||||
|
declare_gauge_metric_with_value(&mut index);
|
||||||
|
|
||||||
|
let parsed = PromqlQuery::parse("histogram_quantile(0.5, queue_depth)").unwrap();
|
||||||
|
let series = index.evaluate_promql(&parsed, None, None).unwrap();
|
||||||
|
assert!(series.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bare_query_over_string_kind_metric_is_still_allowed() {
|
fn bare_query_over_string_kind_metric_is_still_allowed() {
|
||||||
let mut index = EngineSnapshot::default();
|
let mut index = EngineSnapshot::default();
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
# 098 — No histogram_quantile()-style percentile query
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The PromQL layer's `histogram()` op buckets raw sample values by a
|
|
||||||
fixed width and returns per-bucket counts — it's unrelated to
|
|
||||||
Prometheus's classic histogram bucket/quantile model, and there's no
|
|
||||||
way to ask for p50/p95/p99 out of a metric today.
|
|
||||||
|
|
||||||
## Where
|
|
||||||
|
|
||||||
- `src/engine/promql.rs:9-16` — `AggOp::Histogram` is one of the six
|
|
||||||
supported ops.
|
|
||||||
- `src/engine/snapshot/mod.rs:1002` onward — `Histogram` evaluation:
|
|
||||||
buckets values by `bucket_width` into counts, grouped by
|
|
||||||
`by_labels`. This answers "how many samples fell in each bucket",
|
|
||||||
not "what value is the Nth percentile".
|
|
||||||
- No `histogram_quantile()` function or equivalent exists anywhere in
|
|
||||||
`promql.rs`'s parser (`parse_query`, `:261-324` recognizes only
|
|
||||||
`sum`, `avg`, `count`, `rate`, `histogram`, `gauge`).
|
|
||||||
- Depends on ticket 097 (gauge/counter semantics) for the broader
|
|
||||||
question of whether a metric needs a declared "this holds raw
|
|
||||||
observations for quantile purposes" flag, but quantile computation
|
|
||||||
itself doesn't strictly require that — it can operate on whatever
|
|
||||||
raw values are already stored in a metric's buckets.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Decide the quantile computation model: interpolate over the raw
|
|
||||||
stored sample values in a bucket/window (simplest, exact for the
|
|
||||||
data actually retained) vs. building a genuine bucketed-histogram
|
|
||||||
representation with configurable bucket boundaries (closer to
|
|
||||||
Prometheus's classic histograms, more work, needed only if retained
|
|
||||||
raw values are later dropped in favor of pre-aggregated buckets).
|
|
||||||
- Add a `histogram_quantile(p, metric[range])`-shaped PromQL construct
|
|
||||||
(or extend `histogram()` with a quantile argument) to
|
|
||||||
`promql.rs`'s parser and `AggOp`, plus an evaluator in
|
|
||||||
`snapshot/mod.rs` alongside the existing `Histogram` handling.
|
|
||||||
- Add dashboard/panel UI support for selecting a quantile once the
|
|
||||||
query-language side exists (`webapp/src/components/Dashboards.tsx`
|
|
||||||
or wherever panel query construction lives).
|
|
||||||
@@ -381,6 +381,15 @@ const SECTIONS: DocSection[] = [
|
|||||||
— the most-recent value per label combination in the active range, i.e. the counter collapsed to its
|
— the most-recent value per label combination in the active range, i.e. the counter collapsed to its
|
||||||
latest reading.
|
latest reading.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-text">
|
||||||
|
<Code>histogram_quantile(p, metric_name)</Code>
|
||||||
|
</span>{' '}
|
||||||
|
— the <Code>p</Code>th quantile (<Code>0</Code>–<Code>1</Code>, e.g. <Code>0.95</Code> for p95) of each
|
||||||
|
label combination's retained values, interpolated over the raw samples rather than pre-aggregated
|
||||||
|
buckets. Add a bracketed range to narrow to a trailing window instead of the whole active range, e.g.{' '}
|
||||||
|
<Code>histogram_quantile(0.95, durations[5m])</Code>.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="font-semibold text-text">Worked example</p>
|
<p className="font-semibold text-text">Worked example</p>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
Reference in New Issue
Block a user