Skip to main content

freeswitch_log_parser/stream/
entry.rs

1//! Types produced per log entry: the reassembled [`Block`] variants, the
2//! [`LogEntry`] record itself, and parsing anomalies ([`ParseWarning`]).
3
4use std::fmt;
5
6use crate::attached::AttachedLines;
7use crate::codec::{CodecMedia, CodecOffer, CodecParseError};
8use crate::decode::truncate_at_char_boundary;
9use crate::fields::FieldLocation;
10use crate::level::LogLevel;
11use crate::line::LineKind;
12use crate::message::{MessageKind, SdpDirection};
13
14use super::collision::MOD_LOGFILE_BUF_SIZE;
15
16/// Structured data extracted from a multi-line dump that follows a primary log entry.
17///
18/// Each variant corresponds to a block type that the stream state machine
19/// recognizes and reassembles from continuation lines.
20#[derive(Debug, Clone, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum Block {
23    /// Channel variable dump — `Channel-*` fields and `variable_*` key-value pairs.
24    /// Multi-line variable values (e.g. embedded SDP) are reassembled with `\n` separators.
25    ChannelData {
26        fields: Vec<(String, String)>,
27        variables: Vec<(String, String)>,
28    },
29    /// SDP session description body, collected line by line.
30    ///
31    /// The marker may be a continuation of another entry — sofia logs the
32    /// outbound invite line and the marker together — and a body carrying no
33    /// marker at all, such as the SIP message a recorder packet dump prints,
34    /// opens on its version line with [`SdpDirection::Unknown`]. Lines ahead of
35    /// the body stay in [`LogEntry::attached`], spanned by [`LogEntry::fields`]
36    /// but not read as a SIP message: that is `freeswitch-sofia-trace-parser`'s
37    /// job, not this crate's.
38    Sdp {
39        direction: SdpDirection,
40        body: Vec<String>,
41    },
42    /// Codec negotiation sequence for one media type. A run only ever covers a
43    /// single [`CodecMedia`]; audio and video traces never share a block.
44    CodecNegotiation {
45        media: CodecMedia,
46        /// `(remote offer, local implementation)` for each pair compared.
47        comparisons: Vec<(CodecOffer, CodecOffer)>,
48        matched: Vec<CodecOffer>,
49        /// Codecs kept as fallbacks because only their ptime differed.
50        near_matched: Vec<CodecOffer>,
51    },
52}
53
54impl Block {
55    /// Value of a `Channel-*` field in a CHANNEL_DATA dump, or `None` if this
56    /// block is another kind or never carried that field.
57    pub fn field(&self, name: &str) -> Option<&str> {
58        let Block::ChannelData { fields, .. } = self else {
59            return None;
60        };
61        fields
62            .iter()
63            .find(|(n, _)| n == name)
64            .map(|(_, v)| v.as_str())
65    }
66
67    /// Value of a channel variable in a CHANNEL_DATA dump.
68    ///
69    /// Accepts any of `freeswitch-types`' variable-name enums, and spares the
70    /// caller from knowing that a dump spells its keys with the `variable_`
71    /// prefix that [`SessionState::variable`](crate::SessionState::variable)
72    /// strips — the two surfaces answer the same question the same way.
73    pub fn variable<V: freeswitch_types::variables::VariableName>(&self, var: V) -> Option<&str> {
74        let Block::ChannelData { variables, .. } = self else {
75            return None;
76        };
77        let wanted = var.as_str();
78        variables
79            .iter()
80            .find(|(n, _)| n.strip_prefix("variable_").unwrap_or(n) == wanted)
81            .map(|(_, v)| v.as_str())
82    }
83}
84
85#[cfg(feature = "sdp")]
86impl Block {
87    /// Codecs described by an SDP body.
88    ///
89    /// `None` when there is no body to read — another block type, or an SDP
90    /// marker that carried none. Sofia logs several (`Duplicate SDP`,
91    /// `Processing updated SDP`) purely as announcements, and an empty body is
92    /// absence rather than a malformed session.
93    ///
94    /// Parsed on each call rather than stored — see `docs/design-rationale.md`.
95    /// Only a session-level failure is an `Err`: a malformed `a=rtpmap` or a
96    /// broken media section degrades into
97    /// [`SdpCodecs::warnings`](freeswitch_types::sdp::SdpCodecs::warnings).
98    pub fn sdp_codecs(
99        &self,
100    ) -> Option<Result<freeswitch_types::sdp::SdpCodecs, freeswitch_types::sdp::SdpCodecError>>
101    {
102        let Block::Sdp { body, .. } = self else {
103            return None;
104        };
105        let text = body.join("\n");
106        if text.trim().is_empty() {
107            return None;
108        }
109        Some(freeswitch_types::sdp::SdpCodecs::parse(&text))
110    }
111}
112
113/// Longest line excerpt a warning carries. An offending line can be tens of
114/// kilobytes; the excerpt is for a human reading the warning, not for matching on.
115pub(super) const WARNING_EXCERPT_LEN: usize = 80;
116
117/// A per-session reading whose value its vocabulary did not know.
118///
119/// Named rather than free-form so a consumer can tell which reading lapsed
120/// without matching on the value it choked on.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122#[non_exhaustive]
123pub enum SessionReading {
124    ChannelState,
125    CallState,
126    CallDirection,
127    HangupCause,
128}
129
130impl fmt::Display for SessionReading {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        let label = match self {
133            SessionReading::ChannelState => "channel state",
134            SessionReading::CallState => "call state",
135            SessionReading::CallDirection => "call direction",
136            SessionReading::HangupCause => "hangup cause",
137        };
138        f.write_str(label)
139    }
140}
141
142/// A parsing anomaly, attached to the entry whose lines produced it.
143///
144/// The set is closed and every kind is named — see `docs/design-rationale.md`.
145#[derive(Debug, Clone, PartialEq, Eq)]
146#[non_exhaustive]
147pub enum ParseWarning {
148    /// A CHANNEL_DATA variable opened its `[` and the block ended before the `]`.
149    /// The value collected so far is still recorded.
150    UnclosedVariable { name: String },
151    /// A CHANNEL_DATA variable's value was cut by the logger's write buffer. The
152    /// value is recorded with the lost middle missing: reassembly resumes after
153    /// the cut and runs to the value's `]` or to the next name, whichever comes
154    /// first.
155    TruncatedVariable { name: String },
156    /// A line inside a CHANNEL_DATA block matched neither the field nor the
157    /// variable shape, so it contributed nothing to the block. Most often a
158    /// name the write buffer cut mid-token, not a shape the parser cannot read.
159    UnparseableChannelData { line: String },
160    /// A codec negotiation line matched no known trace shape, or its bracketed
161    /// token would not parse. That codec is missing from the block.
162    UnrecognizedCodecLine {
163        line: String,
164        source: Option<CodecParseError>,
165    },
166    /// A continuation line arrived while a codec negotiation block was open.
167    /// The trace has no continuations, so the line belongs to nothing.
168    UnexpectedCodecContinuation { line: String },
169    /// The record ends where `mod_logfile`'s write spent its whole budget, so
170    /// the trailing newline never fit and the text was cut short. Whatever
171    /// followed is either the next record, split out, or lost.
172    CutLine,
173    /// The entry's attached lines outgrew the offsets addressing them, so this
174    /// line could not be stored. Counted in
175    /// [`ParseStats::lines_dropped`](super::ParseStats::lines_dropped).
176    AttachedOverflow { line: String },
177    /// A per-session reading met a value its vocabulary does not know — either
178    /// FreeSWITCH gained one or the line is corrupt. The state that reading
179    /// feeds keeps its last resolved value.
180    UnreadableValue {
181        reading: SessionReading,
182        value: String,
183    },
184}
185
186impl ParseWarning {
187    /// Trim a line down to what a warning is willing to carry.
188    pub(crate) fn excerpt(msg: &str) -> String {
189        truncate_at_char_boundary(msg, WARNING_EXCERPT_LEN).to_string()
190    }
191}
192
193impl fmt::Display for ParseWarning {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        match self {
196            ParseWarning::UnclosedVariable { name } => {
197                write!(f, "unclosed multi-line variable: {name}")
198            }
199            ParseWarning::TruncatedVariable { name } => {
200                write!(f, "multi-line variable cut by the log buffer: {name}")
201            }
202            ParseWarning::UnparseableChannelData { line } => {
203                write!(f, "CHANNEL_DATA line is neither a field nor a variable, possibly a fragment left by a cut: {line}")
204            }
205            ParseWarning::UnrecognizedCodecLine {
206                line,
207                source: Some(e),
208            } => write!(f, "unrecognized codec negotiation line ({e}): {line}"),
209            ParseWarning::UnrecognizedCodecLine { line, source: None } => {
210                write!(f, "unrecognized codec negotiation line: {line}")
211            }
212            ParseWarning::UnexpectedCodecContinuation { line } => {
213                write!(f, "unexpected codec negotiation continuation: {line}")
214            }
215            ParseWarning::CutLine => write!(
216                f,
217                "record cut by mod_logfile's {MOD_LOGFILE_BUF_SIZE}-byte write buffer"
218            ),
219            ParseWarning::UnreadableValue { reading, value } => {
220                write!(f, "unreadable {reading}: {value}")
221            }
222            ParseWarning::AttachedOverflow { line } => {
223                write!(f, "entry's attached lines full, dropped: {line}")
224            }
225        }
226    }
227}
228
229/// A complete parsed log entry with all context resolved.
230///
231/// Produced by [`LogStream`](super::LogStream). Continuation lines have been
232/// grouped, UUID/timestamp inherited from context where needed, and
233/// multi-line blocks reassembled.
234#[derive(Debug)]
235pub struct LogEntry {
236    /// Session UUID; `None` for system lines (no channel context).
237    pub uuid: Option<String>,
238    /// Timestamp with microsecond precision; inherited from the previous entry for continuations.
239    pub timestamp: String,
240    /// `None` for continuation and truncated lines.
241    pub level: Option<LogLevel>,
242    /// Core scheduler idle percentage; `None` for continuations.
243    pub idle_pct: Option<String>,
244    /// Source file:line; `None` for continuations.
245    pub source: Option<String>,
246    /// The primary message text.
247    pub message: String,
248    /// Which line format originated this entry.
249    pub kind: LineKind,
250    /// Semantic classification of the message content.
251    pub message_kind: MessageKind,
252    /// Typed, parsed multi-line block; `None` for entries without a trailing block.
253    pub block: Option<Block>,
254    /// Raw continuation lines that followed the primary line.
255    pub attached: AttachedLines,
256    /// 1-based line number in the input stream.
257    pub line_number: u64,
258    /// Per-entry warnings about parsing anomalies.
259    pub warnings: Vec<ParseWarning>,
260    /// The entry's own texts that ended at a split rather than at their own
261    /// newline, because the logger's write buffer cut the record short.
262    /// [`is_truncated`](LogEntry::is_truncated) is how a span asks about them.
263    pub cut_texts: Vec<FieldLocation>,
264}
265
266impl LogEntry {
267    /// An entry for output the parser never produced — a separator line
268    /// between files or dates, or a hand-built test fixture. Carries no
269    /// uuid, timestamp, level, source or block; override individual fields
270    /// with struct-update syntax for callers that need one set.
271    pub fn synthetic(message: impl Into<String>) -> LogEntry {
272        LogEntry {
273            uuid: None,
274            timestamp: String::new(),
275            level: None,
276            idle_pct: None,
277            source: None,
278            message: message.into(),
279            kind: LineKind::Full,
280            message_kind: MessageKind::General,
281            block: None,
282            attached: AttachedLines::new(),
283            line_number: 0,
284            warnings: Vec::new(),
285            cut_texts: Vec::new(),
286        }
287    }
288}