Filter DSL - NormB/sipnab GitHub Wiki
Quick start:
sipnab --filter "state == 'Failed'"to find all failed calls, orsipnab --problemsfor 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.
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-dialogsemits one line per matching dialog. -
--reportprints 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.
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.
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.
| 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
payloadvs-e/--match: thepayloadfield matches per dialog (true if any message matches). The-e/--matchflag 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-efor grep-style streaming output; usepayloadinside a larger--filterexpression.
| 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.jitterandrtp.lossare 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 forNULL, for the same reason.Reading them as
0instead would put them below every threshold anyone would type, sortp.mos < 3.0would 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 anAND rtp.packets > 0guard is redundant rather than wrong.To select the calls that carry no media, ask for that directly:
rtp.packets == 0is a real count of zero, andno_mediais the diagnosis itself. Pair anyrtp.*threshold withrtp.packets > 0to ask about calls that actually had media.
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.
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.
| 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_mismatchcompares. 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_mismatchis true when a stream carries a source address that none of them named. Set membership, not equality against onec=line: a healthy two-way call sources RTP from two different advertised addresses, so comparing each stream against a singlec=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_mediarequires. 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=port0, a black-holedc=0.0.0.0, and non-RTP transports such as T.38m=image ... udptlโ so a call held for its whole life is not reported as a media failure.
no_mediais silent on a capture that holds no RTP. A signaling-only tap, a HEP feed or--no-rtpcannot 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. Pairno_mediawith the knowledge of where your tap sits: if media does not traverse it, the question is unanswerable and sipnab declines to answer it.
rtp.orphanedis 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 whileNOT rtp.orphanedmatches 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.
| 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_subnettakes 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.
statevalues 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'.
| 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]$'overfrom.user =~ '.*100[0-9].*').
| 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.
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.
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.
method == 'INVITE'from.user == '1001'state == 'InCall'
ua =~ 'friendly-scanner'from.user =~ '^100[0-9]'call_id =~ 'abc.*@'
pdd > 3.0rtp.mos < 3.0rtp.loss > 2.0duration < 5.0retransmits > 3rtp.jitter > 50.0rtp.packets > 10000
one_way == truenat_mismatch == trueno_media == truecodec_asymmetry == truelate_media == true
method == 'INVITE' AND rtp.mos < 3.0from.user =~ '^1001' AND state == 'Failed'pdd > 3.0 OR retransmits > 5NOT ua =~ 'friendly-scanner'(state == 'Failed' OR state == 'Canceled') AND duration < 1.0
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.).
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.
sipnab -N -I capture.pcap --filter "rtp.mos < 3.0 AND rtp.packets > 0 AND state == 'Completed'" --jsonrtp.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.
sipnab -N -I capture.pcap --filter "one_way == true AND duration > 10.0" --reportThe 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.
sipnab -N -I capture.pcap --filter "nat_mismatch == true AND method == 'INVITE'" --jsonNAT 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.
sipnab -N -I capture.pcap --filter "rtp.jitter > 50.0 OR rtp.loss > 1.0" --jsonJitter 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.
sipnab -N -I capture.pcap --filter "from.user =~ '^\+' AND (state == 'Failed' OR state == 'Canceled')" --jsonThe ^\+ regex matches E.164 formatted numbers (international prefix).
sipnab -N -I capture.pcap --filter "method == 'REGISTER' AND retransmits > 5" --reportHigh 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.
sipnab -N -I capture.pcap --filter "ua =~ 'sipvicious|friendly-scanner|sipcli'" --jsonsipnab -N -I capture.pcap --filter "duration < 5.0 AND state == 'Completed' AND method == 'INVITE'" --jsonsipnab -N -I capture.pcap --filter "dst.ip == '198.51.100.100' AND state == 'Failed' AND method == 'INVITE'" --reportFilter for failures targeting a specific SIP trunk IP.
sipnab -N -I capture.pcap --reportOrphaned 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.
sipnab -N -I capture.pcap --filter "(from.user == '1001' OR to.user == '1001') AND rtp.loss > 0.5" --reportTracks a specific user's calls that have packet loss, regardless of call direction.
sipnab -N -I capture.pcap --filter "msg_count > 20 AND method == 'INVITE'" --jsonDialogs with many messages often indicate retransmission issues or complex call flows (transfers, re-INVITEs).
Select every dialog carrying one codec, for codec-specific quality analysis:
sipnab -N -I capture.pcap --filter "rtp.codec == 'PCMU'" --jsonTrace 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'" --jsonWhen 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.
-
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 byvalidate_filterand 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
-
cli-reference.md โ the
--filterflag 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