How Routing Logs Work
Every time the switch runs a dialed number through a translation chain, it writes a forensic routing record: which translation contexts were walked, which blocks were tested and what they said, every rewrite the number went through, and what the walk resolved to. Where a CDR tells you what happened on a call, the routing log tells you why the switch routed it the way it did — the tool for “why did this number go to voicemail?”, “what rewrote this prefix?”, and “which block caught this call?”. Managing routing logs covers querying and retention.
One record per router walk
Section titled “One record per router walk”The unit is a single router invocation, not a call. A simple
answered call usually produces one record; a call that gets forwarded or
transferred produces one per re-route. Each record’s source says where
the router ran:
source |
When |
|---|---|
edge |
The network edge resolved the initial INVITE — the normal path for every new call. No media channel exists yet, so edge records carry no leg_uuid. |
ingest |
The call controller resolved the initial INVITE itself, because the call arrived without an edge decision (fallback path). |
reroute |
An in-call re-walk: a call forward firing, a namespace transfer, an application redirect, a voicemail-driven redial. |
Only genuine translation-chain walks are recorded — a feature code like
*98 short-circuits before the router and produces no record.
Writes are deliberately fire-and-forget: persisting a forensic record never blocks call setup, and a storage hiccup drops the record rather than disturbing the call. The routing log is an investigative aid, not an accounting ledger — for billing-grade completeness, use CDRs.
Correlation — the same identifiers as CDRs
Section titled “Correlation — the same identifiers as CDRs”The edge mints the call ULID at the moment it feeds the INVITE into
the router, and the controller adopts it — so a call’s routing records
and its CDR share one identifier. The workflow this enables: spot a
suspect call in the CDRs, paste its call_ulid into the routing log,
and read every routing decision that call went through.
call_ulid— on every record; groups all of a call’s walks.leg_uuid— the inbound leg, oningest/rerouterecords only (edge records predate the channel).sip_call_id— the inbound SIP dialog Call-ID, carried on every record as a correlation fallback (the edge takes it from the routing request, controller records from the inbound leg).
Anatomy of a record
Section titled “Anatomy of a record”Each record has two layers: a small envelope (what you see in listings) and the full trace (fetched per record).
The envelope
Section titled “The envelope”| Field | Meaning |
|---|---|
call_ulid / record_id / leg_uuid |
Correlation (record_id orders a call’s walks chronologically) |
at / source |
When and where the router ran |
start_context |
The translation context the walk began in |
starting_number |
The number as dialed, before any rewriting |
final_number |
The number after every rewrite that fired |
matched |
Whether the walk found a destination |
target_type / target_id |
What it resolved to (see below) |
force_treatment |
Set when the walk was aborted into a treatment (a failed must-* rewrite, a policy denial) instead of a destination |
target_type is the resolved destination’s kind — line,
ring-group, device, trunk, route, voicemail-box, treatment,
recording, namespace, pstn, hangup, translation-context, or
application — and target_id names the specific resource.
The trace
Section titled “The trace”The trace is the walk’s full story, in two parallel lists:
hops— one entry per translation context walked (atranslation-contextredirect ends one hop and opens the next). Each hop lists the blocks the router actually evaluated, in priority order, each with a human-readable descriptor of its match condition ("ast _9XXXX","exact 1234","always","and(…)"), whether it matched, and — for a match — the target it carried, if any. A matched block with no target is a rewrite-only block: its modifications were applied and the walk continued. Blocks after the final winner never appear, because they were never tested.rewrites— every modify operation that fired, tagged with the context and block priority that owned it, with the number before and after each step. A failedmust-*operation is markedfailedand pairs with the envelope’sforce_treatment.
Together they answer the two forensic questions precisely: which block decided this call (the last evaluated entry with a target) and how the dialed number became the final number (the rewrite chain, step by step). A trimmed example:
{ "hops": [ { "context": "internal", "evaluated": [ { "priority": 10, "matched": false, "match": "exact 0" }, { "priority": 20, "matched": true, "has_target": false, "match": "ast _9XXXXXXXXXX" }, { "priority": 30, "matched": true, "has_target": true, "target": "translation-context:pstn-out", "match": "always" } ]}, { "context": "pstn-out", "evaluated": [ { "priority": 10, "matched": true, "has_target": true, "target": "pstn:", "match": "ast _+1XXXXXXXXXX" } ]} ], "rewrites": [ { "context": "internal", "priority": 20, "op": "strip-prefix", "arg": "9", "before": "95551234567", "after": "5551234567" }, { "context": "internal", "priority": 20, "op": "prepend", "arg": "+1", "before": "5551234567", "after": "+15551234567" } ]}vsscli route-logs get renders exactly this as a readable step ledger —
you rarely need to read the raw JSON. Records also carry a
schema_version so the format can evolve without breaking consumers.
How records reach storage
Section titled “How records reach storage”The generation flow, end to end:
- Edge: the edge proxy asks the routing service to resolve a new INVITE. The resolver returns the decision and the trace; the edge handler assembles the record, stamps the freshly minted call ULID, and hands it to an asynchronous recorder. The call proceeds immediately.
- Controller: ingest-fallback and in-call re-walks emit the record onto the same per-call event stream that feeds the CDR writer; a dedicated forensics writer persists it off the call’s hot path.
- Records are append-only and immutable — written once, never updated. There is no “open record” phase as with CDRs; a record appears once its walk has completed, effectively immediately.
Storage is organized around the four questions the log answers, which is why the query surface is shaped the way it is: by call (every decision of one call, with the full trace), by leg, by dialed number over time (an exact-number audit index), and by namespace + date (the listing scan). Ad-hoc analytics across namespaces or by arbitrary fields belong in a downstream analytics store, not here.
Retention — routing logs expire on their own
Section titled “Retention — routing logs expire on their own”Unlike CDRs (kept until purged), routing records auto-expire, by default after 90 days — the dataset is high-volume and self-bounds. The window is a platform-level storage setting, not per-namespace. The range purge exists for erasing records earlier than the automatic expiry (compliance erasure), not as the primary retention mechanism.