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
+229
-15
@@ -1,4 +1,4 @@
|
||||
use crate::data_value::{DataValue, MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
|
||||
use crate::data_value::{DataValue, MetricPrimitiveKind, ParamRole, RegexToMetricPrimitiveMatcher};
|
||||
use crate::dto::{
|
||||
BucketValueDto, LogEntryDto, LogsParams, MetricDeclarationDto, ParamDeclarationDto,
|
||||
PromqlSeriesDto, StatsDto, TimelineBucketDto, TimelineParams,
|
||||
@@ -231,6 +231,13 @@ fn declare_metric(
|
||||
return Err(errors.join("; "));
|
||||
}
|
||||
|
||||
let value_params = request.params.iter().filter(|matcher| matcher.role == ParamRole::Value).count();
|
||||
if value_params > 1 {
|
||||
return Err(format!(
|
||||
"a metric may declare at most one Value-role param, found {value_params}"
|
||||
));
|
||||
}
|
||||
|
||||
backfill_metric(index, &request.name, &re, &request.params);
|
||||
index.insert(
|
||||
IndexKey::MetricDeclaration { name: request.name },
|
||||
@@ -263,17 +270,22 @@ fn parse_metric_primitive(raw: &str, kind: MetricPrimitiveKind) -> Option<Metric
|
||||
}
|
||||
|
||||
/// Runs one metric's regex against a single log line and, on a match, appends a
|
||||
/// bucket value entry.
|
||||
/// bucket value entry: this is the "counter increment" (ticket 022) — a match is
|
||||
/// an occurrence at a point in time, optionally labeled.
|
||||
///
|
||||
/// Design decision: `Bucket.labels` is the only structured place a `Bucket` key has
|
||||
/// for a metric's declared params, so v1 folds ALL of a metric's params into
|
||||
/// `labels` rather than splitting them into "label" vs "value" params. The stored
|
||||
/// value is therefore a fixed presence marker (`MetricPrimitive::Number(Number::Int(1))`)
|
||||
/// — a bucket's `values` is the timeseries of *when that label combination occurred*
|
||||
/// (a count/occurrence series), not a record of the captured values themselves. If a
|
||||
/// param's capture group doesn't match, or its captured text fails to parse as its
|
||||
/// declared `kind`, the whole match is skipped for this line rather than storing a
|
||||
/// partially-labeled bucket.
|
||||
/// `Bucket.labels` holds every `Label`-role param (the default role — see
|
||||
/// [`ParamRole`]); at most one param may instead be `Value`-role (enforced at
|
||||
/// declare time), and its parsed primitive becomes the pushed event's value.
|
||||
/// With no `Value`-role param, the pushed value is a fixed presence marker
|
||||
/// (`MetricPrimitive::Number(Number::Int(1))`), matching the original v1
|
||||
/// behavior where a bucket's `values` was purely the timeseries of *when that
|
||||
/// label combination occurred* — histogram()/gauge() derived queries
|
||||
/// (`crate::promql::AggOp`) read this same `value`, so a `Value`-role param is
|
||||
/// what makes those meaningful (bucketing/reading an occurrence-count-of-1
|
||||
/// series degenerates to "everything in one bucket"/"always 1"). If a param's
|
||||
/// capture group doesn't match, or its captured text fails to parse as its
|
||||
/// declared `kind`, the whole match is skipped for this line rather than
|
||||
/// storing a partially-labeled bucket.
|
||||
fn apply_metric_to_log(
|
||||
index: &mut BTreeMap<IndexKey, DataValue>,
|
||||
metric_name: &str,
|
||||
@@ -288,14 +300,22 @@ fn apply_metric_to_log(
|
||||
};
|
||||
|
||||
let mut labels = BTreeMap::new();
|
||||
let mut value = None;
|
||||
for matcher in params {
|
||||
let Some(raw) = captures.name(&matcher.capture_group) else {
|
||||
return;
|
||||
};
|
||||
let Some(value) = parse_metric_primitive(raw.as_str(), matcher.kind) else {
|
||||
let Some(parsed) = parse_metric_primitive(raw.as_str(), matcher.kind) else {
|
||||
return;
|
||||
};
|
||||
labels.insert(matcher.param_name.clone(), value);
|
||||
match matcher.role {
|
||||
ParamRole::Label => {
|
||||
labels.insert(matcher.param_name.clone(), parsed);
|
||||
}
|
||||
ParamRole::Value => {
|
||||
value = Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let key = IndexKey::Bucket { metric_name: metric_name.to_string(), labels };
|
||||
@@ -303,7 +323,7 @@ fn apply_metric_to_log(
|
||||
.entry(key)
|
||||
.or_insert_with(|| DataValue::BucketValue { values: Vec::new() });
|
||||
if let DataValue::BucketValue { values } = entry {
|
||||
values.push((sequence, timestamp, MetricPrimitive::Number(Number::Int(1))));
|
||||
values.push((sequence, timestamp, value.unwrap_or(MetricPrimitive::Number(Number::Int(1)))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -980,6 +1000,51 @@ fn evaluate_promql(
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Some(AggOp::Gauge) => {
|
||||
// Ticket 022: collapse each label combination's counter events
|
||||
// down to their most-recent value in the queried time range —
|
||||
// "value at time T" derived from the last matching event, rather
|
||||
// than a distinct stored gauge kind.
|
||||
series_points
|
||||
.into_iter()
|
||||
.filter_map(|(labels, points)| {
|
||||
let last = *points.last()?;
|
||||
Some(PromqlSeriesDto { labels, points: vec![last] })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Some(AggOp::Histogram) => {
|
||||
// Validated by `promql::parse_query`: `histogram()` never parses
|
||||
// without one.
|
||||
let bucket_width =
|
||||
parsed.bucket_width.expect("histogram() always carries a bucket width");
|
||||
let last_ts = series_points
|
||||
.values()
|
||||
.flat_map(|points| points.iter().map(|(ts, _)| *ts))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
// Ticket 022: bucket every matching counter event's value
|
||||
// (across all label combinations — histogram collapses the
|
||||
// label dimension the same way sum()/avg() do) into fixed-width
|
||||
// buckets, one output series per bucket labeled `le` (Prometheus's
|
||||
// "less-than-or-equal" convention) holding a single
|
||||
// `(last_ts, count)` point.
|
||||
let mut counts: BTreeMap<i64, u64> = BTreeMap::new();
|
||||
for points in series_points.values() {
|
||||
for (_, value) in points {
|
||||
let bucket_index = (value / bucket_width).floor() as i64;
|
||||
*counts.entry(bucket_index).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
counts
|
||||
.into_iter()
|
||||
.map(|(bucket_index, count)| {
|
||||
let upper_bound = (bucket_index + 1) as f64 * bucket_width;
|
||||
let labels = BTreeMap::from([("le".to_string(), upper_bound.to_string())]);
|
||||
PromqlSeriesDto { labels, points: vec![(last_ts, count as f64)] }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1103,16 +1168,32 @@ mod tests {
|
||||
name: &str,
|
||||
pattern: &str,
|
||||
params: Vec<(&str, &str, MetricPrimitiveKind)>,
|
||||
) -> DeclareMetricRequest {
|
||||
metric_request_with_roles(
|
||||
name,
|
||||
pattern,
|
||||
params
|
||||
.into_iter()
|
||||
.map(|(param_name, capture_group, kind)| (param_name, capture_group, kind, ParamRole::Label))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn metric_request_with_roles(
|
||||
name: &str,
|
||||
pattern: &str,
|
||||
params: Vec<(&str, &str, MetricPrimitiveKind, ParamRole)>,
|
||||
) -> DeclareMetricRequest {
|
||||
DeclareMetricRequest {
|
||||
name: name.to_string(),
|
||||
message_match_regex: pattern.to_string(),
|
||||
params: params
|
||||
.into_iter()
|
||||
.map(|(param_name, capture_group, kind)| RegexToMetricPrimitiveMatcher {
|
||||
.map(|(param_name, capture_group, kind, role)| RegexToMetricPrimitiveMatcher {
|
||||
param_name: param_name.to_string(),
|
||||
capture_group: capture_group.to_string(),
|
||||
kind,
|
||||
role,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
@@ -1439,6 +1520,139 @@ mod tests {
|
||||
assert_eq!(series[0].points, vec![(5, 1.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declares_metric_with_value_role_param() {
|
||||
let mut index = BTreeMap::new();
|
||||
let result = declare_metric(
|
||||
&mut index,
|
||||
metric_request_with_roles(
|
||||
"durations",
|
||||
r"request path=(?P<path>\S+) duration=(?P<duration>\d+)",
|
||||
vec![
|
||||
("path", "path", MetricPrimitiveKind::String, ParamRole::Label),
|
||||
("duration", "duration", MetricPrimitiveKind::Number, ParamRole::Value),
|
||||
],
|
||||
),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_metric_with_multiple_value_role_params() {
|
||||
let mut index = BTreeMap::new();
|
||||
let result = declare_metric(
|
||||
&mut index,
|
||||
metric_request_with_roles(
|
||||
"bad",
|
||||
r"a=(?P<a>\d+) b=(?P<b>\d+)",
|
||||
vec![
|
||||
("a", "a", MetricPrimitiveKind::Number, ParamRole::Value),
|
||||
("b", "b", MetricPrimitiveKind::Number, ParamRole::Value),
|
||||
],
|
||||
),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("at most one Value-role param"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_role_param_becomes_the_bucket_value_not_a_label() {
|
||||
let mut index = BTreeMap::new();
|
||||
declare_metric(
|
||||
&mut index,
|
||||
metric_request_with_roles(
|
||||
"durations",
|
||||
r"request path=(?P<path>\S+) duration=(?P<duration>\d+)",
|
||||
vec![
|
||||
("path", "path", MetricPrimitiveKind::String, ParamRole::Label),
|
||||
("duration", "duration", MetricPrimitiveKind::Number, ParamRole::Value),
|
||||
],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
ingest_log(&mut index, 0, "request path=/a duration=5");
|
||||
|
||||
let values: Vec<_> = bucket_values(&index, "durations", None).collect();
|
||||
assert_eq!(values.len(), 1);
|
||||
let (labels, (_, _, value)) = &values[0];
|
||||
assert!(!labels.contains_key("duration"));
|
||||
assert_eq!(labels.get("path").map(|v| v.to_string()), Some("/a".to_string()));
|
||||
assert_eq!(*value, MetricPrimitive::Number(Number::Int(5)));
|
||||
}
|
||||
|
||||
fn declare_durations_metric(index: &mut BTreeMap<IndexKey, DataValue>) {
|
||||
declare_metric(
|
||||
index,
|
||||
metric_request_with_roles(
|
||||
"durations",
|
||||
r"request path=(?P<path>\S+) duration=(?P<duration>\d+)",
|
||||
vec![
|
||||
("path", "path", MetricPrimitiveKind::String, ParamRole::Label),
|
||||
("duration", "duration", MetricPrimitiveKind::Number, ParamRole::Value),
|
||||
],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gauge_returns_most_recent_value_per_label_combination() {
|
||||
let mut index = BTreeMap::new();
|
||||
declare_durations_metric(&mut index);
|
||||
ingest_log(&mut index, 0, "request path=/a duration=5");
|
||||
ingest_log(&mut index, 1, "request path=/a duration=9");
|
||||
ingest_log(&mut index, 2, "request path=/b duration=42");
|
||||
|
||||
let parsed = promql::parse_query("gauge(durations)").unwrap();
|
||||
let mut series = evaluate_promql(&index, &parsed, None, None);
|
||||
series.sort_by(|a, b| a.labels.get("path").cmp(&b.labels.get("path")));
|
||||
|
||||
assert_eq!(series.len(), 2);
|
||||
assert_eq!(series[0].labels.get("path").map(String::as_str), Some("/a"));
|
||||
assert_eq!(series[0].points, vec![(1, 9.0)]);
|
||||
assert_eq!(series[1].labels.get("path").map(String::as_str), Some("/b"));
|
||||
assert_eq!(series[1].points, vec![(2, 42.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gauge_respects_time_range_bounds() {
|
||||
let mut index = BTreeMap::new();
|
||||
declare_durations_metric(&mut index);
|
||||
ingest_log(&mut index, 0, "request path=/a duration=5");
|
||||
ingest_log(&mut index, 5, "request path=/a duration=9");
|
||||
ingest_log(&mut index, 10, "request path=/a duration=42");
|
||||
|
||||
let parsed = promql::parse_query("gauge(durations)").unwrap();
|
||||
let series = evaluate_promql(&index, &parsed, None, Some(9));
|
||||
|
||||
assert_eq!(series.len(), 1);
|
||||
assert_eq!(series[0].points, vec![(5, 9.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_buckets_captured_values_by_fixed_width() {
|
||||
let mut index = BTreeMap::new();
|
||||
declare_durations_metric(&mut index);
|
||||
for (seq, duration) in [(0, 2), (1, 4), (2, 12), (3, 15), (4, 21)] {
|
||||
ingest_log(&mut index, seq, &format!("request path=/a duration={duration}"));
|
||||
}
|
||||
|
||||
let parsed = promql::parse_query("histogram(durations[10])").unwrap();
|
||||
let mut series = evaluate_promql(&index, &parsed, None, None);
|
||||
series.sort_by(|a, b| a.labels.get("le").cmp(&b.labels.get("le")));
|
||||
|
||||
// durations 2,4 -> bucket [0,10) le=10 (count 2); 12,15 -> [10,20) le=20 (count 2);
|
||||
// 21 -> [20,30) le=30 (count 1).
|
||||
assert_eq!(series.len(), 3);
|
||||
assert_eq!(series[0].labels.get("le").map(String::as_str), Some("10"));
|
||||
assert_eq!(series[0].points[0].1, 2.0);
|
||||
assert_eq!(series[1].labels.get("le").map(String::as_str), Some("20"));
|
||||
assert_eq!(series[1].points[0].1, 2.0);
|
||||
assert_eq!(series[2].labels.get("le").map(String::as_str), Some("30"));
|
||||
assert_eq!(series[2].points[0].1, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_query_promql_rejects_malformed_query() {
|
||||
let index = BTreeMap::new();
|
||||
|
||||
Reference in New Issue
Block a user