From a9855de3ad65508ed2455a3cae8c4c5f75cf4687 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 19:35:02 +0100 Subject: [PATCH] Add Boolean param kind that is always present true/false (ticket 018) MetricPrimitiveKind gains Boolean, serialized as "boolean". A param declared with kind Boolean skips capture-group validation and, instead of being omitted when message_match_regex doesn't match, is present on every log line as true/false based purely on whether the regex matches (case-insensitively or per the regex's own flags). Params.tsx lets you pick boolean as a kind and disables the now-unused capture group input. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- src/data_value.rs | 5 ++++ src/engine.rs | 51 ++++++++++++++++++++++++++++++-- src/metric_primitive.rs | 5 ++++ src/profile.rs | 4 +++ webapp/src/components/Params.tsx | 17 +++++++---- webapp/src/lib/api.ts | 4 ++- 6 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/data_value.rs b/src/data_value.rs index 731ecfb..10d2174 100644 --- a/src/data_value.rs +++ b/src/data_value.rs @@ -33,6 +33,11 @@ pub enum MetricPrimitiveKind { Timestamp, /// On a Value-role param this is derived as elapsed time since the previous event (not parsed from text); otherwise it's a literal i64 millisecond count. Duration, + /// A standalone param declared with this kind doesn't require (or use) a capture group: its value is + /// simply whether `message_match_regex` matches the line, case-insensitively or per the regex's own + /// flags — see `crate::engine::derive_log_params`. Unlike other kinds it's present as `true`/`false` + /// on every line rather than omitted when unmatched. + Boolean, } /// Value type paired with [`crate::index_key::IndexKey`] in the engine's primary index. diff --git a/src/engine.rs b/src/engine.rs index d9b11ee..31b9e2b 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -207,7 +207,10 @@ fn declare_param( ) -> Result<(), String> { let re = Regex::new(&request.message_match_regex) .map_err(|err| format!("invalid regex: {err}"))?; - if !re.capture_names().flatten().any(|name| name == request.capture_group) { + // A Boolean-kind param never uses a capture group — its value is just whether the regex matched. + if request.kind != MetricPrimitiveKind::Boolean + && !re.capture_names().flatten().any(|name| name == request.capture_group) + { return Err(format!( "regex has no capture group named '{}'", request.capture_group @@ -294,6 +297,10 @@ fn parse_metric_primitive(raw: &str, kind: MetricPrimitiveKind) -> Option raw.parse::().ok().map(MetricPrimitive::Duration), + // A standalone Boolean param never reaches this branch (see derive_log_params, which derives its + // value from whether the regex matched rather than parsing captured text); reached only if a metric + // matcher references a Boolean-kind param, where it's parsed as a literal "true"/"false" capture. + MetricPrimitiveKind::Boolean => raw.parse::().ok().map(MetricPrimitive::Boolean), } } @@ -616,6 +623,13 @@ fn compare_metric_primitive( .map_err(|_| format!("expected a numeric value, got {raw:?}"))?; (*ms as f64).partial_cmp(&v) } + (MetricPrimitive::Boolean(b), FilterValue::Raw(raw)) => { + let v: bool = raw + .parse() + .map_err(|_| format!("expected true or false, got {raw:?}"))?; + b.partial_cmp(&v) + } + (MetricPrimitive::Boolean(_), FilterValue::Number(_)) => None, }; Ok(ordering.is_some_and(|ordering| apply_compare_op(ordering, op))) } @@ -707,13 +721,20 @@ fn compiled_param_declarations( .collect() } -/// A param is omitted (not included as null/empty) when its regex/capture group doesn't match or the captured text fails to parse — see `LogEntryDto::params`. +/// A param is omitted (not included as null/empty) when its regex/capture group doesn't match or the +/// captured text fails to parse — see `LogEntryDto::params`. The one exception is a `Boolean`-kind param, +/// which never uses a capture group and is instead present as `true`/`false` on every line based purely on +/// whether `message_match_regex` matches (case-insensitively or per the regex's own flags). fn derive_log_params( text: &str, declarations: &[(String, Regex, String, MetricPrimitiveKind)], ) -> BTreeMap { let mut params = BTreeMap::new(); for (name, regex, capture_group, kind) in declarations { + if *kind == MetricPrimitiveKind::Boolean { + params.insert(name.clone(), MetricPrimitive::Boolean(regex.is_match(text))); + continue; + } let Some(captures) = regex.captures(text) else { continue; }; @@ -939,6 +960,13 @@ fn coerce_to_f64(value: &MetricPrimitive) -> f64 { MetricPrimitive::Timestamp(ts) => ts.as_millis() as f64, MetricPrimitive::Duration(ms) => *ms as f64, MetricPrimitive::String(_) => 1.0, + MetricPrimitive::Boolean(b) => { + if *b { + 1.0 + } else { + 0.0 + } + } } } @@ -1978,6 +2006,25 @@ mod tests { assert!(entries[1].params.get("level").is_none()); } + #[test] + fn boolean_param_declares_without_capture_group_and_is_always_present() { + let mut index = BTreeMap::new(); + let mut req = request("IsError", r"(?i)error", ""); + req.kind = MetricPrimitiveKind::Boolean; + assert!(declare_param(&mut index, req).is_ok()); + + ingest_log(&mut index, 0, "an ERROR occurred"); + ingest_log(&mut index, 1, "everything is fine"); + + let EngineResponse::Logs(entries) = handle_query(&index, logs_query(None), 0) else { + panic!("expected Logs response"); + }; + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].params.get("IsError"), Some(&MetricPrimitive::Boolean(true))); + assert_eq!(entries[1].params.get("IsError"), Some(&MetricPrimitive::Boolean(false))); + } + #[test] fn timeline_query_applies_filter_before_bucketing() { let mut index = BTreeMap::new(); diff --git a/src/metric_primitive.rs b/src/metric_primitive.rs index 267651c..8acedf9 100644 --- a/src/metric_primitive.rs +++ b/src/metric_primitive.rs @@ -12,6 +12,8 @@ pub enum MetricPrimitive { Timestamp(Timestamp), /// Milliseconds elapsed; distinct from `Number` so it reads unambiguously in JSON/label form. Duration(i64), + /// A `Boolean`-kind param's value: whether `message_match_regex` matched the line. See `MetricPrimitiveKind::Boolean`. + Boolean(bool), } impl PartialEq for MetricPrimitive { @@ -35,6 +37,7 @@ impl Ord for MetricPrimitive { MetricPrimitive::Number(_) => 1, MetricPrimitive::Timestamp(_) => 2, MetricPrimitive::Duration(_) => 3, + MetricPrimitive::Boolean(_) => 4, } } match (self, other) { @@ -42,6 +45,7 @@ impl Ord for MetricPrimitive { (MetricPrimitive::Number(a), MetricPrimitive::Number(b)) => a.cmp(b), (MetricPrimitive::Timestamp(a), MetricPrimitive::Timestamp(b)) => a.cmp(b), (MetricPrimitive::Duration(a), MetricPrimitive::Duration(b)) => a.cmp(b), + (MetricPrimitive::Boolean(a), MetricPrimitive::Boolean(b)) => a.cmp(b), (a, b) => rank(a).cmp(&rank(b)), } } @@ -56,6 +60,7 @@ impl fmt::Display for MetricPrimitive { MetricPrimitive::Number(Number::Float(n)) => write!(f, "{n}"), MetricPrimitive::Timestamp(ts) => write!(f, "{}", ts.as_millis()), MetricPrimitive::Duration(ms) => write!(f, "{ms}"), + MetricPrimitive::Boolean(b) => write!(f, "{b}"), } } } diff --git a/src/profile.rs b/src/profile.rs index f8fb82c..321d7a8 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -30,6 +30,8 @@ pub enum ProfileMetricPrimitiveKind { Timestamp, /// Mirrors `MetricPrimitiveKind::Duration`. Duration, + /// Mirrors `MetricPrimitiveKind::Boolean`. + Boolean, } impl From for ProfileMetricPrimitiveKind { @@ -39,6 +41,7 @@ impl From for ProfileMetricPrimitiveKind { MetricPrimitiveKind::Number => ProfileMetricPrimitiveKind::Number, MetricPrimitiveKind::Timestamp => ProfileMetricPrimitiveKind::Timestamp, MetricPrimitiveKind::Duration => ProfileMetricPrimitiveKind::Duration, + MetricPrimitiveKind::Boolean => ProfileMetricPrimitiveKind::Boolean, } } } @@ -50,6 +53,7 @@ impl From for MetricPrimitiveKind { ProfileMetricPrimitiveKind::Number => MetricPrimitiveKind::Number, ProfileMetricPrimitiveKind::Timestamp => MetricPrimitiveKind::Timestamp, ProfileMetricPrimitiveKind::Duration => MetricPrimitiveKind::Duration, + ProfileMetricPrimitiveKind::Boolean => MetricPrimitiveKind::Boolean, } } } diff --git a/webapp/src/components/Params.tsx b/webapp/src/components/Params.tsx index c7b2c8b..1d51d58 100644 --- a/webapp/src/components/Params.tsx +++ b/webapp/src/components/Params.tsx @@ -14,12 +14,15 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD const [submitting, setSubmitting] = useState(false) const [collapsed, setCollapsed] = useSectionCollapsed('params') + const isBoolean = kind === 'boolean' + async function handleSubmit(e: React.FormEvent) { e.preventDefault() setSubmitError(null) setSubmitting(true) try { - await declareParam({ name, message_match_regex: regex, capture_group: captureGroup, kind }) + // A boolean-kind param never uses a capture group — its value is just whether the regex matched. + await declareParam({ name, message_match_regex: regex, capture_group: isBoolean ? '' : captureGroup, kind }) setName('') setRegex('') setCaptureGroup('') @@ -68,13 +71,16 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
- + setCaptureGroup(e.target.value)} placeholder="path" - required + required={!isBoolean} + disabled={isBoolean} />
{submitError &&
{submitError}
} @@ -108,7 +115,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.kind === 'boolean' ? '' : `, group ${p.capture_group}`}) ))} diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts index 91c32ee..e6c8cce 100644 --- a/webapp/src/lib/api.ts +++ b/webapp/src/lib/api.ts @@ -4,11 +4,13 @@ export type MetricPrimitive = | { Number: { Int: number } | { Float: number } } | { Timestamp: number } | { Duration: number } + | { Boolean: boolean } export function metricPrimitiveToDisplayString(value: MetricPrimitive): string { if ('String' in value) return value.String if ('Timestamp' in value) return String(value.Timestamp) if ('Duration' in value) return `${value.Duration}ms` + if ('Boolean' in value) return String(value.Boolean) if ('Int' in value.Number) return String(value.Number.Int) return String(value.Number.Float) } @@ -25,7 +27,7 @@ export interface ParamDeclaration { name: string message_match_regex: string capture_group: string - kind: 'string' | 'number' | 'timestamp' | 'duration' + kind: 'string' | 'number' | 'timestamp' | 'duration' | 'boolean' } export interface TimelineBucket {