Merge ticket/016-filter-by-json-field

This commit is contained in:
Hugh Kaznowski
2026-09-05 19:41:39 +01:00
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 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::{HashMap, HashSet, VecDeque};
@@ -13,7 +13,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use regex::Regex;
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_parser::{self, CompareOp, FilterExpr, FilterValue};
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> {
let expr = filter_parser::parse(query).map_err(|err| err.to_string())?;
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 \
'{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) => {
Ok(eval_alert_expr(lhs, sequence, timestamp, text)? && eval_alert_expr(rhs, sequence, timestamp, text)?)
}
+87
View File
@@ -505,6 +505,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>),
}
@@ -533,6 +537,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)?),
@@ -650,6 +659,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 {
@@ -673,6 +735,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)
}
@@ -2063,6 +2126,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();
+7 -1
View File
@@ -3,6 +3,7 @@
// Supports:
// - regex("...") raw-text regex search over log lines
// - 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])
// - timestamp <op> value reserved metric name (no [param])
// - `and` / `or` boolean combinators, with `and` binding tighter than `or`
@@ -31,7 +32,12 @@ string_literal = ${ "\"" ~ inner_str ~ "\"" }
inner_str = @{ (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 }
param = @{ ident }
+75 -1
View File
@@ -23,6 +23,13 @@ pub enum FilterExpr {
op: CompareOp,
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>),
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> {
match pair.as_rule() {
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),
other => Err(FilterParseError::new(format!(
"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> {
match s {
"<" => 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]
fn parses_and_or_with_and_binding_tighter() {
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.