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