Metrics rethink: counters as the primitive, histogram()/gauge() derived (ticket 022)

Every metric declaration is now explicitly documented and modeled as
a counter: a match is an occurrence/increment, optionally labeled.
The bucket representation (BucketValue { values: Vec<(Sequence,
Timestamp, MetricPrimitive)> }) already was this; what it lacked was
any way to capture a *value* distinct from the occurrence-count-of-1
marker. Adds RegexToMetricPrimitiveMatcher::role (ParamRole::Label,
the default, or ParamRole::Value — at most one per metric, enforced
at declare time) so a metric can designate one param's captured
number as its per-event value instead of a label.

promql.rs/engine.rs add two new PromQL-evaluable forms built on top
of that value, both consumed as regular PromqlSeriesDto series like
sum()/avg()/rate():
- histogram(metric_name[width]): buckets matching events' values into
  fixed-width buckets, one series per bucket labeled `le`.
- gauge(metric_name): the most-recent value per label combination in
  the queried range.

Existing raw counter queries (metrics with only Label-role params,
sum/avg/rate) are unchanged. Profile files saved before this ticket
still load (role defaults to Label via #[serde(default)]). Metrics.tsx
gains a label/value role picker per param, and the Docs panel explains
the model and the two derived query forms.

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 15:18:25 +01:00
co-authored by Claude Sonnet 5
parent 1d0f324c43
commit 0c57ed2953
7 changed files with 388 additions and 33 deletions
+91 -13
View File
@@ -14,21 +14,34 @@
//! ```text
//! query := aggregation | vector_selector
//! aggregation := agg_op "(" vector_selector ")"
//! agg_op := "sum" | "avg" | "rate"
//! agg_op := "sum" | "avg" | "rate" | "histogram" | "gauge"
//! vector_selector := metric_name label_matchers? range_selector?
//! metric_name := [a-zA-Z_][a-zA-Z0-9_]*
//! label_matchers := "{" label_matcher ("," label_matcher)* "}"
//! label_matcher := label_name "=" quoted_string
//! label_name := [a-zA-Z_][a-zA-Z0-9_]*
//! quoted_string := '"' (any char except '"' or '\')* '"'
//! range_selector := "[" digits duration_unit "]"
//! range_selector := "[" digits duration_unit? "]"
//! duration_unit := "s" | "m" | "h" | "d"
//! ```
//!
//! Only exact-match label matchers (`=`) are supported — PromQL's `!=`, `=~`, `!~`
//! are out of scope for this ticket's required subset. `rate()` requires its inner
//! vector selector to carry a range selector (matching real PromQL), and is rejected
//! at parse time otherwise.
//! vector selector to carry a range selector with a duration unit (matching real
//! PromQL), and is rejected at parse time otherwise.
//!
//! Ticket 022 (metrics-as-counters rethink) adds two derived forms on top of the
//! same grammar, both consumed as regular `PromqlSeriesDto` series like every
//! other query shape:
//! - `histogram(metric_name[width])`: buckets every matching counter event's
//! (coerced-to-f64) value into fixed-width buckets of size `width` (a bare
//! number, no duration unit — reusing the `[...]` range-selector slot for a
//! different purpose than `rate()`'s duration), and emits one series per
//! bucket, labeled `le` (Prometheus's "less-than-or-equal" convention) with
//! that bucket's upper bound, holding a single `(timestamp_ms, count)` point.
//! - `gauge(metric_name)`: takes no range selector; emits the most-recent
//! matching counter event's value per label combination as a single point,
//! i.e. the counter's series collapsed down to its last value.
use std::time::Duration;
@@ -37,6 +50,11 @@ pub enum AggOp {
Sum,
Avg,
Rate,
/// Ticket 022: buckets counter values into fixed-width buckets. Requires
/// `bucket_width`.
Histogram,
/// Ticket 022: the most-recent counter value per label combination.
Gauge,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -50,7 +68,18 @@ pub struct PromqlQuery {
pub agg: Option<AggOp>,
pub metric_name: String,
pub matchers: Vec<LabelMatcher>,
/// Duration range selector, e.g. `[5m]`. Only meaningful for `rate()`.
pub range: Option<Duration>,
/// Bare-number range selector, e.g. `[10]`. Only meaningful for
/// `histogram()`, where it's the bucket width.
pub bucket_width: Option<f64>,
}
/// What a `[...]` range selector turned out to hold, disambiguated by whether
/// a duration unit followed the digits.
enum RangeSelector {
Duration(Duration),
BareWidth(f64),
}
/// Parses a PromQL query string per the grammar documented above. Returns a
@@ -66,6 +95,11 @@ pub fn parse_query(input: &str) -> Result<PromqlQuery, String> {
if query.agg == Some(AggOp::Rate) && query.range.is_none() {
return Err("rate() requires a range selector, e.g. rate(my_metric[5m])".to_string());
}
if query.agg == Some(AggOp::Histogram) && query.bucket_width.is_none() {
return Err(
"histogram() requires a bucket width, e.g. histogram(my_metric[10])".to_string(),
);
}
Ok(query)
}
@@ -180,8 +214,10 @@ impl<'a> Parser<'a> {
Ok(matchers)
}
/// `[<digits><unit>]`, e.g. `[5m]`.
fn parse_range_selector(&mut self) -> Result<Option<Duration>, String> {
/// `[<digits><unit>]` (e.g. `[5m]`, a duration — used by `rate()`) or a
/// bare `[<digits>]` with no unit (a plain number — used by `histogram()`
/// as a bucket width).
fn parse_range_selector(&mut self) -> Result<Option<RangeSelector>, String> {
self.skip_whitespace();
if self.peek() != Some('[') {
return Ok(None);
@@ -190,16 +226,21 @@ impl<'a> Parser<'a> {
let rest = self.remaining();
let digits_end = rest
.find(|c: char| !c.is_ascii_digit())
.ok_or("unterminated range selector (missing unit and ']')")?;
.ok_or("unterminated range selector (missing ']')")?;
if digits_end == 0 {
return Err("range selector must start with a number, e.g. [5m]".to_string());
return Err("range selector must start with a number, e.g. [5m] or [10]".to_string());
}
let digits: i64 = rest[..digits_end]
.parse()
.map_err(|_| "range selector duration is not a valid number".to_string())?;
.map_err(|_| "range selector number is not a valid number".to_string())?;
self.pos += digits_end;
let unit = self.peek().ok_or("range selector is missing a duration unit")?;
if self.peek() == Some(']') {
self.expect_char(']')?;
return Ok(Some(RangeSelector::BareWidth(digits as f64)));
}
let unit = self.peek().ok_or("range selector is missing a duration unit or ']'")?;
let seconds_per_unit: i64 = match unit {
's' => 1,
'm' => 60,
@@ -213,14 +254,20 @@ impl<'a> Parser<'a> {
};
self.pos += unit.len_utf8();
self.expect_char(']')?;
Ok(Some(Duration::from_secs((digits * seconds_per_unit).max(0) as u64)))
Ok(Some(RangeSelector::Duration(Duration::from_secs(
(digits * seconds_per_unit).max(0) as u64,
))))
}
fn parse_vector_selector(&mut self) -> Result<PromqlQuery, String> {
let metric_name = self.parse_identifier()?;
let matchers = self.parse_label_matchers()?;
let range = self.parse_range_selector()?;
Ok(PromqlQuery { agg: None, metric_name, matchers, range })
let (range, bucket_width) = match self.parse_range_selector()? {
None => (None, None),
Some(RangeSelector::Duration(d)) => (Some(d), None),
Some(RangeSelector::BareWidth(w)) => (None, Some(w)),
};
Ok(PromqlQuery { agg: None, metric_name, matchers, range, bucket_width })
}
fn parse_query(&mut self) -> Result<PromqlQuery, String> {
@@ -231,6 +278,8 @@ impl<'a> Parser<'a> {
"sum" => Some(AggOp::Sum),
"avg" => Some(AggOp::Avg),
"rate" => Some(AggOp::Rate),
"histogram" => Some(AggOp::Histogram),
"gauge" => Some(AggOp::Gauge),
_ => None,
};
self.skip_whitespace();
@@ -366,4 +415,33 @@ mod tests {
let err = parse_query("requests extra").unwrap_err();
assert!(err.contains("unexpected trailing input"));
}
#[test]
fn parses_histogram_with_bare_width() {
let query = parse_query("histogram(latency_ms[10])").unwrap();
assert_eq!(query.agg, Some(AggOp::Histogram));
assert_eq!(query.metric_name, "latency_ms");
assert_eq!(query.bucket_width, Some(10.0));
assert_eq!(query.range, None);
}
#[test]
fn rejects_histogram_without_width() {
let err = parse_query("histogram(latency_ms)").unwrap_err();
assert!(err.contains("bucket width"));
}
#[test]
fn parses_gauge_with_no_range_selector() {
let query = parse_query(r#"gauge(connections{host="a"})"#).unwrap();
assert_eq!(query.agg, Some(AggOp::Gauge));
assert_eq!(query.matchers, vec![LabelMatcher { name: "host".to_string(), value: "a".to_string() }]);
}
#[test]
fn rate_range_selector_is_still_a_duration_not_a_bare_width() {
let query = parse_query("rate(requests[5m])").unwrap();
assert_eq!(query.range, Some(Duration::from_secs(300)));
assert_eq!(query.bucket_width, None);
}
}