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}