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:
co-authored by
Claude Sonnet 5
parent
1d0f324c43
commit
0c57ed2953
+91
-13
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user