Add json[field] filter accessor for JSON log lines (ticket 016)

Extends the FilterBar query language with a json[field] <op> value
comparison, parsed by filter_parser.pest/rs alongside metric[param],
and evaluated in engine.rs by parsing each line as JSON at query time
(lines that aren't valid JSON, or lack the field, simply don't match).
Also wired into alerts.rs since json[field], like regex/sequence/
timestamp, only needs the line's own text.

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:34:21 +01:00
co-authored by Claude Sonnet 5
parent e6e5bc8f50
commit a198eae1fd
5 changed files with 175 additions and 16 deletions
+6 -3
View File
@@ -1,6 +1,6 @@
//! Alert rules: definitions, trigger evaluation, and ntfy delivery. //! Alert rules: definitions, trigger evaluation, and ntfy delivery.
//! //!
//! Alert queries are restricted to regex/sequence/timestamp — a metric[param] comparison would require a full bucket rescan per line, breaking the proportional-cost guarantee. //! Alert queries are restricted to regex/sequence/timestamp/json[field] — a metric[param] comparison would require a full bucket rescan per line, breaking the proportional-cost guarantee. json[field] is fine here since, like regex/sequence/timestamp, it only inspects the line's own text/fields, no index lookup.
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
@@ -13,7 +13,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use regex::Regex; use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::engine::apply_compare_op; use crate::engine::{apply_compare_op, eval_json_comparison};
use crate::filter_datetime; use crate::filter_datetime;
use crate::filter_parser::{self, CompareOp, FilterExpr, FilterValue}; use crate::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
use crate::settings::Settings; use crate::settings::Settings;
@@ -155,7 +155,7 @@ fn prune_window(timestamps: &mut VecDeque<i64>, now_ms: i64, window_ms: i64) {
} }
} }
/// Evaluates a FilterBar query against a single line's own fields only (metric[param] comparisons aren't supported — see module doc). /// Evaluates a FilterBar query against a single line's own fields only (metric[param] comparisons aren't supported — see module doc; json[field] is supported since it's also line-local).
fn eval_alert_query(query: &str, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> { fn eval_alert_query(query: &str, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> {
let expr = filter_parser::parse(query).map_err(|err| err.to_string())?; let expr = filter_parser::parse(query).map_err(|err| err.to_string())?;
eval_alert_expr(&expr, sequence, timestamp, text) eval_alert_expr(&expr, sequence, timestamp, text)
@@ -189,6 +189,9 @@ fn eval_alert_expr(expr: &FilterExpr, sequence: u64, timestamp: Timestamp, text:
"alert queries only support regex()/sequence/timestamp (not metric comparisons like \ "alert queries only support regex()/sequence/timestamp (not metric comparisons like \
'{metric}[param]'): metric-based alert conditions aren't supported yet" '{metric}[param]'): metric-based alert conditions aren't supported yet"
)), )),
FilterExpr::JsonComparison { field, op, value } => {
Ok(eval_json_comparison(text, field, *op, value))
}
FilterExpr::And(lhs, rhs) => { FilterExpr::And(lhs, rhs) => {
Ok(eval_alert_expr(lhs, sequence, timestamp, text)? && eval_alert_expr(rhs, sequence, timestamp, text)?) Ok(eval_alert_expr(lhs, sequence, timestamp, text)? && eval_alert_expr(rhs, sequence, timestamp, text)?)
} }
+87
View File
@@ -482,6 +482,10 @@ enum CompiledFilter {
Sequence { op: CompareOp, target: f64 }, Sequence { op: CompareOp, target: f64 },
Timestamp { op: CompareOp, target_ms: i64 }, Timestamp { op: CompareOp, target_ms: i64 },
MetricMatch(HashSet<u64>), MetricMatch(HashSet<u64>),
/// `json[field] <op> value` — unlike `MetricMatch`, this can't be pre-resolved against the
/// index (the JSON lives in the log line itself, not a declared metric/param), so it's
/// evaluated per line in `eval_compiled` by parsing `text` as JSON.
Json { field: String, op: CompareOp, value: FilterValue },
And(Box<CompiledFilter>, Box<CompiledFilter>), And(Box<CompiledFilter>, Box<CompiledFilter>),
Or(Box<CompiledFilter>, Box<CompiledFilter>), Or(Box<CompiledFilter>, Box<CompiledFilter>),
} }
@@ -510,6 +514,11 @@ fn compile_filter(
"timestamp" => compile_timestamp_comparison(*op, value, now), "timestamp" => compile_timestamp_comparison(*op, value, now),
other => compile_metric_comparison(index, other, param.as_deref(), *op, value, now), other => compile_metric_comparison(index, other, param.as_deref(), *op, value, now),
}, },
FilterExpr::JsonComparison { field, op, value } => Ok(CompiledFilter::Json {
field: field.clone(),
op: *op,
value: value.clone(),
}),
FilterExpr::And(lhs, rhs) => Ok(CompiledFilter::And( FilterExpr::And(lhs, rhs) => Ok(CompiledFilter::And(
Box::new(compile_filter(index, lhs, now)?), Box::new(compile_filter(index, lhs, now)?),
Box::new(compile_filter(index, rhs, now)?), Box::new(compile_filter(index, rhs, now)?),
@@ -620,6 +629,59 @@ fn compare_metric_primitive(
Ok(ordering.is_some_and(|ordering| apply_compare_op(ordering, op))) Ok(ordering.is_some_and(|ordering| apply_compare_op(ordering, op)))
} }
/// `json[field] <op> value`: the log line is parsed as JSON fresh on every call (no per-line
/// cache), since this doesn't go through the index-backed pre-resolution `MetricMatch` uses.
/// Any line that isn't a JSON object, or lacks `field`, simply doesn't match — no error.
pub(crate) fn eval_json_comparison(text: &str, field: &str, op: CompareOp, value: &FilterValue) -> bool {
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(text) else {
return false;
};
let Some(field_value) = parsed.as_object().and_then(|obj| obj.get(field)) else {
return false;
};
compare_json_value(field_value, op, value)
}
/// Mirrors `compare_metric_primitive`'s "mismatched kinds never match" behavior. Only `Eq`/`Ne`
/// are meaningful for booleans, and `null`/array/object fields aren't scalar-comparable at all.
fn compare_json_value(field_value: &serde_json::Value, op: CompareOp, value: &FilterValue) -> bool {
match field_value {
serde_json::Value::Number(n) => {
let target = match value {
FilterValue::Number(v) => Some(*v),
FilterValue::Raw(raw) => raw.parse::<f64>().ok(),
};
match (n.as_f64(), target) {
(Some(f), Some(v)) => f.partial_cmp(&v).is_some_and(|ordering| apply_compare_op(ordering, op)),
_ => false,
}
}
serde_json::Value::String(s) => {
let target = match value {
FilterValue::Raw(raw) => raw.clone(),
FilterValue::Number(n) => n.to_string(),
};
s.as_str()
.partial_cmp(target.as_str())
.is_some_and(|ordering| apply_compare_op(ordering, op))
}
serde_json::Value::Bool(b) => {
let FilterValue::Raw(raw) = value else {
return false;
};
match raw.parse::<bool>() {
Ok(v) => match op {
CompareOp::Eq => *b == v,
CompareOp::Ne => *b != v,
_ => false,
},
Err(_) => false,
}
}
serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => false,
}
}
/// `pub(crate)` so `crate::alerts` can reuse it for `Threshold`/`Ratio` comparisons instead of duplicating the match. /// `pub(crate)` so `crate::alerts` can reuse it for `Threshold`/`Ratio` comparisons instead of duplicating the match.
pub(crate) fn apply_compare_op(ordering: Ordering, op: CompareOp) -> bool { pub(crate) fn apply_compare_op(ordering: Ordering, op: CompareOp) -> bool {
match op { match op {
@@ -643,6 +705,7 @@ fn eval_compiled(compiled: &CompiledFilter, sequence: u64, timestamp: Timestamp,
apply_compare_op(timestamp.as_millis().cmp(target_ms), *op) apply_compare_op(timestamp.as_millis().cmp(target_ms), *op)
} }
CompiledFilter::MetricMatch(sequences) => sequences.contains(&sequence), CompiledFilter::MetricMatch(sequences) => sequences.contains(&sequence),
CompiledFilter::Json { field, op, value } => eval_json_comparison(text, field, *op, value),
CompiledFilter::And(lhs, rhs) => { CompiledFilter::And(lhs, rhs) => {
eval_compiled(lhs, sequence, timestamp, text) && eval_compiled(rhs, sequence, timestamp, text) eval_compiled(lhs, sequence, timestamp, text) && eval_compiled(rhs, sequence, timestamp, text)
} }
@@ -1915,6 +1978,30 @@ mod tests {
} }
} }
#[test]
fn logs_query_with_json_field_comparison_filters_by_json_field() {
let mut index = BTreeMap::new();
ingest_log(&mut index, 0, r#"{"level":"error","code":500}"#);
ingest_log(&mut index, 1, r#"{"level":"info","code":200}"#);
ingest_log(&mut index, 2, "not json at all");
match handle_query(&index, logs_query(Some(r#"json[level]==error"#)), 0) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![0]);
}
_ => panic!("expected Logs response"),
}
match handle_query(&index, logs_query(Some("json[code]>=300")), 0) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![0]);
}
_ => panic!("expected Logs response"),
}
}
#[test] #[test]
fn logs_query_combines_regex_and_metric_comparison_with_and() { fn logs_query_combines_regex_and_metric_comparison_with_and() {
let mut index = BTreeMap::new(); let mut index = BTreeMap::new();
+7 -1
View File
@@ -3,6 +3,7 @@
// Supports: // Supports:
// - regex("...") raw-text regex search over log lines // - regex("...") raw-text regex search over log lines
// - metric_name[param] <op> value metric comparisons // - metric_name[param] <op> value metric comparisons
// - json[field] <op> value JSON field comparisons (line parsed as JSON)
// - sequence <op> value reserved metric name (no [param]) // - sequence <op> value reserved metric name (no [param])
// - timestamp <op> value reserved metric name (no [param]) // - timestamp <op> value reserved metric name (no [param])
// - `and` / `or` boolean combinators, with `and` binding tighter than `or` // - `and` / `or` boolean combinators, with `and` binding tighter than `or`
@@ -31,7 +32,12 @@ string_literal = ${ "\"" ~ inner_str ~ "\"" }
inner_str = @{ (escaped_char | !("\"" | "\\") ~ ANY)* } inner_str = @{ (escaped_char | !("\"" | "\\") ~ ANY)* }
escaped_char = @{ "\\" ~ ANY } escaped_char = @{ "\\" ~ ANY }
comparison = { metric ~ ("[" ~ param ~ "]")? ~ op ~ value } comparison = _{ json_comparison | metric_comparison }
// `json[field] <op> value` must be tried before `metric_comparison` since "json" is
// otherwise a syntactically valid (if never declared) metric name.
json_comparison = { "json" ~ "[" ~ param ~ "]" ~ op ~ value }
metric_comparison = { metric ~ ("[" ~ param ~ "]")? ~ op ~ value }
metric = @{ ident } metric = @{ ident }
param = @{ ident } param = @{ ident }
+75 -1
View File
@@ -23,6 +23,13 @@ pub enum FilterExpr {
op: CompareOp, op: CompareOp,
value: FilterValue, value: FilterValue,
}, },
/// `json[field] <op> value` — a comparison against a field of the line's parsed JSON.
/// Lines that aren't valid JSON (or lack the field) simply don't match.
JsonComparison {
field: String,
op: CompareOp,
value: FilterValue,
},
And(Box<FilterExpr>, Box<FilterExpr>), And(Box<FilterExpr>, Box<FilterExpr>),
Or(Box<FilterExpr>, Box<FilterExpr>), Or(Box<FilterExpr>, Box<FilterExpr>),
} }
@@ -130,7 +137,8 @@ fn build_and_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
fn build_term(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> { fn build_term(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
match pair.as_rule() { match pair.as_rule() {
Rule::regex_expr => build_regex(pair), Rule::regex_expr => build_regex(pair),
Rule::comparison => build_comparison(pair), Rule::metric_comparison => build_comparison(pair),
Rule::json_comparison => build_json_comparison(pair),
Rule::or_expr => build_or_expr(pair), Rule::or_expr => build_or_expr(pair),
other => Err(FilterParseError::new(format!( other => Err(FilterParseError::new(format!(
"unexpected grammar element: {other:?}" "unexpected grammar element: {other:?}"
@@ -187,6 +195,27 @@ fn build_comparison(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
}) })
} }
fn build_json_comparison(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
let mut inner = pair.into_inner();
let field_pair = inner
.next()
.ok_or_else(|| FilterParseError::new("malformed json[...] comparison: missing field name"))?;
let field = field_pair.as_str().to_string();
let op_pair = inner
.next()
.ok_or_else(|| FilterParseError::new("malformed json[...] comparison: missing operator"))?;
let op = parse_op(op_pair.as_str())?;
let value_pair = inner
.next()
.ok_or_else(|| FilterParseError::new("malformed json[...] comparison: missing value"))?;
let value = parse_value(value_pair.as_str());
Ok(FilterExpr::JsonComparison { field, op, value })
}
fn parse_op(s: &str) -> Result<CompareOp, FilterParseError> { fn parse_op(s: &str) -> Result<CompareOp, FilterParseError> {
match s { match s {
"<" => Ok(CompareOp::Lt), "<" => Ok(CompareOp::Lt),
@@ -282,6 +311,51 @@ mod tests {
); );
} }
#[test]
fn parses_json_field_comparison() {
let expr = parse("json[level]==error").expect("should parse");
assert_eq!(
expr,
FilterExpr::JsonComparison {
field: "level".to_string(),
op: CompareOp::Eq,
value: FilterValue::Raw("error".to_string()),
}
);
}
#[test]
fn parses_json_field_numeric_comparison() {
let expr = parse("json[status]>=500").expect("should parse");
assert_eq!(
expr,
FilterExpr::JsonComparison {
field: "status".to_string(),
op: CompareOp::Ge,
value: FilterValue::Number(500.0),
}
);
}
#[test]
fn json_field_composes_with_and_or() {
let expr = parse(r#"json[level]==error and sequence<5"#).expect("should parse");
let expected = FilterExpr::And(
Box::new(FilterExpr::JsonComparison {
field: "level".to_string(),
op: CompareOp::Eq,
value: FilterValue::Raw("error".to_string()),
}),
Box::new(FilterExpr::Comparison {
metric: "sequence".to_string(),
param: None,
op: CompareOp::Lt,
value: FilterValue::Number(5.0),
}),
);
assert_eq!(expr, expected);
}
#[test] #[test]
fn parses_and_or_with_and_binding_tighter() { fn parses_and_or_with_and_binding_tighter() {
let expr = parse(r#"regex("error") and sequence<5 or timestamp>12:00"#) let expr = parse(r#"regex("error") and sequence<5 or timestamp>12:00"#)
-11
View File
@@ -1,11 +0,0 @@
# Support filtering/querying by JSON field in the filter bar
Follow-up to the JSON log ingestion design decision (repowise decision fdb47034ad954e3ea0b898fdc2750c32).
Extend the filter query language with a JSON field accessor (e.g. `json[field] <op> value`), comparable to the existing `metric[param] <op> value` comparison, so JSON fields can be filtered without first declaring a metric/param for them.
## Acceptance criteria
- `filter_parser.pest` gains a `json[field]` (or similar) accessor usable anywhere `metric[param]` is today, for comparisons.
- The engine evaluates `json[field]` against a line's parsed JSON (when it parses as an object), consistent with how declared params/metrics are evaluated today.
- Lines that aren't valid JSON simply don't match any `json[field]` comparison (no error).
- Existing filter syntax (metric/param comparisons, regex(), sequence, timestamp, and/or) is unaffected.