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}