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