Rename reserved sequence/timestamp params to ottyl_seq/ottyl_ts; add ottyl_fmt

Ticket 044: `sequence` and `timestamp` collided with ordinary user-declared
param names and didn't signal they're ottyl-owned, so they're renamed
(breaking change, no back-compat alias) to `ottyl_seq`/`ottyl_ts` in the
filter grammar (src/filter_parser.pest, src/filter_parser.rs), alert query
restriction/evaluation (src/alerts.rs), and the filter-bar docs/placeholder
in the webapp.

Also adds a new reserved param `ottyl_fmt` exposing which format a line was
auto-detected as ("json"/"logfmt"/"none") via a new
`crate::logfmt::detect_format`, wired into the same filter grammar and alert
restriction list as `ottyl_seq`/`ottyl_ts`. A mixed-format fixture test in
engine.rs verifies `ottyl_fmt` reflects the actual detected format per line.

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-06 01:50:16 +01:00
co-authored by Claude Sonnet 5
parent c84276937a
commit 534dc52e05
7 changed files with 179 additions and 41 deletions
+64 -11
View File
@@ -738,6 +738,9 @@ enum CompiledFilter {
Regex(Regex),
Sequence { op: CompareOp, target: f64 },
Timestamp { op: CompareOp, target_ms: i64 },
/// `ottyl_fmt <op> value` — like `Json`, this is line-local and re-detected fresh per line in
/// `eval_compiled` (`crate::logfmt::detect_format`) rather than pre-resolved against the index.
Fmt { op: CompareOp, target: String },
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
@@ -767,8 +770,9 @@ fn compile_filter(
.map(CompiledFilter::Regex)
.map_err(|err| format!("invalid regex in filter: {err}")),
FilterExpr::Comparison { metric, param, op, value } => match metric.as_str() {
"sequence" => compile_sequence_comparison(*op, value),
"timestamp" => compile_timestamp_comparison(*op, value, now),
"ottyl_seq" => compile_ottyl_seq_comparison(*op, value),
"ottyl_ts" => compile_ottyl_ts_comparison(*op, value, now),
"ottyl_fmt" => Ok(compile_ottyl_fmt_comparison(*op, value)),
other => compile_metric_comparison(index, other, param.as_deref(), *op, value, now),
},
FilterExpr::JsonComparison { field, op, value } => Ok(CompiledFilter::Json {
@@ -787,19 +791,19 @@ fn compile_filter(
}
}
/// A `Raw` value is accepted defensively (parsed as a number) even though the grammar shouldn't produce one for `sequence`.
fn compile_sequence_comparison(op: CompareOp, value: &FilterValue) -> Result<CompiledFilter, String> {
/// A `Raw` value is accepted defensively (parsed as a number) even though the grammar shouldn't produce one for `ottyl_seq`.
fn compile_ottyl_seq_comparison(op: CompareOp, value: &FilterValue) -> Result<CompiledFilter, String> {
let target = match value {
FilterValue::Number(n) => *n,
FilterValue::Raw(raw) => raw.parse::<f64>().map_err(|_| {
format!("'sequence' comparisons require a numeric value, got {raw:?}")
format!("'ottyl_seq' comparisons require a numeric value, got {raw:?}")
})?,
};
Ok(CompiledFilter::Sequence { op, target })
}
/// A `Number` is treated as raw milliseconds; a `Raw` token is resolved via `filter_datetime::parse_datetime` against `now`.
fn compile_timestamp_comparison(
fn compile_ottyl_ts_comparison(
op: CompareOp,
value: &FilterValue,
now: Timestamp,
@@ -813,7 +817,19 @@ fn compile_timestamp_comparison(
Ok(CompiledFilter::Timestamp { op, target_ms })
}
/// `metric[param] <op> value` for any metric name other than `sequence`/`timestamp` — see [`CompiledFilter`]'s doc comment.
/// A `Number` target is stringified since `ottyl_fmt`'s value ("json"/"logfmt"/"none",
/// `crate::logfmt::detect_format`) is always text; any op is accepted since the strings compare
/// lexicographically fine for `==`/`!=` (the only ops that make real sense here), same as
/// `compare_metric_primitive`'s `String` arm.
fn compile_ottyl_fmt_comparison(op: CompareOp, value: &FilterValue) -> CompiledFilter {
let target = match value {
FilterValue::Number(n) => n.to_string(),
FilterValue::Raw(raw) => raw.clone(),
};
CompiledFilter::Fmt { op, target }
}
/// `metric[param] <op> value` for any metric name other than `ottyl_seq`/`ottyl_ts`/`ottyl_fmt` — see [`CompiledFilter`]'s doc comment.
fn compile_metric_comparison(
index: &BTreeMap<IndexKey, DataValue>,
metric: &str,
@@ -985,6 +1001,9 @@ fn eval_compiled(compiled: &CompiledFilter, sequence: u64, timestamp: Timestamp,
CompiledFilter::Timestamp { op, target_ms } => {
apply_compare_op(timestamp.as_millis().cmp(target_ms), *op)
}
CompiledFilter::Fmt { op, target } => {
apply_compare_op(crate::logfmt::detect_format(text).cmp(target.as_str()), *op)
}
CompiledFilter::MetricMatch(sequences) => sequences.contains(&sequence),
CompiledFilter::Json { field, op, value } => eval_json_comparison(text, field, *op, value),
CompiledFilter::And(lhs, rhs) => {
@@ -2983,13 +3002,13 @@ mod tests {
}
#[test]
fn logs_query_with_sequence_comparison_filters_by_sequence() {
fn logs_query_with_ottyl_seq_comparison_filters_by_sequence() {
let mut index = BTreeMap::new();
for i in 0..5u64 {
ingest_log(&mut index, i, &format!("line {i}"));
}
match handle_query(&index, logs_query(Some("sequence<2")), 0) {
match handle_query(&index, logs_query(Some("ottyl_seq<2")), 0) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![0, 1]);
@@ -2999,7 +3018,7 @@ mod tests {
}
#[test]
fn logs_query_with_timestamp_comparison_filters_by_timestamp() {
fn logs_query_with_ottyl_ts_comparison_filters_by_timestamp() {
let mut index = BTreeMap::new();
for i in 0..5u64 {
let timestamp = Timestamp::from_millis(i as i64 * 1000);
@@ -3009,7 +3028,7 @@ mod tests {
);
}
match handle_query(&index, logs_query(Some("timestamp>2000")), 0) {
match handle_query(&index, logs_query(Some("ottyl_ts>2000")), 0) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![3, 4]);
@@ -3018,6 +3037,40 @@ mod tests {
}
}
#[test]
fn logs_query_with_ottyl_fmt_comparison_filters_by_detected_format_for_mixed_format_lines() {
let mut index = BTreeMap::new();
// Seeded fixture mixing all three detectable formats on purpose (ticket 044).
ingest_log(&mut index, 0, r#"{"level":"error","code":500}"#);
ingest_log(&mut index, 1, r#"ts=1788648105773 msg="entity_moved" x1=-4"#);
ingest_log(&mut index, 2, "just some plain prose here");
ingest_log(&mut index, 3, r#"{"level":"info","code":200}"#);
match handle_query(&index, logs_query(Some("ottyl_fmt==json")), 0) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![0, 3]);
}
_ => panic!("expected Logs response"),
}
match handle_query(&index, logs_query(Some("ottyl_fmt==logfmt")), 0) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![1]);
}
_ => panic!("expected Logs response"),
}
match handle_query(&index, logs_query(Some("ottyl_fmt==none")), 0) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![2]);
}
_ => panic!("expected Logs response"),
}
}
#[test]
fn logs_query_with_json_field_comparison_filters_by_json_field() {
let mut index = BTreeMap::new();