CDR records¶
The record siphon writes to its CDR sinks: one JSON object per HTTP POST, per
line of the JSON-lines file, per syslog message. siphon_sdk.cdr is the typed
mirror of that shape, so a collector imports the contract rather than guessing
at a dict.
from siphon_sdk.cdr import CallDetailRecord
record = CallDetailRecord.from_json(line)
record.call_id, record.duration_secs, record.destination_ip
record.reason_cause # Q.850 cause off the Reason header
record.extra["billing_id"] # cdr.write(extra={...}) lands here
For writing CDRs from a script see the cdr namespace;
this page is the record a collector receives.
Take the body as a dict¶
from fastapi import FastAPI
from siphon_sdk.cdr import CallDetailRecord
app = FastAPI()
@app.post("/cdr")
async def collect(payload: dict) -> dict:
record = CallDetailRecord.from_dict(payload)
...
return {"ok": True}
Custom fields are flattened into the top level of the JSON, not nested:
cdr.write(extra={...}), an LCR route's cdr_fields, lcr_attempts, and a
MEDIA record's per-leg figures all arrive as ordinary top-level keys.
from_dict() routes every unrecognised key into .extra. Annotating the handler
parameter as the model instead makes the framework validate against the declared
fields and drop all of them before your code runs.
examples/cdr_collector.py is a runnable version of the above.
Three record kinds, one shape¶
method |
What it is | Notes |
|---|---|---|
INVITE / BYE / … |
the call record | parties, timing, teardown side, destination_ip |
REGISTER |
a registrar state change | cdr.include_register; the change is in reg_event |
MEDIA |
end-of-call media summary | carries no URIs — join it to the call on call_id |
is_media / is_register tell them apart.
Which egress address is which¶
A call record's destination_ip is the signalling next hop — where siphon
sent the INVITE. A media-anchored call's RTP does not have to go to the same
host, and its media peer is a MEDIA record's per-leg remote_address. Neither
substitutes for the other.
Not measured is not zero¶
A leg's quality figures (mos_average, jitter_ms, loss_percent, rtt_ms)
read None when the media engine relayed that leg without a userspace actor: a
kernelized relay has no jitter buffer to measure with, so it reports counters
only, plus packets_lost when the datapath's RFC 3550 §A.1 gap estimate is
non-zero. A plain G.711 passthrough call is exactly that case, so None MOS
across every leg is the expected shape, not a truncated record. Treat None as
"no measurement", never as a bad call.
The addresses, counters, codec and payload type are present either way.
CallDetailRecord¶
One record as siphon writes it to a CDR sink.
timestamp
class-attribute
instance-attribute
¶
When the record was generated, in :data:TIMESTAMP_FORMAT.
call_id
class-attribute
instance-attribute
¶
SIP Call-ID. The join key between a call record and its MEDIA
record. Empty on a REGISTER record — the registrar event stream carries
no Call-ID.
method
class-attribute
instance-attribute
¶
SIP method — INVITE / BYE / …, or the two synthetic kinds
REGISTER (registrar event) and MEDIA (end-of-call media summary).
response_code
class-attribute
instance-attribute
¶
Final response code; 0 when the call got no final response.
timestamp_start
class-attribute
instance-attribute
¶
When the call started (INVITE sent/received).
timestamp_answer
class-attribute
instance-attribute
¶
When the call was answered (2xx). None on an unanswered call.
timestamp_end
class-attribute
instance-attribute
¶
When the call ended (BYE or timeout).
duration_secs
class-attribute
instance-attribute
¶
Answered duration in seconds — answer to end, 0.0 when never
answered. On a MEDIA record this is the media session's lifetime
instead, with the exact figure in extra["media_duration_ms"].
This is conversation time, not call time: for the ringing period, subtract
:attr:started_at from :attr:answered_at.
destination_ip
class-attribute
instance-attribute
¶
IP of the next hop the call was sent to — the signalling egress.
Stamped when the request goes out, so it is the script's routing decision
(relay() / fork() / call.dial()), not the R-URI host. On a sequential
fork or an LCR failover it is the carrier the call ended up on; the ones
burned on the way are in lcr_attempts. On a parallel fork it is the branch
that answered.
Empty when nothing was ever sent — the script answered locally, or the
record came from a bare cdr.write() with cdr.auto_emit off, which is
written from inside the handler, before the request has been routed
anywhere.
Note this is the signalling peer: a media-anchored call's RTP egress is a
MEDIA record's MediaLeg.remote_address, which need not be the same
host.
transport
class-attribute
instance-attribute
¶
"udp" | "tcp" | "tls" | "ws" | "wss".
user_agent
class-attribute
instance-attribute
¶
User-Agent header, when the message carried one.
auth_user
class-attribute
instance-attribute
¶
Authenticated username, after digest auth.
disconnect_initiator
class-attribute
instance-attribute
¶
Who ended the call: "caller" | "callee" | "timeout" |
"error".
sip_reason
class-attribute
instance-attribute
¶
Reason header value from the BYE (RFC 3326), when present.
rf_session_id
class-attribute
instance-attribute
¶
Diameter Rf accounting Session-Id (3GPP TS 32.299) the CDF returned, when Rf auto-emit is on — cross-references this record with the accounting record. Absent from the JSON when unset.
rf_result_code
class-attribute
instance-attribute
¶
Result-Code (RFC 6733 §7.1) of the final ACR-STOP exchange, so rejected or dropped accounting is visible without joining a second stream. Absent from the JSON when unset.
extra
class-attribute
instance-attribute
¶
Custom fields, flattened into the top level of the JSON — siphon does
not nest them. Sources: cdr.write(extra={...}) from a script, an LCR
route's cdr_fields, reg_event on a REGISTER record, and the
per-leg media figures on a MEDIA record. Values are always strings on
the wire; use :attr:media_legs for the media ones.
is_media
property
¶
True for the media engine's end-of-call summary — see :attr:media_legs.
It carries no URIs, source or transport; join it to the call record on
:attr:call_id.
is_register
property
¶
True for a registrar state-change record — see :attr:reg_event.
answered
property
¶
True when the call reached an answer (it has an answer timestamp).
reg_event
property
¶
On a REGISTER record, the registrar change: "registered" |
"refreshed" | "deregistered" | "expired". The AoR is in
:attr:from_uri / :attr:to_uri / :attr:ruri.
lcr_attempts
property
¶
The carriers this call burned before it settled, decoded from
extra["lcr_attempts"]. Empty when the call took one route (or the
deployment doesn't use LCR)::
burned = [a for a in record.lcr_attempts if a.dialed
and a.status >= 400]
reason_protocol
property
¶
Protocol of the Reason header (RFC 3326): "Q.850", "SIP", …
reason_cause
property
¶
The Reason header's cause= value (RFC 3326) — for Q.850, the
ITU cause code: 16 normal clearing, 31 normal unspecified, 102 recovery
on timer expiry, …
The cause is what separates a clean hangup from a timer teardown on two
calls that both read response_code: 200 with the callee as
:attr:disconnect_initiator.
generated_at
property
¶
:attr:timestamp as a timezone-aware UTC datetime.
started_at
property
¶
:attr:timestamp_start as a timezone-aware UTC datetime.
answered_at
property
¶
:attr:timestamp_answer as a timezone-aware UTC datetime.
ended_at
property
¶
:attr:timestamp_end as a timezone-aware UTC datetime.
media_reason
property
¶
On a MEDIA record, why the media session ended: "delete"
(controller teardown) or "media_timeout" (dead-path reap). A
media_timeout on a call the signalling side thinks completed is the
one-way-audio / stalled-media signature worth alerting on.
media_duration_ms
property
¶
On a MEDIA record, the media session lifetime in milliseconds
(the engine's logical clock, ~1 s grain).
media_legs
property
¶
The per-leg figures of a MEDIA record, in engine order: index 0 is
the near (offerer) leg, index 1 the far (answerer) leg.
Empty on any other record::
if record.is_media:
worst = min((leg.mos_average for leg in record.media_legs
if leg.mos_average is not None), default=None)
from_dict
classmethod
¶
Parse one record, routing every unrecognised top-level key to :attr:extra.
That routing is the inverse of the Rust #[serde(flatten)] and is why
this method exists: it is what keeps a script's custom fields.
from_json
classmethod
¶
Parse one record from a JSON document — a webhook body, or one line of the JSON-lines file sink::
with open("/var/log/siphon/cdr.jsonl") as handle:
records = [CallDetailRecord.from_json(line) for line in handle]
to_dict
¶
Serialize back to siphon's wire shape.
Round-trips: the nullable fields stay null, rf_* are omitted when
unset, and :attr:extra is flattened back into the top level.
MediaLeg¶
One leg's end-of-call figures, parsed out of a MEDIA record's extra.
The media engine writes each leg's figures into the flat extra map under
a per-leg prefix (near_, far_, then leg2_, leg3_, …), all as
strings. :attr:CallDetailRecord.media_legs turns them back into numbers.
The quality fields are None on a leg with no userspace actor (a plain
in-kernel relay) or one that never received media, so "not measured" stays
distinguishable from "measured as zero" — never treat a None MOS as a
bad call.
role
instance-attribute
¶
Which leg: "near" (the offerer), "far" (the answerer), or
"leg2"/"leg3"/… for further legs. This is the extra key prefix.
tag
instance-attribute
¶
The leg's SIP tag — the offerer's from_tag (near) or the answerer's
to_tag (far).
codec
class-attribute
instance-attribute
¶
Negotiated audio codec name, when known.
remote_address
class-attribute
instance-attribute
¶
Where this party's media actually came from ("host:port") — the
source the datapath latched, else its signalled address.
The media-plane peer, and the only egress address a media record carries.
It is not a duplicate of the call record's destination_ip: that is the
signalling next hop, and an anchored call need not send its media to the
same host. Present on a relay-only leg too, where the quality fields are
not.
local_address
class-attribute
instance-attribute
¶
The engine's own media address toward this party ("host:port").
payload_type
class-attribute
instance-attribute
¶
RTP payload type of the negotiated codec — the number on the wire, where
:attr:codec is the name it was negotiated under.
egress_ssrc
class-attribute
instance-attribute
¶
SSRC of the stream the engine sent this party (RFC 3550), when a
userspace actor originated it. :attr:ssrc is the inbound counterpart.
packets_dropped
class-attribute
instance-attribute
¶
Packets dropped on the engine's side of this leg (source-gate / latch /
jitter overflow) — not network loss. For that see :attr:packets_lost.
ssrc
class-attribute
instance-attribute
¶
The inbound stream's SSRC (RFC 3550), when measured.
packets_lost
class-attribute
instance-attribute
¶
Cumulative network packets lost inbound (RFC 3550 §6.4.1), when measured.
loss_percent
class-attribute
instance-attribute
¶
Inbound network packet loss as a percentage, when measured.
jitter_ms
class-attribute
instance-attribute
¶
Inbound interarrival jitter in milliseconds (RFC 3550 §6.4.1).
rtt_ms
class-attribute
instance-attribute
¶
Engine↔peer round-trip time in milliseconds, when a reception report yielded one.
mos_average
class-attribute
instance-attribute
¶
Mean ITU-T G.107 MOS across the call, when measured.
mos_min
class-attribute
instance-attribute
¶
Lowest MOS across the call.
mos_max
class-attribute
instance-attribute
¶
Highest MOS across the call.
mos_basis
class-attribute
instance-attribute
¶
How the MOS was derived: "full" (includes the G.107 delay term) or
"loss+jitter". Compare MOS values only within the same basis.
text_packets
class-attribute
instance-attribute
¶
RFC 4103 real-time text packets received. Present only when the call
negotiated a plaintext m=text stream and a text observability feature
(recording, or text_events) promoted it.
text_characters
class-attribute
instance-attribute
¶
T.140 characters received on the text stream.
text_missing_markers
class-attribute
instance-attribute
¶
U+FFFD loss markers inserted in the text stream (RFC 4103 §5.3).
text_recovered_from_redundancy
class-attribute
instance-attribute
¶
Text packets recovered from RFC 4103 redundancy.
from_extra
classmethod
¶
Build one leg from the {role}_* keys of a MEDIA record's extra.
LcrAttempt¶
One carrier siphon burned on a sequential-failover call.
The B2BUA stamps the whole attempt list onto the CDR as a JSON array in
extra["lcr_attempts"] (extra is a flat string map, so it is encoded
rather than nested). :attr:CallDetailRecord.lcr_attempts decodes it — this
is how a carrier that answers 5xx before the call completes on the next one
is trendable at all; the winning carrier's own fields arrive as ordinary
extra keys.
status
instance-attribute
¶
Final SIP status this carrier gave, or the synthesized one on timeout.
elapsed_ms
instance-attribute
¶
Milliseconds from dialing this carrier to that status.
dialed
instance-attribute
¶
False when the route was never dialed (no healthy gateway in its group, for example) — those cost no time and mean something different from a carrier that was tried and failed.