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::line::LineKind;
11use crate::message::{MessageKind, SdpDirection, VarName};
12use freeswitch_types::LogLevel;
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<(VarName, 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, which name a
70 /// variable the way [`VarName::bare`] does — the same question
71 /// [`SessionState::variable`](crate::SessionState::variable) answers, the
72 /// 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.bare() == 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 PeerUuid,
129}
130
131impl fmt::Display for SessionReading {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 let label = match self {
134 SessionReading::ChannelState => "channel state",
135 SessionReading::CallState => "call state",
136 SessionReading::CallDirection => "call direction",
137 SessionReading::HangupCause => "hangup cause",
138 SessionReading::PeerUuid => "peer uuid",
139 };
140 f.write_str(label)
141 }
142}
143
144/// A parsing anomaly, attached to the entry whose lines produced it.
145///
146/// The set is closed and every kind is named — see `docs/design-rationale.md`.
147#[derive(Debug, Clone, PartialEq, Eq)]
148#[non_exhaustive]
149pub enum ParseWarning {
150 /// A CHANNEL_DATA variable opened its `[` and the block ended before the `]`.
151 /// The value collected so far is still recorded.
152 UnclosedVariable { name: VarName },
153 /// A CHANNEL_DATA variable's value was cut by the logger's write buffer. The
154 /// value is recorded with the lost middle missing: reassembly resumes after
155 /// the cut and runs to the value's `]` or to the next name, whichever comes
156 /// first.
157 TruncatedVariable { name: VarName },
158 /// A line inside a CHANNEL_DATA block matched neither the field nor the
159 /// variable shape, so it contributed nothing to the block. Most often a
160 /// name the write buffer cut mid-token, not a shape the parser cannot read.
161 UnparseableChannelData { line: String },
162 /// A codec negotiation line matched no known trace shape, or its bracketed
163 /// token would not parse. That codec is missing from the block.
164 UnrecognizedCodecLine {
165 line: String,
166 source: Option<CodecParseError>,
167 },
168 /// A continuation line arrived while a codec negotiation block was open.
169 /// The trace has no continuations, so the line belongs to nothing.
170 UnexpectedCodecContinuation { line: String },
171 /// The record ends where `mod_logfile`'s write spent its whole budget, so
172 /// the trailing newline never fit and the text was cut short. Whatever
173 /// followed is either the next record, split out, or lost.
174 CutLine,
175 /// The entry's attached lines outgrew the offsets addressing them, so this
176 /// line could not be stored. Counted in
177 /// [`ParseStats::lines_dropped`](super::ParseStats::lines_dropped).
178 AttachedOverflow { line: String },
179 /// A per-session reading met a value its vocabulary or shape does not
180 /// admit — either FreeSWITCH gained one or the line is corrupt. The state
181 /// that reading feeds keeps its last resolved value.
182 UnreadableValue {
183 reading: SessionReading,
184 value: String,
185 },
186}
187
188impl ParseWarning {
189 /// Trim a line down to what a warning is willing to carry.
190 pub(crate) fn excerpt(msg: &str) -> String {
191 truncate_at_char_boundary(msg, WARNING_EXCERPT_LEN).to_string()
192 }
193}
194
195impl fmt::Display for ParseWarning {
196 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197 match self {
198 ParseWarning::UnclosedVariable { name } => {
199 write!(f, "unclosed multi-line variable: {name}")
200 }
201 ParseWarning::TruncatedVariable { name } => {
202 write!(f, "multi-line variable cut by the log buffer: {name}")
203 }
204 ParseWarning::UnparseableChannelData { line } => {
205 write!(f, "CHANNEL_DATA line is neither a field nor a variable, possibly a fragment left by a cut: {line}")
206 }
207 ParseWarning::UnrecognizedCodecLine {
208 line,
209 source: Some(e),
210 } => write!(f, "unrecognized codec negotiation line ({e}): {line}"),
211 ParseWarning::UnrecognizedCodecLine { line, source: None } => {
212 write!(f, "unrecognized codec negotiation line: {line}")
213 }
214 ParseWarning::UnexpectedCodecContinuation { line } => {
215 write!(f, "unexpected codec negotiation continuation: {line}")
216 }
217 ParseWarning::CutLine => write!(
218 f,
219 "record cut by mod_logfile's {MOD_LOGFILE_BUF_SIZE}-byte write buffer"
220 ),
221 ParseWarning::UnreadableValue { reading, value } => {
222 write!(f, "unreadable {reading}: {value}")
223 }
224 ParseWarning::AttachedOverflow { line } => {
225 write!(f, "entry's attached lines full, dropped: {line}")
226 }
227 }
228 }
229}
230
231/// A complete parsed log entry with all context resolved.
232///
233/// Produced by [`LogStream`](super::LogStream). Continuation lines have been
234/// grouped, UUID/timestamp inherited from context where needed, and
235/// multi-line blocks reassembled.
236#[derive(Debug)]
237pub struct LogEntry {
238 /// Session UUID; `None` for system lines (no channel context).
239 pub uuid: Option<String>,
240 /// Timestamp with microsecond precision; inherited from the previous entry for continuations.
241 pub timestamp: String,
242 /// `None` for continuation and truncated lines.
243 pub level: Option<LogLevel>,
244 /// Core scheduler idle percentage; `None` for continuations.
245 pub idle_pct: Option<String>,
246 /// Source file:line; `None` for continuations.
247 pub source: Option<String>,
248 /// The primary message text.
249 pub message: String,
250 /// Which line format originated this entry.
251 pub kind: LineKind,
252 /// Semantic classification of the message content.
253 pub message_kind: MessageKind,
254 /// Typed, parsed multi-line block; `None` for entries without a trailing block.
255 pub block: Option<Block>,
256 /// Raw continuation lines that followed the primary line.
257 pub attached: AttachedLines,
258 /// 1-based line number in the input stream.
259 pub line_number: u64,
260 /// Per-entry warnings about parsing anomalies.
261 pub warnings: Vec<ParseWarning>,
262 /// The entry's own texts that ended at a split rather than at their own
263 /// newline, because the logger's write buffer cut the record short.
264 /// [`is_truncated`](LogEntry::is_truncated) is how a span asks about them.
265 pub cut_texts: Vec<FieldLocation>,
266}
267
268impl LogEntry {
269 /// An entry for output the parser never produced — a separator line
270 /// between files or dates, or a hand-built test fixture. Carries no
271 /// uuid, timestamp, level, source or block; override individual fields
272 /// with struct-update syntax for callers that need one set.
273 pub fn synthetic(message: impl Into<String>) -> LogEntry {
274 LogEntry {
275 uuid: None,
276 timestamp: String::new(),
277 level: None,
278 idle_pct: None,
279 source: None,
280 message: message.into(),
281 kind: LineKind::Full,
282 message_kind: MessageKind::General,
283 block: None,
284 attached: AttachedLines::new(),
285 line_number: 0,
286 warnings: Vec::new(),
287 cut_texts: Vec::new(),
288 }
289 }
290}