freeswitch_log_parser/session/tracker.rs
1//! Layer 3 per-session state machine.
2
3use std::collections::{HashMap, HashSet};
4
5use crate::message::{LifecycleEvent, MessageKind};
6use crate::stream::{LogEntry, LogStream, ParseStats, UnclassifiedLine};
7use crate::uuid::is_uuid;
8
9use super::conference::{
10 self, ConferenceEvent, ConferenceMembership, ConferenceRegistry, ConferenceTarget,
11};
12use super::index::IndexedFields;
13use super::loopback;
14use super::parse::parse_new_channel;
15use super::state::{SessionSnapshot, SessionState};
16use super::SessionHook;
17
18/// A [`LogEntry`] paired with the session's state snapshot at that point in time.
19#[derive(Debug)]
20pub struct EnrichedEntry {
21 pub entry: LogEntry,
22 /// `None` exactly when `entry.uuid` is `None` — system lines carry no session.
23 pub session: Option<SessionSnapshot>,
24}
25
26/// Layer 3 per-session state machine — tracks per-UUID state (dialplan context,
27/// channel state, variables) across entries and yields [`EnrichedEntry`] values.
28///
29/// Wraps a [`LogStream`] and maintains a `HashMap<String, SessionState>` keyed by UUID.
30/// Sessions are never automatically cleaned up; call [`remove_session()`](SessionTracker::remove_session)
31/// when a call ends.
32pub struct SessionTracker<I> {
33 inner: LogStream<I>,
34 pub(super) sessions: HashMap<String, SessionState>,
35 pub(super) by_channel_name: HashMap<String, HashSet<String>>,
36 /// Bridge target name to the sessions waiting on it. A target string repeats
37 /// across concurrent calls, so this is a set — a lone live candidate links,
38 /// several link nothing.
39 pub(super) by_pending_target: HashMap<String, HashSet<String>>,
40 pub(super) by_other_leg: HashMap<String, String>,
41 pub(super) conferences: ConferenceRegistry,
42 pre_hook: Option<SessionHook>,
43 post_hook: Option<SessionHook>,
44}
45
46impl<I: Iterator<Item = String>> SessionTracker<I> {
47 /// Wrap a [`LogStream`] to add per-session state tracking.
48 pub fn new(inner: LogStream<I>) -> Self {
49 SessionTracker {
50 inner,
51 sessions: HashMap::new(),
52 by_channel_name: HashMap::new(),
53 by_pending_target: HashMap::new(),
54 by_other_leg: HashMap::new(),
55 conferences: ConferenceRegistry::default(),
56 pre_hook: None,
57 post_hook: None,
58 }
59 }
60
61 /// Register a hook that runs BEFORE built-in field extraction.
62 ///
63 /// Use this to override how specific fields are extracted. Fields set
64 /// by the pre-hook may be preserved by built-in extraction if it uses
65 /// `is_none()` guards. Indexed fields set by the hook (`channel_name`,
66 /// `other_leg_uuid`) feed cross-session leg correlation like built-in
67 /// extraction does.
68 pub fn with_pre_hook<F>(mut self, hook: F) -> Self
69 where
70 F: Fn(&LogEntry, &mut SessionState) + Send + 'static,
71 {
72 self.pre_hook = Some(Box::new(hook));
73 self
74 }
75
76 /// Register a hook that runs AFTER all built-in processing.
77 ///
78 /// Use this for custom field extraction and relationship detection.
79 /// The hook can read fields populated by built-in extraction and
80 /// fill gaps with application-specific patterns (e.g., `uuid_bridge`
81 /// API results, custom SIP headers). Indexed fields set by the hook
82 /// (`channel_name`, `other_leg_uuid`) feed cross-session leg
83 /// correlation like built-in extraction does.
84 ///
85 /// # Example
86 ///
87 /// ```
88 /// use freeswitch_log_parser::{LogStream, SessionTracker, MessageKind};
89 ///
90 /// let stream = LogStream::new(std::iter::empty::<String>());
91 /// let tracker = SessionTracker::new(stream)
92 /// .with_post_hook(|entry, state| {
93 /// if let MessageKind::Execute { application, arguments, .. } = &entry.message_kind {
94 /// if application == "set" && arguments.starts_with("api_result=+OK ") {
95 /// // extract UUID and set state.other_leg_uuid
96 /// }
97 /// }
98 /// });
99 /// ```
100 pub fn with_post_hook<F>(mut self, hook: F) -> Self
101 where
102 F: Fn(&LogEntry, &mut SessionState) + Send + 'static,
103 {
104 self.post_hook = Some(Box::new(hook));
105 self
106 }
107
108 /// All currently tracked sessions, keyed by UUID.
109 pub fn sessions(&self) -> &HashMap<String, SessionState> {
110 &self.sessions
111 }
112
113 /// UUIDs currently in the conference instance named by
114 /// [`ConferenceMembership::instance`]. Empty once the last member leaves.
115 pub fn conference_members<'a>(&'a self, instance: &'a str) -> impl Iterator<Item = &'a str> {
116 self.conferences.members(instance)
117 }
118
119 /// Remove and return a session's accumulated state. Call this when a call ends
120 /// (e.g. `CS_DESTROY` or hangup) to free memory.
121 pub fn remove_session(&mut self, uuid: &str) -> Option<SessionState> {
122 let state = self.sessions.remove(uuid)?;
123 // Removal is the every-field-to-None diff, so a field indexed later
124 // cannot be forgotten here.
125 self.apply_index_changes(uuid, IndexedFields::of(&state), IndexedFields::default());
126 // The peer's own pointer at this session is not in that diff, and left
127 // standing it back-links the next channel to reuse the uuid.
128 self.by_other_leg.remove(uuid);
129 Some(state)
130 }
131
132 /// Delegates to [`LogStream::stats()`].
133 pub fn stats(&self) -> &ParseStats {
134 self.inner.stats()
135 }
136
137 /// Delegates to [`LogStream::drain_unclassified()`].
138 pub fn drain_unclassified(&mut self) -> Vec<UnclassifiedLine> {
139 self.inner.drain_unclassified()
140 }
141 /// Conference membership. Called after `update_from_entry` so the channel
142 /// variables this reads are already populated. Only `state.conference` is
143 /// written here; the registry is updated from the post-hook diff, so a
144 /// hook-set membership is registered the same way this one is.
145 fn update_conference(&mut self, uuid: &str, entry: &LogEntry) {
146 let joined = match conference::detect(entry) {
147 Some(ConferenceEvent::Leave) => {
148 if let Some(state) = self.sessions.get_mut(uuid) {
149 state.conference = None;
150 }
151 return;
152 }
153 Some(ConferenceEvent::Join(target)) => Some(target),
154 None => None,
155 };
156
157 let seat = self.conference_seat(uuid, joined);
158 if let Some(state) = self.sessions.get_mut(uuid) {
159 seat_conference(state, seat);
160 }
161 }
162
163 /// The conference this entry puts `uuid` in, paired with the instance
164 /// identity to use. Staying in the same conference keeps the instance
165 /// already recorded; otherwise adopt the live instance for that name, or
166 /// open one keyed on this session because it is the first member.
167 fn conference_seat(
168 &self,
169 uuid: &str,
170 joined: Option<ConferenceTarget>,
171 ) -> Option<(ConferenceTarget, String)> {
172 let state = self.sessions.get(uuid)?;
173 let target = joined.or_else(|| conference::target_from_variables(&state.variables))?;
174 let instance = match state.conference.as_ref() {
175 Some(current) if current.name == target.name => current.instance.clone(),
176 _ => self
177 .conferences
178 .instance_for(&target.name)
179 .map(str::to_string)
180 .unwrap_or_else(|| uuid.to_string()),
181 };
182 Some((target, instance))
183 }
184
185 /// The one live session among `candidates` other than `exclude`, or `None`
186 /// when the log leaves the choice ambiguous. Sessions in a terminal state are
187 /// stragglers from earlier calls and never count.
188 fn sole_live_leg(&self, candidates: &HashSet<String>, exclude: &str) -> Option<String> {
189 let mut live = candidates
190 .iter()
191 .filter(|u| u.as_str() != exclude)
192 .filter(|u| {
193 self.sessions
194 .get(*u)
195 .map(|s| !s.is_terminal())
196 .unwrap_or(false)
197 });
198 match (live.next(), live.next()) {
199 (Some(only), None) => Some(only.clone()),
200 _ => None,
201 }
202 }
203
204 /// The one live session answering to `channel`, other than `exclude`.
205 fn unique_live_leg(&self, channel: &str, exclude: &str) -> Option<String> {
206 self.sole_live_leg(self.by_channel_name.get(channel)?, exclude)
207 }
208
209 /// The one live session waiting on bridge target `target`, other than `exclude`.
210 fn unique_pending_leg(&self, target: &str, exclude: &str) -> Option<String> {
211 self.sole_live_leg(self.by_pending_target.get(target)?, exclude)
212 }
213
214 /// The A leg of the loopback whose B leg just appeared. Concurrent loopbacks
215 /// to the same destination produce identical names, so this inherits the
216 /// ambiguity guard rather than guessing between them.
217 fn loopback_a_leg(&self, b_channel: &str, b_uuid: &str) -> Option<String> {
218 let a_channel = loopback::a_leg_name(b_channel)?;
219 self.unique_live_leg(&a_channel, b_uuid)
220 }
221
222 /// Point two legs at each other and retire the A leg's pending bridge target.
223 ///
224 /// The diff bracket around `next()` covers whichever leg produced the entry
225 /// and neither the peer nor, on the back-link path, the A leg — so each side
226 /// takes a bracket of its own and one mechanism maintains every index.
227 fn link_pair(&mut self, a_uuid: &str, b_uuid: &str) {
228 self.mutate_indexed(a_uuid, |a| {
229 a.other_leg_uuid = Some(b_uuid.to_string());
230 a.pending_bridge_target = None;
231 });
232 self.mutate_indexed(b_uuid, |b| {
233 b.other_leg_uuid = Some(a_uuid.to_string());
234 });
235 }
236
237 /// Cross-session leg linking. Called after `update_from_entry` so per-session
238 /// state (bridge target, channel name) is already populated.
239 fn link_legs(&mut self, uuid: &str, entry: &LogEntry) {
240 match &entry.message_kind {
241 // A suffix that is not a UUID names a peer this cannot resolve, and
242 // falling through to the channel name would link the wrong leg.
243 MessageKind::OriginateSuccess {
244 peer_uuid: Some(peer),
245 ..
246 } => {
247 if is_uuid(peer) {
248 self.link_pair(uuid, peer);
249 }
250 }
251 // Builds whose originate line omits the `Peer UUID:` suffix leave the
252 // channel name as the only handle on the B leg.
253 MessageKind::OriginateSuccess {
254 channel,
255 peer_uuid: None,
256 } => {
257 if let Some(b_uuid) = self.unique_live_leg(channel, uuid) {
258 self.link_pair(uuid, &b_uuid);
259 }
260 }
261 // New Channel on this UUID — another session may have been waiting for
262 // it, either by forced origination UUID or by the target it named.
263 MessageKind::ChannelLifecycle {
264 event: LifecycleEvent::NewChannel,
265 detail,
266 } => {
267 if let Some(channel_name) = parse_new_channel(detail) {
268 let a_uuid = self
269 .by_other_leg
270 .get(uuid)
271 .cloned()
272 .or_else(|| self.unique_pending_leg(channel_name, uuid))
273 .or_else(|| self.loopback_a_leg(channel_name, uuid))
274 .filter(|a| a.as_str() != uuid);
275
276 if let Some(a_uuid) = a_uuid {
277 self.link_pair(&a_uuid, uuid);
278 }
279 }
280 }
281 _ => {}
282 }
283 }
284}
285
286/// Seat the session in the conference resolved for it, then refresh whatever
287/// membership it holds against its variables.
288fn seat_conference(state: &mut SessionState, seat: Option<(ConferenceTarget, String)>) {
289 let SessionState {
290 conference,
291 variables,
292 ..
293 } = state;
294 if let Some((target, instance)) = seat {
295 if conference.as_ref().is_none_or(|c| c.name != target.name) {
296 *conference = Some(ConferenceMembership {
297 name: target.name,
298 profile: target.profile.clone(),
299 instance,
300 member_id: None,
301 conference_uuid: None,
302 });
303 }
304 if let (Some(membership), Some(profile)) = (conference.as_mut(), target.profile) {
305 membership.profile = Some(profile);
306 }
307 }
308 if let Some(membership) = conference.as_mut() {
309 conference::refresh(membership, variables);
310 }
311}
312
313impl<I: Iterator<Item = String>> Iterator for SessionTracker<I> {
314 type Item = EnrichedEntry;
315
316 fn next(&mut self) -> Option<EnrichedEntry> {
317 let mut entry = self.inner.next()?;
318
319 let Some(uuid) = entry.uuid.clone() else {
320 return Some(EnrichedEntry {
321 entry,
322 session: None,
323 });
324 };
325
326 let state = self.sessions.entry(uuid.clone()).or_default();
327
328 // One bracket over pre-hook, extraction and post-hook, so a hook-set
329 // field maintains the cross-session indexes too.
330 let old = IndexedFields::of(state);
331
332 if let Some(hook) = &self.pre_hook {
333 hook(&entry, state);
334 }
335
336 let unreadable = state.update_from_entry(&entry);
337
338 self.update_conference(&uuid, &entry);
339 self.link_legs(&uuid, &entry);
340
341 let post_hook = self.post_hook.as_ref();
342 // Inserted above, and nothing between here removes a session.
343 let state = self
344 .sessions
345 .get_mut(&uuid)
346 .expect("the session this entry opened is still tracked");
347 if let Some(hook) = post_hook {
348 hook(&entry, state);
349 }
350
351 let new = IndexedFields::of(state);
352 let snapshot = state.snapshot();
353 self.apply_index_changes(&uuid, old, new);
354 entry.warnings.extend(unreadable);
355
356 Some(EnrichedEntry {
357 entry,
358 session: Some(snapshot),
359 })
360 }
361}