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