Docs Look up a tool in the MCP core bundle
Look up a tool in the MCP core bundle
On this page
The core bundle is the smallest set of tools that answers what happened on
one call, and whether the fault was in signaling or media, from listing the
calls to the bytes that prove it.
Load only these tools with --mcp-tools core, or combine bundles as Choosing
which tools load describes. Every
tool on this page follows the rules every tool
follows, and each section states only
what its tool does differently. The MCP tool reference lists
the other bundles and finds any tool by name.
| Tool | Parameters | Returns |
|---|---|---|
capture_status | – | What this server captures: live or file, uptime, and whether stopping loses unsaved packets |
get_capture_report | format? | Whole-capture analysis: findings, orphaned media, STUN/ICMP evidence, what the caps shed |
list_dialogs | filter?, limit?, cursor? | A page of dialog summaries, with the total behind it |
aggregate_dialogs | group_by, filter?, top_n? | Counts dialogs grouped by ONE field, in the store rather than in the model |
find_problems | kinds?, filter?, limit?, cursor? | A page of dialogs matching one or more diagnostic alias names |
search_messages | query, limit?, cursor? | A page of substring matches across method/From/To/UA/body, with the total behind it |
triage_call | call_id | First-pass verdict: signaling problem, media problem, both, or none, with evidence |
get_dialog | call_id, max_messages?, cursor? | Paginated dialog with full SIP messages |
rtp_stats | call_id?, min_mos?, max_mos?, limit?, cursor? | One call’s RTP quality and diagnosis, or a capture-wide stream sweep |
Survey — what is in this capture
Start here when you have a capture and no particular suspect. These answer “what is in it”, “how much”, and “is the server even seeing traffic”.
capture_status
Ask this first. It answers what the server is actually attached to — a live interface or a replayed file — which nothing else on this surface reveals.
No parameters. Returns:
{
"schema_version": 2, // 2 absorbed the counters the old stats tool returned
"source": "live", // "live" | "file" | "unknown"
"name": "eth0", // interface, or file path
"uptime_sec": 3612,
"dialog_count": 128,
"stream_count": 64,
"orphaned_stream_count": 3, // streams no dialog claims
"active_dialog_count": 12, // any non-terminal state
"active_call_count": 9, // InCall only — narrower, hence the version bump
"capture_quality": {
"kernel_dropped_packets": 0,
"interface_dropped_packets": 0,
"invalid_timestamps": 0,
"undecodable_frames": 0,
"degraded": false
},
"caveats": {
"media_creating_commands": 0 // relay commands seen and NOT attributed
},
"source_exhausted": false, // true once a file is read to the end
"source_stopped_early": false, // true when a read ended before the file did
"writing_to": null, // path packets are being saved to, if any
"unsaved": true, // stopping now would lose packets
"capture_identity": {
"node": "capture01",
"instance": "1f4a17c8e2b91d40-1",
"dialog_generation": 412,
"stream_generation": 96
},
"unanalysed_sip_messages": 0, // SIP that --portrange excluded
"unanalysed_busiest_ports": [],
"unanalysed_websocket_messages": 0, // SIP-over-WebSocket the WS port set excluded
"unanalysed_websocket_ports": [], // pass these to --ws-portrange
"load": null // an open_capture load in flight, if any
}
unsaved is the field that matters. It is true only for a live capture
with no output file — packets held in memory and nowhere else. A file replay is
already on disk, so it is never unsaved.
source: "unknown" means nobody gave the server capture context. It
reports that rather than guessing, because a wrong "live" would be worse than
an admission of ignorance.
capture_identity says which capture this is and how many times its stores have
changed. Compare it across calls: a higher generation on the same instance means
the capture grew, and a different instance means open_capture loaded a
different file and every cursor you hold is void. The same object appears on
capture_status, list_dialogs, find_problems, tail_dialogs, find_correlated,
save_findings, open_capture and the capture-wide rtp_stats sweep. node names
the box that answered, which decides whose capture a fact came from once an agent
holds several servers at once.
load is null except while an open_capture read runs. During one:
"load": {
"filename": "outage-0722.pcap",
"instance": "1f4a17c8e2b91d40-2",
"packets": 184320, // climbing
"elapsed_sec": 4,
"done": false,
"error": null // set when a load stopped early
}
The stores fill as the read goes, so dialogs appear before done. Wait for
done before concluding anything about how many calls the capture holds — a
partial answer looks exactly like a complete one.
Read the two unanalysed_ pairs before reading anything into dialog_count.
They report SIP that sipnab recognized and did not analyze, and they are the
only way to tell a capture that holds no calls from one whose calls fell
outside a port setting. They count different losses with different
remedies, so a non-zero figure names its own flag:
unanalysed_sip_messages/unanalysed_busiest_ports— plain SIP signaling with both ports outside--portrange. Re-run with a range that covers the ports listed.unanalysed_websocket_messages/unanalysed_websocket_ports— SIP-over-WebSocket (RFC 7118) on a port outside the WebSocket set. Re-run with--ws-portrangecovering the ports listed. Widening--portrangerecovers none of it. This is the common case on a WSS listener behind a reverse proxy, and on Kamailio, OpenSIPS and Janus, which all default outside sipnab’s shipped 80/443/8080/8443.
Both are zero on a live capture, where BPF filtered before the pipeline saw anything and there is nothing to under-report.
ami_cleartext — a manager password on the wire
Asterisk’s Manager Interface speaks a line protocol on TCP/5038, and a login is
four lines of plain text: Action: login, Username:, Secret:. AMI can
originate calls, read configuration and run shell commands, so the credential
is worth strictly more than the SIP digest --digest-leak already reports.
Reported here because this is where the misleading zero was.
unanalysed_sip_messages: 0 was the complete answer for a capture full of AMI,
and the zero is honest — AMI is not SIP, so there was no unparsed SIP message
to count. Every detector sipnab had ran on parsed SIP messages, and this
traffic never became one.
| Field | Type | Description |
|---|---|---|
observation | object | banner (the server greeted a client) or cleartext-login |
observation.username | string? | The account named, bounded to MAX_AMI_VALUE_CHARS (64). Echoed on purpose: an operator can act on a finding that names the exposed account, and cannot act on one saying “an account” |
observation.secret_present | bool | Whether a Secret: line was there. A boolean, never the value |
src / dst / dst_port | string / string / u16 | Who, to whom, on what port |
default_port | bool | Whether dst_port is 5038 |
count | u64 | Payloads carrying this observation between this pair |
The secret is never recorded. sipnab writes findings to logs, exports
them in containers and hands them to an agent’s context, so the Secret: line
becomes the fact that a credential crossed the wire and nothing more.
Not a decoder. It matches a banner and a login line and records where they were. No session tracking, no response following, nothing else the protocol carries.
The port is an observation, not a filter. An interface moved off 5038 is
still reported, with default_port: false — a deployment that moved the
service is the one most likely to think nobody can find it, and a rule keyed on
the default would agree.
No arming needed, unlike the detectors behind
security_findings. It costs two literal prefix tests on
payloads that have already failed the SIP check, so there is no cost to gate
against. A login with an AuthType: MD5 challenge and no Secret: line
reports secret_present: false, so it never counts as a cleartext password.
Sightings aggregate per address pair, so one session reconnecting in a loop is one row with a count. The account is deliberately outside that key: this path runs before authentication, so the field is attacker-chosen.
Always present, and empty when there is nothing to report. sipnab omits
every other optional block on this surface because it may never have run the
check at all. It runs this one on every capture, so [] means “looked, found
none” — a stronger answer than a missing key.
What the capture DECLINED — caveats
capture_quality above counts what the capture lost. caveats counts what
sipnab chose not to do, which is a different thing and is not a defect.
| Key | What it counts |
|---|---|
media_creating_commands | rtpengine subscribe, publish and start recording commands seen and deliberately not attributed |
tls | TLS decryption state — absent unless a decryptor ran |
Those commands create media belonging to a call without being one of its two legs. Decoding one as an ordinary leg would make a two-party call report three streams, after which the analysis that judges one-way audio and asymmetry answers a question nobody asked — so sipnab counts them instead.
Read this before reasoning about a stream count. A call with a recording
fork has media on the wire that no rtp_stats row explains, and this number is
how you know that is a decision rather than a gap. Zero is a real
answer and the key is always present.
caveats.tls — is decryption working?
Absent when nobody supplied keys, which is not a decryption failure. When a
decryptor ran, the block carries keylog_entries, sessions_with_keys,
app_data_records, decrypted_records, undecrypted_records,
late_recovered, late_evicted and read_nothing.
Act on read_nothing, never on the arithmetic. A TLS handshake carries
records sipnab never loads keys for, so app_data_records > decrypted_records
is not by itself a failure — an agent deriving a verdict from the two counts
reports a working capture as broken and sends an operator after keys that
already work. read_nothing is true only when application data arrived and
none of it opened.
That case is why the block exists. A capture holding ciphertext nobody can open
produces a dialog listing identical to one from a quiet network. If you are
about to answer “there was no SIP on this capture”, check this field first:
server_capabilities.can_decrypt says whether the BUILD can decrypt, and this
says whether this RUN did.
GET /v1/stats carries the same block under the same name.
get_capture_report
The whole-capture analysis, the one --analyze prints. Backed by
analysis::analyze
and rendered by output::analysis_report.
get_dialog_report and render_ladder
both answer for ONE Call-ID, so everything the report says about the capture as
a whole had no MCP path: orphaned media, STUN, ICMP errors quoting SIP or RTP,
and what the retention caps shed. capture_status could hand an agent a count, and no tool
could expand it.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
format | string? | "json", "yang-json", "markdown" or "text". Anything else fails with unknown format 'x', expected json|yang-json|markdown|text. | "json". |
Frames read comes from the same process-global counter the Prometheus scrape
reports (sipnab_capture_packets_total), so the denominator here is the one
every other number in the run measures against.
yang-json answers the same analysis as json, encoded per
RFC 7951 against the YANG module
sipnab-diagnosis, which sipnab --print-yang-module prints. The document is
the first content block, and its one top-level member is
sipnab-diagnosis:capture-analysis. RFC 7951 allows no other member there, so
source_exhausted and source_stopped_early arrive in a second block instead
of inside the document. complete inside the document still reads false
while the capture loads, as it does in json.
The YANG section of Output formats lists every difference
between the two encodings.
A capture with no findings still answers with the clean line rather than an empty body, because silence is indistinguishable from the tool not having run:
// get_capture_report { "format": "text" }
// A capture with findings names each one; a clean capture says so outright.
"Capture analysis: 2 finding(s) across 1 dialog(s), 2 stream(s), 535000 frame(s) read."
json is the default and returns an OBJECT, serialized from the analysis
itself — schema_version, findings, dialogs_examined, streams_examined,
frames_read and complete. It is the object --json-analyze prints and
GET /v1/report answers, described by
tests/schemas/capture_analysis.schema.json.
It never carries filter, because this tool analyzes every dialog in the
store.
Each findings row is one ranked problem, worst first:
| Field | Type | Description |
|---|---|---|
kind | string | Which problem this is, such as one_way_audio. |
severity | string | How bad it is, carried on the row so a consumer sorts without a second table. |
occurrences | u64 | How many times sipnab saw it, in units. Exact, and not the length of evidence, which a cap bounds. |
unit | string | What one occurrence is — call, stream, frame. |
evidence | object[] | Up to a fixed cap of verifiable instances. |
evidence_omitted | u64 | Evidence rows the cap kept out. sipnab counts these while accumulating rather than subtracting, because one row often stands for many occurrences and the subtraction reported omissions that never existed. |
An evidence row carries call_id, endpoints (most specific first), at,
counts (named integers such as rtp_packets and streams) and note.
sipnab leaves out any of the five that does not apply rather than sending
null. A capture-level finding that belongs to no call, such as a STUN probe
or an undecodable frame, simply has no call_id.
// get_capture_report { "format": "json" } — one findings row, against
// tests/pcap-samples/sip-rtp-g711.pcap
{
"kind": "one_way_audio",
"severity": "critical",
"occurrences": 2,
"unit": "call",
"evidence": [
{
"call_id": "1-1966@10.0.2.20",
"endpoints": ["10.0.2.20:5060 -> 10.0.2.15", "SDP 10.0.2.20"],
"at": "2016-11-26T14:52:59.666393Z",
"counts": { "rtp_packets": 425, "streams": 1 },
"note": "10.0.2.15:27942 -> 10.0.2.20:6000 carried 425 packet(s); nothing came back the other way"
}
],
"evidence_omitted": 0
}
Read complete before the findings: it is false when the capture
lost packets, hit a retention cap, or held frames no decoder could read, and a
findings list from such a capture is a floor, not a total.
complete also reads false while the load runs, and for a source whose read
stopped before its end. It answers “did sipnab read all of its input”, so a file
still arriving cannot satisfy it.
Until this gate landed it ignored the load
entirely and read backwards during one: on a 100 MB capture the same tool in the same
session answered complete: true at frames_read: 312 and complete: false at
frames_read: 365747 — true over 0.09% of the file, false once the whole
file had arrived.
The field keeps its name and its meaning. What changed is that
the two facts under it, source_exhausted and source_stopped_early, now gate
it and travel beside it.
markdown and text answer with a rendered document, which has no envelope to
carry either flag, so the document states the fact itself.
A report drawn while
the capture is still loading, or over one whose read stopped before its end,
ends with an INCOMPLETE RUN block naming each reason — the same block
--report appends, and for the same reason: the reader of a rendered report has
no $? and no JSON in front of them, and a partial report looks exactly like a
whole one.
A capture read in full adds nothing and the report ends where it
always did. Ask for json when you want the two booleans as fields.
Before 0.5.125 this default returned the TEXT rendering.
formatchooses between markdown headings and plain text inside the renderer and never had a JSON arm, so the tool asked for JSON, got prose, and fell back to a text block. An agent that called it with no argument received prose and nothing to say the structure it asked for was never there.
GET /v1/report answers the same question over REST.
list_dialogs
Returns one page of dialog summaries from the live capture store.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
filter | string? | A diagnostic alias name — problems, slow-setup, short-calls, one-way, nat-issues, codec-asym, ptime-asym, payload-asym, duration-asym, late-media — or a raw filter DSL expression, which may use a named filter as @name. A value may be bare or quoted: method == INVITE and method == 'INVITE' are the same filter. Anything else fails with invalid_params naming the position it stopped parsing at, or, for an undefined @name, listing the names the run defines. | Every dialog in the store matches. |
limit | u32? | Ceiling is --mcp-max-rows (1000 unless the operator changed it). Higher clamps to it, 0 means the default. | 50 rows. |
cursor | string? | The previous response’s next_cursor, verbatim (<RFC 3339 created_at>|<Call-ID>). A malformed timestamp half fails with invalid_params. | Starts at the oldest dialog. |
fields | string[]? | Field names to keep on each row, plus call_id, which survives whether or not you list it. An unknown name fails with invalid_params naming both the typo and the fields a row carries. See Narrowing a row with fields. | The whole row. |
max_response_bytes | u64? | Bytes for the whole response. Ceiling is --mcp-max-response-bytes (1048576 unless the operator changed it). Higher clamps to it, 0 means the ceiling. Past it the page ends early at a whole row with truncated_by: "bytes" — see Rules every tool follows. | The ceiling. |
Returns — a page object, not a bare array:
| Field | Type | Description |
|---|---|---|
dialogs | DialogSummary[] | This page, oldest first (ties broken by Call-ID). |
returned | usize | Rows in dialogs, so counting the array is never necessary. |
total_matched | usize | Dialogs matching the filter across the whole store, whatever limit and cursor say. This is the number that answers “how many”. |
by_method | {method, count}[] | total_matched split by the method that opened each dialog, dominant class first. Covers the whole match set, not this page. |
truncated | bool | true when matches remain after this page. Absent while the answer is not whole — see Rules every tool follows. |
truncated_by | string? | rows when the row limit cut the page, bytes when the byte budget did. Absent when neither bound cut the page. |
source_exhausted | bool | true once sipnab has read the capture source to its end. |
source_stopped_early | bool | true when a source’s read ended before the source did. |
next_cursor | string? | Pass back to continue. null on the final page. |
schema_version | u32 | 1 for this shape. |
capture_identity | object | Which capture answered — see capture_status. A changed instance voids every cursor you hold. |
Each DialogSummary row carries call_id, state, method, from_user,
to_user, msg_count, duration_sec, created_at, updated_at, a timing
object (pdd_ms, setup_ms, retransmits, duration_ms, each null when the
capture never showed it) and a frame pointer for
show_evidence. from_user and to_user arrive fenced,
because an endpoint chose them.
Two more keys appear when sipnab knows them, and are left out rather than sent as null when it does not, so an absent key means “not known here”:
final_status_code— the final response the call reached.statecollapses every release cause into one word, so 403, 404, 408, 486, 503 and 603 all read asFailed, and this is the field that tells them apart. Absent while a call is still in progress, never0.input_origin— which capture source delivered the message that OPENED the dialog:wire,heporuprobe. It is what keepsframehonest, because a uprobe pointer (uprobe:opensips/954#3) has the same shape as a capture offset and only one of the two leads to bytes on disk.
The example below runs against tests/pcap-samples/sipp-branch-scenario.pcapng,
which holds 1334 dialogs. limit: 2 therefore reports 2 of 1334 — and says so:
// list_dialogs { "limit": 2 }
{
"schema_version": 1,
"dialogs": [
{
"call_id": "call-1-synth@192.0.2.10",
"state": "Registered",
"method": "REGISTER",
"from_user": "⟦untrusted-capture-data⟧ua-a⟦/untrusted-capture-data⟧",
"to_user": "⟦untrusted-capture-data⟧ua-a⟦/untrusted-capture-data⟧",
"msg_count": 7,
"final_status_code": 403,
"duration_sec": 0.036,
"created_at": "2016-11-17T21:52:35.303349+00:00",
"updated_at": "2016-11-17T21:52:35.339349+00:00",
"timing": {
"pdd_ms": null,
"setup_ms": null,
"retransmits": 0,
"duration_ms": null
},
"frame": "tests/pcap-samples/sipp-branch-scenario.pcapng#0@0f039ad14545671e",
"input_origin": "wire"
},
{
"call_id": "call-2-synth@192.0.2.10",
"state": "Registered",
"method": "REGISTER",
"from_user": "⟦untrusted-capture-data⟧ua-a⟦/untrusted-capture-data⟧",
"to_user": "⟦untrusted-capture-data⟧ua-a⟦/untrusted-capture-data⟧",
"msg_count": 7,
"final_status_code": 403,
"duration_sec": 0.036,
"created_at": "2016-11-17T21:52:35.403349+00:00",
"updated_at": "2016-11-17T21:52:35.439349+00:00",
"timing": {
"pdd_ms": null,
"setup_ms": null,
"retransmits": 0,
"duration_ms": null
},
"frame": "tests/pcap-samples/sipp-branch-scenario.pcapng#7@f7951dd197e419b9",
"input_origin": "wire"
}
],
"returned": 2,
"total_matched": 1334,
"by_method": [
{ "method": "REGISTER", "count": 1334 }
],
"truncated": true,
"truncated_by": "rows",
"next_cursor": "2016-11-17T21:52:35.403349Z|call-2-synth@192.0.2.10",
"capture_identity": {
"node": "capture-01",
"instance": "1ae7318cb5c11b1a306dd-1",
"dialog_generation": 9015,
"stream_generation": 0
}
}
by_method is why this fixture is a fair example rather than a flattering one:
the page shows two dialogs and the breakdown shows that all 1334 are REGISTER.
Nothing in the rows could have told a caller that, and a client that reasoned
about “the calls in this capture” from the page would have been reasoning about
a registration flood.
Two fields in there catch people writing a client from this page. from_user
reads ⟦untrusted-capture-data⟧ua-a⟦/untrusted-capture-data⟧, not ua-a, so a
comparison against the bare name never matches — strip the markers, or match
inside them. And capture_identity.node names the box that answered, which
decides whose capture a fact came from once an agent holds several servers.
Why the page object, and why the cursor is compound
A bare array hides its own size. This tool returned 50 of 2311 dialogs on a
production capture with nothing in the reply to mark the cut, and limit alone
could not close the gap: requests above 1000 clamp to the hard cap, leaving 1311
dialogs no call could reach.
An agent asked “how many calls failed?” counts the
rows it holds and answers with that number, so a short list does not read as an
incomplete answer — it reads as a confident wrong one. total_matched and
truncated name the shortfall. cursor closes it.
next_cursor pairs a timestamp with a Call-ID for the same reason
tail_dialogs does. Dialogs share a created_at routinely — a
burst of registrations lands on one millisecond — and a bare timestamp forces a
choice between dropping the rest of that group and serving it twice. Resuming
after the (created_at, Call-ID) pair splits the group exactly where the page
ended. Pass the value back unmodified. A bare RFC 3339 timestamp still
parses, so rebuilding one by hand fails silently rather than erroring.
The two tools page on different clocks, deliberately. tail_dialogs follows
updated_at, because reporting change is its job. list_dialogs pages on
created_at, which never moves: a dialog that gains one more message mid-sweep
would jump forward in an updated_at ordering, past a cursor that had already
gone by, and vanish from the listing.
aggregate_dialogs
Counts dialogs grouped by one field, inside the store.
Counting is the operation a language model gets wrong most reliably, and this
page documents that failure elsewhere: an agent asked “how many calls failed?”
counts the rows it is holding and answers with that number. The page object
fixed exactly one count — total_matched, for one filter. Every other count
still meant fetching pages and tallying them in the model’s head, or issuing N
filtered queries with the buckets guessed in advance.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
group_by | string | One of state, response_code, method, from.user, to.user, ua, src.ip, dst.ip, rtp.codec. Anything else fails with invalid_params naming the legal set. | Required. |
filter | string? | Alias or DSL, applied before grouping. | No filter — the whole store. |
top_n | integer? | Bounded by --mcp-max-rows, the same knob that bounds dialog rows — not a second ceiling of sipnab’s own. Anything past it lands in other_count rather than disappearing. | The same default as any row limit. |
Returns group_by (echoed), buckets (each { value, count }, largest
first), other_count, distinct_values, total_matched, capture_identity
and schema_version.
The example runs against tests/pcap-samples/sipp-branch-scenario.pcapng,
whose 1334 dialogs land in two states, with top_n: 1 asking for only the
larger one:
// aggregate_dialogs { "group_by": "state", "top_n": 1 }
{
"schema_version": 1,
"group_by": "state",
"buckets": [ { "value": "Registered", "count": 1207 } ],
"other_count": 127,
"distinct_values": 2,
"total_matched": 1334,
"capture_identity": {
"node": "capture-01",
"instance": "f078218d2bfca5995cf37-2",
"dialog_generation": 9015,
"stream_generation": 0
}
}
The buckets plus other_count always equal total_matched. A truncated
aggregate that does not say what it left out is a wrong total rather than a
partial one, so nothing is silently dropped — including nulls, which become the
literal (none): “how many dialogs carry no User-Agent” is a real question.
One dimension, and no time bucketing. That is a deliberate cap, not an
unfinished feature. Two dimensions is a pivot table, a pivot table wants a UI,
and positioning
puts that outside what sipnab is. Narrow the window with filter instead.
Grouping by from.user, to.user or ua returns fenced values. Those are
text the packet’s sender wrote, and they reach a model here exactly as they
would in a row. A state name, a status code, an IP or a codec is sipnab’s own
derivation and comes back verbatim — fencing those would tell the agent to
distrust the analysis.
Find — narrow to the calls that matter
You know something is wrong but not which call. These cut a capture down to the dialogs worth reading: by symptom, by text, by time, or by relationship to a call you already have.
find_problems
Convenience wrapper over list_dialogs that ORs each named alias, then ANDs
the optional filter.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
kinds | string[]? | One or more of the ten diagnostic aliases listed under list_dialogs, OR-ed together. An unknown name fails with invalid_params. An empty array behaves as omitted. | ["problems"]. |
filter | string? | An alias name or a raw DSL expression, ANDed with the alias match. | The alias match alone decides the page. |
limit | u32? | Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default. | 50 rows. |
cursor | string? | The previous response’s next_cursor, verbatim. | Starts at the oldest match. |
fields | string[]? | Field names to keep on each row, plus call_id. Same rules as list_dialogs — see Narrowing a row with fields. | The whole row. |
max_response_bytes | u64? | Bytes for the whole response. Ceiling is --mcp-max-response-bytes (1048576 unless the operator changed it). Higher clamps to it, 0 means the ceiling. Past it the page ends early at a whole row with truncated_by: "bytes" — see Rules every tool follows. | The ceiling. |
Returns the same page object as list_dialogs — dialogs,
returned, total_matched, by_method, truncated, next_cursor,
schema_version and capture_identity — with the same meanings and the same
fenced from_user and to_user.
Read by_method first on this tool especially. Whatever the deployment does
most dominates a problems page, and that is rarely the calls: on one real
capture 98 of 110 rows carried OPTIONS, and 89 of those tripped an alias solely
on retransmits > 3 — dead qualify peers, not call faults. Without the
breakdown an agent had to already suspect the keepalive plane to exclude it.
filter is what makes this the triage entry point rather than a firehose. The
aliases answer “is this call interesting”. The filter answers “is it one of
mine”, so {"kinds": ["problems"], "filter": "dst.ip == '203.0.113.9'"} asks a
question that previously needed a client-side join. The two AND together —
ORing them would widen the sweep instead of narrowing it.
An unknown alias, or a filter that neither names an alias nor parses, returns invalid_params (-32602) naming the offending value.
// find_problems { "limit": 1, "filter": "msg_count > 5" }
{
"schema_version": 1,
"dialogs": [
{
"call_id": "call-1197-synth@192.0.2.10",
"state": "Failed",
"method": "REGISTER",
"from_user": "⟦untrusted-capture-data⟧ua-a⟦/untrusted-capture-data⟧",
"to_user": "⟦untrusted-capture-data⟧ua-a⟦/untrusted-capture-data⟧",
"msg_count": 6,
"final_status_code": 403,
"duration_sec": 0.03,
"created_at": "2016-11-17T21:54:34.903349+00:00",
"updated_at": "2016-11-17T21:54:34.933349+00:00",
"timing": {
"pdd_ms": null,
"setup_ms": null,
"retransmits": 0,
"duration_ms": null
},
"frame": "tests/pcap-samples/sipp-branch-scenario.pcapng#8028@42ba0f02341ca2f7",
"input_origin": "wire"
}
],
"returned": 1,
"total_matched": 6,
"by_method": [
{ "method": "REGISTER", "count": 6 }
],
"truncated": true,
"truncated_by": "rows",
"next_cursor": "2016-11-17T21:54:34.903349Z|call-1197-synth@192.0.2.10",
"capture_identity": {
"node": "capture-01",
"instance": "1ae7318cb5c11b1a306dd-1",
"dialog_generation": 9015,
"stream_generation": 0
}
}
The same capture answers find_problems {} with total_matched: 127. Six of
those 127 carry more than five messages, which is what the filter selects.
search_messages
Case-insensitive substring search over method, status, From, To, User-Agent, and body across all dialogs.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
query | string | Any substring. Matching ignores case. | Required — the call fails. |
limit | u32? | Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default. | 50 hits. |
cursor | string? | The previous response’s next_cursor, verbatim (<RFC 3339 created_at>|<Call-ID>#<zero-padded message index>). A malformed timestamp half fails with invalid_params. | Starts at the oldest match. |
max_response_bytes | u64? | Bytes for the whole response. Ceiling is --mcp-max-response-bytes (1048576 unless the operator changed it). Higher clamps to it, 0 means the ceiling. Past it the page ends early at a whole row with truncated_by: "bytes" — see Rules every tool follows. | The ceiling. |
Returns — the same page shape list_dialogs returns, not
a bare array, plus the provenance note as a second content block:
| Field | Type | Description |
|---|---|---|
hits | object[] | This page of { call_id, message_index, snippet }, ordered by the dialog’s created_at, then Call-ID, then message index. |
returned | usize | Rows in hits. |
total_matched | usize | Messages matching the query across the whole store, whatever limit and cursor say. This is the number that answers “how many”. |
truncated | bool | true when matches remain after this page. Absent while the answer is not whole — see Rules every tool follows. |
truncated_by | string? | rows when the row limit cut the page, bytes when the byte budget did. Absent when neither bound cut the page. |
source_exhausted | bool | true once sipnab has read the capture source to its end. |
source_stopped_early | bool | true when a source’s read ended before the source did. |
next_cursor | string? | Pass back to continue. null on the final page. |
schema_version | u32 | 1 for this shape. |
capture_identity | object | Which capture answered. A changed instance voids the cursor. |
snippet holds the whole raw message, fenced, and stops at 4 KB. Pass call_id
and message_index straight to get_message for the parsed
form.
Count from the fields, never from the rows. A capped result and a complete one hold the same shape, so the row count cannot tell them apart: on the sample capture below,
{ "query": "REGISTER" }returns 50 rows andlimit: 1000returns 1000, while 2668 messages match.total_matchedis the number that answers “how many are there”,truncatedsays whether you are seeing all of them, andnext_cursorreaches the rest.
The example runs against tests/pcap-samples/sipp-branch-scenario.pcapng:
// search_messages { "query": "REGISTER", "limit": 1 }
{
"schema_version": 1,
"hits": [
{
"call_id": "call-1-synth@192.0.2.10",
"message_index": 0,
"snippet": "⟦untrusted-capture-data⟧REGISTER sip:example.net SIP/2.0\r\n…⟦/untrusted-capture-data⟧"
}
],
"returned": 1,
"total_matched": 2668,
"truncated": true,
"truncated_by": "rows",
"next_cursor": "2016-11-17T21:52:35.303349Z|call-1-synth@192.0.2.10#0000000000",
"capture_identity": {
"node": "capture-01",
"instance": "12100b18cb6971cf461cff-1",
"dialog_generation": 9015,
"stream_generation": 0
}
}
Passing that next_cursor back returns message 1 of call-1-synth@192.0.2.10
and reports the same total_matched: 2668: the total describes the store, not
the page.
The index half of the cursor carries leading zeros because the server compares
the cursor as text — rebuild one by hand and #10 sorts before #2, which
silently skips eight messages of a dialog. Pass it back exactly as it arrived.
Diagnose one call
You have a call-id. These explain what happened to that one call – its signaling, its media, and the specific negotiations that tend to break.
triage_call
Start here. The first question in VoIP triage is which half of the stack failed. Signaling decides whether a call connects. RTP decides whether you can hear it. They have different causes and different fixes, and confusing them is the most common wrong turn — so ask this before anything else.
Parameters: call_id (string, required) — a Call-ID the store holds, as
returned by list_dialogs. An unknown one fails with
invalid_params (-32602) naming the value. There are no optional parameters.
// triage_call { "call_id": "1-1966@10.0.2.20" }
{
"schema_version": 1,
"call_id": "1-1966@10.0.2.20",
"verdict": "media", // "signaling" | "media" | "both" | "none"
"state": "Completed",
"final_status_code": 200,
"signaling": { "problem": false, "hints": [] },
"media": {
"problem": true,
"one_way_audio": true,
"nat_mismatch": false,
"no_media": false,
"stream_count": 1,
"hints": ["RTP flowed 10.0.2.15:27942 -> 10.0.2.20:6000 only (SSRC 0x343da99b). No reverse media flow detected."]
},
"termination": { // absent when nothing said why the call ended
"cause_code": 38,
"cause_text": "Network out of order",
"protocol": "Q.850",
"source_header": "Reason",
"frame_ref": 7
}
}
A clean 200 OK with one-way audio is a media problem. Nothing in the SIP
exchange is wrong, and time spent reading it is time lost.
Where to go next, by verdict:
| Verdict | Next tool |
|---|---|
signaling | explain_response_code on the final code, then get_dialog |
media | rtp_stats, and check_codec_negotiation if the call failed |
both | Signaling first — media symptoms are often downstream of a failed negotiation |
none | The call is fine. Check you have the right Call-ID |
Why the call ended
termination is a fact about the call, not a fault, so it never moves
verdict. A call that cleared normally reports verdict: "none" and a
Q.850 cause of 16 at the same time, and both are true.
It answers the question the verdict cannot: “the call ended” and “the call ended because the far end was out of order” are different answers, and only the second closes a ticket.
| Field | Type | Description |
|---|---|---|
cause_code | u16? | The cause number, on the scale protocol names. Absent when the message named a protocol and no cause — never zero for a missing value, because 0 is itself a Q.850 cause |
cause_text | string? | The human-readable cause. sipnab strips control characters and keeps the first MAX_CAUSE_TEXT_CHARS (200) — far past every value a gateway produces, and short enough that a crafted header cannot spend a context window |
protocol | string? | Which scale cause_code is on. 16 is normal clearing in Q.850 and is not a SIP status code at all, so a code without this is not interpretable |
source_header | string | Which header carried the cause: Reason (RFC 3326) or X-Asterisk-HangupCauseCode |
frame_ref | usize | Index into the dialog’s message list of the message that carried it |
sipnab reads the last message that names a cause — a call that went through
a challenge, a retry and a clear carries more than one, and the one that says
why it ended is the last thing said. Reason on a BYE or a CANCEL counts,
which is the case this exists for: a call that clears normally has no failure
response to hang a cause on.
Where one message carries both protocols, sipnab picks the non-SIP one,
because a SIP cause restates final_status_code and that is already its own
field. A Q.850 cause is the gateway’s, and the fact nothing else recovers.
The same block appears on get_dialog_report and on
GET /v1/dialogs/{call_id}/report, from one assembler.
get_dialog
Paginated dialog with full SIP messages.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id | string | A Call-ID the store holds. An unknown one fails with call_id 'x' not found. | Required — the call fails. |
max_messages | u32? | At least 1; 0 fails with invalid_params. Ceiling is --mcp-max-rows (1000 by default), and higher clamps to it. | 100 messages. |
cursor | string? | The previous response’s next_cursor, verbatim (<RFC 3339 created_at>|<Call-ID>#<zero-padded message index>, the shape search_messages uses). A malformed cursor, or one issued for a different dialog, fails with invalid_params. A cursor past the last message returns an empty page. | Starts at message 0. |
max_response_bytes | u64? | Bytes for the whole response. Ceiling is --mcp-max-response-bytes (1048576 unless the operator changed it). Higher clamps to it, 0 means the ceiling. Past it the page ends early at a whole message with truncated_by: "bytes" — see Rules every tool follows. | The ceiling. |
Returns:
| Field | Type | Description |
|---|---|---|
dialog | object | The same summary list_dialogs returns, with from_user and to_user fenced. |
messages | object[] | This page of full messages, in dialog order. |
returned | usize | Messages in messages. |
first_index | usize | The index of messages[0] in the dialog, so row N of the page is get_message index: first_index + N. |
total_messages | usize | Messages in the whole dialog. |
truncated | bool | true when messages remain after this page. Absent while the answer is not whole — see Rules every tool follows. |
truncated_by | string? | rows when max_messages cut the page, bytes when the byte budget did. Absent when neither bound cut the page. |
next_cursor | string? | Pass back to continue. null on the final page. |
complete | bool | true when this page reaches the end of the dialog. Never true over a partial read of the capture. |
schema_version | u32 | 2 for this shape. Responses through 0.5.211 carried no schema_version, an integer next_cursor, and no returned, first_index, truncated or truncated_by. |
Every messages row carries call_id, is_request, cseq (method and
number), from, to, src, src_port, dst, dst_port, transport,
timestamp, frame and schema_version. Two more appear when sipnab observed
them and are left out rather than sent as null when it did not:
dscp— the RFC 2474 marking on the frame that carried this message, 0 to 63. Absent means no IP header was read, which is what a HEP-delivered message looks like.0means sipnab saw the frame and the frame said best effort. Unmarked signaling is itself a common fault, so the two must not collapse into one value.input_origin—wire,heporuprobe, the source that delivered this message.
The rest depends on the direction, so branch on is_request rather than
expecting one shape:
- A request adds
method, andcontactandsdpwhen it carried them. - A response adds
status_code,reason,response_contextandua, and carries nomethod— readcseq.methodfor the transaction it answers.
This tool fences, and it is the one that returns the most capture text.
reason,from,to,contact,ua,sdpandmalformedinmessages[]come back wrapped in⟦untrusted-capture-data⟧markers, the same treatmentget_messagegives them. The SDP body keeps its line structure,--mcp-max-body-bytesbounds it, and the header values arrive capped with every control character removed.This page said the opposite until 0.5.134 — that the fields came back verbatim and that a reader should reach for
get_messageinstead. Both tools fenced the whole time.Treat every string in
messages[]as attacker-written regardless: the markers say where the text came from, they do not make it safe.
The example runs against tests/pcap-samples/sip-rtp-g711.pcap:
// get_dialog { "call_id": "1-1966@10.0.2.20", "max_messages": 1 }
{
"schema_version": 2,
"complete": false,
"returned": 1,
"first_index": 0,
"total_messages": 6,
"truncated": true,
"truncated_by": "rows",
"next_cursor": "2016-11-26T14:52:59.666393Z|1-1966@10.0.2.20#0000000000",
"dialog": {
"call_id": "1-1966@10.0.2.20",
"state": "Completed",
"method": "INVITE",
"from_user": "⟦untrusted-capture-data⟧sipp⟦/untrusted-capture-data⟧",
"to_user": "⟦untrusted-capture-data⟧test⟦/untrusted-capture-data⟧",
"msg_count": 6,
"final_status_code": 200,
"duration_sec": 8.504,
"created_at": "2016-11-26T14:52:59.666393+00:00",
"updated_at": "2016-11-26T14:53:08.170676+00:00",
"timing": { "pdd_ms": null, "setup_ms": 4, "retransmits": 0, "duration_ms": 8499 },
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
"input_origin": "wire"
},
"messages": [
{
"call_id": "1-1966@10.0.2.20",
"method": "INVITE",
"is_request": true,
"cseq": { "method": "INVITE", "number": 1 },
"from": "⟦untrusted-capture-data⟧\"PCMU/8000\" <sip:sipp@10.0.2.20:5060>;tag=1⟦/untrusted-capture-data⟧",
"to": "⟦untrusted-capture-data⟧test <sip:test@10.0.2.15:5060>⟦/untrusted-capture-data⟧",
"contact": "⟦untrusted-capture-data⟧sip:sipp@10.0.2.20:5060⟦/untrusted-capture-data⟧",
"src": "10.0.2.20",
"src_port": 5060,
"dst": "10.0.2.15",
"dst_port": 5060,
"transport": "UDP",
"timestamp": "2016-11-26T14:52:59.666393+00:00",
"sdp": "⟦untrusted-capture-data⟧v=0\no=- 42 42 IN IP4 10.0.2.20\ns=-\nc=IN IP4 10.0.2.20\nt=0 0\nm=audio 6000 RTP/AVP 0\na=rtpmap:0 PCMU/8000\na=recvonly\n⟦/untrusted-capture-data⟧",
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
"dscp": 0,
"input_origin": "wire",
"schema_version": 1
}
]
}
The SDP above arrives with \n line endings, not the \r\n the wire carried.
Fencing strips every control character a quoting document could act on and keeps
only \n and \t, so a client that reassembles the body and compares it byte
for byte against the packet finds a difference that is sipnab’s, not the
endpoint’s. Read it as text, not as the original octets — reach for
show_evidence when you need those.
total_messages is 6 and returned is 1, so five messages remain. Call again
with cursor set to that next_cursor to continue, and stop when complete
turns true.
Through 0.5.211 cursor was the integer index of the first message to return,
and next_cursor the index to resume at. Both are now the opaque string every
other paged tool uses, which a client must pass back unchanged. sipnab refuses
a client that sends an integer with invalid_params. To address one message by
position, use first_index with get_message.
rtp_stats
Per-stream RTP quality for one call, or across the whole capture.
This tool has two modes, and call_id is the switch. Pass it for one
dialog’s streams. Omit it to sweep the whole capture. Six parameters belong to
the sweep alone:
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id | string? | A Call-ID a dialog carries, or one a media relay named for its streams. | Switches modes — the tool sweeps every stream in the capture, orphans included. |
min_mos | f64? | Sweep only. Keeps streams scoring at or above this. Rejected alongside call_id. | No lower bound. |
max_mos | f64? | Sweep only. Keeps streams scoring strictly below this. Rejected alongside call_id. | No upper bound. |
orphaned | bool? | Sweep only. true keeps only streams no dialog claims, false keeps only streams a dialog claimed. Independent of the MOS bounds — an orphan usually has no dialog to ground its clock, so requiring a bound alongside would filter out most of what this exists to find. | Both kinds sweep. |
limit | u32? | Sweep only. Ceiling is --mcp-max-rows (1000 by default), higher clamps to it, 0 means the default. | 50 streams. |
cursor | string? | Sweep only. The previous response’s next_cursor, verbatim (<RFC 3339>|0xSSRC@src>dst). | Starts at the earliest stream. |
max_response_bytes | u64? | Sweep only. Bytes for the whole response. Ceiling is --mcp-max-response-bytes (1048576 unless the operator changed it). Higher clamps to it, 0 means the ceiling. Past it the page ends early at a whole row with truncated_by: "bytes". | The ceiling. |
Only the two MOS bounds fail alongside a call_id. They ask a question
about the whole capture, so sending one with a Call-ID is a misunderstanding of
the tool, and sipnab answers invalid_params (-32602) rather than quietly
doing nothing. Per-call mode simply skips orphaned, limit and cursor: a
call holds every stream it holds, so there is nothing there to page or to
filter.
With call_id the answer keeps its existing shape — { call_id, streams, diagnosis },
where streams is an array of stream JSON objects (codec, MOS, jitter, loss%,
packets, SSRC, quality intervals) and diagnosis carries the standard one-way /
NAT-mismatch flags plus the asymmetry signals (codec_asymmetry,
ptime_asymmetry, payload_type_asymmetry, duration_asymmetry,
late_media). A MOS bound alongside a call_id returns invalid_params
(-32602) rather than quietly doing nothing. dialog_seen says whether this
capture holds the call’s SIP dialog.
On a media-relay host the dialog is not required. A relay sees the RTP and
its own control traffic, never the SIP, so its dialog store is empty while its
streams carry the Call-ID the relay named. rtp_stats answers from those
streams: dialog_seen is false, and dialog_absent says why no dialog
explains the media, with the same reason and note that
reconcile_orphans gives it. The diagnosis still runs,
without the SDP checks (nat_mismatch, no_media, the RFC 4961 port hints), which
need the dialog. media_diagnostics,
explain_attribution and
export_audio follow the same rule.
// rtp_stats { "call_id": "B2B.201.6748799.1790773715.1438467624" }, on the relay host
{
"call_id": "B2B.201.6748799.1790773715.1438467624",
"dialog_seen": false,
"dialog_absent": {
"reason": "relay-asserted-but-no-dialog",
"note": "a relay named this endpoint but no captured dialog claims it -- the signaling is missing, not the media"
},
"streams": [
{ "associated_dialog": "B2B.201.6748799.1790773715.1438467624", "dialog_assertion": "media-relay", "src": "192.0.2.70:41004", "dst": "192.0.2.71:31004", "codec": "PCMU", "mos": 4.36 /* ... */ },
{ "associated_dialog": "B2B.201.6748799.1790773715.1438467624", "dialog_assertion": "media-relay", "src": "192.0.2.71:31004", "dst": "192.0.2.70:41004", "codec": "PCMU", "mos": 4.36 /* ... */ }
],
"diagnosis": { "one_way_audio": false, "nat_mismatch": false, "no_media": false, "sdp_media": null /* ... */ }
}
sipnab refuses a Call-ID that neither a dialog nor any stream carries, with
invalid_params (-32602) and a message naming both searches: no SIP dialog and no RTP stream associated with call_id '…' in this capture. On a relay host that
means the call never crossed this relay, not that the lookup failed.
// rtp_stats { "call_id": "1-1966@10.0.2.20" }
{
"call_id": "1-1966@10.0.2.20",
"streams": [
{
"associated_dialog": "1-1966@10.0.2.20",
"dialog_assertion": "signaled",
"codec": "PCMU",
"src": "10.0.2.15:27942",
"dst": "10.0.2.20:6000",
"ssrc": "0x343da99b",
"payload_type": 0,
"packets": 425,
"octets": 68000,
"loss_pct": 0.0,
"jitter_ms": 0.0054046519599899685,
"mos": 4.358100599599484,
"mos_grounded": true,
"mos_grounding": "published",
"orphaned": false,
"dscp": 0,
"input_origin": "wire",
"first_seen": "2016-11-26T14:52:59.689083+00:00",
"last_seen": "2016-11-26T14:53:08.169060+00:00",
"round_trip_note": "Not measured. No endpoint reported a round trip for this stream, so latency is unknown rather than good — a stream with clean jitter and no loss can still be unusable on delay alone (ITU-T G.114).",
"quality_intervals": [
{
"jitter_ms": 0.0063823922199494915,
"loss_pct": 0.0,
"mos": 4.358100053908192,
"mos_grounded": true,
"packets": 252,
"r_factor": 90.79984682258672,
"timestamp": "2016-11-26T14:53:04.709076+00:00",
"verdict": "acceptable"
}
],
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#5@ae02f78d2d48b4f0",
"schema_version": 1
}
],
"diagnosis": {
"actual_media": null,
"hints": [
"RTP flowed 10.0.2.15:27942 -> 10.0.2.20:6000 only (SSRC 0x343da99b). No reverse media flow detected."
],
"nat_mismatch": false,
"no_media": false,
"one_way_audio": true,
"private_media_address": false,
"sdp_media": "10.0.2.20"
},
"dialog_seen": true
}
diagnosis always carries one_way_audio, nat_mismatch, no_media,
private_media_address, sdp_media, actual_media and hints. The asymmetry
signals named above appear only when sipnab found one, so an absent
codec_asymmetry means the legs agreed rather than that the check did not run.
Per-call mode returns no total_matched, truncated or next_cursor — a call
holds every stream it holds, so there is nothing to page. quality_intervals
holds one entry per completed sampling window, so a short call legitimately
returns an empty array while this eight-second one returns a single row. The
window is five seconds by default. --quality-interval narrows it, down to one
second, without shortening the hour of call time the trend covers.
Each interval carries its own mos, r_factor and verdict. The
stream-level MOS is a mean over the whole call, and a mean hides a burst
shorter than itself: one 90 % loss figure this project had to explain turned
out to be three bursts of half a second. sipnab scores an interval on its own
jitter and loss and on the STREAM’s resolved path delay, so a number in the
trend and the headline beside it rest on one basis.
verdict has three states and the third is the point:
verdict | What it means |
|---|---|
acceptable | R at or above 70, where ITU-T G.107’s worst user-satisfaction category is still “some users dissatisfied”. |
degraded | R below 70, where that category becomes “many users dissatisfied”. |
not_scorable | The codec has no published or declared impairment value, so sipnab refuses to judge the interval. The mos beside it is a placeholder, and mos_grounded is false. |
not_scorable is the only answer for an ungrounded codec, and it is not a
shade of good or bad. Anything that COUNTS degraded intervals has to read the
verdict first: a refusal to judge is not the absence of a problem, and reading
it as one reports a healthy call on a codec nobody can score.
Each stream carries mos_grounded and mos_grounding. estimate_mos
returns the same number — 4.216 at 10 ms jitter — for AMR, AMR-WB, EVS, G.722
and for a stream whose codec was never identified, because sipnab only has
published ITU-T G.113 impairment values for G.711, G.729 and Opus. When
mos_grounded is false the MOS means unknown, not “about 4.2”, and a
mos_note says so.
mos_grounding names which basis the number rests on, because “grounded” now
covers two of them:
mos_grounding | mos_grounded | What the number rests on |
|---|---|---|
published | true | An ITU-T G.113 impairment value sipnab implements. |
operator_declared | true | An Ie this deployment supplied in [media.codec_ie]. A mos_note says so, so an agent citing the figure cites the operator rather than a standard. |
unpublished | false | Nothing published and nothing declared. Placeholder. |
For AMR-WB specifically the placeholder is wrong by roughly a full MOS point in either direction: its nine modes genuinely span about 4.49 down to 3.51. Do not report a MOS to a human without checking this field. See MOS and codecs for the full picture.
An AMR-WB stream whose mode sipnab read also carries the wideband score, the
same three fields GET /v1/streams returns:
| Field | Meaning |
|---|---|
mos_wideband | MOS_CQEW on the ITU-T G.107.1 wideband scale |
mos_wideband_context | The listening context sipnab scored the stream in: monotic or diotic |
mos_wideband_unavailable | Why there is no wideband score: unpublished_mode or loss_not_computable |
mos_wideband and mos are different scales. Do not compare them. A stream
that is not AMR-WB carries none of the three. See
Where the wideband score appears.
Who named the dialog — dialog_assertion
Each stream also says who asserted the SDP media endpoint that tied it to its Call-ID:
dialog_assertion | Who said it | What the address is |
|---|---|---|
signaled | a negotiating party, in its own SDP | that party’s endpoint |
media-relay | an rtpengine relay, about a port it allocated | the leg’s midpoint |
A relay cannot be wrong about which socket it opened, so the address is
authoritative — and it is still not an endpoint. An agent reasoning about where
media went must not report a media-relay address as the far party’s, because
it names the box in the middle and the two lead to opposite conclusions.
sipnab emits the key whenever it knows the answer, signaled included. Absent
means nobody recorded who asserted it, never “a party did”. Do not default it.
Distinct from dialog_origin, which names the capture source that delivered
the assertion rather than its author. See
rtpengine relay attribution.
Capture-wide sweep — omit call_id
“Every stream with a MOS below 3.5” is one call rather than a listing plus one
rtp_stats per dialog, which costs thousands of round trips on a real capture.
The sweep also reaches streams the per-call mode cannot: a stream that never
linked to a dialog has no Call-ID to ask about, and an orphan is not an oddity —
it is what a NAT or one-way-audio fault looks like from the media side.
| Field | Type | Description |
|---|---|---|
streams | object[] | This page, oldest first_seen first. |
returned | usize | Rows in streams. |
total_matched | usize | Streams matching across the whole store. |
ungrounded_excluded | usize | Streams a MOS bound could not judge. |
truncated | bool | true when matches remain after this page. |
truncated_by | string? | rows when the row limit cut the page, bytes when the byte budget did. Absent when neither bound cut the page. |
next_cursor | string? | Pass back to continue. null on the final page. |
capture_identity | object | Which capture answered — see capture_status. A changed instance voids every cursor you hold. |
schema_version | u32 | 1 for this shape. |
A MOS bound only judges codecs sipnab has a real impairment value for —
one G.113 publishes, or one this deployment declared in [media.codec_ie].
min_mos and max_mos skip every ungrounded stream and count it in
ungrounded_excluded,
because a bound on a placeholder picks calls out of a guess — and it goes
wrong in both directions. A healthy AMR-WB stream never appears in a max_mos
sweep, while a degraded one turns up on a figure that never described it.
Reporting the skipped count keeps the difference visible: “2 streams below 3.5” and “2 streams below 3.5, plus 200 I cannot score” describe different captures, and on any network carrying AMR-WB, EVS or G.722 the second one is the truth.
Omit both bounds and the sweep lists every stream, including the codecs with no
published value, each still carrying mos_grounded.
The example runs against tests/pcap-samples/codec-negotiation.pcap, which
carries four streams — two PCMU, two G722 — and no dialogs at all:
// rtp_stats { "max_mos": 4.5, "limit": 1 }
{
"schema_version": 1,
"streams": [
{
"codec": "PCMU",
"dscp": 0,
"dst": "127.0.0.1:5084",
"first_seen": "2026-07-08T18:35:27.407583+00:00",
"frame": "tests/pcap-samples/codec-negotiation.pcap#5@5d4c3e3d970a836b",
"input_origin": "wire",
"jitter_ms": 0.26954164761616456,
"last_seen": "2026-07-08T18:35:30.407077+00:00",
"loss_pct": 0.0,
"mos": 4.357953149337916,
"mos_grounded": true,
"mos_grounding": "published",
"octets": 24160,
"orphaned": true,
"packets": 151,
"payload_type": 0,
"quality_intervals": [],
"round_trip_note": "Not measured. No endpoint reported a round trip for this stream, so latency is unknown rather than good — a stream with clean jitter and no loss can still be unusable on delay alone (ITU-T G.114).",
"schema_version": 1,
"src": "127.0.0.1:5094",
"ssrc": "0x0e330af3"
}
],
"returned": 1,
"total_matched": 2,
"ungrounded_excluded": 2,
"truncated": true,
"truncated_by": "rows",
"next_cursor": "2026-07-08T18:35:27.407583Z|0x0e330af3@127.0.0.1:5094>127.0.0.1:5084",
"capture_identity": {
"node": "capture-01",
"instance": "22fa418cb5c799c57abef-1",
"dialog_generation": 1,
"stream_generation": 4
}
}
The sweep adds frame, capture_identity and the page fields the per-call mode
omits.
orphaned means “no dialog claims this stream”, and nothing else. It is
associated_dialog.is_none(), which sipnab computes while building the
response, so the two fields can never disagree: every stream above reports orphaned: true, which
is the truth about a capture holding four streams and no dialogs at all.
sipnab computes orphaned per response as associated_dialog.is_none(), so a
stream reports it from its FIRST packet. That matters because a short unclaimed
stream is exactly what a NAT or one-way-audio fault looks like from the media
side: an agent filtering for orphans to find one must not have to wait for a
sweep to notice. Reading associated_dialog directly means the same thing.
total_matched: 2 and ungrounded_excluded: 2 account for all four streams.
The two G722 streams score 4.22 from the placeholder arm, which would have put
them under a 4.5 bound on a number that means nothing.