Skip to content

Repository files navigation

freeswitch-log-parser

CI Tests crates.io docs.rs license

Rust library for parsing FreeSWITCH log files. Three-layer streaming architecture, no regex, single runtime dependency (freeswitch-types for typed enums).

Layers

Layer 1: parse_line()       &str -> RawLine        (stateless, zero-alloc)
Layer 2: LogStream          Iterator -> LogEntry    (structural state machine)
Layer 3: SessionTracker     LogStream -> EnrichedEntry (per-UUID state)

Layer 1 classifies individual log lines into the six LineKind variants (Full, System, UuidContinuation, BareContinuation, Truncated, Empty) and extracts positional fields (UUID, timestamp, idle percentage, log level, source, message). The level is freeswitch-types' LogLevel, re-exported here along with its ParseLogLevelError, and level_from_bracketed reads the log's upper-case [LEVEL] token into one.

Layer 2 groups continuation lines, detects block boundaries (CHANNEL_DATA dumps, SDP bodies), reassembles multi-line variable values, and classifies messages into semantic MessageKind variants: Execute, Dialplan, ChannelData, ChannelField, Variable, SdpMarker, StateChange, CodecNegotiation, Media, ChannelLifecycle, OriginateSuccess, SipInvite, EventSocket, Dtmf, General, plus the synthetic FileChange/DateChange markers. SipInvite is the canonical sip_call_id ↔ channel_uuid correlation primitive — sofia emits it for every inbound and outbound call regardless of dialplan. Every entry carries both a typed Block and raw attached lines.

MessageKind::Variable and a Block::ChannelData dump's keys carry a VarName, not a string: it stores the bare name, and Display/to_prefixed() render the dump's variable_ spelling. The type compares against no &str on purpose — a consumer written against the prefixed literal, or one stripping the prefix a second time, has to be fixed rather than silently matching nothing.

attached is AttachedLines, a bounded buffer. A line past LogStream::max_attached_bytes — or past the 4 GiB its offsets address — is refused rather than stored, reported as ParseWarning::AttachedOverflow on the entry and counted in ParseStats::lines_dropped, so nothing goes missing quietly. ParseStats::unaccounted_lines returns an i64: zero when the accounting balances, either sign when it does not.

Codec negotiation blocks are typed per media type: audio and video traces carry different fields and never share a block, and near-match verdicts are recorded rather than treated as parse failures. With the sdp feature, Block::sdp_codecs() parses an SDP body on demand through freeswitch-types — the only place text and DTMF payloads appear, since the negotiation trace never mentions them. It is off by default so a Layer 2 consumer keeps the crate's minimal dependency tree; fslog enables it.

Layer 3 maintains per-UUID session state (dialplan context, channel state, learned variables, call direction, caller/destination numbers, negotiated codecs per media type) and propagates it across entries. Also links bridged a-leg ↔ b-leg sessions via other_leg_uuid — from the explicit Peer UUID: suffix when present, by matching a live b-leg channel_name when FreeSWITCH omits it, or by mod_loopback's A/B leg naming. Conference membership is tracked per conference instance, so a name reused by a later conference does not merge the two; SessionTracker::conference_members lists an instance's UUIDs. SessionState::variable reads a learned variable by typed name, taking any of freeswitch-types' variable-name enums. Yields EnrichedEntry with a SessionSnapshot alongside the raw LogEntry.

Each layer wraps the previous and can be used independently.

Alongside them the crate exposes the primitives a consumer would otherwise re-derive: find_uuids/is_uuid locate channel UUIDs inside a message or a needle without a regex, for_each_peer_uuid walks the channel variables that name another leg (for_each_peer_uuid_with takes a predicate for names a deployment adds), parse_bridge_args reads a bridge() argument list, and log_rotation_stamp/normalize_entry_timestamp put a rotated filename and an entry timestamp into one comparable form, and stamp_lower_bound/stamp_upper_bound widen a partial date into a window in that same form.

For rewriting log text — redacting a caller id, colorizing a channel name — message_fields and LogEntry::fields return the byte ranges the classifier located (FieldKind::ChannelName, CallerIdNumber, CallId, IpAddr, …), each carrying the FieldLocation that says whether it indexes the message or an attached line. LogEntry::render_with applies a replacement per span; overlaps and character boundaries come back as RenderError rather than a panic. fslog --stats reports which kinds a corpus yields.

Performance

Built to handle the worst mod_logfile produces — 2 KiB buffer truncations, multi-line CHANNEL_DATA dumps with embedded SDP/XML, write-contention collisions. An 11 MB fixture (185 physical lines averaging ~60 KB each) parses in ~60 ms. LogEntry::attached stores its lines end to end in one buffer, which keeps a CHANNEL_DATA-heavy entry to a handful of allocations; iteration via &entry.attached yields each line as &str.

Usage

use std::io::{self, BufRead};
use freeswitch_log_parser::{LogStream, SessionTracker};

let lines = io::stdin().lock().lines().map(|l| l.unwrap());
let stream = LogStream::new(lines);

for enriched in SessionTracker::new(stream) {
    let entry = &enriched.entry;
    println!("{} {} {}", entry.uuid, entry.message_kind, entry.message);
    if let Some(session) = &enriched.session {
        if let Some(ctx) = &session.dialplan_context {
            println!("  context: {ctx}");
        }
    }
}

Unclassified data tracking

Lines that can't be fully classified are tracked, never silently dropped:

use freeswitch_log_parser::{LogStream, UnclassifiedTracking};

let mut stream = LogStream::new(lines)
    .unclassified_tracking(UnclassifiedTracking::TrackLines);

for entry in stream.by_ref() { /* ... */ }

let stats = stream.stats();
eprintln!("{} lines, {} unclassified",
    stats.lines_processed, stats.lines_unclassified);

Custom relationship detection

The built-in leg linking handles standard FreeSWITCH patterns. For application-specific patterns (Lua API results, custom SIP headers), register a hook:

let tracker = SessionTracker::new(stream)
    .with_post_hook(|entry, state| {
        // Check entry.message_kind, state.variables, etc.
        // Set state.other_leg_uuid if pattern matches
    });

A pre-hook (with_pre_hook) runs before built-in detection, so state it seeds is visible to the built-in patterns of the same entry.

See examples/custom_hooks.rs for a complete example.

Multi-file input with segment tracking

TrackedChain concatenates named input segments (typically rotated log files) into a single iterator and records where each segment starts. SegmentTracker then maps any line number back to its source file — useful for emitting FileChange markers or reporting errors with the original filename:

use freeswitch_log_parser::{LogStream, TrackedChain};

let segments = vec![
    ("freeswitch.log.1".into(), open_xz("freeswitch.log.1.xz")),
    ("freeswitch.log".into(), open_plain("freeswitch.log")),
];
let (chain, tracker) = TrackedChain::new(segments);
let stream = LogStream::new(chain);
// ... consume stream; query tracker.segment_for_line(n) as needed.

fslog binary

The crate ships an fslog CLI that turns the parser into a log-search tool: structured, UUID-aware, and able to follow a call across its bridged and transferred legs. It reads rotated .xz files directly and chains them in date order, so a single query spans the whole retention window.

fslog rendering one call with --blocks

fslog read docs/demo.log --blocks — one synthetic call, start to hangup.

Install

Each release carries an amd64 and an arm64 .deb (binary plus bash/zsh/fish completions), the same binaries standalone, and SHA256SUMS:

curl -LO https://github.com/ticpu/freeswitch-log-parser/releases/latest/download/fslog_<version>_amd64.deb
sudo dpkg -i fslog_<version>_amd64.deb

They are built in a Debian bullseye container, so they run on any glibc 2.30 or newer — Debian 11+, Ubuntu 20.04+, RHEL 9+ — and need only liblzma5 beyond libc. make deb reproduces them locally with podman (DEB_ARCH=arm64 for the other architecture).

From source, build with cargo build --release --features cli for everything below, or --features tui to also get the monitor dashboard. The library itself pulls no CLI dependencies unless a feature is enabled. The four features are sdp (SDP body parsing through freeswitch-types), cli, tui — each enabling the one before it — and fixtures, which is orthogonal and gates the production-log test suite on a corpus in tests/fixtures/. docs.rs builds them all.

Commands

Command Purpose
fslog list List discovered log files with dates and sizes
fslog search Filter entries across many files (date range, UUID, level, pattern)
fslog read [FILE] Parse a single file (or stdin with -)
fslog tail [FILE] Follow a file live, parsed and colorized
fslog monitor Live TUI call table (requires --features tui)
fslog completions <SHELL> Emit a shell completion script

Global flags: --dir <PATH> (or FSLOG_DIR, default /var/log/freeswitch), --color auto|always|never, --pager (alias --less), --version.

A FILE argument is resolved the same way by every command that takes one: a path that is absolute or already exists is used as typed, anything else is a name looked for under --dir. Omitted, it is freeswitch.log there.

Output goes straight to stdout unless --pager is given, and the pager is started only once there is something to show, so an empty search never leaves less holding the terminal. FSLOG_PAGER overrides the command (default less -RFX); tail and completions never page.

search

fslog search [OPTIONS] [PATTERN]

PATTERN is a case-insensitive fixed-string shorthand for --fgrep.

Filtering:

  • -u, --uuid <UUID> — session UUID substring, matched against the channel-UUID column; repeat for OR matching
  • -l, --level <LEVEL> — keep entries at this severity or more severe; debug keeps everything, console only the most severe
  • -c, --category <KIND> — message kind (execute, dialplan, media, …); repeat for OR matching
  • --fgrep <PATTERN> — case-insensitive fixed-string match on the message
  • --grep <REGEX> — regex match on the message
  • --codec <NAME> — codec named in a negotiation or SDP block, including on a stream the SDP holds, case-insensitive; repeat for OR matching
  • --match-blocks — also match --fgrep/--grep/PATTERN inside attached block lines (SDP, CHANNEL_DATA, codec negotiation), not just the message
  • --related — expand matching sessions to their bridged/transferred peer legs (originate, bridge(), uuid_bridge, Other-Leg-Unique-ID, peer-UUID channel variables, mod_loopback A/B legs) and to everyone in the same conference

Pattern search reads the message field; -u reads the channel-UUID column. Neither reaches the other's, so --grep <uuid> shows only the lines that name the session and -u <uuid> only the lines it produced. Entries kept out by that boundary alone are counted and reported on stderr at end of run, with the flag — and where the pattern names a UUID, the command — that widens to them:

$ fslog read --grep 393d8167-062d-4652-b86e-c4e0acf488ff
17:03:56.190807  notice 393d8167-…88ff [channel-lifecycle] New Channel sofia/…
note: 49 more entries carry this pattern in the channel-UUID column, which --grep does not search
hint: rerun with -u 393d8167-062d-4652-b86e-c4e0acf488ff

Context (grep-style), with -- dividers between non-contiguous groups:

  • -A, --after-context <N>, -B, --before-context <N>, -C, --context <N>

Output and selection:

  • --blocks — expand structured content inline (see below)
  • --session — annotate each entry with tracked state (context, channel state, channel name, conference and member id)
  • --stats / --unclassified — summary and unclassified-line report
  • -n, --line-numbers — show physical line numbers
  • --from <DATE> / --until <DATE> — bound discovery (progressive: 2026-03, 2026-03-08, 2026-03-08T15:48)
  • --on <DATE> — a single day; --today — today, in the machine's local timezone
  • --file <FILE> — scan explicit files instead of date discovery (repeatable)
  • -y, --yes — skip the confirmation prompt for large scans

Reading the output

Each entry is one line — time, level, UUID, message kind, message — followed by whatever structure it carries. Time and level share the level's color, so a run of one severity reads as a band down the left edge.

UUIDs get a stable per-call truecolor, in the UUID column and wherever one appears inside a message or a continuation line, so a bridge target is the same color as the leg it names.

Continuation lines the parser did not fold into a typed block — dialplan condition traces, EXECUTE output — print inline under --blocks, with (PASS) and (FAIL) verdicts colored. Without --blocks they collapse to a count, unless there is only one.

A dialplan block prints its channel once, on the header, and every line under it starts with what differs:

19:06:09.550802    info 9865d278-…78f2 [dialplan] Dialplan: sofia/internal/1262@pbx:5062
         parsing [default->unloop] continue=false
         Regex (PASS) [unloop] ${unroll_loops}(true) =~ /^true$/ break=on-false
         Regex (FAIL) [unloop] ${sip_looped_call}() =~ /^true$/ break=on-false
         Action set(call_debug=false)

--blocks also expands:

  • CHANNEL_DATA — every field and variable, values in full
  • SDP — the body, tagged local or remote, over a summary of the codecs and DTMF/comfort-noise payloads it offers and of the streams it holds or the parser reads no payload type from
  • Codec negotiation — offered-versus-local comparisons and what matched
  • Dial stringsbridge() and att_xfer() arguments broken into their global variables, failover groups and endpoints, with ARRAY:: values split into entries and presence_id surfaced as the extension

Speed

search decompresses and parses only what it must. When the search term is a full UUID or a SIP Call-ID — identifiers that cannot span a line break — candidate files are scanned for the raw bytes in parallel first, and those that cannot match are never parsed. Free-text and regex searches read everything, because a match there can legitimately span an entry and the continuation lines the parser reassembles into it.

Scanning a large set prompts first; -y skips the prompt, and FSLOG_CONFIRM_SIZE (bytes) moves the threshold. When nothing matches, search reports the span of log files on hand, so "not in these logs" is distinguishable from "already rotated away".

Examples

# Follow one call across all its legs, with session state
fslog search --from 2026-03-08 -u 9bee8676 --related --session

# grep a string with two lines of context on each side
fslog search --from 2026-03-08 'receiving invite' -C 2

# Find calls by an SDP attribute that only appears in the media block
fslog search --from 2026-03-08 --grep 'm=audio' --match-blocks --blocks

# Every leg that negotiated or was offered opus, with the codec detail
fslog search --from 2026-03-08 --codec opus --blocks

# Errors and worse from one session, expanding structured blocks
fslog search --from 2026-03-08 -u 9bee8676 -l err --blocks

# Today's dialplan and execute traces, expanded
fslog search --today -c dialplan -c execute --blocks

Related crates

FreeSWITCH and SIP crates by the same author, usable independently:

  • freeswitch-types — typed FreeSWITCH vocabulary: call direction, channel and call state, hangup causes, the log level and its parse error, the event-header names, the channel variable enums and their variable_ prefix, and the loopback channel-name parser. This crate's only runtime dependency.
  • freeswitch-sofia-trace-parser — parses sofia's tport SIP traces. Complements this crate: fslog gives you the channel-level view, the trace parser gives you the SIP messages behind it.
  • freeswitch-esl-tokio — async ESL client, for reading events off a live switch rather than off its logs.
  • sip-uri — RFC 3261 SIP/SIPS, RFC 3966 tel:, RFC 8141 URN parser. Zero dependencies.
  • sip-header — SIP header field parsers (name-addr, Call-Info, History-Info, Geolocation, conference-info).

License

LGPL-2.1-or-later

About

Rust library and CLI (fslog) for parsing FreeSWITCH logs: streaming line parser, session/call correlation, no regex.

Topics

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages