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:
co-authored by
Claude Sonnet 5
parent
5493358db8
commit
79d252c1df
+105
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user