Resolve tickets 059-061: generic log-stream columns + sort helper indexes

- 059: log stream header/column detection is now generic
  whitespace-columnar splitting (2+ spaces, single tab) instead of one
  fixed six-column regex. Declared params come from whatever columns
  the header actually names, plus a trailing unlabelled message field
  that's never swallowed by the last named column.
- 060: timeline sort-by-property now walks an incrementally-maintained
  per-sort-key helper index (BTreeMap keyed so "has the param, ordered
  by value" sorts before "missing it, ordered by ottyl_seq") instead of
  materializing and re-sorting the full filtered range on every query.
- 061: helper indexes are swept for staleness on ingest and dropped
  after 5 minutes unused; a purged (or never-built) index transparently
  rebuilds via the same lazy one-time scan on the next sorted query.
- Also invalidate a param's helper index on manual re-declaration,
  removal, or ClearParams, so a redefined regex can't leave a sort
  helper index serving values derived under the old declaration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
This commit is contained in:
Hugh Kaznowski
2026-09-06 13:23:10 +01:00
co-authored by Claude Sonnet 5
parent 4ace654019
commit de9ba6f01b
5 changed files with 699 additions and 261 deletions
+400 -82
View File
@@ -164,6 +164,13 @@ pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle
// every currently-ingested log, kept in lockstep with `log_count` — incremented
// here on ingest, decremented in `evict_excess_logs` when a log is dropped.
let mut approx_bytes_total: u64 = 0;
// Ticket 060/061: one entry per param name that's ever been used as a `sort_by`, populated
// lazily on first sorted query and kept incrementally up to date on every ingest thereafter;
// swept for staleness on ingest too (see `purge_stale_sort_helper_indexes`).
let mut sort_helper_indexes: HashMap<String, SortHelperIndexEntry> = HashMap::new();
// Ticket 059: the most recently seen `log stream` header line's column names, cached across
// ingested lines since a `log stream` pipe only prints its header once, before any data line.
let mut log_stream_header: Option<crate::logfmt::LogStreamHeader> = None;
while let Some(message) = rx.recv().await {
match message {
@@ -175,9 +182,11 @@ pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle
DataValue::LogLiteral { text: text.clone() },
);
log_count += 1;
auto_declare_log_stream_params(&mut index, &text);
auto_declare_log_stream_params(&mut index, &mut log_stream_header, &text);
auto_declare_params(&mut index, &text);
apply_all_metrics(&mut index, sequence, timestamp, &text);
update_sort_helper_indexes_on_ingest(&mut sort_helper_indexes, &index, sequence, timestamp, &text);
purge_stale_sort_helper_indexes(&mut sort_helper_indexes);
evict_excess_logs(&mut index, &mut log_count, max_logs, &mut approx_bytes_total);
// Evaluate alert rules against just this line, then hand any fired rule off to delivery via tokio::spawn so it stays off the hot path.
@@ -212,16 +221,26 @@ pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle
evict_excess_logs(&mut index, &mut log_count, max_logs, &mut approx_bytes_total);
}
EngineMessage::DeclareParam { request, respond_to } => {
// A manual re-declaration can change the regex/kind under the same name — drop any
// helper index built under the old declaration so a sorted query rebuilds it fresh
// rather than serving values derived from the now-stale regex.
sort_helper_indexes.remove(&request.name);
let result = declare_param(&mut index, request);
let _ = respond_to.send(result);
}
EngineMessage::RemoveParamDeclaration { name, respond_to } => {
let existed = index.remove(&IndexKey::ParamDeclaration { name }).is_some();
let existed = index.remove(&IndexKey::ParamDeclaration { name: name.clone() }).is_some();
// A helper index built under the old declaration (or a same-named one re-declared
// with a different regex/kind later) would otherwise keep serving stale derived
// values until ticket 061's sweep happens to purge it — dropping it here instead
// means the next sorted query for this param rebuilds it fresh.
sort_helper_indexes.remove(&name);
let _ = respond_to.send(existed);
}
EngineMessage::ClearParams { respond_to } => {
let removed = clear_all_param_declarations(&mut index);
let (reextracted, logs_considered) = reextract_params_from_retained_logs(&mut index);
sort_helper_indexes.clear();
let _ = respond_to.send(ClearParamsResult { removed, reextracted, logs_considered });
}
EngineMessage::DeclareMetric { request, respond_to } => {
@@ -237,7 +256,7 @@ pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle
let _ = respond_to.send(existed);
}
EngineMessage::Query { query, respond_to } => {
let response = handle_query(&index, query, approx_bytes_total);
let response = handle_query(&index, query, approx_bytes_total, &mut sort_helper_indexes);
let _ = respond_to.send(response);
}
}
@@ -438,43 +457,68 @@ fn auto_declare_params(index: &mut BTreeMap<IndexKey, DataValue>, text: &str) {
}
}
/// Declares `log_type`/`process`/`pid` as standalone params the first time a `log stream`-shaped line
/// is seen (ticket 050/054) — `Type`, the process name, and `PID` are low-cardinality, genuinely
/// useful columns derivable directly from `log stream`'s fixed-column shape
/// (`crate::logfmt::is_log_stream_line`), with none of the `=`-scanning ambiguity `auto_declare_params`
/// has to guard against. Gated on that shape so ordinary logfmt/JSON/prose input never gets these three
/// params. Unlike `auto_declare_params`, adding these is never *skipped* on account of
/// `MAX_AUTO_DECLARED_PARAMS` — it can only ever add exactly these three names, once each — though
/// since they're stored as `auto_derived` too, once present they do count against that shared ceiling,
/// tightening (slightly) the budget left for generic `key=value` pairs. Like `auto_declare_params`,
/// never overwrites an existing declaration (manual or auto) for any of the three names.
fn auto_declare_log_stream_params(index: &mut BTreeMap<IndexKey, DataValue>, text: &str) {
/// Declares one param per column of the most-recently-seen `log stream` header line, plus a trailing
/// unlabelled `message` param for whatever text follows the last named column (ticket 059) — replacing
/// the old fixed three (`log_type`/`process`/`pid`) with whatever columns the header actually names.
/// `header` is threaded through by the caller (`run`'s ingest loop) rather than owned here: a
/// `log stream` pipe prints its header once, before any data line, and this function only ever sees
/// one ingested line at a time, so the header has to be cached across calls somewhere. A later header
/// line (e.g. a fresh `log stream` invocation piped into the same run) simply replaces the cached one.
/// Unlike `auto_declare_params`, adding these is never *skipped* on account of
/// `MAX_AUTO_DECLARED_PARAMS` — though since they're stored as `auto_derived` too, once present they do
/// count against that shared ceiling, tightening (slightly) the budget left for generic `key=value`
/// pairs. Like `auto_declare_params`, never overwrites an existing declaration (manual or auto) for
/// any of these names.
fn auto_declare_log_stream_params(
index: &mut BTreeMap<IndexKey, DataValue>,
header: &mut Option<crate::logfmt::LogStreamHeader>,
text: &str,
) {
if let Some(detected) = crate::logfmt::detect_log_stream_header(text) {
*header = Some(detected);
return;
}
let Some(header) = header.as_ref() else {
return;
};
if !crate::logfmt::is_log_stream_line(text) {
return;
}
let candidates: [(&str, &str, &str, MetricPrimitiveKind); 3] = [
("log_type", crate::logfmt::LOG_STREAM_TYPE_PATTERN, "log_type", MetricPrimitiveKind::String),
("process", crate::logfmt::LOG_STREAM_PROCESS_PATTERN, "process", MetricPrimitiveKind::String),
("pid", crate::logfmt::LOG_STREAM_PID_PATTERN, "pid", MetricPrimitiveKind::Number),
];
for (name, pattern, group, kind) in candidates {
let index_key = IndexKey::ParamDeclaration { name: name.to_string() };
let row = crate::logfmt::split_log_stream_row(text, header);
for (i, column) in header.columns.iter().enumerate() {
let Some(value) = row.get(i) else { continue };
let index_key = IndexKey::ParamDeclaration { name: column.name.clone() };
if index.contains_key(&index_key) {
continue;
}
index.insert(
index_key,
DataValue::ParamDeclaration(ParamDeclaration {
name: name.to_string(),
message_match_regex: pattern.to_string(),
capture_group: group.to_string(),
name: column.name.clone(),
message_match_regex: crate::logfmt::log_stream_column_pattern(header, i, &column.name),
capture_group: column.name.clone(),
json_field_path: None,
kind,
kind: infer_kind(value.trim()),
elapsed_since_last: false,
auto_derived: true,
}),
);
}
if let Some(message) = row.get(header.columns.len()).filter(|m| !m.trim().is_empty()) {
index.entry(IndexKey::ParamDeclaration { name: "message".to_string() }).or_insert_with(|| {
DataValue::ParamDeclaration(ParamDeclaration {
name: "message".to_string(),
message_match_regex: crate::logfmt::log_stream_message_pattern(header, "message"),
capture_group: "message".to_string(),
json_field_path: None,
kind: infer_kind(message.trim()),
elapsed_since_last: false,
auto_derived: true,
})
});
}
}
/// Ticket 052: removes every `IndexKey::ParamDeclaration` entry — manual and auto-derived alike.
@@ -1285,6 +1329,103 @@ fn param_declarations_range(
/// meaningful reading as a standalone logs-table column (its value depends on which metric's label
/// set it's grouped under, which only exists once it's referenced by a metric's `label_params`/
/// `value_param`).
/// Ticket 060: key type for a sort helper index — one such index exists per param name that's ever
/// been used as a `sort_by`. Declaration order matters for the derived `Ord`: every `WithParam` key
/// sorts before every `WithoutParam` key, and within each variant ordering falls out of the tuple
/// fields (value then sequence; sequence alone), which is exactly `sort_missing_fallback`'s "sorted
/// lines first, then lines missing the param ordered by `ottyl_seq`" semantics — expressed as part of
/// the index's own ordering instead of a separate partition-and-append step per query.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum SortHelperKey {
WithParam(MetricPrimitive, u64),
WithoutParam(u64),
}
/// One param's helper index plus when a query last actually read from it — ticket 061 sweeps entries
/// whose `last_accessed` is stale. The map's value is that line's `Timestamp`, not `()`, purely so a
/// query can reconstruct the exact `IndexKey::IngestedLog { sequence, timestamp }` primary-index key
/// and look its text up in O(log n) instead of re-scanning `logs_range` to find it by sequence alone.
struct SortHelperIndexEntry {
index: BTreeMap<SortHelperKey, Timestamp>,
last_accessed: std::time::Instant,
}
/// How long a sort helper index may sit unread before ticket 061's sweep drops it. Not exposed via
/// `Settings` (ticket 061 explicitly scopes that out) — a dropped index is always transparently
/// rebuildable on the next query for that sort key, so this is purely a memory-bound knob, not a
/// user-visible behavior change.
const SORT_HELPER_INDEX_PURGE_AFTER: std::time::Duration = std::time::Duration::from_secs(300);
/// Derives a single named param's value from `text`, for helper-index maintenance where deriving
/// every declared param (`derive_log_params`) per ingested line would be wasted work when only one
/// sort key's helper index actually needs updating.
fn derive_single_param(
text: &str,
declarations: &[(String, Regex, String, Option<String>, MetricPrimitiveKind)],
param_name: &str,
) -> Option<MetricPrimitive> {
let (_, regex, capture_group, json_field_path, kind) =
declarations.iter().find(|(name, ..)| name == param_name)?;
if *kind == MetricPrimitiveKind::Boolean {
return Some(MetricPrimitive::Boolean(regex.is_match(text)));
}
let captures = regex.captures(text)?;
let raw: String = match json_field_path.as_deref().filter(|path| !path.is_empty()) {
Some(field_path) => crate::data_value::extract_json_field(text, field_path)?,
None => captures.name(capture_group)?.as_str().to_string(),
};
parse_metric_primitive(&raw, *kind)
}
/// One-time full scan building a sort helper index for `sort_by` from every currently-ingested log —
/// used both the first time a param is sorted by (no helper index exists yet) and to transparently
/// rebuild one ticket 061's sweep purged.
fn build_sort_helper_index(index: &BTreeMap<IndexKey, DataValue>, sort_by: &str) -> BTreeMap<SortHelperKey, Timestamp> {
let compiled_params = compiled_param_declarations(index);
logs_range(index)
.map(|(sequence, timestamp, text)| {
let key = match derive_single_param(text, &compiled_params, sort_by) {
Some(value) => SortHelperKey::WithParam(value, sequence),
None => SortHelperKey::WithoutParam(sequence),
};
(key, timestamp)
})
.collect()
}
/// Incrementally extends every currently-live helper index with one freshly-ingested line, called
/// from `EngineMessage::Ingest` right alongside the primary index insert (ticket 060) — this is what
/// lets a sorted query avoid re-deriving params for the whole timeline on every call once a helper
/// index for that sort key exists. A no-op when no helper index has been built yet (the common case
/// for a param nobody has sorted by), so ingest pays nothing for sort keys that are never queried.
fn update_sort_helper_indexes_on_ingest(
sort_helper_indexes: &mut HashMap<String, SortHelperIndexEntry>,
index: &BTreeMap<IndexKey, DataValue>,
sequence: u64,
timestamp: Timestamp,
text: &str,
) {
if sort_helper_indexes.is_empty() {
return;
}
let compiled_params = compiled_param_declarations(index);
for (sort_by, entry) in sort_helper_indexes.iter_mut() {
let key = match derive_single_param(text, &compiled_params, sort_by) {
Some(value) => SortHelperKey::WithParam(value, sequence),
None => SortHelperKey::WithoutParam(sequence),
};
entry.index.insert(key, timestamp);
}
}
/// Ticket 061: drops any helper index whose `last_accessed` exceeds `SORT_HELPER_INDEX_PURGE_AFTER`.
/// Cheap to call opportunistically on every ingest (`HashMap::retain` is a no-op scan when nothing's
/// stale) rather than needing a dedicated timer/task — a purged index rebuilds transparently via
/// `build_sort_helper_index` the next time something sorts by it.
fn purge_stale_sort_helper_indexes(sort_helper_indexes: &mut HashMap<String, SortHelperIndexEntry>) {
sort_helper_indexes.retain(|_, entry| entry.last_accessed.elapsed() < SORT_HELPER_INDEX_PURGE_AFTER);
}
fn compiled_param_declarations(
index: &BTreeMap<IndexKey, DataValue>,
) -> Vec<(String, Regex, String, Option<String>, MetricPrimitiveKind)> {
@@ -1409,6 +1550,7 @@ fn handle_query(
index: &BTreeMap<IndexKey, DataValue>,
query: EngineQuery,
approx_bytes_total: u64,
sort_helper_indexes: &mut HashMap<String, SortHelperIndexEntry>,
) -> EngineResponse {
match query {
EngineQuery::Logs(params) => {
@@ -1453,34 +1595,64 @@ fn handle_query(
params: derive_log_params(text, &compiled_params),
})
.collect(),
// An alternate sort key needs every matching line's params derived up front (there's
// no way to know which lines will end up in the first `limit` before sorting), then
// reordered before the limit is finally applied.
// Ticket 060: walk the (lazily built-or-reused) helper index for this sort key in
// key order instead of materializing and sorting the full filtered set — the index's
// own key ordering already expresses "sorted-by-value lines, then lines missing the
// param ordered by ottyl_seq" (`sort_missing_fallback`'s semantics), so this just
// filters/limits as it walks rather than partition-sort-concat afterward.
Some(sort_by) => {
let mut all: Vec<LogEntryDto> = filtered
.map(|(sequence, timestamp, text)| LogEntryDto {
let helper = sort_helper_indexes.entry(sort_by.to_string()).or_insert_with(|| {
SortHelperIndexEntry {
index: build_sort_helper_index(index, sort_by),
last_accessed: std::time::Instant::now(),
}
});
helper.last_accessed = std::time::Instant::now();
let sort_missing_fallback = params.sort_missing_fallback.unwrap_or(true);
let mut entries = Vec::new();
for (helper_key, timestamp) in helper.index.iter() {
let sequence = match helper_key {
SortHelperKey::WithParam(_, sequence) => *sequence,
SortHelperKey::WithoutParam(sequence) => {
if !sort_missing_fallback {
continue;
}
*sequence
}
};
if !(params.since_sequence.is_none_or(|since| sequence > since)
&& params.start_ms.is_none_or(|start| timestamp.as_millis() >= start)
&& params.end_ms.is_none_or(|end| timestamp.as_millis() <= end))
{
continue;
}
// A line evicted (by `max_logs`) since this key was recorded is simply
// skipped, same as buckets/other derived data aren't retroactively pruned
// on eviction elsewhere in this file.
let Some(DataValue::LogLiteral { text }) =
index.get(&IndexKey::IngestedLog { sequence, timestamp: *timestamp })
else {
continue;
};
if !compiled_filter
.as_ref()
.is_none_or(|filter| eval_compiled(filter, sequence, *timestamp, text))
{
continue;
}
entries.push(LogEntryDto {
sequence,
timestamp_ms: timestamp.as_millis(),
text: text.to_string(),
text: text.clone(),
params: derive_log_params(text, &compiled_params),
})
.collect();
let sort_missing_fallback = params.sort_missing_fallback.unwrap_or(true);
if sort_missing_fallback {
let (mut with_param, mut without_param): (Vec<_>, Vec<_>) =
all.drain(..).partition(|entry| entry.params.contains_key(sort_by));
with_param.sort_by(|a, b| a.params[sort_by].cmp(&b.params[sort_by]));
// Lines missing the sort param are only ordered relative to each other (by
// `ottyl_seq`) and appended after the sorted lines, rather than interleaved
// into the overall ordering — see ticket 045.
without_param.sort_by_key(|entry| entry.sequence);
with_param.into_iter().chain(without_param).collect()
} else {
all.retain(|entry| entry.params.contains_key(sort_by));
all.sort_by(|a, b| a.params[sort_by].cmp(&b.params[sort_by]));
all
});
if entries.len() >= limit {
break;
}
}
entries
}
};
let entries: Vec<LogEntryDto> = entries.into_iter().take(limit).collect();
EngineResponse::Logs(entries)
@@ -2106,8 +2278,9 @@ mod tests {
/// Runs both auto-declaration passes over every fixture line, same order as `EngineMessage::Ingest`.
fn ingest_log_stream_fixture(index: &mut BTreeMap<IndexKey, DataValue>) {
let mut header = None;
for line in LOG_STREAM_FIXTURE.lines() {
auto_declare_log_stream_params(index, line);
auto_declare_log_stream_params(index, &mut header, line);
auto_declare_params(index, line);
}
}
@@ -2115,13 +2288,13 @@ mod tests {
#[test]
fn ingesting_log_stream_fixture_does_not_blow_up_declared_param_count() {
// Ticket 050: unbounded auto-declaration on this real `log stream` dump produced 220 distinct
// params. The bound (MAX_AUTO_DECLARED_PARAMS + the 3 fixed log_stream params) must keep the
// total in the tens, nowhere near "hundreds".
// params. The bound (MAX_AUTO_DECLARED_PARAMS + the header-derived log_stream columns) must
// keep the total in the tens, nowhere near "hundreds".
let mut index = BTreeMap::new();
ingest_log_stream_fixture(&mut index);
let declared = param_declarations_range(&index).count();
// `log_type`/`process`/`pid` are stored as `auto_derived` too, so the shared
// The header-derived columns (ticket 059) are stored as `auto_derived` too, so the shared
// `MAX_AUTO_DECLARED_PARAMS` ceiling covers all of them together (see
// `auto_declare_log_stream_params`'s doc comment).
assert!(
@@ -2144,29 +2317,38 @@ mod tests {
}
#[test]
fn log_stream_shaped_lines_expose_type_process_and_pid_as_queryable_params() {
fn log_stream_shaped_lines_expose_header_named_columns_as_queryable_params() {
let mut index = BTreeMap::new();
ingest_log_stream_fixture(&mut index);
let declared: HashSet<&str> = param_declarations_range(&index).map(|d| d.0).collect();
assert!(declared.contains("log_type"));
assert!(declared.contains("process"));
assert!(declared.contains("pid"));
// Derived generically from the fixture's own header row (ticket 059), not a fixed set of three.
for name in ["Timestamp", "Thread", "Type", "Activity", "PID", "TTL", "message"] {
assert!(declared.contains(name), "expected {name} to be declared, got {declared:?}");
}
let compiled = compiled_param_declarations(&index);
let data_line = LOG_STREAM_FIXTURE.lines().nth(1).unwrap();
let params = derive_log_params(data_line, &compiled);
assert_eq!(params.get("log_type"), Some(&MetricPrimitive::String("Default".to_string())));
assert_eq!(params.get("process"), Some(&MetricPrimitive::String("kitty".to_string())));
assert_eq!(params.get("pid"), Some(&MetricPrimitive::Number(Number::Int(1343))));
assert_eq!(params.get("Type"), Some(&MetricPrimitive::String("Default".to_string())));
assert_eq!(params.get("PID"), Some(&MetricPrimitive::Number(Number::Int(1343))));
assert_eq!(params.get("TTL"), Some(&MetricPrimitive::Number(Number::Int(7))));
assert_eq!(
params.get("message"),
Some(&MetricPrimitive::String(
"kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active".to_string()
))
);
}
#[test]
fn non_log_stream_lines_do_not_get_log_type_process_or_pid_declared() {
fn non_log_stream_lines_do_not_get_any_columns_declared_without_a_cached_header() {
let mut index = BTreeMap::new();
auto_declare_log_stream_params(&mut index, r#"ts=1788648105773 msg="entity_moved" x1=-4"#);
let mut header = None;
auto_declare_log_stream_params(&mut index, &mut header, r#"ts=1788648105773 msg="entity_moved" x1=-4"#);
assert!(param_declarations_range(&index).next().is_none());
assert!(header.is_none());
}
#[test]
@@ -2176,8 +2358,9 @@ mod tests {
let mut index = BTreeMap::new();
let start = std::time::Instant::now();
for _ in 0..repeats {
let mut header = None;
for line in LOG_STREAM_FIXTURE.lines() {
auto_declare_log_stream_params(&mut index, line);
auto_declare_log_stream_params(&mut index, &mut header, line);
auto_declare_params(&mut index, line);
}
}
@@ -3578,6 +3761,7 @@ mod tests {
end_ms: None,
},
0,
&mut HashMap::new(),
);
match response {
EngineResponse::Promql(Err(message)) => assert!(!message.is_empty()),
@@ -3599,6 +3783,7 @@ mod tests {
end_ms: None,
},
0,
&mut HashMap::new(),
);
match response {
EngineResponse::Promql(Ok(series)) => {
@@ -3691,6 +3876,7 @@ mod tests {
&index,
EngineQuery::Promql { query: "count(ottyl_seq)".to_string(), start_ms: None, end_ms: None },
0,
&mut HashMap::new(),
);
match response {
EngineResponse::Promql(Ok(series)) => {
@@ -3753,7 +3939,7 @@ mod tests {
ingest_log(&mut index, 2, "still fine");
ingest_log(&mut index, 3, "another error here");
match handle_query(&index, logs_query(Some(r#"regex("error")"#)), 0) {
match handle_query(&index, logs_query(Some(r#"regex("error")"#)), 0, &mut HashMap::new()) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![1, 3]);
@@ -3769,7 +3955,7 @@ mod tests {
ingest_log(&mut index, i, &format!("line {i}"));
}
match handle_query(&index, logs_query(Some("ottyl_seq<2")), 0) {
match handle_query(&index, logs_query(Some("ottyl_seq<2")), 0, &mut HashMap::new()) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![0, 1]);
@@ -3789,7 +3975,7 @@ mod tests {
);
}
match handle_query(&index, logs_query(Some("ottyl_ts>2000")), 0) {
match handle_query(&index, logs_query(Some("ottyl_ts>2000")), 0, &mut HashMap::new()) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![3, 4]);
@@ -3816,7 +4002,7 @@ mod tests {
ingest_log(&mut index, 1, "priority=1 boom");
ingest_log(&mut index, 2, "priority=2 boom");
match handle_query(&index, logs_query_sorted("priority", None), 0) {
match handle_query(&index, logs_query_sorted("priority", None), 0, &mut HashMap::new()) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![1, 2, 0]);
@@ -3844,7 +4030,7 @@ mod tests {
ingest_log(&mut index, 2, "priority=1 boom"); // has param
ingest_log(&mut index, 3, "also missing"); // missing
match handle_query(&index, logs_query_sorted("priority", Some(true)), 0) {
match handle_query(&index, logs_query_sorted("priority", Some(true)), 0, &mut HashMap::new()) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
// Lines with the param sorted ascending by value (2 then 0), followed by the
@@ -3870,7 +4056,7 @@ mod tests {
ingest_log(&mut index, 2, "priority=1 boom");
ingest_log(&mut index, 3, "also missing");
match handle_query(&index, logs_query_sorted("priority", Some(false)), 0) {
match handle_query(&index, logs_query_sorted("priority", Some(false)), 0, &mut HashMap::new()) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![2, 0]);
@@ -3884,7 +4070,7 @@ mod tests {
let mut index = BTreeMap::new();
ingest_log(&mut index, 0, "hello");
match handle_query(&index, logs_query_sorted("does_not_exist", None), 0) {
match handle_query(&index, logs_query_sorted("does_not_exist", None), 0, &mut HashMap::new()) {
EngineResponse::Error(err) => assert!(err.contains("does_not_exist")),
_ => panic!("expected EngineResponse::Error"),
}
@@ -3908,7 +4094,7 @@ mod tests {
ingest_log(&mut index, 2, "priority=1 boom");
ingest_log(&mut index, 3, "also missing");
match handle_query(&index, logs_query(None), 0) {
match handle_query(&index, logs_query(None), 0, &mut HashMap::new()) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![0, 1, 2, 3]);
@@ -3917,6 +4103,136 @@ mod tests {
}
}
// -- Ticket 060: sort helper index --
#[test]
fn sort_helper_index_is_built_lazily_and_reused_across_queries() {
let mut index = BTreeMap::new();
declare_test_param(
&mut index,
"priority",
r"priority=(?P<priority>\d+)",
"priority",
MetricPrimitiveKind::Number,
);
ingest_log(&mut index, 0, "priority=3 boom");
ingest_log(&mut index, 1, "priority=1 boom");
ingest_log(&mut index, 2, "priority=2 boom");
let mut sort_helper_indexes = HashMap::new();
assert!(!sort_helper_indexes.contains_key("priority"));
// First query has no helper index yet — must lazily build one rather than erroring or
// silently returning unsorted results.
match handle_query(&index, logs_query_sorted("priority", None), 0, &mut sort_helper_indexes) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![1, 2, 0]);
}
_ => panic!("expected Logs response"),
}
assert!(sort_helper_indexes.contains_key("priority"));
assert_eq!(sort_helper_indexes["priority"].index.len(), 3);
// Ingest more lines directly into the already-built helper index (bypassing the full
// `EngineMessage::Ingest` actor loop, same as every other test in this file exercises
// `handle_query` directly) via the same incremental-update path `run()` calls on ingest,
// then requery — the new lines must appear correctly ordered without the helper index
// having been thrown away and rebuilt from scratch.
ingest_log(&mut index, 3, "priority=0 boom");
update_sort_helper_indexes_on_ingest(
&mut sort_helper_indexes,
&index,
3,
Timestamp::from_millis(3),
"priority=0 boom",
);
ingest_log(&mut index, 4, "no priority here");
update_sort_helper_indexes_on_ingest(
&mut sort_helper_indexes,
&index,
4,
Timestamp::from_millis(4),
"no priority here",
);
assert_eq!(sort_helper_indexes["priority"].index.len(), 5);
match handle_query(&index, logs_query_sorted("priority", None), 0, &mut sort_helper_indexes) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
// 3 (priority=0) sorts before the original three; 4 (missing the param) falls back
// to ottyl_seq order, appended after every line that has the param.
assert_eq!(sequences, vec![3, 1, 2, 0, 4]);
}
_ => panic!("expected Logs response"),
}
}
#[test]
fn sort_helper_index_incremental_update_is_a_noop_when_no_index_is_live() {
// Guards `update_sort_helper_indexes_on_ingest`'s early return: ingest must not pay to
// derive a param nobody has ever sorted by.
let mut sort_helper_indexes: HashMap<String, SortHelperIndexEntry> = HashMap::new();
let index = BTreeMap::new();
update_sort_helper_indexes_on_ingest(&mut sort_helper_indexes, &index, 0, Timestamp::from_millis(0), "hello");
assert!(sort_helper_indexes.is_empty());
}
// -- Ticket 061: purging stale sort helper indexes --
#[test]
fn purge_stale_sort_helper_indexes_drops_only_entries_past_the_purge_duration() {
let mut sort_helper_indexes: HashMap<String, SortHelperIndexEntry> = HashMap::new();
sort_helper_indexes.insert(
"stale".to_string(),
SortHelperIndexEntry {
index: BTreeMap::new(),
last_accessed: std::time::Instant::now() - SORT_HELPER_INDEX_PURGE_AFTER
- std::time::Duration::from_secs(1),
},
);
sort_helper_indexes.insert(
"fresh".to_string(),
SortHelperIndexEntry { index: BTreeMap::new(), last_accessed: std::time::Instant::now() },
);
purge_stale_sort_helper_indexes(&mut sort_helper_indexes);
assert!(!sort_helper_indexes.contains_key("stale"));
assert!(sort_helper_indexes.contains_key("fresh"));
}
#[test]
fn sorted_query_transparently_rebuilds_a_purged_helper_index() {
let mut index = BTreeMap::new();
declare_test_param(
&mut index,
"priority",
r"priority=(?P<priority>\d+)",
"priority",
MetricPrimitiveKind::Number,
);
ingest_log(&mut index, 0, "priority=3 boom");
ingest_log(&mut index, 1, "priority=1 boom");
ingest_log(&mut index, 2, "priority=2 boom");
let mut sort_helper_indexes = HashMap::new();
let _ = handle_query(&index, logs_query_sorted("priority", None), 0, &mut sort_helper_indexes);
assert!(sort_helper_indexes.contains_key("priority"));
// Simulate ticket 061's sweep having purged this entry for staleness.
sort_helper_indexes.remove("priority");
match handle_query(&index, logs_query_sorted("priority", None), 0, &mut sort_helper_indexes) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![1, 2, 0]);
}
_ => panic!("expected Logs response"),
}
assert!(sort_helper_indexes.contains_key("priority"));
}
#[test]
fn logs_query_with_ottyl_fmt_comparison_filters_by_detected_format_for_mixed_format_lines() {
let mut index = BTreeMap::new();
@@ -3926,7 +4242,7 @@ mod tests {
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) {
match handle_query(&index, logs_query(Some("ottyl_fmt==json")), 0, &mut HashMap::new()) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![0, 3]);
@@ -3934,7 +4250,7 @@ mod tests {
_ => panic!("expected Logs response"),
}
match handle_query(&index, logs_query(Some("ottyl_fmt==logfmt")), 0) {
match handle_query(&index, logs_query(Some("ottyl_fmt==logfmt")), 0, &mut HashMap::new()) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![1]);
@@ -3942,7 +4258,7 @@ mod tests {
_ => panic!("expected Logs response"),
}
match handle_query(&index, logs_query(Some("ottyl_fmt==none")), 0) {
match handle_query(&index, logs_query(Some("ottyl_fmt==none")), 0, &mut HashMap::new()) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![2]);
@@ -3958,7 +4274,7 @@ mod tests {
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) {
match handle_query(&index, logs_query(Some(r#"json[level]==error"#)), 0, &mut HashMap::new()) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![0]);
@@ -3966,7 +4282,7 @@ mod tests {
_ => panic!("expected Logs response"),
}
match handle_query(&index, logs_query(Some("json[code]>=300")), 0) {
match handle_query(&index, logs_query(Some("json[code]>=300")), 0, &mut HashMap::new()) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![0]);
@@ -3991,6 +4307,7 @@ mod tests {
&index,
logs_query(Some(r#"regex("boom") and requests[level]==error"#)),
0,
&mut HashMap::new(),
) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
@@ -4004,6 +4321,7 @@ mod tests {
&index,
logs_query(Some(r#"regex("boom") or requests[level]==error"#)),
0,
&mut HashMap::new(),
) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
@@ -4020,7 +4338,7 @@ mod tests {
ingest_log(&mut index, 0, "level=info hello");
ingest_log(&mut index, 1, "no match here");
let EngineResponse::Logs(entries) = handle_query(&index, logs_query(None), 0) else {
let EngineResponse::Logs(entries) = handle_query(&index, logs_query(None), 0, &mut HashMap::new()) else {
panic!("expected Logs response");
};
@@ -4042,7 +4360,7 @@ mod tests {
ingest_log(&mut index, 0, "an ERROR occurred");
ingest_log(&mut index, 1, "everything is fine");
let EngineResponse::Logs(entries) = handle_query(&index, logs_query(None), 0) else {
let EngineResponse::Logs(entries) = handle_query(&index, logs_query(None), 0, &mut HashMap::new()) else {
panic!("expected Logs response");
};
@@ -4058,7 +4376,7 @@ mod tests {
ingest_log(&mut index, 1, "an error occurred");
ingest_log(&mut index, 2, "another error here");
match handle_query(&index, timeline_query(Some(r#"regex("error")"#), 1_000_000), 0) {
match handle_query(&index, timeline_query(Some(r#"regex("error")"#), 1_000_000), 0, &mut HashMap::new()) {
EngineResponse::Timeline(buckets) => {
let total: u64 = buckets.iter().map(|b| b.count).sum();
assert_eq!(total, 2);
@@ -4072,12 +4390,12 @@ mod tests {
let mut index = BTreeMap::new();
ingest_log(&mut index, 0, "hello");
match handle_query(&index, logs_query(Some("@@@ not a valid filter @@@")), 0) {
match handle_query(&index, logs_query(Some("@@@ not a valid filter @@@")), 0, &mut HashMap::new()) {
EngineResponse::Error(message) => assert!(!message.is_empty()),
_ => panic!("expected an Error response for a malformed filter string"),
}
match handle_query(&index, timeline_query(Some("@@@ not a valid filter @@@"), 1000), 0) {
match handle_query(&index, timeline_query(Some("@@@ not a valid filter @@@"), 1000), 0, &mut HashMap::new()) {
EngineResponse::Error(message) => assert!(!message.is_empty()),
_ => panic!("expected an Error response for a malformed filter string"),
}
@@ -4091,7 +4409,7 @@ mod tests {
declare_metric(&mut index, metric_request("requests", pattern, vec!["level"])).unwrap();
ingest_log(&mut index, 0, "level=error");
match handle_query(&index, logs_query(Some("requests==5")), 0) {
match handle_query(&index, logs_query(Some("requests==5")), 0, &mut HashMap::new()) {
EngineResponse::Error(message) => assert!(message.contains("param")),
_ => panic!("expected an Error response for a metric comparison without [param]"),
}
@@ -4106,7 +4424,7 @@ mod tests {
declare_metric(&mut index, metric_request("requests", pattern, vec!["level", "duration"])).unwrap();
ingest_log(&mut index, 0, "level=error duration=5");
match handle_query(&index, logs_query(Some("requests[nonexistent_param]==\"x\"")), 0) {
match handle_query(&index, logs_query(Some("requests[nonexistent_param]==\"x\"")), 0, &mut HashMap::new()) {
EngineResponse::Error(message) => {
assert!(
message.contains("nonexistent_param"),
@@ -4136,7 +4454,7 @@ mod tests {
ingest_log(&mut index, 0, "level=error duration=5");
ingest_log(&mut index, 1, "level=info duration=2");
match handle_query(&index, logs_query(Some("requests[level]==error")), 0) {
match handle_query(&index, logs_query(Some("requests[level]==error")), 0, &mut HashMap::new()) {
EngineResponse::Logs(entries) => {
let sequences: Vec<u64> = entries.iter().map(|e| e.sequence).collect();
assert_eq!(sequences, vec![0]);
+299 -17
View File
@@ -101,16 +101,223 @@ pub fn is_log_stream_line(line: &str) -> bool {
LOG_STREAM_RE.is_match(line)
}
/// `message_match_regex` patterns `crate::engine::auto_declare_log_stream_params` uses to declare
/// `log_type`/`process`/`pid` as standalone params the first time a `log stream`-shaped line is seen.
/// Each isolates exactly one named capture group (matching `derive_log_params`'s one-group-per-param
/// convention) over the same column layout `LOG_STREAM_RE` detects.
pub const LOG_STREAM_TYPE_PATTERN: &str =
r"^\d{4}-\d{2}-\d{2}\s+\S+\s+0x[0-9a-fA-F]+\s+(?P<log_type>\S+)\s+0x[0-9a-fA-F]+\s+\d+\s+\d+\s+[^\s:]+:\s";
pub const LOG_STREAM_PROCESS_PATTERN: &str =
r"^\d{4}-\d{2}-\d{2}\s+\S+\s+0x[0-9a-fA-F]+\s+\S+\s+0x[0-9a-fA-F]+\s+\d+\s+\d+\s+(?P<process>[^\s:]+):\s";
pub const LOG_STREAM_PID_PATTERN: &str =
r"^\d{4}-\d{2}-\d{2}\s+\S+\s+0x[0-9a-fA-F]+\s+\S+\s+0x[0-9a-fA-F]+\s+(?P<pid>\d+)\s+\d+\s+[^\s:]+:\s";
/// Regex fragment for one column separator under the generic whitespace-columnar rule ticket 059
/// asks for: a run of 2+ whitespace chars, or a lone tab, separates columns; a single plain space does
/// not (so a value that legitimately contains one -- e.g. `log stream`'s `date time+tz` timestamp --
/// stays intact within its own column). `is_separator_run` is the same rule applied at split time;
/// this is that rule re-expressed as a regex fragment for the trailing message field's
/// `message_match_regex`, which re-derives its value the same way whenever a caller queries by it
/// later (declarations don't cache extracted values -- see `crate::engine::extract_param_raw`).
const COLUMN_SEPARATOR_PATTERN: &str = r"(?:\t|[ \t]{2,})";
/// A column's value: one or more non-whitespace tokens joined by single spaces, with no upper bound
/// on length. Used for the trailing (unbounded-width) message field and the last named column, where
/// there's no next column's start position to bound against.
const COLUMN_VALUE_PATTERN: &str = r"\S+(?: \S+)*";
/// Same idea as `COLUMN_VALUE_PATTERN`, but bounded to a known column `width` (in chars) -- needed for
/// every column except the last, since two adjacent `log stream` values can legitimately be separated
/// by only a single space when the first one nearly fills its column's on-screen width (e.g. the
/// `Timestamp` column: its own `date time+tz` value already contains one internal space, so a
/// content-only pattern can't tell that boundary apart from the one-space gap before the next column
/// starts -- seed this from `LogStreamColumn::start` positions instead). Greedily matches up to
/// `width` chars while requiring the match to end on a non-whitespace char, which is what excludes the
/// column's own trailing padding spaces from the capture.
fn bounded_column_value_pattern(width: usize) -> String {
if width <= 1 {
r"\S".to_string()
} else {
format!(r"\S(?:.{{0,{}}}\S)?", width - 2)
}
}
fn is_separator_run(run: &str) -> bool {
run.len() >= 2 || run.contains('\t')
}
static WHITESPACE_RUN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[ \t]+").expect("static regex must compile"));
/// Splits `line` at whitespace runs that qualify as a column separator (`is_separator_run`), returning
/// each piece alongside its byte offset in `line`; a lone plain space never splits. Consumes at most
/// `max_columns.map(|n| n - 1)` separators -- the final piece returned is the untouched remainder of
/// the line, including any further whitespace runs inside it. That's what lets a trailing unlabelled
/// `log stream` message tail (whose own internal spacing can be arbitrary) come back as one field
/// instead of being chopped up by it.
fn split_columns_with_offsets(line: &str, max_columns: Option<usize>) -> Vec<(usize, &str)> {
let mut columns = Vec::new();
let mut start = 0usize;
let mut consumed = 0usize;
for m in WHITESPACE_RUN.find_iter(line) {
if max_columns.is_some_and(|max| consumed + 1 >= max) {
break;
}
let run = &line[m.start()..m.end()];
if is_separator_run(run) {
columns.push((start, &line[start..m.start()]));
start = m.end();
consumed += 1;
}
}
columns.push((start, &line[start..]));
columns
}
fn split_columns(line: &str, max_columns: Option<usize>) -> Vec<&str> {
split_columns_with_offsets(line, max_columns).into_iter().map(|(_, s)| s).collect()
}
/// A column-name identifier a generated `message_match_regex`'s named capture group can use as-is
/// (the regex crate requires `[A-Za-z_][0-9A-Za-z_]*`).
static COLUMN_NAME: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[A-Za-z][A-Za-z0-9_]*$").expect("static regex must compile"));
/// One column named by a detected `log stream` header (ticket 059). `start` is that column's position
/// in `char`s (not bytes -- see `char_slice`) within the header line -- meaningful only when
/// `LogStreamHeader::tab_delimited` is `false` (see that field's doc comment).
pub struct LogStreamColumn {
pub name: String,
start: usize,
}
/// A detected `log stream` header: its named columns, plus whether they were tab- or space-separated.
/// `log stream`'s real output right-pads each header name to a fixed on-screen width using runs of
/// 2+ spaces, and every data line's values land in that same fixed alignment regardless of how much
/// of that width they individually fill -- so for that shape, a data line's column boundaries are
/// best found at the header's own fixed character positions, not by re-splitting the data line's own
/// whitespace (see `split_log_stream_row`'s doc comment for why that alone isn't reliable there).
/// A tab-separated header carries no such alignment guarantee (values aren't padded to a shared
/// width), but a lone tab is itself an unambiguous separator, so that case instead re-splits each data
/// line directly.
pub struct LogStreamHeader {
pub columns: Vec<LogStreamColumn>,
tab_delimited: bool,
}
/// Byte-indexed `str` slicing doesn't work directly with `LogStreamColumn::start` (a *char* offset),
/// since `log stream` output is expected to stay ASCII in practice but callers shouldn't panic if a
/// message contains multi-byte chars before the slice point. `end` of `None` slices to end of string.
fn char_slice(s: &str, start: usize, end: Option<usize>) -> &str {
if end.is_some_and(|end| end <= start) {
return "";
}
let mut indices = s.char_indices();
let byte_start = indices.nth(start).map(|(i, _)| i).unwrap_or(s.len());
let byte_end = match end {
Some(end) => s.char_indices().nth(end).map(|(i, _)| i).unwrap_or(s.len()),
None => s.len(),
};
&s[byte_start..byte_end.max(byte_start)]
}
/// Recognizes a `log stream`-style header row generically (ticket 059), instead of assuming the fixed
/// `Timestamp Thread Type Activity PID TTL` names: a short run of bareword column names separated by
/// qualifying whitespace runs (`split_columns_with_offsets`) that is, critically, *not itself* a
/// `log stream` data line (`is_log_stream_line`) -- so a header is never mistaken for the data line
/// immediately following it. `crate::engine::auto_declare_log_stream_params` caches the result across
/// ingested lines, since `log stream` prints its header once, before any data line, and this only ever
/// sees one line at a time.
pub fn detect_log_stream_header(line: &str) -> Option<LogStreamHeader> {
if is_log_stream_line(line) {
return None;
}
let mut columns = Vec::new();
for (byte_start, slice) in split_columns_with_offsets(line, None) {
let trimmed = slice.trim();
if trimmed.is_empty() {
continue;
}
let leading_ws = slice.len() - slice.trim_start().len();
let start = line[..byte_start + leading_ws].chars().count();
columns.push(LogStreamColumn { name: trimmed.to_string(), start });
}
if columns.len() < 2 || !columns.iter().all(|c| COLUMN_NAME.is_match(&c.name)) {
return None;
}
let tab_delimited = WHITESPACE_RUN.find_iter(line).any(|m| m.as_str().contains('\t'));
Some(LogStreamHeader { columns, tab_delimited })
}
/// Splits a `log stream` data line into `header`'s named columns plus, when present, one trailing
/// unlabelled tail -- `crate::engine::auto_declare_log_stream_params` maps the first
/// `header.columns.len()` pieces onto the header's names and the optional last piece onto the
/// `message` param.
///
/// For a space-padded fixed-width header, every column but the last is sliced at the header's own
/// fixed character positions (`LogStreamColumn::start`) rather than by re-splitting the data line's
/// own whitespace, since two adjacent values can legitimately be joined by only a single space when
/// the first nearly fills its column's width (e.g. `log stream`'s `Timestamp` column, whose value
/// already contains one internal space). Only the last named column's end is whitespace-run-detected
/// (`split_columns`), since that's what distinguishes its own value from the free-text message tail
/// `log stream` appends after it.
///
/// For a tab-delimited header, there's no shared on-screen width to align against, but a lone tab is
/// itself unambiguous, so this instead re-splits the data line directly on qualifying separators.
pub fn split_log_stream_row<'a>(line: &'a str, header: &LogStreamHeader) -> Vec<&'a str> {
if header.tab_delimited {
return split_columns(line, Some(header.columns.len() + 1));
}
let mut result = Vec::with_capacity(header.columns.len() + 1);
for (i, column) in header.columns.iter().enumerate() {
match header.columns.get(i + 1) {
Some(next) => result.push(char_slice(line, column.start, Some(next.start)).trim()),
None => {
let tail = char_slice(line, column.start, None);
let pieces = split_columns(tail, Some(2));
result.push(pieces[0].trim());
if let Some(message) = pieces.get(1) {
result.push(message);
}
}
}
}
result
}
/// Builds the `message_match_regex` for the `index`-th (0-based) of `header`'s named columns.
///
/// Fixed-width case: skips exactly `header.columns[index].start` chars, then captures this column's
/// own value bounded to its known width (`bounded_column_value_pattern`) -- unbounded for the last
/// column, which has no next column to bound against.
///
/// Tab-delimited case: skips `index` preceding columns' own value+separator pairs instead, since
/// there's no shared fixed width to anchor a character-position skip against.
///
/// Anchored at the start of the line since a `log stream` line never carries this fixed-per-run
/// column prefix anywhere but the very start.
pub fn log_stream_column_pattern(header: &LogStreamHeader, index: usize, capture_group: &str) -> String {
if header.tab_delimited {
let mut pattern = String::from("^");
for _ in 0..index {
pattern.push_str(COLUMN_VALUE_PATTERN);
pattern.push_str(COLUMN_SEPARATOR_PATTERN);
}
pattern.push_str(&format!("(?P<{capture_group}>{COLUMN_VALUE_PATTERN})"));
return pattern;
}
let start = header.columns[index].start;
let value_pattern = match header.columns.get(index + 1) {
Some(next) => bounded_column_value_pattern(next.start.saturating_sub(start)),
None => COLUMN_VALUE_PATTERN.to_string(),
};
format!("^.{{{start}}}(?P<{capture_group}>{value_pattern})")
}
/// Builds the `message_match_regex` for the trailing unlabelled tail after `header`'s last named
/// column: everything from the end of that column's own separator to end of line, verbatim -- its own
/// internal whitespace, however irregular, is never treated as a further separator.
pub fn log_stream_message_pattern(header: &LogStreamHeader, capture_group: &str) -> String {
if header.tab_delimited {
let mut pattern = String::from("^");
for _ in 0..header.columns.len() {
pattern.push_str(COLUMN_VALUE_PATTERN);
pattern.push_str(COLUMN_SEPARATOR_PATTERN);
}
pattern.push_str(&format!("(?P<{capture_group}>.*)$"));
return pattern;
}
let start = header.columns.last().map(|c| c.start).unwrap_or(0);
format!("^.{{{start}}}{COLUMN_VALUE_PATTERN}{COLUMN_SEPARATOR_PATTERN}(?P<{capture_group}>.*)$")
}
/// Go `log/slog` `NewJSONHandler` output (the `go-log` label): a single-line JSON object whose first
/// three fields are always `time`, `level`, `msg` in that fixed order (slog's `JSONHandler` writes the
@@ -290,15 +497,90 @@ mod tests {
assert!(!is_log_stream_line(header));
}
fn column_names(header: &LogStreamHeader) -> Vec<&str> {
header.columns.iter().map(|c| c.name.as_str()).collect()
}
#[test]
fn log_stream_patterns_capture_type_process_and_pid() {
fn detects_log_stream_header_columns_generically() {
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
let header = detect_log_stream_header(header_line).expect("fixture header must be detected");
assert_eq!(column_names(&header), vec!["Timestamp", "Thread", "Type", "Activity", "PID", "TTL"]);
assert!(!header.tab_delimited);
}
#[test]
fn a_log_stream_data_line_is_never_detected_as_its_own_header() {
let data_line = LOG_STREAM_FIXTURE.lines().nth(1).unwrap();
assert!(detect_log_stream_header(data_line).is_none());
}
#[test]
fn plain_prose_is_not_detected_as_a_log_stream_header() {
assert!(detect_log_stream_header("just some plain prose here").is_none());
assert!(detect_log_stream_header("ts=1788648105773 msg=\"entity_moved\"").is_none());
}
#[test]
fn splits_log_stream_row_into_named_columns_plus_trailing_message() {
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
let header = detect_log_stream_header(header_line).unwrap();
let data_line = "2026-09-06 01:48:54.518117+0100 0x4dfed2 Default 0x0 606 0 WindowServer: (HSTouchHIDService) something happened";
let row = split_log_stream_row(data_line, &header);
assert_eq!(row.len(), header.columns.len() + 1);
assert_eq!(row[0], "2026-09-06 01:48:54.518117+0100");
assert_eq!(row[1], "0x4dfed2");
assert_eq!(row[2], "Default");
assert_eq!(row[3], "0x0");
assert_eq!(row[4], "606");
assert_eq!(row[5], "0");
// The message tail is never chopped up by its own internal single/double spacing.
assert_eq!(row[6], "WindowServer: (HSTouchHIDService) something happened");
}
#[test]
fn a_single_tab_separates_columns_exactly_like_a_run_of_spaces() {
let header = detect_log_stream_header("Timestamp\tThread\tType").unwrap();
assert_eq!(column_names(&header), vec!["Timestamp", "Thread", "Type"]);
assert!(header.tab_delimited);
// Two tabs in a row still qualify as a single separator (like a run of 2+ spaces), so this
// still cleanly separates Timestamp/Thread/Type despite the doubled tab before "Default".
let spaced = split_log_stream_row("2026-09-06 01:48:54\t0x4dfed2\t\tDefault trailing message", &header);
let tabbed = split_log_stream_row("2026-09-06 01:48:54\t0x4dfed2\tDefault trailing message", &header);
assert_eq!(spaced[0], "2026-09-06 01:48:54");
assert_eq!(spaced[1], "0x4dfed2");
assert_eq!(spaced[2], "Default trailing message");
assert_eq!(tabbed[0], "2026-09-06 01:48:54");
assert_eq!(tabbed[1], "0x4dfed2");
assert_eq!(tabbed[2], "Default trailing message");
}
#[test]
fn column_and_message_patterns_capture_the_same_fields_split_log_stream_row_finds() {
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
let header = detect_log_stream_header(header_line).unwrap();
let line = "2026-09-06 01:48:54.518117+0100 0x4dfed2 Default 0x0 606 0 WindowServer: (HSTouchHIDService) something happened";
let type_re = Regex::new(LOG_STREAM_TYPE_PATTERN).unwrap();
let process_re = Regex::new(LOG_STREAM_PROCESS_PATTERN).unwrap();
let pid_re = Regex::new(LOG_STREAM_PID_PATTERN).unwrap();
assert_eq!(type_re.captures(line).unwrap().name("log_type").unwrap().as_str(), "Default");
assert_eq!(process_re.captures(line).unwrap().name("process").unwrap().as_str(), "WindowServer");
assert_eq!(pid_re.captures(line).unwrap().name("pid").unwrap().as_str(), "606");
let type_re = Regex::new(&log_stream_column_pattern(&header, 2, "Type")).unwrap();
assert_eq!(type_re.captures(line).unwrap().name("Type").unwrap().as_str(), "Default");
let pid_re = Regex::new(&log_stream_column_pattern(&header, 4, "PID")).unwrap();
assert_eq!(pid_re.captures(line).unwrap().name("PID").unwrap().as_str(), "606");
let message_re = Regex::new(&log_stream_message_pattern(&header, "message")).unwrap();
assert_eq!(
message_re.captures(line).unwrap().name("message").unwrap().as_str(),
"WindowServer: (HSTouchHIDService) something happened"
);
// Also exercise the Timestamp column specifically -- its own value contains one internal
// space, so a naive content-only pattern would previously over-consume into the next column.
let timestamp_re = Regex::new(&log_stream_column_pattern(&header, 0, "Timestamp")).unwrap();
assert_eq!(
timestamp_re.captures(line).unwrap().name("Timestamp").unwrap().as_str(),
"2026-09-06 01:48:54.518117+0100"
);
}
#[test]
@@ -1,62 +0,0 @@
# 059 — `log stream` parsing is one hardcoded regex, not generic whitespace-columnar detection
## Summary
Current `log stream` support is a single hardcoded regex,
`LOG_STREAM_RE` (`src/logfmt.rs:93-113`), matching exactly six fixed
columns (`Timestamp Thread Type Activity PID TTL`) by content shape
(dates/hex/decimals) rather than by parsing the header row generically.
`auto_declare_log_stream_params` (`src/engine.rs:451-472`) then extracts
only three hardcoded fields (`log_type`, `process`, `pid`) via three more
bespoke regexes (`LOG_STREAM_TYPE_PATTERN` etc., `logfmt.rs:108-113`).
The fixture `src/fixtures/log_stream_sample.txt` has a real header row
(`Timestamp Thread Type Activity PID TTL `)
whose columns are separated by runs of 2+ spaces — the general CSV-like
shape the wishlist asks for — plus a trailing unlabelled `process:
message` tail that must not get merged into the preceding column. Today
nothing derives column names or positions from that header; every field
is matched by a fixed, hand-tuned regex, so any deviation from this
exact `log stream` output shape silently fails to parse.
## Scope
- Replace `LOG_STREAM_RE`/`LOG_STREAM_*_PATTERN`'s fixed-shape matching
with a generic whitespace-columnar detector: given a header line,
split on runs of duplicated whitespace (2+ spaces, or a single tab, or
" \t " — any whitespace repeated) to determine column names and
positions, then apply the same column boundaries to subsequent data
lines.
- The header's last named column's value must still capture through
to end-of-line without swallowing the trailing unlabelled message
column that `log stream` appends after the last header-named field
(i.e. that trailing text becomes its own unlabelled/message param,
not merged into the last header column).
- Single tabs are acceptable separators even where surrounding spaces
aren't doubled.
- Wire the new column-driven extraction into
`auto_declare_log_stream_params` in place of the three bespoke
per-field regexes, so declared params are derived from whatever
columns the header actually names, not a fixed list of three.
- Use `src/fixtures/log_stream_sample.txt` as the reference fixture and
add/extend tests in `src/logfmt.rs`/`src/engine.rs` asserting header
columns are correctly detected and the trailing message field is kept
separate.
- Out of scope: changing param-cardinality bounding introduced in ticket
050/054 (commit f48d3c6f) — this is purely about how columns are
detected/split, not about pruning noisy auto-declarations.
## Acceptance criteria
- Piping real `log stream` output (as in `log_stream_sample.txt`)
through ottyl declares params matching the header's actual column
names, not the previous fixed three.
- The trailing unlabelled message text on each `log stream` line is
captured as its own field and never merged into the last named
header column.
- A header/data line whose columns are separated by a single tab is
parsed identically to one separated by 2+ spaces.
## Depends on
None.
@@ -1,57 +0,0 @@
# 060 — Timeline sort-by-property does a full materialize+sort per query instead of using a helper index
## Summary
Sorting the timeline by an arbitrary property does work today (from
ticket 045): in the `EngineQuery::Logs` branch (`src/engine.rs:1408-1487`),
when `sort_by` is set, every filtered line is materialized into a
`Vec<LogEntryDto>` (deriving params for all of them), then sorted
in-memory with `.sort_by(...)`, with `sort_missing_fallback` (default
true) partitioning entries that have the sort param from those that
don't and ordering the latter by `ottyl_seq` (`:1468-1482`).
The problem is architectural: there is no incremental secondary index
keyed by the sort property, only the primary `BTreeMap` keyed by
`IndexKey::IngestedLog { sequence, timestamp }` (`src/index_key.rs`,
inserted at `engine.rs:156-181`). Every sorted query re-derives params
for and re-sorts the *entire* filtered range from scratch, and range
selection (e.g. "give me the next page in this order") has no way to
seek directly into a sorted position — it can only sort everything then
slice.
## Scope
- Introduce a "helper index": a secondary `BTreeMap` keyed by
`(sort-param-value, ottyl_seq)` (or equivalent composite key so
`ottyl_seq` is the tie-break/fallback ordering baked into the key
itself, not a post-hoc partition step) per distinct param name that's
been sorted by.
- Populate a helper index incrementally as lines are ingested (near
`engine.rs:156-181`, alongside the primary index insert), for any
param name that currently has a live helper index, rather than
rebuilding from scratch per query.
- Lines missing the sort param must still be captured in the helper
index's ordering, falling back to `ottyl_seq` ordering — matching
current `sort_missing_fallback` semantics but expressed as part of the
index's key/ordering rather than a separate partition-and-append step.
- Adapt the `EngineQuery::Logs` sorted branch (`engine.rs:1459-1483`) to
do a range walk over the relevant helper index instead of
materializing and sorting the full filtered set every time.
- Out of scope: the eviction/purge policy for helper indexes that
haven't been used recently — that's ticket 061.
## Acceptance criteria
- Querying the timeline sorted by a declared param returns results
ordered correctly, including lines missing that param falling back to
`ottyl_seq` order, matching current behavior.
- A sorted query no longer re-sorts the full filtered range from scratch
on every call once a helper index for that sort key exists — verified
by a test/benchmark showing incremental maintenance rather than
full materialize-then-sort.
- Existing sort-related tests (from ticket 045) continue to pass
unmodified in their assertions.
## Depends on
None.
@@ -1,43 +0,0 @@
# 061 — Sort helper indexes need to be purged after a period of no access
## Summary
Ticket 060 introduces per-sort-key helper `BTreeMap` indexes. Left
unbounded, a user (or dashboard) that sorts by many distinct params over
a session accumulates a helper index per param forever, each duplicating
the full timeline's worth of keys. There's no existing time-since-access
eviction pattern in `engine.rs` to model this on: the only eviction today
is `evict_excess_logs` (`src/engine.rs:1356-1363`, called from ingest at
`:181` and from `SetMaxLogs` at `:212`), which is a hard count-cap
("drop oldest once over `max_logs`"), not an LRU/last-accessed scheme.
## Scope
- Track a last-accessed timestamp per helper index (e.g.
`HashMap<String, (BTreeMap<...>, Instant)>` or equivalent), updated
whenever a query actually reads from that index.
- Add a sweep (triggered periodically, or opportunistically on
ingest/query — match whichever fits the existing engine actor's
message-loop structure) that drops any helper index whose
last-accessed time exceeds a fixed duration.
- A dropped helper index must be transparently rebuildable: the next
query sorted by that param should lazily rebuild the index (a one-time
full scan) exactly as if it had never existed, rather than erroring or
falling back permanently to unsorted behavior.
- Out of scope: making the purge duration user-configurable via
`Settings` — pick a reasonable fixed constant unless the ticket
implementer finds a strong reason to expose it.
## Acceptance criteria
- A helper index untouched for longer than the purge duration is
removed from engine memory.
- Querying a sort key whose helper index was purged transparently
rebuilds it and returns correct results, with no user-visible error.
- Actively-used helper indexes (queried more recently than the purge
duration) are never evicted.
## Depends on
Ticket 060 (timeline custom sort needs helper BTreeMap index) — this
ticket assumes helper indexes already exist.