Reject sum()/avg() over string-typed metrics; add explicit count()

`coerce_to_f64` (src/engine.rs) mapped every MetricPrimitive::String to a
flat 1.0, so sum()/avg() over a string-kind metric silently became an
occurrence count with no signal that the aggregate didn't mean what it
looked like it meant.

evaluate_promql now looks up the target metric's declared value kind (the
kind of its Value-role param, defaulting to Number when it has none) and
rejects sum()/avg() with a clear, actionable error when that kind is
String. sum(rate(...))/avg(rate(...)) are exempt since rate() only counts
occurrences per window and never reads the coerced value. Also add a real
count() PromQL aggregation (parser + evaluator) so occurrence-counting is
something a user opts into by name, matching the ticket's preferred
"count() or numeric metric" error message and the existing
sum()/avg()/rate()/histogram()/gauge() aggregation design.

Audited existing fixtures/tests for sum()/avg() over string-kind metrics
that this would newly reject: none found — the repo's only sum()/avg()
usage in tests and docs is over the "requests" occurrence-counter metric,
which has no Value-role param and so is Number-kind, unaffected by this
change.

EngineResponse::Promql already carried Result<Vec<PromqlSeriesDto>,
String> for parser errors, so evaluate_promql now returns that same
Result and the new error flows through the existing 400 Bad Request path
in run_promql_query with no API changes needed.

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 22:27:57 +01:00
co-authored by Claude Sonnet 5
parent 85fb145be7
commit af7a4f1327
2 changed files with 188 additions and 21 deletions
+165 -18
View File
@@ -1111,13 +1111,13 @@ fn handle_query(
} }
EngineQuery::Promql { query, start_ms, end_ms } => { EngineQuery::Promql { query, start_ms, end_ms } => {
let response = promql::parse_query(&query) let response = promql::parse_query(&query)
.map(|parsed| evaluate_promql(index, &parsed, start_ms, end_ms)); .and_then(|parsed| evaluate_promql(index, &parsed, start_ms, end_ms));
EngineResponse::Promql(response) EngineResponse::Promql(response)
} }
} }
} }
/// Coerces a bucket's `MetricPrimitive` into a plottable `f64`: Number/Timestamp/Duration use their natural numeric value; `String` has no numeric reading, so it falls back to `1.0` (matching the presence/occurrence marker used for every bucket) rather than being skipped, keeping every declared metric queryable. /// Coerces a bucket's `MetricPrimitive` into a plottable `f64`: Number/Timestamp/Duration use their natural numeric value; `String` has no numeric reading, so it falls back to `1.0` (matching the presence/occurrence marker used for every bucket) rather than being skipped, keeping every declared metric queryable as a bare series or under `rate()`/`gauge()`, which only ever count or sample points rather than summing this value. `sum()`/`avg()` do read it, so `evaluate_promql` rejects those aggregations up front for a `String`-kind metric (see `declared_value_kind`) rather than letting this 1.0 stand in for a sum.
fn coerce_to_f64(value: &MetricPrimitive) -> f64 { fn coerce_to_f64(value: &MetricPrimitive) -> f64 {
match value { match value {
MetricPrimitive::Number(n) => match n { MetricPrimitive::Number(n) => match n {
@@ -1222,13 +1222,48 @@ fn merge_series_locf(sub_series: &[Vec<(i64, f64)>], is_avg: bool) -> Vec<(i64,
.collect() .collect()
} }
/// The value kind a metric's buckets carry: the kind of its `Value`-role param, or `Number` when
/// it has none (every occurrence then falls back to a fixed `Number::Int(1)` marker — see
/// `apply_metric_to_log`). Returns `None` when `metric_name` has no declaration at all, in which
/// case there's nothing to validate against and the query simply yields no series.
fn declared_value_kind(
index: &BTreeMap<IndexKey, DataValue>,
metric_name: &str,
) -> Option<MetricPrimitiveKind> {
match index.get(&IndexKey::MetricDeclaration { name: metric_name.to_string() }) {
Some(DataValue::MetricDeclaration { params, .. }) => Some(
params
.iter()
.find(|param| param.role == ParamRole::Value)
.map(|param| param.kind)
.unwrap_or(MetricPrimitiveKind::Number),
),
_ => None,
}
}
/// Evaluates a parsed PromQL query against `index`, producing chart-ready series: baseline is one series per label combination; `sum()`/`avg()` merge series into one series per `by_labels` group (the whole result collapses to a single label-less series when `by_labels` is empty); `rate()` — bare or wrapped in `sum()`/`avg()` — turns each series into a windowed occurrences-per-second reading via `windowed_rate_series`, so it plots as a real line rather than one trailing point. /// Evaluates a parsed PromQL query against `index`, producing chart-ready series: baseline is one series per label combination; `sum()`/`avg()` merge series into one series per `by_labels` group (the whole result collapses to a single label-less series when `by_labels` is empty); `rate()` — bare or wrapped in `sum()`/`avg()` — turns each series into a windowed occurrences-per-second reading via `windowed_rate_series`, so it plots as a real line rather than one trailing point.
/// `sum()`/`avg()` over a `String`-kind metric are rejected up front — `coerce_to_f64` has no
/// numeric reading for a string, so silently treating every occurrence as `1.0` would let a sum
/// pass for a count with no signal to the caller. `sum(rate(...))`/`avg(rate(...))` are exempt:
/// the inner `rate()` only counts occurrences per window and never reads the coerced value, so a
/// string-kind metric under `rate()` is already the count semantic the ticket asks for.
fn evaluate_promql( fn evaluate_promql(
index: &BTreeMap<IndexKey, DataValue>, index: &BTreeMap<IndexKey, DataValue>,
parsed: &PromqlQuery, parsed: &PromqlQuery,
start_ms: Option<i64>, start_ms: Option<i64>,
end_ms: Option<i64>, end_ms: Option<i64>,
) -> Vec<PromqlSeriesDto> { ) -> Result<Vec<PromqlSeriesDto>, String> {
if matches!(parsed.agg, Some(AggOp::Sum) | Some(AggOp::Avg))
&& !parsed.inner_rate
&& declared_value_kind(index, &parsed.metric_name) == Some(MetricPrimitiveKind::String)
{
return Err(format!(
"cannot sum/avg a string-typed metric \"{}\"; use count() or a numeric-typed metric",
parsed.metric_name
));
}
let mut series_points: BTreeMap<BTreeMap<String, String>, Vec<(i64, f64)>> = BTreeMap::new(); let mut series_points: BTreeMap<BTreeMap<String, String>, Vec<(i64, f64)>> = BTreeMap::new();
for (labels, (_, timestamp, value)) in bucket_values(index, &parsed.metric_name, None) for (labels, (_, timestamp, value)) in bucket_values(index, &parsed.metric_name, None)
.filter(|(labels, _)| matches_label_matchers(labels, &parsed.matchers)) .filter(|(labels, _)| matches_label_matchers(labels, &parsed.matchers))
@@ -1248,11 +1283,30 @@ fn evaluate_promql(
points.sort_by_key(|(timestamp_ms, _)| *timestamp_ms); points.sort_by_key(|(timestamp_ms, _)| *timestamp_ms);
} }
match parsed.agg { Ok(match parsed.agg {
None => series_points None => series_points
.into_iter() .into_iter()
.map(|(labels, points)| PromqlSeriesDto { labels, points }) .map(|(labels, points)| PromqlSeriesDto { labels, points })
.collect(), .collect(),
Some(AggOp::Count) => {
// Occurrence count per group, ignoring each point's coerced value entirely — the
// explicit opt-in for the count-shaped result `sum()`/`avg()` used to give a
// `String`-kind metric by accident (see `coerce_to_f64`).
let mut groups: BTreeMap<BTreeMap<String, String>, Vec<Vec<(i64, f64)>>> = BTreeMap::new();
for (labels, points) in series_points {
let count_points: Vec<(i64, f64)> =
points.iter().map(|(timestamp_ms, _)| (*timestamp_ms, 1.0)).collect();
let group_key = project_labels(&labels, &parsed.by_labels);
groups.entry(group_key).or_default().push(count_points);
}
groups
.into_iter()
.map(|(group_labels, sub_series)| {
let points = merge_series_exact(&sub_series, false);
PromqlSeriesDto { labels: group_labels, points }
})
.collect()
}
Some(AggOp::Sum) | Some(AggOp::Avg) => { Some(AggOp::Sum) | Some(AggOp::Avg) => {
let is_avg = parsed.agg == Some(AggOp::Avg); let is_avg = parsed.agg == Some(AggOp::Avg);
// Validated by `promql::parse_query`: an inner rate() always carries a range. // Validated by `promql::parse_query`: an inner rate() always carries a range.
@@ -1330,7 +1384,7 @@ fn evaluate_promql(
}) })
.collect() .collect()
} }
} })
} }
#[cfg(test)] #[cfg(test)]
@@ -1972,7 +2026,7 @@ mod tests {
ingest_log(&mut index, 2, "request path=/a"); ingest_log(&mut index, 2, "request path=/a");
let parsed = promql::parse_query(r#"requests{path="/a"}"#).unwrap(); let parsed = promql::parse_query(r#"requests{path="/a"}"#).unwrap();
let series = evaluate_promql(&index, &parsed, None, None); let series = evaluate_promql(&index, &parsed, None, None).unwrap();
assert_eq!(series.len(), 1); assert_eq!(series.len(), 1);
assert_eq!(series[0].labels.get("path").map(String::as_str), Some("/a")); assert_eq!(series[0].labels.get("path").map(String::as_str), Some("/a"));
@@ -1987,7 +2041,7 @@ mod tests {
ingest_log(&mut index, 0, "request path=/a"); ingest_log(&mut index, 0, "request path=/a");
let parsed = promql::parse_query(r#"requests{path="/nope"}"#).unwrap(); let parsed = promql::parse_query(r#"requests{path="/nope"}"#).unwrap();
let series = evaluate_promql(&index, &parsed, None, None); let series = evaluate_promql(&index, &parsed, None, None).unwrap();
assert!(series.is_empty()); assert!(series.is_empty());
} }
@@ -1999,7 +2053,7 @@ mod tests {
ingest_log(&mut index, 0, "request path=/b"); ingest_log(&mut index, 0, "request path=/b");
let parsed = promql::parse_query("sum(requests)").unwrap(); let parsed = promql::parse_query("sum(requests)").unwrap();
let series = evaluate_promql(&index, &parsed, None, None); let series = evaluate_promql(&index, &parsed, None, None).unwrap();
assert_eq!(series.len(), 1); assert_eq!(series.len(), 1);
assert!(series[0].labels.is_empty()); assert!(series[0].labels.is_empty());
@@ -2015,7 +2069,7 @@ mod tests {
ingest_log(&mut index, 1, "request path=/a"); ingest_log(&mut index, 1, "request path=/a");
let parsed = promql::parse_query("sum(requests) by (path)").unwrap(); let parsed = promql::parse_query("sum(requests) by (path)").unwrap();
let mut series = evaluate_promql(&index, &parsed, None, None); let mut series = evaluate_promql(&index, &parsed, None, None).unwrap();
series.sort_by(|a, b| a.labels.get("path").cmp(&b.labels.get("path"))); series.sort_by(|a, b| a.labels.get("path").cmp(&b.labels.get("path")));
assert_eq!(series.len(), 2); assert_eq!(series.len(), 2);
@@ -2025,6 +2079,99 @@ mod tests {
assert_eq!(series[1].points, vec![(0, 1.0)]); assert_eq!(series[1].points, vec![(0, 1.0)]);
} }
/// Declares a metric whose captured `status` param is `String`-kind and holds the `Value` role,
/// so every bucket entry for it is a `MetricPrimitive::String` rather than the default occurrence marker.
fn declare_string_valued_metric(index: &mut BTreeMap<IndexKey, DataValue>) {
declare_metric(
index,
metric_request_with_roles(
"statuses",
r"request status=(?P<status>\S+)",
vec![("status", "status", MetricPrimitiveKind::String, ParamRole::Value)],
),
)
.unwrap();
}
#[test]
fn sum_over_string_kind_metric_is_rejected_with_an_actionable_error() {
let mut index = BTreeMap::new();
declare_string_valued_metric(&mut index);
ingest_log(&mut index, 0, "request status=ok");
let parsed = promql::parse_query("sum(statuses)").unwrap();
let err = evaluate_promql(&index, &parsed, None, None).unwrap_err();
assert!(err.contains("statuses"), "error should name the offending metric: {err}");
assert!(err.to_lowercase().contains("string"), "error should explain why: {err}");
}
#[test]
fn avg_over_string_kind_metric_is_rejected_with_an_actionable_error() {
let mut index = BTreeMap::new();
declare_string_valued_metric(&mut index);
ingest_log(&mut index, 0, "request status=ok");
let parsed = promql::parse_query("avg(statuses)").unwrap();
let err = evaluate_promql(&index, &parsed, None, None).unwrap_err();
assert!(err.contains("statuses"));
}
#[test]
fn count_over_string_kind_metric_counts_occurrences_by_name_not_by_accident() {
let mut index = BTreeMap::new();
declare_string_valued_metric(&mut index);
ingest_log(&mut index, 0, "request status=ok");
ingest_log(&mut index, 1, "request status=error");
let parsed = promql::parse_query("count(statuses)").unwrap();
let series = evaluate_promql(&index, &parsed, None, None).unwrap();
assert_eq!(series.len(), 1);
assert!(series[0].labels.is_empty());
assert_eq!(series[0].points, vec![(0, 1.0), (1, 1.0)]);
}
#[test]
fn sum_over_numeric_kind_metric_is_unaffected_by_the_string_kind_guard() {
let mut index = BTreeMap::new();
declare_requests_metric(&mut index);
ingest_log(&mut index, 0, "request path=/a");
ingest_log(&mut index, 0, "request path=/b");
let parsed = promql::parse_query("sum(requests)").unwrap();
let series = evaluate_promql(&index, &parsed, None, None).unwrap();
assert_eq!(series.len(), 1);
assert_eq!(series[0].points, vec![(0, 2.0)]);
}
#[test]
fn sum_of_rate_over_string_kind_metric_is_still_allowed_since_rate_only_counts_occurrences() {
let mut index = BTreeMap::new();
declare_string_valued_metric(&mut index);
ingest_log(&mut index, 0, "request status=ok");
ingest_log(&mut index, 1000, "request status=error");
let parsed = promql::parse_query("sum(rate(statuses[2s]))").unwrap();
let series = evaluate_promql(&index, &parsed, None, None);
assert!(series.is_ok(), "sum(rate(...)) never reads the coerced value, so it should not be rejected: {series:?}");
}
#[test]
fn bare_query_over_string_kind_metric_is_still_allowed() {
let mut index = BTreeMap::new();
declare_string_valued_metric(&mut index);
ingest_log(&mut index, 0, "request status=ok");
let parsed = promql::parse_query("statuses").unwrap();
let series = evaluate_promql(&index, &parsed, None, None);
assert!(series.is_ok());
}
#[test] #[test]
fn bare_rate_produces_a_windowed_point_per_timestamp_not_a_single_point() { fn bare_rate_produces_a_windowed_point_per_timestamp_not_a_single_point() {
let mut index = BTreeMap::new(); let mut index = BTreeMap::new();
@@ -2033,7 +2180,7 @@ mod tests {
ingest_log(&mut index, 1000, "request path=/a"); ingest_log(&mut index, 1000, "request path=/a");
let parsed = promql::parse_query("rate(requests[2s])").unwrap(); let parsed = promql::parse_query("rate(requests[2s])").unwrap();
let series = evaluate_promql(&index, &parsed, None, None); let series = evaluate_promql(&index, &parsed, None, None).unwrap();
assert_eq!(series.len(), 1); assert_eq!(series.len(), 1);
assert_eq!(series[0].points, vec![(0, 0.5), (1000, 1.0)]); assert_eq!(series[0].points, vec![(0, 0.5), (1000, 1.0)]);
@@ -2048,7 +2195,7 @@ mod tests {
ingest_log(&mut index, 0, "request path=/b"); ingest_log(&mut index, 0, "request path=/b");
let parsed = promql::parse_query("sum(rate(requests[2s])) by (path)").unwrap(); let parsed = promql::parse_query("sum(rate(requests[2s])) by (path)").unwrap();
let mut series = evaluate_promql(&index, &parsed, None, None); let mut series = evaluate_promql(&index, &parsed, None, None).unwrap();
series.sort_by(|a, b| a.labels.get("path").cmp(&b.labels.get("path"))); series.sort_by(|a, b| a.labels.get("path").cmp(&b.labels.get("path")));
assert_eq!(series.len(), 2); assert_eq!(series.len(), 2);
@@ -2066,7 +2213,7 @@ mod tests {
ingest_log(&mut index, 0, "request path=/b"); ingest_log(&mut index, 0, "request path=/b");
let parsed = promql::parse_query("sum(rate(requests[2s]))").unwrap(); let parsed = promql::parse_query("sum(rate(requests[2s]))").unwrap();
let series = evaluate_promql(&index, &parsed, None, None); let series = evaluate_promql(&index, &parsed, None, None).unwrap();
assert_eq!(series.len(), 1); assert_eq!(series.len(), 1);
assert!(series[0].labels.is_empty()); assert!(series[0].labels.is_empty());
@@ -2082,7 +2229,7 @@ mod tests {
ingest_log(&mut index, 10, "request path=/a"); ingest_log(&mut index, 10, "request path=/a");
let parsed = promql::parse_query("requests").unwrap(); let parsed = promql::parse_query("requests").unwrap();
let series = evaluate_promql(&index, &parsed, Some(1), Some(9)); let series = evaluate_promql(&index, &parsed, Some(1), Some(9)).unwrap();
assert_eq!(series.len(), 1); assert_eq!(series.len(), 1);
assert_eq!(series[0].points, vec![(5, 1.0)]); assert_eq!(series[0].points, vec![(5, 1.0)]);
@@ -2173,7 +2320,7 @@ mod tests {
ingest_log(&mut index, 2, "request path=/b duration=42"); ingest_log(&mut index, 2, "request path=/b duration=42");
let parsed = promql::parse_query("gauge(durations)").unwrap(); let parsed = promql::parse_query("gauge(durations)").unwrap();
let mut series = evaluate_promql(&index, &parsed, None, None); let mut series = evaluate_promql(&index, &parsed, None, None).unwrap();
series.sort_by(|a, b| a.labels.get("path").cmp(&b.labels.get("path"))); series.sort_by(|a, b| a.labels.get("path").cmp(&b.labels.get("path")));
assert_eq!(series.len(), 2); assert_eq!(series.len(), 2);
@@ -2192,7 +2339,7 @@ mod tests {
ingest_log(&mut index, 10, "request path=/a duration=42"); ingest_log(&mut index, 10, "request path=/a duration=42");
let parsed = promql::parse_query("gauge(durations)").unwrap(); let parsed = promql::parse_query("gauge(durations)").unwrap();
let series = evaluate_promql(&index, &parsed, None, Some(9)); let series = evaluate_promql(&index, &parsed, None, Some(9)).unwrap();
assert_eq!(series.len(), 1); assert_eq!(series.len(), 1);
assert_eq!(series[0].points, vec![(5, 9.0)]); assert_eq!(series[0].points, vec![(5, 9.0)]);
@@ -2207,7 +2354,7 @@ mod tests {
} }
let parsed = promql::parse_query("histogram(durations[10])").unwrap(); let parsed = promql::parse_query("histogram(durations[10])").unwrap();
let mut series = evaluate_promql(&index, &parsed, None, None); let mut series = evaluate_promql(&index, &parsed, None, None).unwrap();
series.sort_by(|a, b| a.labels.get("le").cmp(&b.labels.get("le"))); series.sort_by(|a, b| a.labels.get("le").cmp(&b.labels.get("le")));
// durations 2,4 -> le=10 (count 2); 12,15 -> le=20 (count 2); 21 -> le=30 (count 1). // durations 2,4 -> le=10 (count 2); 12,15 -> le=20 (count 2); 21 -> le=30 (count 1).
@@ -2278,7 +2425,7 @@ mod tests {
ingest_log(&mut index, 10, "user=alice logged in"); ingest_log(&mut index, 10, "user=alice logged in");
let parsed = promql::parse_query("login_gap").unwrap(); let parsed = promql::parse_query("login_gap").unwrap();
let series = evaluate_promql(&index, &parsed, None, None); let series = evaluate_promql(&index, &parsed, None, None).unwrap();
assert_eq!(series.len(), 1); assert_eq!(series.len(), 1);
assert_eq!(series[0].points, vec![(10, 10.0)]); assert_eq!(series[0].points, vec![(10, 10.0)]);
@@ -2327,7 +2474,7 @@ mod tests {
ingest_log(&mut index, 2, "all good again"); ingest_log(&mut index, 2, "all good again");
let parsed = promql::parse_query("log_category").unwrap(); let parsed = promql::parse_query("log_category").unwrap();
let mut series = evaluate_promql(&index, &parsed, None, None); let mut series = evaluate_promql(&index, &parsed, None, None).unwrap();
series.sort_by(|a, b| a.labels.get("category").cmp(&b.labels.get("category"))); series.sort_by(|a, b| a.labels.get("category").cmp(&b.labels.get("category")));
assert_eq!(series.len(), 2); assert_eq!(series.len(), 2);
+23 -3
View File
@@ -1,6 +1,7 @@
//! Hand-rolled parser for a small PromQL subset: metric name + label matchers, an optional `[5m]` range, and a top-level `sum()`/`avg()`/`rate()`/`histogram()`/`gauge()` wrapper. //! Hand-rolled parser for a small PromQL subset: metric name + label matchers, an optional `[5m]` range, and a top-level `sum()`/`avg()`/`count()`/`rate()`/`histogram()`/`gauge()` wrapper.
//! Only exact-match (`=`) label matchers are supported; `rate()` requires a duration range selector, while `histogram(metric[width])` reuses that `[...]` slot for a bare bucket width instead. //! Only exact-match (`=`) label matchers are supported; `rate()` requires a duration range selector, while `histogram(metric[width])` reuses that `[...]` slot for a bare bucket width instead.
//! `sum()`/`avg()` may additionally wrap a `rate(...)` call directly (e.g. `sum(rate(requests[5m]))`), and either may be followed by a `by (label, ...)` clause to preserve one series per distinct value of the named labels instead of collapsing to a single series. //! `sum()`/`avg()` may additionally wrap a `rate(...)` call directly (e.g. `sum(rate(requests[5m]))`), and either may be followed by a `by (label, ...)` clause to preserve one series per distinct value of the named labels instead of collapsing to a single series.
//! `count()` is a plain vector selector wrapper only (no `rate()` nesting) — it counts occurrences per group regardless of the metric's value, which is the deliberate opt-in for occurrence counting that `sum()`/`avg()` no longer fall back to for a `String`-kind metric (see `crate::engine::evaluate_promql`).
use std::time::Duration; use std::time::Duration;
@@ -8,6 +9,9 @@ use std::time::Duration;
pub enum AggOp { pub enum AggOp {
Sum, Sum,
Avg, Avg,
/// Occurrence count per group, ignoring the metric's stored value entirely — the explicit,
/// opt-in-by-name alternative to summing a `String`-kind metric's coerced-to-1.0 values.
Count,
Rate, Rate,
/// Buckets counter values into fixed-width buckets; requires `bucket_width`. /// Buckets counter values into fixed-width buckets; requires `bucket_width`.
Histogram, Histogram,
@@ -278,6 +282,7 @@ impl<'a> Parser<'a> {
let agg = match ident.as_str() { let agg = match ident.as_str() {
"sum" => Some(AggOp::Sum), "sum" => Some(AggOp::Sum),
"avg" => Some(AggOp::Avg), "avg" => Some(AggOp::Avg),
"count" => Some(AggOp::Count),
"rate" => Some(AggOp::Rate), "rate" => Some(AggOp::Rate),
"histogram" => Some(AggOp::Histogram), "histogram" => Some(AggOp::Histogram),
"gauge" => Some(AggOp::Gauge), "gauge" => Some(AggOp::Gauge),
@@ -415,11 +420,26 @@ mod tests {
#[test] #[test]
fn rejects_unknown_aggregation() { fn rejects_unknown_aggregation() {
// `count` isn't a supported agg_op, so this parses as a bare metric name and trips on the trailing `(...)`. // `median` isn't a supported agg_op, so this parses as a bare metric name and trips on the trailing `(...)`.
let err = parse_query("count(requests)").unwrap_err(); let err = parse_query("median(requests)").unwrap_err();
assert!(err.contains("unexpected trailing input")); assert!(err.contains("unexpected trailing input"));
} }
#[test]
fn parses_count_aggregation() {
let query = parse_query("count(requests)").unwrap();
assert_eq!(query.agg, Some(AggOp::Count));
assert_eq!(query.metric_name, "requests");
assert!(!query.inner_rate);
}
#[test]
fn parses_count_with_by_clause() {
let query = parse_query("count(requests) by (path)").unwrap();
assert_eq!(query.agg, Some(AggOp::Count));
assert_eq!(query.by_labels, vec!["path".to_string()]);
}
#[test] #[test]
fn rejects_regex_label_matcher() { fn rejects_regex_label_matcher() {
let err = parse_query(r#"requests{path=~"/a"}"#).unwrap_err(); let err = parse_query(r#"requests{path=~"/a"}"#).unwrap_err();