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
+25
View File
@@ -11,6 +11,7 @@ import {
updateMetric,
type BucketValue,
type MetricDeclaration,
type MetricSemantics,
type ParamDeclaration,
} from '../lib/api'
import { FormFooter, PopOutButton, SectionHeader, useSectionCollapsed } from './CollapsibleSection'
@@ -82,6 +83,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
// (ticket 037) rather than re-entering a regex/capture-group/kind per param.
const [labelParams, setLabelParams] = useState<string[]>([])
const [valueParam, setValueParam] = useState<string>('')
const [semantics, setSemantics] = useState<MetricSemantics>('gauge')
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [collapsed, setCollapsed] = useSectionCollapsed('metrics')
@@ -141,6 +143,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
setRegex('')
setLabelParams([])
setValueParam('')
setSemantics('gauge')
setSubmitError(null)
}
@@ -151,6 +154,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
setRegex(metric.message_match_regex)
setLabelParams(metric.label_params)
setValueParam(metric.value_param ?? '')
setSemantics(metric.semantics ?? 'gauge')
setSubmitError(null)
}
@@ -182,6 +186,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
message_match_regex: regex,
label_params: labelParams,
value_param: valueParam || null,
semantics,
}
const saved = editingName ? await updateMetric(editingName, request) : await declareMetric(request)
resetForm()
@@ -276,6 +281,22 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
))}
</select>
</div>
<div>
<label
className="mb-1 block text-xs text-text-subtle"
title="Gauge: a point-in-time reading (e.g. queue depth) — rate() over it is rejected. Counter: a monotonically-increasing total (e.g. requests served) — sum()/avg() directly on it is rejected; wrap it in rate() first."
>
Semantics
</label>
<select
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
value={semantics}
onChange={(e) => setSemantics(e.target.value as MetricSemantics)}
>
<option value="gauge">Gauge (point-in-time reading)</option>
<option value="counter">Counter (monotonically increasing)</option>
</select>
</div>
{editingName && (
<div className="rounded border border-accent bg-accent/5 px-2 py-1 text-xs text-accent-text">
Editing "{editingName}"
@@ -345,6 +366,10 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
<span className="text-text-subtle">Value param: </span>
<span className="font-mono">{selectedMetric.value_param ?? <span className="text-text-faint">none</span>}</span>
</div>
<div>
<span className="text-text-subtle">Semantics: </span>
<span className="font-mono">{selectedMetric.semantics ?? 'gauge'}</span>
</div>
</div>
)}
{groups.length === 0 ? (
+4
View File
@@ -1,6 +1,8 @@
import { getJson, postJson, putJson, sendJson } from './shared'
import type { MetricPrimitive } from './logs'
export type MetricSemantics = 'gauge' | 'counter'
export interface MetricDeclaration {
name: string
message_match_regex: string
@@ -11,6 +13,8 @@ export interface MetricDeclaration {
/** Fixed labels applied on a match/non-match, on top of any captured params. */
matched_labels?: Record<string, string> | null
unmatched_labels?: Record<string, string> | null
/** Point-in-time reading ('gauge', e.g. queue depth) vs. monotonically-increasing total ('counter', e.g. requests served); guards which PromQL aggregations are valid. Defaults to 'gauge'. */
semantics?: MetricSemantics
}
export interface BucketValue {