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
+80
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user