Skip to main content

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.mark_variable_cut();
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}