freeswitch_log_parser/stream/mod.rs
1//! Layer 2 structural state machine — groups continuation lines into
2//! [`LogEntry`] values, classifying messages and reassembling multi-line
3//! blocks (CHANNEL_DATA, SDP, codec negotiation).
4
5mod block;
6mod collision;
7mod entry;
8mod stats;
9#[cfg(test)]
10mod tests;
11
12use std::collections::VecDeque;
13
14use crate::attached::AttachedLines;
15use crate::chain::SEGMENT_BOUNDARY;
16use crate::fields::FieldLocation;
17use crate::line::{
18 is_date_at, is_log_header_at, is_uuid_at, parse_line, LineKind, RawLine, UUID_PREFIX_LEN,
19};
20use crate::message::{classify_message, MessageKind};
21
22use block::BlockBuilder;
23use collision::{WriteCursor, DECODE_DRIFT, WRITE_LIMIT};
24
25pub use entry::{Block, LogEntry, ParseWarning, SessionReading};
26pub use stats::{ParseStats, UnclassifiedLine, UnclassifiedReason, UnclassifiedTracking};
27
28/// Where the next cut would fall after a split at `at`, if the chunk starting
29/// there is a prepend write that can reach its budget inside this line.
30fn arm_boundary(prepended: bool, at: usize, line_len: usize) -> Option<usize> {
31 (prepended && at + WRITE_LIMIT <= line_len).then_some(at + WRITE_LIMIT)
32}
33
34/// The entry being assembled, together with the block it owns. Pairing them
35/// is what keeps a block from outliving or preceding its entry.
36struct Pending {
37 entry: LogEntry,
38 block: BlockBuilder,
39}
40
41/// Layer 2 structural state machine — groups continuation lines, classifies
42/// messages, and detects multi-line blocks (CHANNEL_DATA, SDP, codec negotiation).
43///
44/// Wraps any `Iterator<Item = String>` and yields [`LogEntry`] values.
45/// Maintains `last_uuid` and `last_timestamp` to fill in context for
46/// continuation lines that lack their own.
47///
48/// Use the builder method [`unclassified_tracking()`](LogStream::unclassified_tracking)
49/// to control diagnostic detail before iterating.
50pub struct LogStream<I> {
51 lines: I,
52 last_uuid: String,
53 last_timestamp: String,
54 pending: Option<Pending>,
55 stats: ParseStats,
56 tracking: UnclassifiedTracking,
57 line_number: u64,
58 split_pending: VecDeque<String>,
59 /// Whether the line dispatched before the current one ended at a split
60 /// rather than at its own newline — the logger cut it short.
61 prev_line_cut: bool,
62 /// The same verdict about the line currently being dispatched, claimed by
63 /// whichever of `open_entry`/`attach` ends up owning it.
64 line_cut: bool,
65 /// A warning about the line currently being dispatched, claimed by whichever
66 /// of `open_entry`/`attach` ends up owning it. Emitting it on arrival would
67 /// pin it on the pending entry, which for a line that starts a new one is
68 /// the wrong entry entirely.
69 line_warning: Option<ParseWarning>,
70 /// How much of its budget the `mod_logfile` write in progress has spent.
71 cursor: WriteCursor,
72 /// Per chunk of the physical line being dispatched, whether it ends at the
73 /// write's spent budget. One physical line can hold several such cuts, and
74 /// each is the record it ends, so a single slot would report only the first.
75 cut_verdicts: VecDeque<bool>,
76}
77
78impl<I: Iterator<Item = String>> LogStream<I> {
79 /// Create a new stream from any line iterator.
80 pub fn new(lines: I) -> Self {
81 LogStream {
82 lines,
83 last_uuid: String::new(),
84 last_timestamp: String::new(),
85 pending: None,
86 stats: ParseStats::default(),
87 tracking: UnclassifiedTracking::CountOnly,
88 line_number: 0,
89 split_pending: VecDeque::new(),
90 prev_line_cut: false,
91 line_cut: false,
92 line_warning: None,
93 cursor: WriteCursor::default(),
94 cut_verdicts: VecDeque::new(),
95 }
96 }
97
98 /// Set the unclassified line tracking level (builder pattern). Defaults to `CountOnly`.
99 pub fn unclassified_tracking(mut self, level: UnclassifiedTracking) -> Self {
100 self.tracking = level;
101 self
102 }
103
104 /// Cumulative parsing statistics up to the current position.
105 pub fn stats(&self) -> &ParseStats {
106 &self.stats
107 }
108
109 /// Take all accumulated unclassified line records, leaving the internal vec empty.
110 ///
111 /// The `lines_unclassified` counter is not reset.
112 pub fn drain_unclassified(&mut self) -> Vec<UnclassifiedLine> {
113 std::mem::take(&mut self.stats.unclassified_lines)
114 }
115
116 fn record_unclassified(&mut self, reason: UnclassifiedReason, data: Option<&str>) {
117 self.stats.lines_unclassified += 1;
118 match self.tracking {
119 UnclassifiedTracking::CountOnly => {}
120 UnclassifiedTracking::TrackLines => {
121 self.stats.unclassified_lines.push(UnclassifiedLine {
122 line_number: self.line_number,
123 reason,
124 data: None,
125 });
126 }
127 UnclassifiedTracking::CaptureData => {
128 self.stats.unclassified_lines.push(UnclassifiedLine {
129 line_number: self.line_number,
130 reason,
131 data: data.map(|s| s.to_string()),
132 });
133 }
134 }
135 }
136
137 /// Close the pending entry's block into it and hand the entry over.
138 fn take_pending(&mut self) -> Option<LogEntry> {
139 let mut pending = self.pending.take()?;
140 let (block, warnings) = pending.block.finish();
141 pending.entry.block = block;
142 pending.entry.warnings.extend(warnings);
143 self.stats.lines_in_entries += 1 + pending.entry.attached.len() as u64;
144 Some(pending.entry)
145 }
146
147 /// Store a raw line on the pending entry, or record that the entry has no
148 /// room left for it. Every attach goes through here so a dropped line is
149 /// counted once and the accounting invariant still balances.
150 fn attach(&mut self, line: &str) {
151 let Some(pending) = self.pending.as_mut() else {
152 return;
153 };
154 if pending.entry.attached.push(line).is_err() {
155 pending.entry.warnings.push(ParseWarning::AttachedOverflow {
156 line: ParseWarning::excerpt(line),
157 });
158 self.stats.lines_dropped += 1;
159 } else if self.line_cut {
160 let i = pending.entry.attached.len() - 1;
161 pending.entry.cut_texts.push(FieldLocation::Attached(i));
162 }
163 pending.entry.warnings.extend(self.line_warning.take());
164 }
165
166 /// Absorb a codec trace line into the run the pending entry already owns,
167 /// reporting whether it belonged there.
168 ///
169 /// Only a matching UUID *and* media type continues a run: a video run
170 /// following an audio one describes a different negotiation. The message is
171 /// classified last, so the common case — no codec run open — costs nothing.
172 fn merge_codec_run(&mut self, parsed: &RawLine<'_>, uuid: &str, line: &str) -> bool {
173 let Some(pending) = self.pending.as_mut() else {
174 return false;
175 };
176 let Some(open_media) = pending.block.codec_media() else {
177 return false;
178 };
179 if pending.entry.uuid.as_deref().unwrap_or("") != uuid {
180 return false;
181 }
182 let MessageKind::CodecNegotiation { media } = classify_message(parsed.message) else {
183 return false;
184 };
185 if media != open_media {
186 return false;
187 }
188
189 let warning = pending.block.push_codec_trace(parsed.message);
190 pending.entry.warnings.extend(warning);
191 self.attach(line);
192 true
193 }
194
195 /// Feed a continuation line to the pending entry — both its block and its
196 /// raw attached lines.
197 fn accumulate_continuation(&mut self, msg: &str, line: &str, has_uuid: bool, prev_cut: bool) {
198 let Some(pending) = self.pending.as_mut() else {
199 return;
200 };
201 if prev_cut {
202 let warning = pending.block.close_cut_variable();
203 pending.entry.warnings.extend(warning);
204 }
205 let warning = pending.block.push_continuation(msg, has_uuid);
206 pending.entry.warnings.extend(warning);
207 self.attach(line);
208 }
209
210 /// Install a fresh pending entry for a line that starts one, opening
211 /// whatever block its message calls for.
212 ///
213 /// `uuid` and `timestamp` are passed in rather than read off `parsed`
214 /// because a continuation inherits them from context; everything else the
215 /// line carries is copied straight across, and is `None` for the
216 /// continuation kinds that carry no header.
217 fn open_entry(&mut self, parsed: &RawLine<'_>, uuid: String, timestamp: String) {
218 let message_kind = classify_message(parsed.message);
219
220 if !uuid.is_empty() {
221 self.last_uuid = uuid.clone();
222 }
223 if parsed.timestamp.is_some() {
224 self.last_timestamp = timestamp.clone();
225 }
226
227 let mut block = BlockBuilder::open(&message_kind);
228 // A codec run's opening line is itself a trace line, and the entry it
229 // belongs to does not exist until below — so its warning is collected
230 // here rather than routed through `warn`.
231 let opening_warning = block.push_codec_trace(parsed.message);
232
233 let entry = LogEntry {
234 uuid: if uuid.is_empty() { None } else { Some(uuid) },
235 timestamp,
236 message: parsed.message.to_string(),
237 kind: parsed.kind,
238 message_kind,
239 level: parsed.level,
240 idle_pct: parsed.idle_pct.map(|s| s.to_string()),
241 source: parsed.source.map(|s| s.to_string()),
242 block: None,
243 attached: AttachedLines::new(),
244 line_number: self.line_number,
245 warnings: self
246 .line_warning
247 .take()
248 .into_iter()
249 .chain(opening_warning)
250 .collect(),
251 cut_texts: if self.line_cut {
252 vec![FieldLocation::Message]
253 } else {
254 Vec::new()
255 },
256 };
257 self.pending = Some(Pending { entry, block });
258 }
259}
260
261impl<I: Iterator<Item = String>> LogStream<I> {
262 /// Detect same-line collisions where multiple log entries were concatenated
263 /// without a newline separator.
264 ///
265 /// Two collision mechanisms exist in production:
266 ///
267 /// 1. **Buffer truncation** (Format E): a `mod_logfile` write spends the
268 /// whole 2047-byte budget, so the `\n` it still owed never fits and the
269 /// next write lands on the same physical line. The offset is exact —
270 /// `WriteCursor` tracks how much of the budget the write in progress has
271 /// spent — and a write may span a prefixed line plus the bare ones after
272 /// it, so the cut often falls on a short line.
273 ///
274 /// 2. **Write contention**: multiple threads writing to the log file can
275 /// interleave output, producing concatenated entries at any line length.
276 /// Common with system lines (Format B) that lack UUID prefixes.
277 ///
278 /// A UUID splits only at the exact boundary; a full timestamp header splits
279 /// anywhere, since contention answers to no budget.
280 ///
281 /// Returns the (possibly truncated) line. If a collision is detected,
282 /// the suffix is stored in `split_pending` for processing in the next
283 /// iteration. Recursive: split suffixes pass through this function again.
284 fn detect_collision(&mut self, line: String, on_disk_len: usize) -> String {
285 let bytes = line.as_bytes();
286 let prepended = is_uuid_at(bytes, 0);
287
288 // A write starts at every prefixed line and is extended by the bare
289 // lines after it; anything else came off the verbatim path, which has
290 // no budget to spend and no start to carry forward.
291 if prepended {
292 self.cursor.begin();
293 } else if bytes.is_empty() || is_date_at(bytes, 0) {
294 self.cursor.lose();
295 }
296
297 // Skip past the line's own header to avoid matching itself.
298 let min_scan = if prepended {
299 if bytes.len() > UUID_PREFIX_LEN && bytes[UUID_PREFIX_LEN].is_ascii_digit() {
300 64 // Full line: UUID + timestamp
301 } else {
302 UUID_PREFIX_LEN // UUID continuation
303 }
304 } else if is_date_at(bytes, 0) {
305 27 // System line: skip own timestamp
306 } else {
307 0
308 };
309
310 let end = bytes.len();
311
312 // Single linear pass collecting every split point. Two collision
313 // mechanisms handled in one walk:
314 //
315 // * the write budget (Format E), checked only in the few bytes
316 // the cursor points at. A bare UUID is the parser's weakest
317 // signature; the boundary is what earns it the right to split,
318 // which is also why this check runs first at a shared offset.
319 //
320 // * `is_log_header_at`: timestamp header (Format B write
321 // contention, Full/System line collisions). Fast-fails after
322 // one byte for non-digit input, so the per-offset cost stays
323 // low even on hundreds-of-KB lines.
324 //
325 // Collecting all splits in one pass (rather than splitting,
326 // re-feeding the suffix, and re-scanning from scratch) is the
327 // structural fix for the prior O(n²) behavior.
328 let mut next_boundary = self.cursor.boundary_in(end);
329 let mut splits: Vec<usize> = Vec::new();
330 // Per chunk, whether it ends where the write spent its budget rather
331 // than at a header the heuristic found. Only the first is a cut.
332 let mut cut_verdicts: Vec<bool> = Vec::new();
333 let mut chunk_start = 0usize;
334 let mut offset = 0usize;
335 while offset <= end {
336 if let Some(boundary) = next_boundary {
337 if offset >= boundary && offset <= boundary + DECODE_DRIFT {
338 let uuid = is_uuid_at(bytes, offset);
339 if uuid || is_log_header_at(bytes, offset) {
340 splits.push(offset);
341 cut_verdicts.push(true);
342 chunk_start = offset;
343 next_boundary = arm_boundary(uuid, offset, end);
344 offset += UUID_PREFIX_LEN;
345 continue;
346 }
347 }
348 }
349 // `min_scan` guards only the heuristic: on the second and later
350 // lines of one write the boundary sits early, often inside the
351 // header the heuristic has to skip.
352 if offset >= min_scan && is_log_header_at(bytes, offset) {
353 let split_at = if offset >= chunk_start + UUID_PREFIX_LEN
354 && is_uuid_at(bytes, offset - UUID_PREFIX_LEN)
355 {
356 offset - UUID_PREFIX_LEN
357 } else {
358 offset
359 };
360 if split_at > chunk_start {
361 splits.push(split_at);
362 cut_verdicts.push(false);
363 chunk_start = split_at;
364 next_boundary = arm_boundary(is_uuid_at(bytes, split_at), split_at, end);
365 offset += 27;
366 } else {
367 // Header at current chunk's own start — already
368 // accounted for. Step past it without recording a
369 // split. The max guarantees forward progress when
370 // the UUID-prefix check rewinds split_at behind us.
371 offset = (offset + 27).max(offset + 1);
372 }
373 continue;
374 }
375 offset += 1;
376 }
377
378 // A boundary still armed is one the walk passed with nothing
379 // recognisable on it: the write was cut and its remainder lost rather
380 // than glued to a successor we could name.
381 cut_verdicts.push(next_boundary.is_some());
382 self.cut_verdicts = cut_verdicts.into();
383
384 // The trailing chunk carries the write state into the next line.
385 let last_start = splits.last().copied().unwrap_or(0);
386 if last_start > 0 {
387 if is_uuid_at(bytes, last_start) {
388 self.cursor.begin();
389 } else {
390 self.cursor.lose();
391 }
392 }
393 // On-disk cost of the trailing chunk, newline included — which is what
394 // `advance` adds back, and which the trimmed `end` no longer carries.
395 self.cursor.advance(on_disk_len - last_start - 1);
396
397 if splits.is_empty() {
398 return line;
399 }
400
401 // First chunk returned; the rest queued for subsequent iterations.
402 // Building right-to-left with split_off avoids intermediate copies.
403 let mut tail = line;
404 let mut chunks: Vec<String> = Vec::with_capacity(splits.len());
405 for &at in splits.iter().rev() {
406 chunks.push(tail.split_off(at));
407 }
408 chunks.reverse();
409 self.split_pending.extend(chunks);
410 tail
411 }
412}
413
414impl<I: Iterator<Item = String>> Iterator for LogStream<I> {
415 type Item = LogEntry;
416
417 fn next(&mut self) -> Option<LogEntry> {
418 loop {
419 let line = if let Some(split) = self.split_pending.pop_front() {
420 self.stats.lines_split += 1;
421 // Already split out by a prior detect_collision pass —
422 // skip re-scanning, which would just walk the chunk again
423 // and find nothing.
424 split
425 } else {
426 let Some(mut line) = self.lines.next() else {
427 return self.take_pending();
428 };
429
430 // Exactly the sentinel, never merely starting with it: crash
431 // padding leaves real log lines with a leading NUL, and decode
432 // passes those through as valid text. Treating one as a segment
433 // boundary would discard its content before any counter saw it.
434 if line == SEGMENT_BOUNDARY {
435 let yielded = self.take_pending();
436 self.last_uuid.clear();
437 self.last_timestamp.clear();
438 // A write cannot continue across files, and the cut
439 // verdicts describe lines the next segment never saw.
440 self.cursor.lose();
441 self.cut_verdicts.clear();
442 self.prev_line_cut = false;
443 self.line_cut = false;
444 if yielded.is_some() {
445 return yielded;
446 }
447 continue;
448 }
449
450 self.line_number += 1;
451 self.stats.lines_processed += 1;
452
453 // Trimmed here rather than at decode so the CR is still on the
454 // line when its cost is counted. Split offsets are computed
455 // against the trimmed text; only the budget sees the byte.
456 let on_disk_len = line.len() + 1;
457 if line.ends_with('\r') {
458 line.pop();
459 }
460 self.detect_collision(line, on_disk_len)
461 };
462
463 // Only the boundary mechanism's verdict: a contention split
464 // concatenated records that were each written whole.
465 let chunk_cut = self.cut_verdicts.pop_front().unwrap_or(false);
466 self.line_cut = chunk_cut;
467 if chunk_cut {
468 self.line_warning = Some(ParseWarning::CutLine);
469 }
470 let prev_cut = std::mem::replace(&mut self.prev_line_cut, self.line_cut);
471
472 let parsed = parse_line(&line);
473
474 match parsed.kind {
475 LineKind::Full | LineKind::System | LineKind::Truncated => {
476 let uuid = parsed.uuid.unwrap_or("").to_string();
477
478 // Merge consecutive codec negotiation entries with the same
479 // UUID *and* media type — a video run following an audio one
480 // describes a different negotiation and gets its own block.
481 if self.merge_codec_run(&parsed, &uuid, &line) {
482 continue;
483 }
484
485 let yielded = self.take_pending();
486 let timestamp = parsed
487 .timestamp
488 .map(|t| t.to_string())
489 .unwrap_or_else(|| self.last_timestamp.clone());
490 self.open_entry(&parsed, uuid, timestamp);
491
492 if yielded.is_some() {
493 return yielded;
494 }
495 }
496
497 LineKind::UuidContinuation => {
498 let uuid = parsed.uuid.unwrap_or("").to_string();
499 // An EXECUTE trace is its own entry even mid-block, and a
500 // different UUID means a different session's output.
501 let continues = !parsed.message.starts_with("EXECUTE ")
502 && self
503 .pending
504 .as_ref()
505 .is_some_and(|p| p.entry.uuid.as_deref() == Some(uuid.as_str()));
506
507 if continues {
508 self.accumulate_continuation(parsed.message, &line, true, prev_cut);
509 } else {
510 let yielded = self.take_pending();
511 self.open_entry(&parsed, uuid, self.last_timestamp.clone());
512 if yielded.is_some() {
513 return yielded;
514 }
515 }
516 }
517
518 LineKind::BareContinuation => {
519 if self.pending.is_some() {
520 self.accumulate_continuation(parsed.message, &line, false, prev_cut);
521 } else {
522 self.record_unclassified(
523 UnclassifiedReason::OrphanContinuation,
524 Some(&line),
525 );
526 let (uuid, timestamp) =
527 (self.last_uuid.clone(), self.last_timestamp.clone());
528 self.open_entry(&parsed, uuid, timestamp);
529 }
530 }
531
532 LineKind::Empty => {
533 if self.pending.is_some() {
534 self.attach(&line);
535 } else {
536 self.stats.lines_empty_orphan += 1;
537 }
538 }
539 }
540 }
541 }
542}