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
+87
View File
@@ -482,6 +482,10 @@ enum CompiledFilter {
Sequence { op: CompareOp, target: f64 },
Timestamp { op: CompareOp, target_ms: i64 },
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>),
Or(Box<CompiledFilter>, Box<CompiledFilter>),
}
@@ -510,6 +514,11 @@ fn compile_filter(
"timestamp" => compile_timestamp_comparison(*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(
Box::new(compile_filter(index, lhs, 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)))
}
/// `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) fn apply_compare_op(ordering: Ordering, op: CompareOp) -> bool {
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)
}
CompiledFilter::MetricMatch(sequences) => sequences.contains(&sequence),
CompiledFilter::Json { field, op, value } => eval_json_comparison(text, field, *op, value),
CompiledFilter::And(lhs, rhs) => {
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]
fn logs_query_combines_regex_and_metric_comparison_with_and() {
let mut index = BTreeMap::new();