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