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:
Hugh Kaznowski
2026-09-05 13:10:41 +01:00
co-authored by Claude Sonnet 5
parent e48c03218f
commit ac67f9e120
5 changed files with 727 additions and 3 deletions
+80 -1
View File
@@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex};
use crate::dto::{
BucketValuesParams, CssEntryDto, ErrorDto, ImportCssRequest, LinkCssRequest,
MetricDeclarationDto, ParamDeclarationDto, LogsParams, TimelineParams,
MetricDeclarationDto, ParamDeclarationDto, LogsParams, PromqlParams, TimelineParams,
};
use crate::engine::{
DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery,
@@ -105,6 +105,7 @@ pub fn router(
.route("/api/params", post(declare_param))
.route("/api/metrics", get(list_metrics).post(declare_metric))
.route("/api/metrics/{name}", get(get_metric_bucket_values))
.route("/api/query", get(run_promql_query))
.route("/api/settings/css", get(list_css).post(import_css))
.route("/api/settings/css/link", post(link_css))
.route("/api/settings/css/{id}", delete(remove_css))
@@ -268,6 +269,31 @@ async fn get_metric_bucket_values(
}
}
/// `GET /api/query` — evaluates a PromQL query (ticket 013; see `crate::promql` for
/// the supported grammar) against declared metrics. Query params: `promql` (the
/// query string), `start_ms`, `end_ms` (bound which points are included, mirroring
/// `/api/logs`/`/api/metrics/:name`). Returns `Vec<PromqlSeriesDto>` — chart-ready
/// `labels` + `(timestamp_ms, value)` series — on success (`200 OK`), or
/// `400 Bad Request` with `{ "error": "..." }` for a malformed query.
async fn run_promql_query(
State(state): State<ApiState>,
Query(params): Query<PromqlParams>,
) -> impl IntoResponse {
let query = EngineQuery::Promql {
query: params.promql,
start_ms: params.start_ms,
end_ms: params.end_ms,
};
match ask(&state.engine, query).await {
Ok(EngineResponse::Promql(Ok(series))) => Json(series).into_response(),
Ok(EngineResponse::Promql(Err(err))) => {
(StatusCode::BAD_REQUEST, Json(ErrorDto { error: err })).into_response()
}
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
Err(status) => status.into_response(),
}
}
/// Maps a `CssError` from a settings mutation/lookup to a response status
/// and message: `NotFound` -> 404, `NotImported` -> 400 (the id is valid but
/// refers to a linked entry, which this operation doesn't apply to), `Io` ->
@@ -580,6 +606,59 @@ mod tests {
assert_eq!(values[0]["sequence"], 2);
}
#[tokio::test]
async fn promql_query_returns_series_for_seeded_fixture() {
let (app, engine, _settings, _dir) = test_app();
send_json(
&app,
"POST",
"/api/metrics",
json!({
"name": "requests",
"message_match_regex": r"request path=(?P<path>\S+)",
"params": [
{ "param_name": "path", "capture_group": "path", "kind": "string" }
]
}),
)
.await;
for (sequence, text) in [
(0u64, "request path=/a"),
(1u64, "request path=/b"),
(2u64, "request path=/a"),
] {
engine
.send(EngineMessage::Ingest(IngestedLine {
sequence,
timestamp: Timestamp::from_millis(sequence as i64 * 1000),
text: text.to_string(),
}))
.await
.unwrap();
}
let (status, body) =
get_json(&app, "/api/query?promql=requests%7Bpath%3D%22%2Fa%22%7D").await;
assert_eq!(status, StatusCode::OK);
let series = body.as_array().unwrap();
assert_eq!(series.len(), 1);
assert_eq!(series[0]["labels"]["path"], "/a");
let points = series[0]["points"].as_array().unwrap();
assert_eq!(points.len(), 2);
assert_eq!(points[0], json!([0, 1.0]));
assert_eq!(points[1], json!([2000, 1.0]));
}
#[tokio::test]
async fn promql_query_rejects_malformed_query_with_400() {
let (app, _engine, _settings, _dir) = test_app();
let (status, body) = get_json(&app, "/api/query?promql=not+valid(").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body["error"].as_str().is_some_and(|s| !s.is_empty()));
}
#[tokio::test]
async fn declaring_param_persists_into_active_profile() {
let (app, _engine, settings_handle, dir) = test_app();