From a9855de3ad65508ed2455a3cae8c4c5f75cf4687 Mon Sep 17 00:00:00 2001
From: Hugh Kaznowski
Date: Sat, 5 Sep 2026 19:35:02 +0100
Subject: [PATCH 1/2] 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
{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 {
From 21419ae8ea79292b8b2c1b1844bd4ce5cb6bc052 Mon Sep 17 00:00:00 2001
From: Hugh Kaznowski
Date: Sat, 5 Sep 2026 19:35:32 +0100
Subject: [PATCH 2/2] Add IsError worked example to Parameters docs (ticket
019)
Shows how to declare a boolean IsError param (regex (?i)error, kind
boolean, no capture group) that's true/false on every log line, using
the boolean param kind added in ticket 018.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
---
webapp/src/components/Docs.tsx | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/webapp/src/components/Docs.tsx b/webapp/src/components/Docs.tsx
index c12b610..f9c03d9 100644
--- a/webapp/src/components/Docs.tsx
+++ b/webapp/src/components/Docs.tsx
@@ -178,6 +178,14 @@ const SECTIONS: DocSection[] = [
a metric (see Filtering above — bare params aren't queryable by name on their own, only metric
labels/values are).
+
+ Given the same kind of lines, declaring a param named IsError with regex{' '}
+ {String.raw`(?i)error`} and type boolean — no capture group needed — shows{' '}
+ IsError as true or false on every line in the log table, based on
+ whether the regex matches that line's text case-insensitively. Unlike string/number
+ /timestamp/duration params, a boolean param is never omitted for a
+ non-matching line.
+
),
},