Add JSON field path extraction for params/metrics (ticket 015)

RegexToMetricPrimitiveMatcher and the standalone param declaration
(DeclareParamRequest/DataValue::ParamDeclaration) gain an optional
json_field_path, an alternative to capture_group that reads a
dot-separated field path (e.g. "foo.bar") out of lines that parse as
JSON objects. It's additive and serde-defaulted, so existing
regex-based params/metrics are unaffected. Params.tsx now offers a
"JSON field" extraction option when the message match regex looks
like a JSON object.

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 19:38:39 +01:00
co-authored by Claude Sonnet 5
parent e6e5bc8f50
commit b3e0272544
8 changed files with 287 additions and 54 deletions
+93 -24
View File
@@ -1,28 +1,59 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { ApiError, declareParam, type ParamDeclaration } from '../lib/api'
import { CollapseToggle, PopOutButton, sectionHeaderClassName, sectionHeaderLeftClassName, useSectionCollapsed } from './CollapsibleSection'
import { HelpIcon } from './HelpIcon'
const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
type ExtractionSource = 'regex' | 'json_field'
/// A JSON *object* (not an array/scalar) parses cleanly and pretty-prints with braces — the same
/// shape a JSON field path can be read from, so this is the "line looks like JSON" gate that
/// unlocks the "JSON field" extraction option.
function looksLikeJsonObject(text: string): boolean {
const trimmed = text.trim()
if (!trimmed.startsWith('{')) return false
try {
const parsed = JSON.parse(trimmed)
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
} catch {
return false
}
}
export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onDeclared: () => void }) {
const [name, setName] = useState('')
const [regex, setRegex] = useState('')
const [captureGroup, setCaptureGroup] = useState('')
const [jsonFieldPath, setJsonFieldPath] = useState('')
const [source, setSource] = useState<ExtractionSource>('regex')
const [kind, setKind] = useState<ParamDeclaration['kind']>(EMPTY_KIND)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [collapsed, setCollapsed] = useSectionCollapsed('params')
// The message match regex doubles as a sample line here so a pasted JSON log line unlocks the
// "JSON field" extraction option — regex-based params are unaffected either way.
const detectedJson = useMemo(() => looksLikeJsonObject(regex), [regex])
const effectiveSource: ExtractionSource = detectedJson ? source : 'regex'
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSubmitError(null)
setSubmitting(true)
try {
await declareParam({ name, message_match_regex: regex, capture_group: captureGroup, kind })
await declareParam({
name,
message_match_regex: regex,
capture_group: effectiveSource === 'json_field' ? '' : captureGroup,
json_field_path: effectiveSource === 'json_field' ? jsonFieldPath : null,
kind,
})
setName('')
setRegex('')
setCaptureGroup('')
setJsonFieldPath('')
setSource('regex')
setKind(EMPTY_KIND)
onDeclared()
} catch (err) {
@@ -66,28 +97,66 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
required
/>
</div>
<div className="flex items-end gap-1">
<div className="min-w-0 flex-1">
<label className="mb-1 block text-xs text-text-subtle">Capture group</label>
<input
className="w-full min-w-0 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={captureGroup}
onChange={(e) => setCaptureGroup(e.target.value)}
placeholder="path"
required
/>
{detectedJson && (
<div>
<label className="mb-1 block text-xs text-text-subtle">Extraction</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={source}
onChange={(e) => setSource(e.target.value as ExtractionSource)}
>
<option value="regex">Regex capture group</option>
<option value="json_field">JSON field</option>
</select>
</div>
<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={kind}
onChange={(e) => setKind(e.target.value as ParamDeclaration['kind'])}
>
<option value="string">string</option>
<option value="number">number</option>
<option value="timestamp">timestamp</option>
<option value="duration">duration</option>
</select>
</div>
)}
{effectiveSource === 'json_field' ? (
<div className="flex items-end gap-1">
<div className="min-w-0 flex-1">
<label className="mb-1 block text-xs text-text-subtle">JSON field path</label>
<input
className="w-full min-w-0 rounded border border-border-soft bg-surface-1 px-1.5 py-1 font-mono text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={jsonFieldPath}
onChange={(e) => setJsonFieldPath(e.target.value)}
placeholder="foo.bar"
required
/>
</div>
<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={kind}
onChange={(e) => setKind(e.target.value as ParamDeclaration['kind'])}
>
<option value="string">string</option>
<option value="number">number</option>
<option value="timestamp">timestamp</option>
<option value="duration">duration</option>
</select>
</div>
) : (
<div className="flex items-end gap-1">
<div className="min-w-0 flex-1">
<label className="mb-1 block text-xs text-text-subtle">Capture group</label>
<input
className="w-full min-w-0 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={captureGroup}
onChange={(e) => setCaptureGroup(e.target.value)}
placeholder="path"
required
/>
</div>
<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={kind}
onChange={(e) => setKind(e.target.value as ParamDeclaration['kind'])}
>
<option value="string">string</option>
<option value="number">number</option>
<option value="timestamp">timestamp</option>
<option value="duration">duration</option>
</select>
</div>
)}
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
<button
type="submit"
@@ -108,7 +177,7 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
className="rounded border border-border-soft px-2 py-1 text-xs text-text-secondary"
title={p.message_match_regex}
>
{p.name} ({p.kind}, group {p.capture_group})
{p.name} ({p.kind}, {p.json_field_path ? `json field ${p.json_field_path}` : `group ${p.capture_group}`})
</span>
))}
</div>
+4
View File
@@ -25,6 +25,8 @@ export interface ParamDeclaration {
name: string
message_match_regex: string
capture_group: string
/** Alternative to `capture_group`: a dot-separated JSON field path (e.g. "foo.bar"), read from lines that parse as a JSON object. When set (non-empty), takes precedence over `capture_group`. */
json_field_path?: string | null
kind: 'string' | 'number' | 'timestamp' | 'duration'
}
@@ -48,6 +50,8 @@ 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