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}