Fix regex help icon click-toggle and add PromQL by()/sum(rate()) grouping

RegexHelpIcon previously used a single `open` boolean driven by both hover
and click, so clicking to pin the popover open was undone by the next
mouseleave. Split into separate `pinned` (click) and `hovering` (mouse-over)
states so a click-toggle actually persists until clicked again.

The seeded `log_category` dashboard's line panel plotted the bare metric
name, since `sum()`/`rate()` always collapsed all label combinations into
one series and PromQL syntax had no way to preserve a label dimension
through an aggregation. Added `by (label, ...)` grouping and nested
`sum(rate(metric[range]))`/`avg(rate(...))` parsing, and made `rate()`
compute a real windowed time series instead of a single trailing point, so
a query like `sum(rate(log_category[1m])) by (category)` now renders one
line per category.

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 20:17:50 +01:00
co-authored by Claude Sonnet 5
parent d6b7f7dfb6
commit 5aa7c1bae9
4 changed files with 330 additions and 34 deletions
+170 -22
View File
@@ -1147,7 +1147,82 @@ fn matches_label_matchers(
})
}
/// Evaluates a parsed PromQL query against `index`, producing chart-ready series: baseline is one series per label combination; `sum()`/`avg()` merge series by timestamp into one label-less series; `rate()` is simplified to a single point over `(last_ts - range, last_ts]` (an occurrence-count-per-second reading, not PromQL's true sliding-window-per-step) since bucket values are occurrence markers, not cumulative counters.
/// Projects a full label map down to just the names in `by_labels`; an empty `by_labels` collapses
/// everything to the empty map, i.e. "one group total" (the historical `sum()`/`avg()` behavior).
fn project_labels(
labels: &BTreeMap<String, String>,
by_labels: &[String],
) -> BTreeMap<String, String> {
by_labels
.iter()
.filter_map(|name| labels.get(name).map(|value| (name.clone(), value.clone())))
.collect()
}
/// Turns one series' occurrence points into a windowed rate: at each of the series' own timestamps,
/// counts how many points fall in the trailing `(ts - range_ms, ts]` window and divides by the window
/// width in seconds. Points are occurrence markers rather than cumulative counters, so this reads as
/// "occurrences per second over the trailing window", evaluated at every point instead of just the last.
fn windowed_rate_series(points: &[(i64, f64)], range_ms: i64) -> Vec<(i64, f64)> {
let range_seconds = range_ms as f64 / 1000.0;
points
.iter()
.map(|(ts, _)| {
let window_start = ts - range_ms;
let count_in_window =
points.iter().filter(|(t, _)| *t > window_start && *t <= *ts).count();
(*ts, count_in_window as f64 / range_seconds)
})
.collect()
}
/// Merges several series by exact timestamp match: values sharing a timestamp are summed (or averaged).
/// This is the plain `sum()`/`avg()` behavior — appropriate when the underlying points are occurrence
/// markers rather than a continuous reading, so there's nothing to carry forward between timestamps.
fn merge_series_exact(sub_series: &[Vec<(i64, f64)>], is_avg: bool) -> Vec<(i64, f64)> {
let mut grouped: BTreeMap<i64, Vec<f64>> = BTreeMap::new();
for points in sub_series {
for (timestamp_ms, value) in points {
grouped.entry(*timestamp_ms).or_default().push(*value);
}
}
grouped
.into_iter()
.map(|(timestamp_ms, values)| {
let sum: f64 = values.iter().sum();
let value = if is_avg { sum / values.len() as f64 } else { sum };
(timestamp_ms, value)
})
.collect()
}
/// Merges several already-windowed rate series by sweeping the union of their timestamps, carrying each
/// sub-series' last-known value forward (0.0 before its first point) so every step reflects every
/// sub-series' most recent reading — unlike `merge_series_exact`, which only combines exact-timestamp matches.
fn merge_series_locf(sub_series: &[Vec<(i64, f64)>], is_avg: bool) -> Vec<(i64, f64)> {
let mut all_timestamps: std::collections::BTreeSet<i64> = std::collections::BTreeSet::new();
for points in sub_series {
all_timestamps.extend(points.iter().map(|(ts, _)| *ts));
}
let mut cursors = vec![0usize; sub_series.len()];
let mut last_values = vec![0.0f64; sub_series.len()];
all_timestamps
.into_iter()
.map(|ts| {
for (i, points) in sub_series.iter().enumerate() {
while cursors[i] < points.len() && points[cursors[i]].0 <= ts {
last_values[i] = points[cursors[i]].1;
cursors[i] += 1;
}
}
let sum: f64 = last_values.iter().sum();
let value = if is_avg { sum / last_values.len() as f64 } else { sum };
(ts, value)
})
.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.
fn evaluate_promql(
index: &BTreeMap<IndexKey, DataValue>,
parsed: &PromqlQuery,
@@ -1179,36 +1254,42 @@ fn evaluate_promql(
.map(|(labels, points)| PromqlSeriesDto { labels, points })
.collect(),
Some(AggOp::Sum) | Some(AggOp::Avg) => {
let mut grouped: BTreeMap<i64, Vec<f64>> = BTreeMap::new();
for points in series_points.values() {
for (timestamp_ms, value) in points {
grouped.entry(*timestamp_ms).or_default().push(*value);
}
}
let is_avg = parsed.agg == Some(AggOp::Avg);
let points = grouped
// Validated by `promql::parse_query`: an inner rate() always carries a range.
let rate_range_ms = parsed
.inner_rate
.then(|| parsed.range.expect("inner rate() always carries a range").as_millis() as i64);
let mut groups: BTreeMap<BTreeMap<String, String>, Vec<Vec<(i64, f64)>>> = BTreeMap::new();
for (labels, points) in series_points {
let points = match rate_range_ms {
Some(range_ms) => windowed_rate_series(&points, range_ms),
None => points,
};
let group_key = project_labels(&labels, &parsed.by_labels);
groups.entry(group_key).or_default().push(points);
}
groups
.into_iter()
.map(|(timestamp_ms, values)| {
let sum: f64 = values.iter().sum();
let value = if is_avg { sum / values.len() as f64 } else { sum };
(timestamp_ms, value)
.map(|(group_labels, sub_series)| {
let points = if rate_range_ms.is_some() {
merge_series_locf(&sub_series, is_avg)
} else {
merge_series_exact(&sub_series, is_avg)
};
PromqlSeriesDto { labels: group_labels, points }
})
.collect();
vec![PromqlSeriesDto { labels: BTreeMap::new(), points }]
.collect()
}
Some(AggOp::Rate) => {
// Validated by `promql::parse_query`: `rate()` never parses without one.
let range_ms = parsed.range.expect("rate() always carries a range").as_millis() as i64;
let range_seconds = range_ms as f64 / 1000.0;
series_points
.into_iter()
.filter_map(|(labels, points)| {
let last_ts = points.last()?.0;
let window_start = last_ts - range_ms;
let count_in_window =
points.iter().filter(|(ts, _)| *ts > window_start && *ts <= last_ts).count();
let rate = count_in_window as f64 / range_seconds;
Some(PromqlSeriesDto { labels, points: vec![(last_ts, rate)] })
.map(|(labels, points)| PromqlSeriesDto {
labels,
points: windowed_rate_series(&points, range_ms),
})
.collect()
}
@@ -1925,6 +2006,73 @@ mod tests {
assert_eq!(series[0].points, vec![(0, 2.0)]);
}
#[test]
fn promql_sum_by_preserves_the_named_label_dimension() {
let mut index = BTreeMap::new();
declare_requests_metric(&mut index);
ingest_log(&mut index, 0, "request path=/a");
ingest_log(&mut index, 0, "request path=/b");
ingest_log(&mut index, 1, "request path=/a");
let parsed = promql::parse_query("sum(requests) 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")));
assert_eq!(series.len(), 2);
assert_eq!(series[0].labels.get("path").map(String::as_str), Some("/a"));
assert_eq!(series[0].points, vec![(0, 1.0), (1, 1.0)]);
assert_eq!(series[1].labels.get("path").map(String::as_str), Some("/b"));
assert_eq!(series[1].points, vec![(0, 1.0)]);
}
#[test]
fn bare_rate_produces_a_windowed_point_per_timestamp_not_a_single_point() {
let mut index = BTreeMap::new();
declare_requests_metric(&mut index);
ingest_log(&mut index, 0, "request path=/a");
ingest_log(&mut index, 1000, "request path=/a");
let parsed = promql::parse_query("rate(requests[2s])").unwrap();
let series = evaluate_promql(&index, &parsed, None, None);
assert_eq!(series.len(), 1);
assert_eq!(series[0].points, vec![(0, 0.5), (1000, 1.0)]);
}
#[test]
fn sum_of_rate_by_label_gives_each_label_value_its_own_line() {
let mut index = BTreeMap::new();
declare_requests_metric(&mut index);
ingest_log(&mut index, 0, "request path=/a");
ingest_log(&mut index, 1000, "request path=/a");
ingest_log(&mut index, 0, "request path=/b");
let parsed = promql::parse_query("sum(rate(requests[2s])) 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")));
assert_eq!(series.len(), 2);
assert_eq!(series[0].labels.get("path").map(String::as_str), Some("/a"));
assert_eq!(series[0].points, vec![(0, 0.5), (1000, 1.0)]);
assert_eq!(series[1].labels.get("path").map(String::as_str), Some("/b"));
assert_eq!(series[1].points, vec![(0, 0.5)]);
}
#[test]
fn sum_of_rate_without_by_clause_still_collapses_to_one_series() {
let mut index = BTreeMap::new();
declare_requests_metric(&mut index);
ingest_log(&mut index, 0, "request path=/a");
ingest_log(&mut index, 0, "request path=/b");
let parsed = promql::parse_query("sum(rate(requests[2s]))").unwrap();
let series = evaluate_promql(&index, &parsed, None, None);
assert_eq!(series.len(), 1);
assert!(series[0].labels.is_empty());
assert_eq!(series[0].points, vec![(0, 1.0)]);
}
#[test]
fn promql_range_bounds_filter_points_via_start_end_ms() {
let mut index = BTreeMap::new();