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};
24
25pub use entry::{Block, LogEntry, ParseWarning, SessionReading};
26pub use stats::{ParseStats, UnclassifiedLine, UnclassifiedReason, UnclassifiedTracking};
27
28/// `YYYY-MM-DD HH:MM:SS.ffffff` and the space after it — what a log header
29/// opens with, and so the least a matched header occupies.
30const TIMESTAMP_FIELD_LEN: usize = 27;
31
32/// A Format A line's header: session UUID prefix, then timestamp field.
33const FULL_HEADER_LEN: usize = UUID_PREFIX_LEN + TIMESTAMP_FIELD_LEN;
34
35/// Bytes of the line's own header, which the heuristic must not match inside or
36/// every line would split on itself.
37fn header_len(bytes: &[u8]) -> usize {
38    if is_uuid_at(bytes, 0) {
39        if bytes.len() > UUID_PREFIX_LEN && bytes[UUID_PREFIX_LEN].is_ascii_digit() {
40            FULL_HEADER_LEN
41        } else {
42            UUID_PREFIX_LEN
43        }
44    } else if is_date_at(bytes, 0) {
45        TIMESTAMP_FIELD_LEN
46    } else {
47        0
48    }
49}
50
51/// Every offset a record starts at after the first, plus one verdict per
52/// resulting chunk — true only where the write's spent budget ended it.
53fn scan_splits(
54    bytes: &[u8],
55    min_scan: usize,
56    first_boundary: Option<usize>,
57) -> (Vec<usize>, Vec<bool>) {
58    let end = bytes.len();
59    let mut next_boundary = first_boundary;
60    let mut splits: Vec<usize> = Vec::new();
61    let mut cut_verdicts: Vec<bool> = Vec::new();
62    let mut chunk_start = 0usize;
63    let mut offset = 0usize;
64    while offset <= end {
65        if let Some(boundary) = next_boundary {
66            if offset >= boundary && offset <= boundary + DECODE_DRIFT {
67                let uuid = is_uuid_at(bytes, offset);
68                if uuid || is_log_header_at(bytes, offset) {
69                    splits.push(offset);
70                    cut_verdicts.push(true);
71                    chunk_start = offset;
72                    next_boundary = WriteCursor::boundary_after(uuid, offset, end);
73                    offset += UUID_PREFIX_LEN;
74                    continue;
75                }
76            }
77        }
78        // `min_scan` guards only the heuristic: past the first line of a write
79        // the boundary sits early, often inside the header this must skip.
80        if offset >= min_scan && is_log_header_at(bytes, offset) {
81            let split_at = if offset >= chunk_start + UUID_PREFIX_LEN
82                && is_uuid_at(bytes, offset - UUID_PREFIX_LEN)
83            {
84                offset - UUID_PREFIX_LEN
85            } else {
86                offset
87            };
88            if split_at > chunk_start {
89                splits.push(split_at);
90                cut_verdicts.push(false);
91                chunk_start = split_at;
92                next_boundary =
93                    WriteCursor::boundary_after(is_uuid_at(bytes, split_at), split_at, end);
94                offset += TIMESTAMP_FIELD_LEN;
95            } else {
96                // The chunk's own header. The max keeps the walk moving when
97                // the UUID-prefix check rewound split_at behind us.
98                offset = (offset + TIMESTAMP_FIELD_LEN).max(offset + 1);
99            }
100            continue;
101        }
102        offset += 1;
103    }
104    cut_verdicts.push(next_boundary.is_some());
105    (splits, cut_verdicts)
106}
107
108/// Cut `line` at each offset in `splits`, returning the leading chunk and the
109/// rest in order. Built right to left with `split_off`, which copies nothing.
110fn split_chunks(line: String, splits: &[usize]) -> (String, Vec<String>) {
111    let mut head = line;
112    let mut chunks: Vec<String> = Vec::with_capacity(splits.len());
113    for &at in splits.iter().rev() {
114        chunks.push(head.split_off(at));
115    }
116    chunks.reverse();
117    (head, chunks)
118}
119
120/// The current line's cut verdict, held until the entry owning that line claims
121/// it — emitted on arrival the warning would land on the entry before it.
122#[derive(Default)]
123struct LineVerdict {
124    /// Per chunk of the line, whether it ends at the write's spent budget. One
125    /// physical line can hold several such cuts, and each is the record it ends.
126    chunks: VecDeque<bool>,
127    /// The chunk dispatched before the current one ended at a cut rather than
128    /// at its own newline.
129    prev_cut: bool,
130    cut: bool,
131    warning: Option<ParseWarning>,
132}
133
134impl LineVerdict {
135    /// Move to the next chunk's verdict, returning the previous chunk's.
136    fn advance(&mut self) -> bool {
137        self.cut = self.chunks.pop_front().unwrap_or(false);
138        if self.cut {
139            self.warning = Some(ParseWarning::CutLine);
140        }
141        std::mem::replace(&mut self.prev_cut, self.cut)
142    }
143
144    /// Take the warning for the entry now owning the line.
145    fn claim(&mut self) -> Option<ParseWarning> {
146        self.warning.take()
147    }
148
149    /// Drop the cut verdicts at a segment boundary. An unclaimed warning is
150    /// still owed to the entry that will claim it, so it stands.
151    fn clear_cuts(&mut self) {
152        self.chunks.clear();
153        self.prev_cut = false;
154        self.cut = false;
155    }
156}
157
158/// The entry being assembled, together with the block it owns. Pairing them
159/// is what keeps a block from outliving or preceding its entry.
160struct Pending {
161    entry: LogEntry,
162    block: BlockBuilder,
163}
164
165/// Layer 2 structural state machine — groups continuation lines, classifies
166/// messages, and detects multi-line blocks (CHANNEL_DATA, SDP, codec negotiation).
167///
168/// Wraps any `Iterator<Item = String>` and yields [`LogEntry`] values.
169/// Maintains `last_uuid` and `last_timestamp` to fill in context for
170/// continuation lines that lack their own.
171///
172/// Use the builder method [`unclassified_tracking()`](LogStream::unclassified_tracking)
173/// to control diagnostic detail before iterating.
174pub struct LogStream<I> {
175    lines: I,
176    last_uuid: String,
177    last_timestamp: String,
178    pending: Option<Pending>,
179    stats: ParseStats,
180    tracking: UnclassifiedTracking,
181    line_number: u64,
182    split_pending: VecDeque<String>,
183    /// What the line being dispatched says about truncation, claimed by
184    /// whichever of `open_entry`/`attach` ends up owning it.
185    verdict: LineVerdict,
186    /// How much of its budget the `mod_logfile` write in progress has spent.
187    cursor: WriteCursor,
188    /// Bytes of attached lines one entry may hold before further ones are
189    /// dropped. The caller's budget, not a shape of the log.
190    max_attached: usize,
191}
192
193impl<I: Iterator<Item = String>> LogStream<I> {
194    /// Create a new stream from any line iterator.
195    pub fn new(lines: I) -> Self {
196        LogStream {
197            lines,
198            last_uuid: String::new(),
199            last_timestamp: String::new(),
200            pending: None,
201            stats: ParseStats::default(),
202            tracking: UnclassifiedTracking::CountOnly,
203            line_number: 0,
204            split_pending: VecDeque::new(),
205            verdict: LineVerdict::default(),
206            cursor: WriteCursor::default(),
207            max_attached: usize::MAX,
208        }
209    }
210
211    /// Set the unclassified line tracking level (builder pattern). Defaults to `CountOnly`.
212    pub fn unclassified_tracking(mut self, level: UnclassifiedTracking) -> Self {
213        self.tracking = level;
214        self
215    }
216
217    /// Cap the bytes of attached lines one entry may hold (builder pattern).
218    ///
219    /// Defaults to the 4 GiB the attached buffer's `u32` offsets address. A line
220    /// past the cap is reported as
221    /// [`ParseWarning::AttachedOverflow`] and counted in
222    /// [`ParseStats::lines_dropped`], exactly as one past the addressable range is.
223    pub fn max_attached_bytes(mut self, max_bytes: usize) -> Self {
224        self.max_attached = max_bytes;
225        self
226    }
227
228    /// Cumulative parsing statistics up to the current position.
229    pub fn stats(&self) -> &ParseStats {
230        &self.stats
231    }
232
233    /// Take all accumulated unclassified line records, leaving the internal vec empty.
234    ///
235    /// The `lines_unclassified` counter is not reset.
236    pub fn drain_unclassified(&mut self) -> Vec<UnclassifiedLine> {
237        std::mem::take(&mut self.stats.unclassified_lines)
238    }
239
240    fn record_unclassified(&mut self, reason: UnclassifiedReason, data: Option<&str>) {
241        self.stats.lines_unclassified += 1;
242        match self.tracking {
243            UnclassifiedTracking::CountOnly => {}
244            UnclassifiedTracking::TrackLines => {
245                self.stats.unclassified_lines.push(UnclassifiedLine {
246                    line_number: self.line_number,
247                    reason,
248                    data: None,
249                });
250            }
251            UnclassifiedTracking::CaptureData => {
252                self.stats.unclassified_lines.push(UnclassifiedLine {
253                    line_number: self.line_number,
254                    reason,
255                    data: data.map(|s| s.to_string()),
256                });
257            }
258        }
259    }
260
261    /// Close the pending entry's block into it and hand the entry over.
262    fn take_pending(&mut self) -> Option<LogEntry> {
263        let mut pending = self.pending.take()?;
264        let (block, warnings) = pending.block.finish();
265        pending.entry.block = block;
266        pending.entry.warnings.extend(warnings);
267        self.stats.lines_in_entries += 1 + pending.entry.attached.len() as u64;
268        Some(pending.entry)
269    }
270
271    /// Store a raw line on the pending entry, or record that the entry has no
272    /// room left for it. Every attach goes through here so a dropped line is
273    /// counted once and the accounting invariant still balances.
274    fn attach(&mut self, line: &str) {
275        let Some(pending) = self.pending.as_mut() else {
276            return;
277        };
278        let over_budget = pending.entry.attached.byte_len() + line.len() + 1 > self.max_attached;
279        if over_budget || pending.entry.attached.push(line).is_err() {
280            pending.entry.warnings.push(ParseWarning::AttachedOverflow {
281                line: ParseWarning::excerpt(line),
282            });
283            self.stats.lines_dropped += 1;
284        } else if self.verdict.cut {
285            let i = pending.entry.attached.len() - 1;
286            pending.entry.cut_texts.push(FieldLocation::Attached(i));
287        }
288        pending.entry.warnings.extend(self.verdict.claim());
289    }
290
291    /// Absorb a codec trace line into the run the pending entry already owns,
292    /// reporting whether it belonged there.
293    ///
294    /// Only a matching UUID *and* media type continues a run: a video run
295    /// following an audio one describes a different negotiation. The message is
296    /// classified last, so the common case — no codec run open — costs nothing.
297    fn merge_codec_run(&mut self, parsed: &RawLine<'_>, uuid: &str, line: &str) -> bool {
298        let Some(pending) = self.pending.as_mut() else {
299            return false;
300        };
301        let Some(open_media) = pending.block.codec_media() else {
302            return false;
303        };
304        if pending.entry.uuid.as_deref().unwrap_or("") != uuid {
305            return false;
306        }
307        let MessageKind::CodecNegotiation { media } = classify_message(parsed.message) else {
308            return false;
309        };
310        if media != open_media {
311            return false;
312        }
313
314        let warning = pending.block.push_codec_trace(parsed.message);
315        pending.entry.warnings.extend(warning);
316        self.attach(line);
317        true
318    }
319
320    /// Feed a continuation line to the pending entry — both its block and its
321    /// raw attached lines.
322    fn accumulate_continuation(&mut self, msg: &str, line: &str, has_uuid: bool, prev_cut: bool) {
323        let Some(pending) = self.pending.as_mut() else {
324            return;
325        };
326        if prev_cut {
327            let warning = pending.block.mark_variable_cut();
328            pending.entry.warnings.extend(warning);
329        }
330        let warning = pending.block.push_continuation(msg, has_uuid);
331        pending.entry.warnings.extend(warning);
332        self.attach(line);
333    }
334
335    /// Install a fresh pending entry for a line that starts one, opening
336    /// whatever block its message calls for.
337    ///
338    /// `uuid` and `timestamp` are passed in rather than read off `parsed`
339    /// because a continuation inherits them from context; everything else the
340    /// line carries is copied straight across, and is `None` for the
341    /// continuation kinds that carry no header.
342    fn open_entry(&mut self, parsed: &RawLine<'_>, uuid: String, timestamp: String) {
343        let message_kind = classify_message(parsed.message);
344
345        if !uuid.is_empty() {
346            self.last_uuid = uuid.clone();
347        }
348        if parsed.timestamp.is_some() {
349            self.last_timestamp = timestamp.clone();
350        }
351
352        let mut block = BlockBuilder::open(&message_kind);
353        // The entry this warning belongs to does not exist until below, so
354        // `warn` has nothing to attach it to yet.
355        let opening_warning = block.push_codec_trace(parsed.message);
356
357        let entry = LogEntry {
358            uuid: if uuid.is_empty() { None } else { Some(uuid) },
359            timestamp,
360            message: parsed.message.to_string(),
361            kind: parsed.kind,
362            message_kind,
363            level: parsed.level,
364            idle_pct: parsed.idle_pct.map(|s| s.to_string()),
365            source: parsed.source.map(|s| s.to_string()),
366            block: None,
367            attached: AttachedLines::new(),
368            line_number: self.line_number,
369            warnings: self
370                .verdict
371                .claim()
372                .into_iter()
373                .chain(opening_warning)
374                .collect(),
375            cut_texts: if self.verdict.cut {
376                vec![FieldLocation::Message]
377            } else {
378                Vec::new()
379            },
380        };
381        self.pending = Some(Pending { entry, block });
382    }
383}
384
385impl<I: Iterator<Item = String>> LogStream<I> {
386    /// Split a physical line holding more than one record, keeping the write
387    /// cursor and the per-chunk cut verdicts in step with what was split.
388    ///
389    /// Returns the leading chunk; the rest are queued in `split_pending` and
390    /// never re-scanned, since [`scan_splits`] already found every offset.
391    fn detect_collision(&mut self, line: String, on_disk_len: usize) -> String {
392        let bytes = line.as_bytes();
393        let prepended = is_uuid_at(bytes, 0);
394
395        // A prefixed line starts a write and a bare line extends it; anything
396        // else came off the verbatim path, which has no budget and no start.
397        if prepended {
398            self.cursor.begin();
399        } else if bytes.is_empty() || is_date_at(bytes, 0) {
400            self.cursor.lose();
401        }
402
403        let boundary = self.cursor.boundary_in(bytes.len());
404        let (splits, cut_verdicts) = scan_splits(bytes, header_len(bytes), boundary);
405        self.verdict.chunks = cut_verdicts.into();
406
407        // The trailing chunk carries the write state into the next line.
408        let last_start = splits.last().copied().unwrap_or(0);
409        if last_start > 0 {
410            if is_uuid_at(bytes, last_start) {
411                self.cursor.begin();
412            } else {
413                self.cursor.lose();
414            }
415        }
416        // On-disk cost of the trailing chunk, newline included — which is what
417        // `advance` adds back, and which the trimmed length no longer carries.
418        self.cursor.advance(on_disk_len - last_start - 1);
419
420        if splits.is_empty() {
421            return line;
422        }
423
424        let (head, chunks) = split_chunks(line, &splits);
425        self.split_pending.extend(chunks);
426        head
427    }
428}
429
430/// What the line source has for the dispatcher.
431enum Sourced {
432    Line(String),
433    /// A segment ended and its context is already cleared; a pending entry is
434    /// the last of that segment, not the first of the next.
435    Segment,
436    Eof,
437}
438
439impl<I: Iterator<Item = String>> LogStream<I> {
440    /// The next chunk to dispatch: a queued split first, then the underlying
441    /// iterator. A queued chunk skips `detect_collision`, which already walked it.
442    fn next_line(&mut self) -> Sourced {
443        if let Some(split) = self.split_pending.pop_front() {
444            self.stats.lines_split += 1;
445            return Sourced::Line(split);
446        }
447
448        let Some(mut line) = self.lines.next() else {
449            return Sourced::Eof;
450        };
451
452        // Exactly the sentinel, never merely starting with it: crash padding
453        // leaves real log lines with a leading NUL that decodes as valid text.
454        if line == SEGMENT_BOUNDARY {
455            self.last_uuid.clear();
456            self.last_timestamp.clear();
457            // A write cannot continue across files, and the cut verdicts
458            // describe lines the next segment never saw.
459            self.cursor.lose();
460            self.verdict.clear_cuts();
461            return Sourced::Segment;
462        }
463
464        self.line_number += 1;
465        self.stats.lines_processed += 1;
466
467        // Trimmed here rather than at decode so the CR is still on the line
468        // when its cost is counted; offsets are against the trimmed text.
469        let on_disk_len = line.len() + 1;
470        if line.ends_with('\r') {
471            line.pop();
472        }
473        Sourced::Line(self.detect_collision(line, on_disk_len))
474    }
475
476    /// A line carrying its own header starts an entry, so it closes the one
477    /// before it — unless it extends the codec run that entry already owns.
478    fn dispatch_primary(&mut self, parsed: &RawLine<'_>, line: &str) -> Option<LogEntry> {
479        let uuid = parsed.uuid.unwrap_or("").to_string();
480        if self.merge_codec_run(parsed, &uuid, line) {
481            return None;
482        }
483
484        let yielded = self.take_pending();
485        let timestamp = parsed
486            .timestamp
487            .map(|t| t.to_string())
488            .unwrap_or_else(|| self.last_timestamp.clone());
489        self.open_entry(parsed, uuid, timestamp);
490        yielded
491    }
492
493    fn dispatch_uuid_continuation(
494        &mut self,
495        parsed: &RawLine<'_>,
496        line: &str,
497        prev_cut: bool,
498    ) -> Option<LogEntry> {
499        let uuid = parsed.uuid.unwrap_or("").to_string();
500        // An EXECUTE trace is its own entry even mid-block, and a different
501        // UUID means a different session's output.
502        let continues = !parsed.message.starts_with("EXECUTE ")
503            && self
504                .pending
505                .as_ref()
506                .is_some_and(|p| p.entry.uuid.as_deref() == Some(uuid.as_str()));
507
508        if continues {
509            self.accumulate_continuation(parsed.message, line, true, prev_cut);
510            return None;
511        }
512        let yielded = self.take_pending();
513        self.open_entry(parsed, uuid, self.last_timestamp.clone());
514        yielded
515    }
516
517    /// A bare line joins the pending entry, or opens one on inherited context
518    /// when there is none to join.
519    fn dispatch_bare_continuation(&mut self, parsed: &RawLine<'_>, line: &str, prev_cut: bool) {
520        if self.pending.is_some() {
521            self.accumulate_continuation(parsed.message, line, false, prev_cut);
522            return;
523        }
524        self.record_unclassified(UnclassifiedReason::OrphanContinuation, Some(line));
525        let (uuid, timestamp) = (self.last_uuid.clone(), self.last_timestamp.clone());
526        self.open_entry(parsed, uuid, timestamp);
527    }
528
529    fn dispatch_empty(&mut self, line: &str) {
530        if self.pending.is_some() {
531            self.attach(line);
532        } else {
533            self.stats.lines_empty_orphan += 1;
534        }
535    }
536}
537
538impl<I: Iterator<Item = String>> Iterator for LogStream<I> {
539    type Item = LogEntry;
540
541    fn next(&mut self) -> Option<LogEntry> {
542        loop {
543            let line = match self.next_line() {
544                Sourced::Line(line) => line,
545                Sourced::Segment => match self.take_pending() {
546                    Some(entry) => return Some(entry),
547                    None => continue,
548                },
549                Sourced::Eof => return self.take_pending(),
550            };
551
552            let prev_cut = self.verdict.advance();
553            let parsed = parse_line(&line);
554
555            let yielded = match parsed.kind {
556                LineKind::Full | LineKind::System | LineKind::Truncated => {
557                    self.dispatch_primary(&parsed, &line)
558                }
559                LineKind::UuidContinuation => {
560                    self.dispatch_uuid_continuation(&parsed, &line, prev_cut)
561                }
562                LineKind::BareContinuation => {
563                    self.dispatch_bare_continuation(&parsed, &line, prev_cut);
564                    None
565                }
566                LineKind::Empty => {
567                    self.dispatch_empty(&line);
568                    None
569                }
570            };
571
572            if yielded.is_some() {
573                return yielded;
574            }
575        }
576    }
577}