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:
co-authored by
Claude Sonnet 5
parent
85fb145be7
commit
e397f9fbdb
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user