Unify param declarations: metrics reference params by name (ticket 037)

Collapse the Params-panel ParamDeclaration and the metric-embedded
RegexToMetricPrimitiveMatcher into a single ParamDeclaration type
(name, regex/capture-group-or-json-field-path, kind). MetricDeclaration
now references params by name via label_params: Vec<String> and
value_param: Option<String> instead of embedding its own extractor per
param, so a param declared once in the Params panel can be reused as a
label or value by any number of metrics. Declaring a metric that
references an undeclared param name is now rejected at declare-time.

Backend:
- src/data_value.rs: unified ParamDeclaration struct; MetricDeclaration
  data value now carries label_params/value_param instead of a Vec of
  inline matchers.
- src/engine.rs: DeclareParamRequest is now an alias for
  ParamDeclaration; DeclareMetricRequest carries label_params/
  value_param; validate_metric_request checks referenced param names
  against the declared-param store; apply_metric_to_log resolves
  label_params/value_param against a per-ingest resolved-param map
  instead of reading an inline matcher list.
- src/profile.rs: MetricDeclarationRecord carries label_params/
  value_param plus a private, serialize-skipped legacy_params field
  that only ever gets populated by deserializing a pre-037 profile
  file's old "params" key. ProfileStore::load migrates any such legacy
  metric into the unified shape (reusing an existing same-shaped bare
  param by name, or creating one implicitly named
  "<metric_name>.<param_name>"), then persists the migrated store so
  the on-disk file only carries the legacy shape once. Migration is
  idempotent (deterministic naming) and covered by dedicated tests.
- src/api.rs / src/dto.rs: MetricDeclarationDto and the declare/update
  metric response bodies updated to the new shape.

Frontend:
- webapp/src/lib/api.ts: MetricDeclaration type now has label_params/
  value_param; MetricParamMatcher/ParamRole types removed.
- webapp/src/components/Metrics.tsx: the declaration form now fetches
  declared params and lets you check off label params / pick a value
  param from that list, instead of re-entering a regex/capture-group/
  kind per metric param.

Leaves ticket 038 (duration as a derived param kind) and 039 (param
query resolving declared params) untouched, as scoped.

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:41:14 +01:00
co-authored by Claude Sonnet 5
parent 85fb145be7
commit e397f9fbdb
7 changed files with 755 additions and 594 deletions
+75 -78
View File
@@ -5,11 +5,12 @@ import {
declareMetric,
fetchMetricBucketValues,
fetchMetricDeclarations,
fetchParamDeclarations,
metricPrimitiveToDisplayString,
updateMetric,
type BucketValue,
type MetricDeclaration,
type MetricParamMatcher,
type ParamDeclaration,
} from '../lib/api'
import {
CollapseToggle,
@@ -22,8 +23,6 @@ import {
import { HelpIcon } from './HelpIcon'
import { RegexHelpIcon } from './RegexHelpIcon'
const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' }
// One row in the labels -> occurrence count table; `values` keeps the raw points for the sparkline rendering.
interface LabelGroup {
key: string
@@ -80,12 +79,16 @@ function sparkline(values: BucketValue[], range: TimeRange | null): string {
export function Metrics({ range, activePreset }: { range: TimeRange | null; activePreset?: TimeRangePresetId | null }) {
const [metrics, setMetrics] = useState<MetricDeclaration[]>([])
const [params, setParams] = useState<ParamDeclaration[]>([])
const [selected, setSelected] = useState<string | null>(null)
const [bucketValues, setBucketValues] = useState<BucketValue[]>([])
const [name, setName] = useState('')
const [regex, setRegex] = useState('')
const [matchers, setMatchers] = useState<MetricParamMatcher[]>([{ ...EMPTY_MATCHER }])
// A metric picks its label/value params from the params already declared in the Params panel
// (ticket 037) rather than re-entering a regex/capture-group/kind per param.
const [labelParams, setLabelParams] = useState<string[]>([])
const [valueParam, setValueParam] = useState<string>('')
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [collapsed, setCollapsed] = useSectionCollapsed('metrics')
@@ -101,8 +104,17 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
}
}
async function loadParams() {
try {
setParams(await fetchParamDeclarations())
} catch {
// engine not reachable yet; leave the previous list in place
}
}
useEffect(() => {
loadMetrics()
loadParams()
}, [])
// Refetch-on-change rather than continuous polling: bucket values only move when the selected metric or range changes, so no interval is needed here like `App.tsx`'s log/timeline polling.
@@ -124,23 +136,18 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
}
}, [selected, range])
function updateMatcher(index: number, patch: Partial<MetricParamMatcher>) {
setMatchers((prev) => prev.map((m, i) => (i === index ? { ...m, ...patch } : m)))
}
function addMatcher() {
setMatchers((prev) => [...prev, { ...EMPTY_MATCHER }])
}
function removeMatcher(index: number) {
setMatchers((prev) => prev.filter((_, i) => i !== index))
function toggleLabelParam(paramName: string) {
setLabelParams((prev) =>
prev.includes(paramName) ? prev.filter((name) => name !== paramName) : [...prev, paramName],
)
}
function resetForm() {
setEditingName(null)
setName('')
setRegex('')
setMatchers([{ ...EMPTY_MATCHER }])
setLabelParams([])
setValueParam('')
setSubmitError(null)
}
@@ -149,7 +156,8 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
setEditingName(metric.name)
setName(metric.name)
setRegex(metric.message_match_regex)
setMatchers(metric.params.length > 0 ? metric.params.map((p) => ({ ...p })) : [{ ...EMPTY_MATCHER }])
setLabelParams(metric.label_params)
setValueParam(metric.value_param ?? '')
setSubmitError(null)
}
@@ -166,7 +174,8 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
const request: MetricDeclaration = {
name,
message_match_regex: regex,
params: matchers.filter((m) => m.param_name && m.capture_group),
label_params: labelParams,
value_param: valueParam || null,
}
const saved = editingName ? await updateMetric(editingName, request) : await declareMetric(request)
resetForm()
@@ -227,61 +236,49 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
/>
</div>
<div>
<div className="mb-1 flex items-center justify-between text-xs text-text-subtle">
<span>Params</span>
<button
type="button"
onClick={addMatcher}
className="rounded border border-border-soft px-2 py-0.5 text-text-secondary hover:bg-surface-2"
>
+ Add
</button>
<label className="mb-1 block text-xs text-text-subtle">
Label params
{params.length === 0 && (
<span className="ml-1 text-text-faint">(declare a param in the Params panel below first)</span>
)}
</label>
<div className="max-h-32 space-y-0.5 overflow-auto rounded border border-border-soft bg-surface-1 p-1.5">
{params.length === 0 ? (
<span className="text-xs text-text-faint">No params declared yet.</span>
) : (
params.map((p) => (
<label key={p.name} className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={labelParams.includes(p.name)}
onChange={() => toggleLabelParam(p.name)}
/>
<span className="font-mono">{p.name}</span>
<span className="text-text-faint">({p.kind})</span>
</label>
))
)}
</div>
<div className="space-y-1">
{matchers.map((matcher, i) => (
<div key={i} className="flex items-center gap-1">
<input
className="w-24 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={matcher.param_name}
onChange={(e) => updateMatcher(i, { param_name: e.target.value })}
placeholder="param_name"
/>
<input
className="w-24 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={matcher.capture_group}
onChange={(e) => updateMatcher(i, { capture_group: e.target.value })}
placeholder="capture_group"
/>
<select
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
value={matcher.kind}
onChange={(e) => updateMatcher(i, { kind: e.target.value as MetricParamMatcher['kind'] })}
>
<option value="string">string</option>
<option value="number">number</option>
<option value="timestamp">timestamp</option>
<option value="duration">duration</option>
</select>
<select
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
value={matcher.role ?? 'label'}
onChange={(e) => updateMatcher(i, { role: e.target.value as MetricParamMatcher['role'] })}
title="Label params identify a bucket; at most one Value param becomes the counter's per-event captured value (used by histogram()/gauge()). A Value param of kind 'duration' is derived automatically — elapsed time since the previous match with the same labels — rather than parsed from the capture group."
>
<option value="label">label</option>
<option value="value">value</option>
</select>
<button
type="button"
onClick={() => removeMatcher(i)}
disabled={matchers.length === 1}
className="rounded border border-border-soft px-1.5 py-1 text-xs text-text-muted hover:bg-surface-2 disabled:opacity-30"
>
×
</button>
</div>
</div>
<div>
<label
className="mb-1 block text-xs text-text-subtle"
title="At most one param becomes the counter's per-event captured value (used by histogram()/gauge()) instead of a label. A 'duration'-kind value param is derived automatically — elapsed time since the previous match with the same labels — rather than parsed from its capture group."
>
Value param
</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={valueParam}
onChange={(e) => setValueParam(e.target.value)}
>
<option value="">(none — occurrence count)</option>
{params.map((p) => (
<option key={p.name} value={p.name}>
{p.name} ({p.kind})
</option>
))}
</div>
</select>
</div>
{editingName && (
<div className="rounded border border-accent bg-accent/5 px-2 py-1 text-xs text-accent-text">
@@ -325,7 +322,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
}`}
title={m.message_match_regex}
>
{m.name} ({m.params.map((p) => p.param_name).join(', ') || 'no params'})
{m.name} ({m.label_params.join(', ') || 'no params'})
</button>
))}
</div>
@@ -342,17 +339,17 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
<span className="font-mono">{selectedMetric.message_match_regex}</span>
</div>
<div>
<span className="text-text-subtle">Params: </span>
{selectedMetric.params.length === 0 ? (
<span className="text-text-subtle">Label params: </span>
{selectedMetric.label_params.length === 0 ? (
<span className="text-text-faint">none</span>
) : (
<span className="font-mono">
{selectedMetric.params
.map((p) => `${p.param_name}←${p.capture_group} (${p.kind}, ${p.role ?? 'label'})`)
.join(', ')}
</span>
<span className="font-mono">{selectedMetric.label_params.join(', ')}</span>
)}
</div>
<div>
<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>
)}
{groups.length === 0 ? (
+4 -13
View File
@@ -47,22 +47,13 @@ export interface Stats {
approx_bytes_total: number
}
export type ParamRole = 'label' | 'value'
export interface MetricParamMatcher {
param_name: string
capture_group: string
/** See `ParamDeclaration.json_field_path`. */
json_field_path?: string | null
kind: 'string' | 'number' | 'timestamp' | 'duration'
/** Defaults to 'label' server-side when omitted. */
role?: ParamRole
}
export interface MetricDeclaration {
name: string
message_match_regex: string
params: MetricParamMatcher[]
/** Names of already-declared `ParamDeclaration`s (see the Params panel) whose values become this metric's bucket labels. */
label_params: string[]
/** Name of an already-declared param (if any) whose value becomes this metric's per-event bucket value instead of a label. */
value_param?: string | null
/** 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