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