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:
Hugh Kaznowski
2026-09-07 12:30:06 +01:00
co-authored by Claude Sonnet 5
parent c3108677ce
commit 4a0fe92ce8
12 changed files with 299 additions and 55 deletions
+61 -2
View File
@@ -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();