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}