Merge ticket/016-filter-by-json-field
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user