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