Skip to main content

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}