Add PromQL query support for dashboard visualizations (ticket 013)
Hand-rolls a minimal PromQL parser (src/promql.rs) for metric-name + label-matcher selection, optional [range] selectors, and top-level sum()/avg()/rate() wrapping, rather than pulling in a full PromQL parser crate — the required subset is small and a hand-rolled parser stays fully engine-independent (mirrors filter_parser.rs) with no unused-AST overhead. Adds EngineQuery::Promql/EngineResponse::Promql (engine parses + evaluates in place against the index, no copying) and GET /api/query, returning Vec<PromqlSeriesDto> (labels + (timestamp_ms, f64) points) for charting, with malformed queries surfaced as 400 + ErrorDto. 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
e48c03218f
commit
ac67f9e120
+252
-2
@@ -1,10 +1,11 @@
|
||||
use crate::data_value::{DataValue, MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
|
||||
use crate::dto::{
|
||||
BucketValueDto, LogEntryDto, LogsParams, MetricDeclarationDto, ParamDeclarationDto, StatsDto,
|
||||
TimelineBucketDto, TimelineParams,
|
||||
BucketValueDto, LogEntryDto, LogsParams, MetricDeclarationDto, ParamDeclarationDto,
|
||||
PromqlSeriesDto, StatsDto, TimelineBucketDto, TimelineParams,
|
||||
};
|
||||
use crate::index_key::IndexKey;
|
||||
use crate::metric_primitive::{MetricPrimitive, Number};
|
||||
use crate::promql::{self, AggOp, LabelMatcher, PromqlQuery};
|
||||
use crate::timestamp::Timestamp;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
@@ -64,6 +65,13 @@ pub enum EngineQuery {
|
||||
since_sequence: Option<u64>,
|
||||
limit: Option<usize>,
|
||||
},
|
||||
/// PromQL query (ticket 013's `GET /api/query`). `query` is parsed here (rather
|
||||
/// than by the API handler) so parsing and evaluation both happen inside the
|
||||
/// single-owner engine loop, matching every other `EngineQuery` variant's
|
||||
/// pattern of evaluating in place against `index` with no copying. A malformed
|
||||
/// query is therefore only discovered once this reaches `handle_query`, hence
|
||||
/// `EngineResponse::Promql` carrying a `Result` rather than a bare `Vec`.
|
||||
Promql { query: String, start_ms: Option<i64>, end_ms: Option<i64> },
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -99,6 +107,9 @@ pub enum EngineResponse {
|
||||
ParamDeclarations(Vec<ParamDeclarationDto>),
|
||||
MetricDeclarations(Vec<MetricDeclarationDto>),
|
||||
BucketValues(Vec<BucketValueDto>),
|
||||
/// `Err` holds a human-readable message for a malformed query (see
|
||||
/// `crate::promql::parse_query`), which the API layer maps to a 400 response.
|
||||
Promql(Result<Vec<PromqlSeriesDto>, String>),
|
||||
}
|
||||
|
||||
pub type EngineHandle = mpsc::Sender<EngineMessage>;
|
||||
@@ -486,6 +497,134 @@ fn handle_query(index: &BTreeMap<IndexKey, DataValue>, query: EngineQuery) -> En
|
||||
.collect();
|
||||
EngineResponse::BucketValues(values)
|
||||
}
|
||||
EngineQuery::Promql { query, start_ms, end_ms } => {
|
||||
let response = promql::parse_query(&query)
|
||||
.map(|parsed| evaluate_promql(index, &parsed, start_ms, end_ms));
|
||||
EngineResponse::Promql(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Coerces a bucket's stored `MetricPrimitive` into a plottable `f64` for
|
||||
/// `PromqlSeriesDto`:
|
||||
/// - `Number` uses its underlying numeric value.
|
||||
/// - `Timestamp` uses its millisecond value (arbitrary but at least meaningful/
|
||||
/// orderable, since there's no other sensible single number for a point in time).
|
||||
/// - `String` has no numeric reading at all, so it falls back to `1.0` — i.e. it is
|
||||
/// treated the same as the presence/occurrence marker `apply_metric_to_log` already
|
||||
/// stores for every bucket value today (ticket 003), meaning a PromQL series over a
|
||||
/// `String`-valued bucket becomes an occurrence-count series rather than being
|
||||
/// skipped. This keeps every declared metric queryable (matching the ticket's
|
||||
/// "documented fallback" requirement) rather than silently omitting non-numeric
|
||||
/// series.
|
||||
fn coerce_to_f64(value: &MetricPrimitive) -> f64 {
|
||||
match value {
|
||||
MetricPrimitive::Number(n) => match n {
|
||||
Number::Int(v) => *v as f64,
|
||||
Number::Float(v) => *v,
|
||||
},
|
||||
MetricPrimitive::Timestamp(ts) => ts.as_millis() as f64,
|
||||
MetricPrimitive::String(_) => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when every matcher in `matchers` is satisfied by `labels` (compared against
|
||||
/// each label value's `Display` form, same convention as `EngineQuery::BucketValues`'s
|
||||
/// `labels_filter`). Unmatched/extra labels on the bucket are ignored — a matcher list
|
||||
/// only needs to be a subset.
|
||||
fn matches_label_matchers(
|
||||
labels: &BTreeMap<String, MetricPrimitive>,
|
||||
matchers: &[LabelMatcher],
|
||||
) -> bool {
|
||||
matchers.iter().all(|matcher| {
|
||||
labels.get(&matcher.name).is_some_and(|value| value.to_string() == matcher.value)
|
||||
})
|
||||
}
|
||||
|
||||
/// Evaluates a parsed PromQL query against `index`, producing chart-ready series.
|
||||
///
|
||||
/// Baseline (no aggregation wrapper): one output series per distinct label
|
||||
/// combination that matches `parsed.metric_name` + `parsed.matchers`, with that
|
||||
/// combination's bucket values as `(timestamp_ms, value)` points (see
|
||||
/// `coerce_to_f64`), sorted by timestamp.
|
||||
///
|
||||
/// `sum()`/`avg()`: merges every matching series' points by exact millisecond
|
||||
/// timestamp (summing, or averaging, the values that land on the same timestamp
|
||||
/// across series) into a single output series with empty labels — the aggregation
|
||||
/// collapses the label dimension, same as PromQL's `sum by ()`/`avg by ()` default.
|
||||
///
|
||||
/// `rate()`: requires (and, per `promql::parse_query`, is only ever given) a range
|
||||
/// selector. This is a simplified, single-point-per-series rate rather than PromQL's
|
||||
/// true sliding-window-per-evaluation-step semantics: for each matching series, it
|
||||
/// takes the window `(last_point_ts - range, last_point_ts]` and divides the number
|
||||
/// of points falling in that window by the range's duration in seconds, producing one
|
||||
/// `(last_point_ts, rate)` point per series. Since bucket values are occurrence
|
||||
/// markers rather than cumulative counters (ticket 003), an occurrence-count-per-
|
||||
/// second reading is the meaningful analogue of PromQL's counter-increase rate here.
|
||||
fn evaluate_promql(
|
||||
index: &BTreeMap<IndexKey, DataValue>,
|
||||
parsed: &PromqlQuery,
|
||||
start_ms: Option<i64>,
|
||||
end_ms: Option<i64>,
|
||||
) -> Vec<PromqlSeriesDto> {
|
||||
let mut series_points: BTreeMap<BTreeMap<String, String>, Vec<(i64, f64)>> = BTreeMap::new();
|
||||
for (labels, (_, timestamp, value)) in bucket_values(index, &parsed.metric_name, None)
|
||||
.filter(|(labels, _)| matches_label_matchers(labels, &parsed.matchers))
|
||||
.filter(|(_, (_, timestamp, _))| {
|
||||
start_ms.is_none_or(|start| timestamp.as_millis() >= start)
|
||||
&& end_ms.is_none_or(|end| timestamp.as_millis() <= end)
|
||||
})
|
||||
{
|
||||
let display_labels: BTreeMap<String, String> =
|
||||
labels.iter().map(|(name, value)| (name.clone(), value.to_string())).collect();
|
||||
series_points
|
||||
.entry(display_labels)
|
||||
.or_default()
|
||||
.push((timestamp.as_millis(), coerce_to_f64(value)));
|
||||
}
|
||||
for points in series_points.values_mut() {
|
||||
points.sort_by_key(|(timestamp_ms, _)| *timestamp_ms);
|
||||
}
|
||||
|
||||
match parsed.agg {
|
||||
None => series_points
|
||||
.into_iter()
|
||||
.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
|
||||
.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();
|
||||
vec![PromqlSeriesDto { labels: BTreeMap::new(), points }]
|
||||
}
|
||||
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)] })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,4 +974,115 @@ mod tests {
|
||||
assert_eq!(bucket_values(&index, "requests", None).count(), 1);
|
||||
assert_eq!(bucket_values(&index, "durations", None).count(), 1);
|
||||
}
|
||||
|
||||
fn declare_requests_metric(index: &mut BTreeMap<IndexKey, DataValue>) {
|
||||
declare_metric(
|
||||
index,
|
||||
metric_request(
|
||||
"requests",
|
||||
r"request path=(?P<path>\S+)",
|
||||
vec![("path", "path", MetricPrimitiveKind::String)],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promql_query_selects_matching_label_combination() {
|
||||
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=/b");
|
||||
ingest_log(&mut index, 2, "request path=/a");
|
||||
|
||||
let parsed = promql::parse_query(r#"requests{path="/a"}"#).unwrap();
|
||||
let series = evaluate_promql(&index, &parsed, None, None);
|
||||
|
||||
assert_eq!(series.len(), 1);
|
||||
assert_eq!(series[0].labels.get("path").map(String::as_str), Some("/a"));
|
||||
// Occurrence markers coerce to 1.0 (see `coerce_to_f64`); timestamps come from
|
||||
// `ingest_log`'s `sequence as millis` helper.
|
||||
assert_eq!(series[0].points, vec![(0, 1.0), (2, 1.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promql_query_with_no_matching_labels_returns_no_series() {
|
||||
let mut index = BTreeMap::new();
|
||||
declare_requests_metric(&mut index);
|
||||
ingest_log(&mut index, 0, "request path=/a");
|
||||
|
||||
let parsed = promql::parse_query(r#"requests{path="/nope"}"#).unwrap();
|
||||
let series = evaluate_promql(&index, &parsed, None, None);
|
||||
assert!(series.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promql_sum_aggregates_across_label_combinations() {
|
||||
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(requests)").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, 2.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promql_range_bounds_filter_points_via_start_end_ms() {
|
||||
let mut index = BTreeMap::new();
|
||||
declare_requests_metric(&mut index);
|
||||
ingest_log(&mut index, 0, "request path=/a");
|
||||
ingest_log(&mut index, 5, "request path=/a");
|
||||
ingest_log(&mut index, 10, "request path=/a");
|
||||
|
||||
let parsed = promql::parse_query("requests").unwrap();
|
||||
let series = evaluate_promql(&index, &parsed, Some(1), Some(9));
|
||||
|
||||
assert_eq!(series.len(), 1);
|
||||
assert_eq!(series[0].points, vec![(5, 1.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_query_promql_rejects_malformed_query() {
|
||||
let index = BTreeMap::new();
|
||||
let response = handle_query(
|
||||
&index,
|
||||
EngineQuery::Promql {
|
||||
query: "not a valid promql query(".to_string(),
|
||||
start_ms: None,
|
||||
end_ms: None,
|
||||
},
|
||||
);
|
||||
match response {
|
||||
EngineResponse::Promql(Err(message)) => assert!(!message.is_empty()),
|
||||
_ => panic!("expected EngineResponse::Promql(Err(_))"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_query_promql_returns_series_for_valid_query() {
|
||||
let mut index = BTreeMap::new();
|
||||
declare_requests_metric(&mut index);
|
||||
ingest_log(&mut index, 0, "request path=/a");
|
||||
|
||||
let response = handle_query(
|
||||
&index,
|
||||
EngineQuery::Promql {
|
||||
query: r#"requests{path="/a"}"#.to_string(),
|
||||
start_ms: None,
|
||||
end_ms: None,
|
||||
},
|
||||
);
|
||||
match response {
|
||||
EngineResponse::Promql(Ok(series)) => {
|
||||
assert_eq!(series.len(), 1);
|
||||
assert_eq!(series[0].points, vec![(0, 1.0)]);
|
||||
}
|
||||
_ => panic!("expected EngineResponse::Promql(Ok(_))"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user