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