Implement ticket 057: fix rate() line charts for sparse/bursty occurrences

A rate() series over a metric whose occurrences are confined to a short
burst within a much wider queried range (e.g. sum by (code)
(rate(KeyPressed[5m])) against a ~10h log where all key presses happen
in the first 10s) rendered as broken: the line simply stopped at the
last real occurrence, and the chart's x-axis auto-scaled to the tiny
span of actual data points rather than the queried range, silently
narrowing a 10-hour view down to ~10 seconds with no indication anything
was off.

Backend (src/engine.rs): windowed_rate_series now appends a synthetic
boundary point at start_ms/end_ms (computed via the existing
rate::rate_at against the original occurrence points, so a boundary
inside a still-warm window gets its true partial value rather than a
hardcoded 0) whenever the real series doesn't already reach that edge.
Wired into both the bare rate() and sum()/avg(rate()) evaluate_promql
branches, which already had start_ms/end_ms in scope.

Frontend (PanelChart.tsx/Dashboards.tsx): the line chart's XAxis now
uses an explicit numeric domain pinned to the panel's queried range
(passed down from Dashboards.tsx's PanelPreview) instead of recharts'
default auto-scale-to-data-extent, so sparse data can no longer make
the axis look narrower than what was actually queried.

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-06 09:53:12 +01:00
co-authored by Claude Sonnet 5
parent 5493358db8
commit 79d252c1df
4 changed files with 125 additions and 105 deletions
+105 -6
View File
@@ -1615,13 +1615,41 @@ fn project_labels(
/// just converts at the boundary and delegates — the actual window-scan logic lives only in
/// `crate::rate::windowed_rate_series`, also used (via `crate::rate::RateWindow`) by alert
/// `Threshold`/`Ratio` conditions in `crate::alerts`, so both agree on what "windowed rate" means.
fn windowed_rate_series(points: &[(i64, f64)], range_ms: i64) -> Vec<(i64, f64)> {
let points: Vec<(Timestamp, f64)> =
///
/// Ticket 057: `rate::windowed_rate_series` only evaluates *at each occurrence's own timestamp*,
/// so a metric whose occurrences stop (or start) well inside a much wider queried range produces a
/// series that just ends (or begins) at the last (or first) real occurrence — nothing says "the
/// rate is 0 here" for the rest of the range. When `start_ms`/`end_ms` are given (a dashboard panel
/// always supplies both), this appends a synthetic boundary point at each edge — computed via
/// `rate::rate_at` against the original occurrence points, so a boundary that falls inside a
/// still-warm window reads its true partial value rather than being forced to 0 — whenever the
/// real series doesn't already reach that edge.
fn windowed_rate_series(
points: &[(i64, f64)],
range_ms: i64,
start_ms: Option<i64>,
end_ms: Option<i64>,
) -> Vec<(i64, f64)> {
let ts_points: Vec<(Timestamp, f64)> =
points.iter().map(|(ms, value)| (Timestamp::from_millis(*ms), *value)).collect();
rate::windowed_rate_series(&points, range_ms)
let mut series: Vec<(i64, f64)> = rate::windowed_rate_series(&ts_points, range_ms)
.into_iter()
.map(|(ts, value)| (ts.as_millis(), value))
.collect()
.collect();
if let Some(end) = end_ms {
if series.last().is_none_or(|(ts, _)| *ts < end) {
let value = rate::rate_at(&ts_points, Timestamp::from_millis(end), range_ms);
series.push((end, value));
}
}
if let Some(start) = start_ms {
if series.first().is_none_or(|(ts, _)| *ts > start) {
let value = rate::rate_at(&ts_points, Timestamp::from_millis(start), range_ms);
series.insert(0, (start, value));
}
}
series
}
/// Fixed window width (ms) used by `merge_series_windowed` to combine multiple raw-occurrence
@@ -1857,7 +1885,7 @@ fn evaluate_promql(
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),
Some(range_ms) => windowed_rate_series(&points, range_ms, start_ms, end_ms),
None => points,
};
let group_key = project_labels(&labels, &parsed.by_labels);
@@ -1883,7 +1911,7 @@ fn evaluate_promql(
.into_iter()
.map(|(labels, points)| PromqlSeriesDto {
labels,
points: windowed_rate_series(&points, range_ms),
points: windowed_rate_series(&points, range_ms, start_ms, end_ms),
})
.collect()
}
@@ -3052,6 +3080,77 @@ mod tests {
assert_eq!(series[1].points, vec![(0, 0.5)]);
}
/// Ticket 057: reproduces the reported `sum by (code) (rate(KeyPressed[5m]))` chart — a burst
/// of occurrences confined to the very start of a much wider queried range. Without a
/// synthetic trailing point, the series would just stop at the last occurrence's timestamp
/// (`1`), giving no indication the rate has decayed to 0 for the rest of `end_ms`.
#[test]
fn rate_series_gets_a_trailing_zero_point_once_occurrences_stop_early_in_a_wider_range() {
let mut index = BTreeMap::new();
declare_requests_metric(&mut index);
ingest_log(&mut index, 0, "request path=/a");
ingest_log(&mut index, 1, "request path=/a");
let parsed = promql::parse_query("rate(requests[2s])").unwrap();
let series = evaluate_promql(&index, &parsed, None, Some(100_000)).unwrap();
assert_eq!(series.len(), 1);
assert_eq!(
series[0].points,
vec![(0, 0.5), (1, 1.0), (100_000, 0.0)],
"the window (2s) is long since aged out by end_ms, so the appended boundary point must read 0"
);
}
/// Mirror of the trailing case: occurrences starting well after `start_ms` shouldn't make the
/// line appear to begin mid-range with no indication the rate was 0 beforehand.
#[test]
fn rate_series_gets_a_leading_zero_point_when_occurrences_start_late_in_a_wider_range() {
let mut index = BTreeMap::new();
declare_requests_metric(&mut index);
ingest_log(&mut index, 50_000, "request path=/a");
let parsed = promql::parse_query("rate(requests[2s])").unwrap();
let series = evaluate_promql(&index, &parsed, Some(0), None).unwrap();
assert_eq!(series[0].points, vec![(0, 0.0), (50_000, 0.5)]);
}
/// Same shape as the two tests above, but through `sum(rate(...)) by (label)` — the exact
/// query form from the ticket's report — so each label's line independently reflects
/// rate-zero for the remainder of the range instead of just its own group stopping early.
#[test]
fn sum_by_rate_gets_boundary_points_per_group_across_a_wider_range() {
let mut index = BTreeMap::new();
declare_requests_metric(&mut index);
ingest_log(&mut index, 0, "request path=/a");
ingest_log(&mut index, 1, "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, Some(100_000)).unwrap();
series.sort_by(|a, b| a.labels.get("path").cmp(&b.labels.get("path")));
assert_eq!(series.len(), 2);
assert_eq!(series[0].points, vec![(0, 0.5), (1, 1.0), (100_000, 0.0)]);
assert_eq!(series[1].points, vec![(0, 0.5), (100_000, 0.0)]);
}
/// A boundary point is only appended when the real series doesn't already reach that edge —
/// continuously-occurring metrics (the common case) must render exactly as before.
#[test]
fn rate_series_skips_boundary_points_already_covered_by_real_data() {
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, Some(0), Some(1000)).unwrap();
assert_eq!(series[0].points, vec![(0, 0.5), (1000, 1.0)]);
}
/// Ticket 040 acceptance criterion: a dashboard `rate()` chart and an alert `Threshold`
/// condition over the same metric/window agree on the computed rate for the same historical
/// data, since both now go through the single shared implementation in `crate::rate` rather