Add gauge/counter semantics to metric declarations (ticket 097)
Metrics previously only declared a data type (String/Number/...), so rate() could be applied to a point-in-time gauge reading and sum()/avg() could be applied directly to a cumulative counter reading with no guard against either. Adds a MetricSemantics enum (Gauge, default; Counter) threaded through DeclareMetricRequest, the engine's MetricDeclaration data value, profile persistence, the API/MCP declaration routes, and the Metrics panel's declaration form, then uses it in evaluate_promql to reject rate() on a declared gauge and bare sum()/avg() on a declared counter (suggesting rate() first). The guard only applies to metrics with a captured value_param — bare occurrence-count metrics are always safe under both operations, so existing dashboards are unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c3108677ce
commit
4a0fe92ce8
@@ -18,7 +18,9 @@ use crate::consts::{
|
||||
MAX_AUTO_DECLARED_PARAMS, MAX_NEW_AUTO_PARAMS_PER_LINE, OTTYL_RESERVED_PARAM_NAMES,
|
||||
SORT_HELPER_INDEX_PURGE_AFTER,
|
||||
};
|
||||
use crate::engine::data_value::{DataValue, MetricPrimitiveKind, ParamDeclaration};
|
||||
use crate::engine::data_value::{
|
||||
DataValue, MetricPrimitiveKind, MetricSemantics, ParamDeclaration,
|
||||
};
|
||||
use crate::engine::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
|
||||
use crate::engine::index_key::IndexKey;
|
||||
use crate::engine::message::{EngineQuery, EngineResponse};
|
||||
@@ -53,6 +55,7 @@ type MetricDeclarationRow<'a> = (
|
||||
Option<&'a str>,
|
||||
Option<&'a BTreeMap<String, String>>,
|
||||
Option<&'a BTreeMap<String, String>>,
|
||||
MetricSemantics,
|
||||
);
|
||||
|
||||
/// Per-label-group point series, keyed by the group's label set, before merging into one
|
||||
@@ -91,6 +94,7 @@ impl EngineSnapshot {
|
||||
value_param,
|
||||
matched_labels,
|
||||
unmatched_labels,
|
||||
semantics,
|
||||
},
|
||||
) => Some((
|
||||
name.as_str(),
|
||||
@@ -99,6 +103,7 @@ impl EngineSnapshot {
|
||||
value_param.as_deref(),
|
||||
matched_labels.as_ref(),
|
||||
unmatched_labels.as_ref(),
|
||||
*semantics,
|
||||
)),
|
||||
_ => None,
|
||||
})
|
||||
@@ -420,6 +425,7 @@ impl EngineSnapshot {
|
||||
value_param: request.value_param,
|
||||
matched_labels: request.matched_labels,
|
||||
unmatched_labels: request.unmatched_labels,
|
||||
semantics: request.semantics,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -618,7 +624,7 @@ impl EngineSnapshot {
|
||||
let metrics: Vec<SeededMetric> = self
|
||||
.metric_declarations_range()
|
||||
.map(
|
||||
|(name, _, label_params, value_param, matched_labels, unmatched_labels)| {
|
||||
|(name, _, label_params, value_param, matched_labels, unmatched_labels, _)| {
|
||||
(
|
||||
name.to_string(),
|
||||
label_params.to_vec(),
|
||||
@@ -708,6 +714,32 @@ impl EngineSnapshot {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn declared_semantics(&self, metric_name: &str) -> Option<MetricSemantics> {
|
||||
match self.index.get(&IndexKey::MetricDeclaration {
|
||||
name: metric_name.to_string(),
|
||||
}) {
|
||||
Some(DataValue::MetricDeclaration { semantics, .. }) => Some(*semantics),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `metric_name` captures a per-event value (rather than just counting matching log
|
||||
/// lines). The gauge/counter semantics guard in `evaluate_promql` only applies to metrics
|
||||
/// with a captured value: a bare occurrence count is always safe to both `rate()` (events per
|
||||
/// window) and `sum()`/`avg()` (total/average occurrences) since each event contributes
|
||||
/// exactly one point, unlike a captured reading that might itself be a cumulative total.
|
||||
fn declares_value_param(&self, metric_name: &str) -> bool {
|
||||
matches!(
|
||||
self.index.get(&IndexKey::MetricDeclaration {
|
||||
name: metric_name.to_string(),
|
||||
}),
|
||||
Some(DataValue::MetricDeclaration {
|
||||
value_param: Some(_),
|
||||
..
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- params: declaring and resolving param values ---
|
||||
@@ -1068,6 +1100,33 @@ impl EngineSnapshot {
|
||||
parsed.metric_name
|
||||
));
|
||||
}
|
||||
// A metric with no captured value (just occurrence counts of matching log lines) is
|
||||
// always safe under both rate() and sum()/avg(): each event contributes exactly one
|
||||
// point, so there's no cumulative-total to misinterpret. The guard below only matters
|
||||
// once a metric captures a per-event value via `value_param`.
|
||||
let declared_semantics = self
|
||||
.declares_value_param(&parsed.metric_name)
|
||||
.then(|| self.declared_semantics(&parsed.metric_name))
|
||||
.flatten();
|
||||
if matches!(parsed.agg, Some(AggOp::Rate))
|
||||
&& declared_semantics == Some(MetricSemantics::Gauge)
|
||||
{
|
||||
return Err(format!(
|
||||
"cannot rate() \"{}\": it's declared as a gauge, not a counter; gauges are point-in-time \
|
||||
readings, so rate() over them isn't meaningful — use it bare or wrap it in gauge()",
|
||||
parsed.metric_name
|
||||
));
|
||||
}
|
||||
if matches!(parsed.agg, Some(AggOp::Sum) | Some(AggOp::Avg))
|
||||
&& !parsed.inner_rate
|
||||
&& declared_semantics == Some(MetricSemantics::Counter)
|
||||
{
|
||||
return Err(format!(
|
||||
"cannot sum/avg \"{}\" directly: it's declared as a counter, so summing/averaging its raw \
|
||||
values double-counts the running total — wrap it in rate() first, e.g. sum(rate({}[5m]))",
|
||||
parsed.metric_name, parsed.metric_name
|
||||
));
|
||||
}
|
||||
|
||||
let mut series_points: BTreeMap<BTreeMap<String, String>, Vec<(i64, f64)>> =
|
||||
BTreeMap::new();
|
||||
|
||||
@@ -280,6 +280,7 @@ pub(super) fn handle_query_inner(
|
||||
value_param,
|
||||
matched_labels,
|
||||
unmatched_labels,
|
||||
semantics,
|
||||
)| {
|
||||
MetricDeclarationDto {
|
||||
name: name.to_string(),
|
||||
@@ -288,6 +289,7 @@ pub(super) fn handle_query_inner(
|
||||
value_param: value_param.map(|s| s.to_string()),
|
||||
matched_labels: matched_labels.cloned(),
|
||||
unmatched_labels: unmatched_labels.cloned(),
|
||||
semantics,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user