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, 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        if line.len() > MAX_LINE_PAYLOAD {
267            self.line_warning = Some(ParseWarning::OversizeLine {
268                bytes: line.len() + UUID_PREFIX_LEN + 1,
269            });
270        }
271
272        // Skip past the line's own header to avoid matching itself.
273        let bytes = line.as_bytes();
274        let min_scan = if is_uuid_at(bytes, 0) {
275            if bytes.len() > UUID_PREFIX_LEN && bytes[UUID_PREFIX_LEN].is_ascii_digit() {
276                64 // Full line: UUID + timestamp
277            } else {
278                UUID_PREFIX_LEN // UUID continuation
279            }
280        } else if is_date_at(bytes, 0) {
281            27 // System line: skip own timestamp
282        } else {
283            0
284        };
285
286        let end = bytes.len().saturating_sub(28);
287        let oversize = bytes.len() > MAX_LINE_PAYLOAD;
288
289        // Single linear pass collecting every split point. Two collision
290        // mechanisms handled in one walk:
291        //
292        //   * `is_log_header_at`: timestamp header (Format B write
293        //     contention, Full/System line collisions). Fast-fails after
294        //     one byte for non-digit input, so the per-offset cost stays
295        //     low even on hundreds-of-KB lines.
296        //
297        //   * `is_uuid_at` within a ±64-byte window around the next
298        //     expected mod_logfile truncation boundary (Format E). The
299        //     boundary is `MAX_LINE_PAYLOAD` bytes past the start of the
300        //     current chunk; we advance it as splits are found. Bounding
301        //     this check is what kept the previous optimization fast on
302        //     60 KB embedded-SDP lines — a 36-byte hex pattern check at
303        //     every offset would dominate the scan.
304        //
305        // Collecting all splits in one pass (rather than splitting,
306        // re-feeding the suffix, and re-scanning from scratch) is the
307        // structural fix for the prior O(n²) behavior.
308        let mut splits: Vec<usize> = Vec::new();
309        let mut chunk_start = 0usize;
310        let mut offset = min_scan;
311        while offset <= end {
312            if is_log_header_at(bytes, offset) {
313                let split_at = if offset >= chunk_start + UUID_PREFIX_LEN
314                    && is_uuid_at(bytes, offset - UUID_PREFIX_LEN)
315                {
316                    offset - UUID_PREFIX_LEN
317                } else {
318                    offset
319                };
320                if split_at > chunk_start {
321                    splits.push(split_at);
322                    chunk_start = split_at;
323                    offset += 27;
324                } else {
325                    // Header at current chunk's own start — already
326                    // accounted for. Step past it without recording a
327                    // split. The max guarantees forward progress when
328                    // the UUID-prefix check rewinds split_at behind us.
329                    offset = (offset + 27).max(offset + 1);
330                }
331                continue;
332            }
333            if oversize {
334                let boundary = chunk_start + MAX_LINE_PAYLOAD;
335                if offset + COLLISION_SCAN_SLACK >= boundary
336                    && offset <= boundary + COLLISION_SCAN_SLACK
337                    && is_uuid_at(bytes, offset)
338                {
339                    splits.push(offset);
340                    chunk_start = offset;
341                    offset += UUID_PREFIX_LEN;
342                    continue;
343                }
344            }
345            offset += 1;
346        }
347
348        if splits.is_empty() {
349            return line;
350        }
351
352        // First chunk returned; the rest queued for subsequent iterations.
353        // Building right-to-left with split_off avoids intermediate copies.
354        let mut tail = line;
355        let mut chunks: Vec<String> = Vec::with_capacity(splits.len());
356        for &at in splits.iter().rev() {
357            chunks.push(tail.split_off(at));
358        }
359        chunks.reverse();
360        self.split_pending.extend(chunks);
361        tail
362    }
363}
364
365impl<I: Iterator<Item = String>> Iterator for LogStream<I> {
366    type Item = LogEntry;
367
368    fn next(&mut self) -> Option<LogEntry> {
369        loop {
370            let line = if let Some(split) = self.split_pending.pop_front() {
371                self.stats.lines_split += 1;
372                // Already split out by a prior detect_collision pass —
373                // skip re-scanning, which would just walk the chunk again
374                // and find nothing.
375                split
376            } else {
377                let Some(line) = self.lines.next() else {
378                    return self.take_pending();
379                };
380
381                // Exactly the sentinel, never merely starting with it: crash
382                // padding leaves real log lines with a leading NUL, and decode
383                // passes those through as valid text. Treating one as a segment
384                // boundary would discard its content before any counter saw it.
385                if line == SEGMENT_BOUNDARY {
386                    let yielded = self.take_pending();
387                    self.last_uuid.clear();
388                    self.last_timestamp.clear();
389                    if yielded.is_some() {
390                        return yielded;
391                    }
392                    continue;
393                }
394
395                self.line_number += 1;
396                self.stats.lines_processed += 1;
397                self.detect_collision(line)
398            };
399
400            // A non-empty queue means this chunk ended at a split, not a newline.
401            // Recomputed every line: a stale flag would close a multi-line value.
402            self.line_cut = !self.split_pending.is_empty();
403            let prev_cut = std::mem::replace(&mut self.prev_line_cut, self.line_cut);
404
405            let parsed = parse_line(&line);
406
407            match parsed.kind {
408                LineKind::Full | LineKind::System | LineKind::Truncated => {
409                    let uuid = parsed.uuid.unwrap_or("").to_string();
410
411                    // Merge consecutive codec negotiation entries with the same
412                    // UUID *and* media type — a video run following an audio one
413                    // describes a different negotiation and gets its own block.
414                    if self.merge_codec_run(&parsed, &uuid, &line) {
415                        continue;
416                    }
417
418                    let yielded = self.take_pending();
419                    let timestamp = parsed
420                        .timestamp
421                        .map(|t| t.to_string())
422                        .unwrap_or_else(|| self.last_timestamp.clone());
423                    self.open_entry(&parsed, uuid, timestamp);
424
425                    if yielded.is_some() {
426                        return yielded;
427                    }
428                }
429
430                LineKind::UuidContinuation => {
431                    let uuid = parsed.uuid.unwrap_or("").to_string();
432                    // An EXECUTE trace is its own entry even mid-block, and a
433                    // different UUID means a different session's output.
434                    let continues = !parsed.message.starts_with("EXECUTE ")
435                        && self
436                            .pending
437                            .as_ref()
438                            .is_some_and(|p| p.entry.uuid.as_deref() == Some(uuid.as_str()));
439
440                    if continues {
441                        self.accumulate_continuation(parsed.message, &line, true, prev_cut);
442                    } else {
443                        let yielded = self.take_pending();
444                        self.open_entry(&parsed, uuid, self.last_timestamp.clone());
445                        if yielded.is_some() {
446                            return yielded;
447                        }
448                    }
449                }
450
451                LineKind::BareContinuation => {
452                    if self.pending.is_some() {
453                        self.accumulate_continuation(parsed.message, &line, false, prev_cut);
454                    } else {
455                        self.record_unclassified(
456                            UnclassifiedReason::OrphanContinuation,
457                            Some(&line),
458                        );
459                        let (uuid, timestamp) =
460                            (self.last_uuid.clone(), self.last_timestamp.clone());
461                        self.open_entry(&parsed, uuid, timestamp);
462                    }
463                }
464
465                LineKind::Empty => {
466                    if self.pending.is_some() {
467                        self.attach(&line);
468                    } else {
469                        self.stats.lines_empty_orphan += 1;
470                    }
471                }
472            }
473        }
474    }
475}