Support by() label grouping for histogram() queries

sum/avg/rate already grouped series by the query's by_labels, but
histogram() collapsed every label combination into one histogram
regardless of by(), silently discarding the grouping. evaluate_promql's
Histogram branch now groups series by by_labels first (reusing the same
project_labels mechanism as sum/avg), then buckets each group's values
independently, so histogram(metric) by (label) yields one histogram per
label group carrying that group's labels alongside `le`. An empty
by_labels still collapses to a single histogram, matching prior
behavior exactly, so sum/avg/rate and unqualified histogram() queries
are unaffected.

The dashboard panel UI needed no change: PromQL is entered as free
text and PanelChart's seriesLabel()/legend rendering already renders
arbitrary label maps generically, so grouped histogram series display
correctly out of the box.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 22:24:43 +01:00
co-authored by Claude Sonnet 5
parent 85fb145be7
commit db6feff76c
+54 -8
View File
@@ -1222,7 +1222,7 @@ fn merge_series_locf(sub_series: &[Vec<(i64, f64)>], is_avg: bool) -> Vec<(i64,
.collect() .collect()
} }
/// Evaluates a parsed PromQL query against `index`, producing chart-ready series: baseline is one series per label combination; `sum()`/`avg()` merge series into one series per `by_labels` group (the whole result collapses to a single label-less series when `by_labels` is empty); `rate()` — bare or wrapped in `sum()`/`avg()` — turns each series into a windowed occurrences-per-second reading via `windowed_rate_series`, so it plots as a real line rather than one trailing point. /// Evaluates a parsed PromQL query against `index`, producing chart-ready series: baseline is one series per label combination; `sum()`/`avg()` merge series into one series per `by_labels` group (the whole result collapses to a single label-less series when `by_labels` is empty); `rate()` — bare or wrapped in `sum()`/`avg()` — turns each series into a windowed occurrences-per-second reading via `windowed_rate_series`, so it plots as a real line rather than one trailing point; `histogram()` groups by `by_labels` the same way sum()/avg() do, then buckets each group's values independently, so `histogram(m) by (label)` yields one histogram (a family of `le`-labeled series carrying the group's own labels) per label group instead of collapsing all groups together.
fn evaluate_promql( fn evaluate_promql(
index: &BTreeMap<IndexKey, DataValue>, index: &BTreeMap<IndexKey, DataValue>,
parsed: &PromqlQuery, parsed: &PromqlQuery,
@@ -1313,21 +1313,31 @@ fn evaluate_promql(
.flat_map(|points| points.iter().map(|(ts, _)| *ts)) .flat_map(|points| points.iter().map(|(ts, _)| *ts))
.max() .max()
.unwrap_or(0); .unwrap_or(0);
// Buckets every value (across all label combinations, collapsing the label dimension like sum()/avg()) into fixed-width buckets, labeled `le` (Prometheus's less-than-or-equal convention). // Groups series by `by_labels` first (empty `by_labels` collapses to a single group,
// matching sum()/avg()'s behavior), then buckets each group's values independently into
// fixed-width buckets, labeled `le` (Prometheus's less-than-or-equal convention) alongside
// the group's own by_labels values.
let mut groups: BTreeMap<BTreeMap<String, String>, Vec<&(i64, f64)>> = BTreeMap::new();
for (labels, points) in &series_points {
let group_key = project_labels(labels, &parsed.by_labels);
groups.entry(group_key).or_default().extend(points.iter());
}
groups
.into_iter()
.flat_map(|(group_labels, points)| {
let mut counts: BTreeMap<i64, u64> = BTreeMap::new(); let mut counts: BTreeMap<i64, u64> = BTreeMap::new();
for points in series_points.values() {
for (_, value) in points { for (_, value) in points {
let bucket_index = (value / bucket_width).floor() as i64; let bucket_index = (value / bucket_width).floor() as i64;
*counts.entry(bucket_index).or_insert(0) += 1; *counts.entry(bucket_index).or_insert(0) += 1;
} }
} counts.into_iter().map(move |(bucket_index, count)| {
counts
.into_iter()
.map(|(bucket_index, count)| {
let upper_bound = (bucket_index + 1) as f64 * bucket_width; let upper_bound = (bucket_index + 1) as f64 * bucket_width;
let labels = BTreeMap::from([("le".to_string(), upper_bound.to_string())]); let mut labels = group_labels.clone();
labels.insert("le".to_string(), upper_bound.to_string());
PromqlSeriesDto { labels, points: vec![(last_ts, count as f64)] } PromqlSeriesDto { labels, points: vec![(last_ts, count as f64)] }
}) })
})
.collect() .collect()
} }
} }
@@ -2220,6 +2230,42 @@ mod tests {
assert_eq!(series[2].points[0].1, 1.0); assert_eq!(series[2].points[0].1, 1.0);
} }
#[test]
fn histogram_with_by_clause_buckets_each_label_group_separately() {
let mut index = BTreeMap::new();
declare_durations_metric(&mut index);
// path=/a: 2,4 -> le=10 (count 2); 12 -> le=20 (count 1).
// path=/b: 21 -> le=30 (count 1).
for (seq, path, duration) in [
(0, "/a", 2),
(1, "/a", 4),
(2, "/a", 12),
(3, "/b", 21),
] {
ingest_log(&mut index, seq, &format!("request path={path} duration={duration}"));
}
let parsed = promql::parse_query("histogram(durations[10]) by (path)").unwrap();
let mut series = evaluate_promql(&index, &parsed, None, None);
series.sort_by(|a, b| {
a.labels.get("path").cmp(&b.labels.get("path")).then(a.labels.get("le").cmp(&b.labels.get("le")))
});
assert_eq!(series.len(), 3);
assert_eq!(series[0].labels.get("path").map(String::as_str), Some("/a"));
assert_eq!(series[0].labels.get("le").map(String::as_str), Some("10"));
assert_eq!(series[0].points[0].1, 2.0);
assert_eq!(series[1].labels.get("path").map(String::as_str), Some("/a"));
assert_eq!(series[1].labels.get("le").map(String::as_str), Some("20"));
assert_eq!(series[1].points[0].1, 1.0);
assert_eq!(series[2].labels.get("path").map(String::as_str), Some("/b"));
assert_eq!(series[2].labels.get("le").map(String::as_str), Some("30"));
assert_eq!(series[2].points[0].1, 1.0);
}
fn declare_login_duration_metric(index: &mut BTreeMap<IndexKey, DataValue>) { fn declare_login_duration_metric(index: &mut BTreeMap<IndexKey, DataValue>) {
declare_metric( declare_metric(
index, index,