Add Metrics API: declare/list params and metrics, query bucket values (ticket 004)

Exposes param/metric declarations and derived bucket data over HTTP:
POST /api/params and POST /api/metrics validate and declare against the
engine, persisting into the active profile (if any) so declarations survive
a restart; GET /api/metrics lists declarations; GET /api/metrics/:name
queries bucket values with label, time-range, sequence, and limit filters.
Adds a new EngineQuery::BucketValues variant that layers this filtering on
top of ticket 003's bucket_values helper.

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:05:00 +01:00
co-authored by Claude Sonnet 5
parent f0348589f5
commit 49e8590cf3
7 changed files with 554 additions and 10 deletions
+63 -5
View File
@@ -1,12 +1,13 @@
use crate::data_value::{DataValue, MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
use crate::dto::{
LogEntryDto, LogsParams, MetricDeclarationDto, ParamDeclarationDto, StatsDto,
BucketValueDto, LogEntryDto, LogsParams, MetricDeclarationDto, ParamDeclarationDto, StatsDto,
TimelineBucketDto, TimelineParams,
};
use crate::index_key::IndexKey;
use crate::metric_primitive::{MetricPrimitive, Number};
use crate::timestamp::Timestamp;
use regex::Regex;
use serde::Deserialize;
use std::collections::BTreeMap;
use tokio::sync::{mpsc, oneshot};
@@ -16,6 +17,10 @@ pub struct IngestedLine {
pub text: String,
}
/// Also the request body for `POST /api/params` (ticket 004): `Deserialize` is
/// derived directly on this engine type rather than adding a separate DTO,
/// since its shape is already exactly what the API needs.
#[derive(Debug, Clone, Deserialize)]
pub struct DeclareParamRequest {
pub name: String,
pub message_match_regex: String,
@@ -28,6 +33,11 @@ pub struct DeclareParamRequest {
/// `RegexToMetricPrimitiveMatcher::capture_group` must name a capture group on that
/// regex). Referencing an already-declared `ParamDeclaration` (001) by name is not
/// supported yet — that's left for a future ticket.
///
/// Also the request body for `POST /api/metrics` (ticket 004) — see
/// `DeclareParamRequest`'s doc comment for why `Deserialize` lives here rather
/// than on a separate DTO.
#[derive(Debug, Clone, Deserialize)]
pub struct DeclareMetricRequest {
pub name: String,
pub message_match_regex: String,
@@ -41,6 +51,19 @@ pub enum EngineQuery {
Stats,
ParamDeclarations,
MetricDeclarations,
/// Bucket values for one metric (ticket 004's `GET /api/metrics/:name`).
/// `labels_filter`, when present, keeps only buckets where every listed
/// label matches — compared against each label value's `Display` form
/// (see `MetricPrimitive`) rather than a typed value, since the filter
/// arrives as plain strings off the query string.
BucketValues {
metric_name: String,
labels_filter: Option<BTreeMap<String, String>>,
start_ms: Option<i64>,
end_ms: Option<i64>,
since_sequence: Option<u64>,
limit: Option<usize>,
},
}
#[allow(dead_code)]
@@ -75,6 +98,7 @@ pub enum EngineResponse {
Stats(StatsDto),
ParamDeclarations(Vec<ParamDeclarationDto>),
MetricDeclarations(Vec<MetricDeclarationDto>),
BucketValues(Vec<BucketValueDto>),
}
pub type EngineHandle = mpsc::Sender<EngineMessage>;
@@ -289,10 +313,10 @@ fn apply_all_metrics(
}
/// Bucket values for one metric, in insertion order, optionally filtered to a single
/// label combination. Internal helper for now — the metrics-API ticket ([004]) will
/// wrap this (or an `EngineQuery` built on it) with time/sequence-range filtering and
/// HTTP plumbing.
#[allow(dead_code)]
/// exact label combination. Used directly by tests (exact-match filtering) and, via
/// `EngineQuery::BucketValues` in `handle_query` below, by the metrics API (ticket
/// 004) — which layers its own textual label-filter and time/sequence-range
/// filtering on top rather than using this function's `labels_filter` parameter.
fn bucket_values<'a>(
index: &'a BTreeMap<IndexKey, DataValue>,
metric_name: &'a str,
@@ -428,6 +452,40 @@ fn handle_query(index: &BTreeMap<IndexKey, DataValue>, query: EngineQuery) -> En
.collect();
EngineResponse::MetricDeclarations(declarations)
}
EngineQuery::BucketValues {
metric_name,
labels_filter,
start_ms,
end_ms,
since_sequence,
limit,
} => {
let limit = limit.unwrap_or(500).min(10_000);
let values: Vec<BucketValueDto> = bucket_values(index, &metric_name, None)
.filter(|(labels, _)| {
labels_filter.as_ref().is_none_or(|filter| {
filter.iter().all(|(name, expected)| {
labels
.get(name)
.is_some_and(|value| &value.to_string() == expected)
})
})
})
.filter(|(_, (sequence, timestamp, _))| {
since_sequence.is_none_or(|since| *sequence > since)
&& start_ms.is_none_or(|start| timestamp.as_millis() >= start)
&& end_ms.is_none_or(|end| timestamp.as_millis() <= end)
})
.take(limit)
.map(|(labels, (sequence, timestamp, value))| BucketValueDto {
labels: labels.clone(),
sequence: *sequence,
timestamp_ms: timestamp.as_millis(),
value: value.clone(),
})
.collect();
EngineResponse::BucketValues(values)
}
}
}