Filter DSL - NormB/sipnab GitHub Wiki

Quick start: sipnab --filter "state == 'Failed'" to find all failed calls, or sipnab --problems for a one-flag diagnostic sweep.

sipnab includes a declarative, non-Turing-complete filter language for matching SIP dialogs and their associated RTP streams. You pass expressions through the --filter CLI flag or the expression key in the [filter] config section. The Diagnostic Aliases CLI flags (--problems, --slow-setup, and so on) expand to the named aliases documented below.

What a filter narrows

A filter selects dialogs, and every output that lists dialogs honors it:

  • The per-message stream (the default output, --json, -T) emits only messages belonging to matching dialogs.
  • --json-dialogs emits one line per matching dialog.
  • --report prints only matching dialogs, and the RTP tables beneath the dialog table hold only the streams linked to them. With no filter it still lists every stream the capture holds, orphans included.
  • --call-report <CALL-ID> is not narrowed: it names one call, and a lookup by name is not a listing.

Grammar

expr        = or_expr
or_expr     = and_expr ("OR" and_expr)*
and_expr    = not_expr ("AND" not_expr)*
not_expr    = "NOT" atom | atom
atom        = comparison | "(" expr ")"
comparison  = field operator value

Operator precedence (highest to lowest): NOT, AND, OR. Use parentheses to override.

Fields

All 32 addressable fields, organized by type. They answer to 33 names: response_code is also spelled final_status_code, and an alias is not a second field.

String fields

Field Description Example Values
from.user User part of the SIP From header "1001", "alice"
to.user User part of the SIP To header "1002", "bob"
method SIP request method "INVITE", "REGISTER", "BYE"
ua User-Agent header (first non-empty across dialog messages) "Olle", "friendly-scanner"
call_id SIP Call-ID header "abc123@host"
payload Raw message content โ€” matches when ANY message in the dialog contains/matches the value (payload grep) "X-Custom-Header", "sipsak"
src.ip Source IP address (first message) "192.0.2.1"
dst.ip Destination IP address (first message) "192.0.2.2"
state Dialog state machine value "Trying", "InCall", "Failed"
response_class IANA class of the final response "2xx", "4xx", "5xx", "6xx"
rtp.codec RTP codec name (matches if ANY linked stream matches) "PCMU", "opus"
rtp.ssrc RTP SSRC in hex format (matches if ANY linked stream matches) "0x12345678"

Valid state values: Trying, Ringing, InCall, Completed, Canceled, Failed, Redirected, Registered, Expired, Pending, Active, Terminated, Transferring

payload vs -e/--match: the payload field matches per dialog (true if any message matches). The -e/--match flag is the per-message match-expression: it selects the matching messages and then follows the dialog โ€” every message after the first match in that dialog is emitted too. Use -e for grep-style streaming output; use payload inside a larger --filter expression.

Numeric fields

Field Description Unit
src.port Source port (first message) port number
dst.port Destination port (first message) port number
duration Dialog duration seconds (float)
msg_count Number of SIP messages in dialog count
response_code Final INVITE response code. Also spelled final_status_code, which is the name the JSON, the schema and the MCP answers return. Unknown while the call is in progress, so a ringing dialog matches no comparison at all 100-699
pdd Post-dial delay (time to first ringing/response) seconds (float)
setup_time Call setup time (INVITE to 200 OK) seconds (float)
retransmits Total retransmit count in dialog count
rtp.mos Mean Opinion Score (worst across streams, E-model R-factor approximation) 1.0 - 5.0, unknown with no RTP
rtp.jitter Jitter (worst/highest across streams) milliseconds, unknown with no RTP
rtp.loss Packet loss (worst/highest across streams) percentage (0-100), unknown with no RTP
rtp.packets Total RTP packets (sum across all streams) count (0 is a real count, never unknown)

An unmeasured value matches no comparison. pdd, setup_time, rtp.mos, rtp.jitter and rtp.loss are unknown when the capture holds nothing to measure them from โ€” no RTP for the media fields, no captured INVITE or 200 OK for the timing ones. An unknown matches no numeric operator, including !=: it is not "different from 3.0", it is unknown. This is the rule SQL uses for NULL, for the same reason.

Reading them as 0 instead would put them below every threshold anyone would type, so rtp.mos < 3.0 would select every call carrying no media at all: 2292 of 2311 dialogs on one real trunk capture, rather than the 2 genuine ones. A saved filter carrying an AND rtp.packets > 0 guard is redundant rather than wrong.

To select the calls that carry no media, ask for that directly: rtp.packets == 0 is a real count of zero, and no_media is the diagnosis itself. Pair any rtp.* threshold with rtp.packets > 0 to ask about calls that actually had media.

Asking by class

The IANA registry groups every response code into six classes, and response_class is that class. Ask by number or by the registry's own name โ€” they mean the same thing:

Class Number Name
Provisional 1xx provisional
Successful 2xx successful, success
Redirection 3xx redirection, redirect
Request Failure 4xx request failure, client failure
Server Failure 5xx server failure
Global Failures 6xx global failures, global failure
sipnab -N -I capture.pcap --filter "response_class == 'server failure'"

failure means all three failure classes โ€” 4xx, 5xx and 6xx together โ€” because that is what the word means at a console. failures and failed are the same question. Naming one class is how you ask for one class:

sipnab -N -I capture.pcap --filter "response_class == 'failure'"

Matching is membership, not string equality, so != stays honest: a 503 is a server failure by every spelling, and response_class != 'Server Failure' is false for it. Case, hyphens and underscores are typing variants, not different questions.

response_code >= 500 AND response_code < 600 says the same as '5xx', and says it as arithmetic. A bound wrong by one is silent.

A dialog is never 1xx, and that is not an omission. This is the class of the FINAL response, and a provisional one is by definition not final โ€” a call sitting on 183 has no outcome yet, so it has no class at all, the same as a call that has heard nothing. Reach for state there instead.

This is not the classification on the response-code reference. That page groups codes by what they mean for a CALL, and it is narrower: there a challenge, a cancellation and a decline are each not failures, because each tells the operator to do something different. response_class is the numeric registry and nothing else. Neither one borrows the other's name, deliberately.

Why response_code and not just state

state answers what happened. response_code answers why, and they are not the same question. Every one of 403, 404, 408, 486, 503 and 603 is state == 'Failed', and they have different owners: a 403 is authorization, a 408 is a timer, a 486 is the callee, a 503 is capacity upstream. Asking "which release cause dominates on this trunk in the last ten minutes" needs the code, and asking it by class needs a numeric field rather than a set of names:

Every server-side failure, whatever the specific cause:

sipnab -N -I capture.pcap --filter "response_code >= 500 AND response_code < 600"

The one cause you suspect, on the carrier you suspect:

sipnab -N -I capture.pcap --filter "response_code == 503 AND dst.ip == '198.51.100.7'"

A call still in progress has no final response. It matches NOTHING -- not response_code < 400, not >= 400, not == 0 -- because a zero default would sweep every ringing call into the success bucket. Auth challenges follow final_status_code: a call challenged and then answered reports 200, not the 407, because the challenge was intermediate. A call only ever challenged reports the challenge, because then it was the outcome.

Boolean fields

Field Description
one_way True if one-way audio detected (via diagnosis engine)
nat_mismatch True if RTP arrived from an address no SDP in the dialog advertised โ€” see the note below
no_media True if an answered call negotiated media that never arrived โ€” see the note below
codec_asymmetry True if A and B legs negotiated different RTP codecs
ptime_asymmetry True if the two legs use different ptime (packetization interval)
payload_asymmetry True if dynamic payload type numbers differ across legs (with the same codec)
duration_asymmetry True if one leg's media duration is materially shorter than the other's
late_media True if RTP starts noticeably later than the answering 200 OK

What nat_mismatch compares. A dialog advertises a media address on each side โ€” the caller's in the offer, the callee's in the answer โ€” and a re-INVITE may add more. nat_mismatch is true when a stream carries a source address that none of them named. Set membership, not equality against one c= line: a healthy two-way call sources RTP from two different advertised addresses, so comparing each stream against a single c= would flag one direction of every call in the capture. Addresses only, never ports, because NAT and RTP proxies rewrite the port on ordinary calls without breaking anything.

What no_media requires. All four: the far end answered an offer, the dialog reached a 2xx, at least one exchange described media that should carry audio, and no RTP arrived for the call. "Should carry audio" excludes the forms that ask for silence โ€” a=inactive, m= port 0, a black-holed c=0.0.0.0, and non-RTP transports such as T.38 m=image ... udptl โ€” so a call held for its whole life is not reported as a media failure.

no_media is silent on a capture that holds no RTP. A signaling-only tap, a HEP feed or --no-rtp cannot show that one call had no audio, because no call in it has any. On such a capture the flag stays false by design rather than selecting every answered dialog. Pair no_media with the knowledge of where your tap sits: if media does not traverse it, the question is unanswerable and sipnab declines to answer it.

rtp.orphaned is a parse error, not a field. It would ask whether a stream belonging to this dialog belongs to no dialog: a stream counts as an orphan exactly when no dialog claims it, so the two halves exclude each other and the field matches nothing on any capture while NOT rtp.orphaned matches everything. Rejecting it at the parser beats a silent falsehood. Orphaned media is real and still reachable: read the "Orphaned Streams" section of --report, or the REST API's /v1/streams?orphaned=true, both of which model streams rather than dialogs.

Operators

Operator Applies To Description
== string, numeric, boolean Equal
!= string, numeric, boolean Not equal
< string, numeric Less than
> string, numeric Greater than
<= string, numeric Less than or equal
>= string, numeric Greater than or equal
=~ string Regex match (Rust regex syntax)
in_subnet address-valued string The left address falls inside the right CIDR block

Notes:

  • Boolean fields only support == and !=.
  • Regex (=~) is not applicable to numeric or boolean fields.
  • in_subnet takes a CIDR literal on the right and an address-valued field on the left (src.ip, dst.ip). It uses the same rule as --hep-allow, so IPv4, IPv6 and IPv4-mapped IPv6 addresses (RFC 4291 section 2.5.5.2) all compare as the allowlist compares them, and a bare address with no prefix reads as a host route. An unparseable address or block matches nothing rather than matching everything.
  • Numeric equality uses epsilon comparison for floating-point precision. For computed values (duration, pdd, rtp.mos) prefer range operators (>=, <) over == โ€” an exact match on a derived float rarely hits.

Note: String comparisons are case-sensitive. state values must exactly match one of the 13 values listed under String Fields above ('Failed', not 'failed'). Use =~ with a case-insensitive regex pattern if you need case-insensitive matching: state =~ '(?i)failed'.

Values

Syntax Type Examples
'...' or "..." String 'INVITE', "alice"
Number Numeric (f64) 3.0, 100, 0.5
true / false Boolean (case-insensitive) true, FALSE
'...' with =~ Regex 'friendly.*scanner', '^1001'

Inside a quoted string a backslash escapes the next character, so the delimiter itself is expressible: \' and \" yield a literal quote ('it\'s', "say \"hi\""). Every other \x sequence โ€” including \\ โ€” stays verbatim, backslash and all, so regex metacharacters and classes still reach the engine unchanged (from.user =~ '\d\d\d\d', payload =~ 'example\.com', and '\\' for a literal backslash in a regex).

Tip: sipnab compiles each regex once and reuses it across all messages. Avoid unbounded quantifiers on large captures (e.g., prefer from.user =~ '^100[0-9]$' over from.user =~ '.*100[0-9].*').

Boolean combinators

Keyword Description
AND Both sides must match (case-insensitive)
OR Either side must match (case-insensitive)
NOT Negates the following atom (case-insensitive)

Parentheses ( ) group sub-expressions to override default precedence.

Named aliases

These preset expressions are available as dedicated CLI flags where they exist (--problems, etc.), as shorthand to --filter (e.g. --filter codec-asym), and as kinds entries for the MCP find_problems tool. They expand to DSL expressions internally.

Alias Dedicated CLI Flag Expansion
problems --problems state == 'Failed' OR one_way == true OR rtp.loss > 5.0 OR rtp.jitter > 50.0 OR nat_mismatch == true OR retransmits > 3 OR pdd > 11.0 OR codec_asymmetry == true OR ptime_asymmetry == true OR payload_asymmetry == true OR duration_asymmetry == true OR late_media == true
slow-setup --slow-setup pdd > 11.0
short-calls --short-calls duration < 3.0 AND state == 'Completed'
one-way --one-way one_way == true
nat-issues --nat-issues nat_mismatch == true
codec-asym โ€” (use --filter codec-asym) codec_asymmetry == true
ptime-asym โ€” (use --filter ptime-asym) ptime_asymmetry == true
payload-asym โ€” (use --filter payload-asym) payload_asymmetry == true
duration-asym โ€” (use --filter duration-asym) duration_asymmetry == true
late-media โ€” (use --filter late-media) late_media == true

--filter first tries to resolve the argument as an alias name. If no alias matches, it parses the argument as a DSL expression. The alias and the expression it expands to select the same dialogs, so sipnab -N -I capture.pcap --filter codec-asym and sipnab -N -I capture.pcap --filter "codec_asymmetry == true" are equivalent โ€” pick whichever reads better in the command you are writing.

The dedicated flag, the --filter <alias> spelling and the MCP kinds entry are three names for one expression: sipnab --short-calls, sipnab --filter short-calls and sipnab --filter "duration < 3.0 AND state == 'Completed'" all select the same dialogs. Combining several flags ORs their expansions together. If --filter is also present, its expression is ANDed with that result. For example, --problems --filter "from.user == '1001'" keeps problem calls from user 1001.

--nat-issues / --filter nat-issues selects the calls whose RTP arrived from an address the SDP never advertised. The boolean-field note above says what that means and what it deliberately ignores.

Examples

Each entry below is one complete expression, and --filter takes exactly one: these are catalogs to pick a line from, not blocks to copy whole.

Basic field matching

  • method == 'INVITE'
  • from.user == '1001'
  • state == 'InCall'

Regex matching

  • ua =~ 'friendly-scanner'
  • from.user =~ '^100[0-9]'
  • call_id =~ 'abc.*@'

Numeric comparisons

  • pdd > 3.0
  • rtp.mos < 3.0
  • rtp.loss > 2.0
  • duration < 5.0
  • retransmits > 3
  • rtp.jitter > 50.0
  • rtp.packets > 10000

Matching boolean fields

  • one_way == true
  • nat_mismatch == true
  • no_media == true
  • codec_asymmetry == true
  • late_media == true

Compound expressions

  • method == 'INVITE' AND rtp.mos < 3.0
  • from.user =~ '^1001' AND state == 'Failed'
  • pdd > 3.0 OR retransmits > 5
  • NOT ua =~ 'friendly-scanner'
  • (state == 'Failed' OR state == 'Canceled') AND duration < 1.0

Real-world diagnostic queries

The DSL has no comment syntax, so this page labels each query in prose โ€” a # line handed to --filter is a parse error, not a note.

  • Find calls with poor quality from a specific extension: from.user =~ '^1001' AND rtp.mos < 3.0
  • Find failed registrations from a subnet: method == 'REGISTER' AND state == 'Failed' AND src.ip =~ '^198\.51\.100\.'
  • Find short calls that completed (possible robocalls): duration < 5.0 AND state == 'Completed' AND method == 'INVITE'
  • Find calls with audio issues: one_way == true OR no_media == true OR rtp.jitter > 100.0
  • Find scanner activity by User-Agent: ua =~ 'sipvicious|friendly-scanner|sipcli'

Note: The filter DSL evaluates against dialogs, not individual messages. A filter like method == 'INVITE' matches dialogs that opened with an INVITE, including all subsequent messages in that dialog (180, 200, ACK, BYE, etc.).

Operational Recipes

Filter-DSL recipes, one per real-world task, each a complete command line. Swap -I capture.pcap for -d eth0 (as root) to run any of them against live traffic. For broader task recipes beyond the DSL, see the Cookbook and Troubleshooting.

Poor audio quality (low MOS)

sipnab -N -I capture.pcap --filter "rtp.mos < 3.0 AND rtp.packets > 0 AND state == 'Completed'" --json

rtp.packets > 0 no longer guards against a 0.0: an unmeasured rtp.mos is UNKNOWN, and an unknown value matches no threshold in either direction -- compare_opt_num returns false for an absent field, and a_dialog_with_no_rtp_does_not_match_a_mos_threshold in src/sip/dsl.rs holds that. This page used to say the clause was mandatory because a signaling-only dialog reported 0.0 and satisfied the threshold. That was true before the fix and is not true now. The clause is still worth keeping for a different reason: it excludes calls that carried too few packets for the E-model to mean anything, which is a judgment about sample size rather than a guard against a phantom zero. Only completed calls -- in-progress calls may not have enough RTP data for an accurate MOS calculation. MOS values follow the ITU-T G.107 E-model: 4.0+ is toll quality, 3.5-4.0 is acceptable, below 3.0 is noticeable degradation.

One-way audio

sipnab -N -I capture.pcap --filter "one_way == true AND duration > 10.0" --report

The duration check avoids false positives during early call setup when RTP hasn't started flowing yet. For calls where no RTP ever flowed at all, use no_media == true instead.

NAT issues

sipnab -N -I capture.pcap --filter "nat_mismatch == true AND method == 'INVITE'" --json

NAT mismatch means RTP reached the capture point from an address that no SDP in the dialog advertised, which is what a NAT rewriting the media source looks like from the wire. It is a common cause of one-way audio and of call setup failures behind NAT -- combine it with the one_way field to catch the classic case.

High jitter or packet loss

sipnab -N -I capture.pcap --filter "rtp.jitter > 50.0 OR rtp.loss > 1.0" --json

Jitter arrives in milliseconds (RFC 3550 interarrival jitter algorithm), and high values indicate network congestion. Loss is a percentage (0.0-100.0) whose acceptable thresholds are codec-dependent.

Failed international calls

sipnab -N -I capture.pcap --filter "from.user =~ '^\+' AND (state == 'Failed' OR state == 'Canceled')" --json

The ^\+ regex matches E.164 formatted numbers (international prefix).

Registration storms

sipnab -N -I capture.pcap --filter "method == 'REGISTER' AND retransmits > 5" --report

High retransmit counts on REGISTER indicate network issues, DNS failures, or server overload. Append AND src.ip == '192.0.2.50' to isolate a specific endpoint.

Scanner activity

sipnab -N -I capture.pcap --filter "ua =~ 'sipvicious|friendly-scanner|sipcli'" --json

Short completed calls (possible robocalls)

sipnab -N -I capture.pcap --filter "duration < 5.0 AND state == 'Completed' AND method == 'INVITE'" --json

SIP trunk failures

sipnab -N -I capture.pcap --filter "dst.ip == '198.51.100.100' AND state == 'Failed' AND method == 'INVITE'" --report

Filter for failures targeting a specific SIP trunk IP.

Orphaned RTP streams

sipnab -N -I capture.pcap --report

Orphaned streams have no matching SIP dialog or SDP, so no dialog filter can select them and the DSL offers no field for them. The boolean-field note above covers why rtp.orphaned is a parse error. The --report output carries an "Orphaned Streams" section, and the REST API answers the same question at /v1/streams?orphaned=true when you run sipnab with --api. Orphans usually mean RTP arriving on unexpected ports (check your NAT/ALG config) or calls that started before capture began.

Track one user's packet loss (B2BUA debugging)

sipnab -N -I capture.pcap --filter "(from.user == '1001' OR to.user == '1001') AND rtp.loss > 0.5" --report

Tracks a specific user's calls that have packet loss, regardless of call direction.

Chatty dialogs (debugging retransmissions)

sipnab -N -I capture.pcap --filter "msg_count > 20 AND method == 'INVITE'" --json

Dialogs with many messages often indicate retransmission issues or complex call flows (transfers, re-INVITEs).

Stream investigation by codec or SSRC

Select every dialog carrying one codec, for codec-specific quality analysis:

sipnab -N -I capture.pcap --filter "rtp.codec == 'PCMU'" --json

Trace a single media stream by its SSRC. rtp.ssrc compares against the SSRC rendered as 0x-prefixed lowercase hex, so the literal needs the 0x prefix or it matches nothing:

sipnab -N -I capture.pcap --filter "rtp.ssrc == '0x12345678'" --json

RTCP extended reports

When a capture carries RTCP XR (PT=207), sipnab decodes the VoIP Metrics block (RFC 3611 Section 4.7) and keeps it beside the stream the block names:

  • Round-trip delay and end-system delay
  • Signal level, noise level and residual echo return loss
  • R-factor and external R-factor
  • MOS-LQ and MOS-CQ
  • Burst and gap loss densities and durations
  • Jitter buffer nominal, maximum and absolute maximum delay

Every figure above belongs to the endpoint that sent it, not to sipnab. The TUI Stream Detail view shows them in a Reported by Far End (RTCP XR) section of their own, below everything sipnab measured. Nothing there feeds the MOS, jitter or loss sipnab computes. RTCP carries no authentication, and a mid-path capture watches a different path segment than the endpoint reports on, so the two disagreeing is the finding rather than a conflict to resolve. This is the same rule the reception-report figures follow -- see mos-and-codecs.md.

RFC 3611 reserves the value 127 for "this parameter is unavailable" on all seven of its single-byte quality fields: the R-factor and the external R-factor, both MOS fields, the signal and noise levels, and the residual echo return loss. sipnab renders each of those as n/a. A raw render would put an R-factor of 127 on a scale that stops at 100, and a MOS of 12.7 on a scale that stops at 5.0.

No surface reads any other XR block type yet. The parser turns three of them into typed values -- Loss RLE, Duplicate RLE and Receiver Reference Time -- and records Packet Receipt Times, DLRR and Statistics Summary by block-type number alone.

--json, --report, the REST API and the Prometheus exporter carry sipnab's own measurements only. No filter DSL field matches an XR value.

Parser constraints

  • Expression nodes: 1024 (MAX_EXPRESSION_NODES). The bound a caller is most likely to reach, and the reason it exists is not tidiness: without it a deeply repetitive expression drove the parser into a stack overflow that aborts the process rather than returning an error. A filter over the cap is refused by validate_filter and by every flag that takes one.
  • Maximum parenthesis nesting depth: 50 levels (MAX_NESTING_DEPTH). It bounds recursion in the parser itself; how large the resulting tree may grow is the separate node bound above.
  • Maximum regex pattern size: 1 MB (1,000,000 bytes)
  • Empty expressions produce a parse error
  • Trailing unparsed input produces a parse error with position
  • Unknown field names produce a parse error
  • Invalid regex patterns produce a parse error

See also

  • cli-reference.md โ€” the --filter flag and the dedicated diagnostic-alias flags
  • keybindings.md โ€” the TUI filter dialog (F7), which compiles its fields down to this DSL
  • examples.md โ€” recipes that put these filters to work
โš ๏ธ **GitHub.com Fallback** โš ๏ธ