Skip to content

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

timestamp: str = ''

When the record was generated, in :data:TIMESTAMP_FORMAT.

call_id class-attribute instance-attribute

call_id: str = ''

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.

from_uri class-attribute instance-attribute

from_uri: str = ''

From URI. Empty on a MEDIA record.

to_uri class-attribute instance-attribute

to_uri: str = ''

To URI. Empty on a MEDIA record.

ruri class-attribute instance-attribute

ruri: str = ''

Request-URI. Empty on a MEDIA record.

method class-attribute instance-attribute

method: str = ''

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

response_code: int = 0

Final response code; 0 when the call got no final response.

timestamp_start class-attribute instance-attribute

timestamp_start: Optional[str] = None

When the call started (INVITE sent/received).

timestamp_answer class-attribute instance-attribute

timestamp_answer: Optional[str] = None

When the call was answered (2xx). None on an unanswered call.

timestamp_end class-attribute instance-attribute

timestamp_end: Optional[str] = None

When the call ended (BYE or timeout).

duration_secs class-attribute instance-attribute

duration_secs: float = 0.0

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.

source_ip class-attribute instance-attribute

source_ip: str = ''

Source IP of the request.

destination_ip class-attribute instance-attribute

destination_ip: str = ''

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

transport: str = ''

"udp" | "tcp" | "tls" | "ws" | "wss".

user_agent class-attribute instance-attribute

user_agent: Optional[str] = None

User-Agent header, when the message carried one.

auth_user class-attribute instance-attribute

auth_user: Optional[str] = None

Authenticated username, after digest auth.

disconnect_initiator class-attribute instance-attribute

disconnect_initiator: Optional[str] = None

Who ended the call: "caller" | "callee" | "timeout" | "error".

sip_reason class-attribute instance-attribute

sip_reason: Optional[str] = None

Reason header value from the BYE (RFC 3326), when present.

rf_session_id class-attribute instance-attribute

rf_session_id: Optional[str] = None

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

rf_result_code: Optional[int] = None

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

extra: Dict[str, str] = field(default_factory=dict)

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

is_media: bool

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

is_register: bool

True for a registrar state-change record — see :attr:reg_event.

answered property

answered: bool

True when the call reached an answer (it has an answer timestamp).

reg_event property

reg_event: Optional[str]

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

lcr_attempts: List[LcrAttempt]

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

reason_protocol: Optional[str]

Protocol of the Reason header (RFC 3326): "Q.850", "SIP", …

reason_cause property

reason_cause: Optional[int]

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.

reason_text property

reason_text: Optional[str]

The Reason header's text= value, unquoted.

generated_at property

generated_at: Optional[datetime]

:attr:timestamp as a timezone-aware UTC datetime.

started_at property

started_at: Optional[datetime]

:attr:timestamp_start as a timezone-aware UTC datetime.

answered_at property

answered_at: Optional[datetime]

:attr:timestamp_answer as a timezone-aware UTC datetime.

ended_at property

ended_at: Optional[datetime]

:attr:timestamp_end as a timezone-aware UTC datetime.

media_reason property

media_reason: Optional[str]

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

media_duration_ms: Optional[int]

On a MEDIA record, the media session lifetime in milliseconds (the engine's logical clock, ~1 s grain).

media_legs property

media_legs: List[MediaLeg]

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

from_dict(data: Dict[str, Any]) -> 'CallDetailRecord'

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

from_json(payload: Any) -> 'CallDetailRecord'

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

to_dict() -> Dict[str, Any]

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.

to_json

to_json() -> str

Serialize to a JSON document (one line, as the file sink writes it).

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

role: str

Which leg: "near" (the offerer), "far" (the answerer), or "leg2"/"leg3"/… for further legs. This is the extra key prefix.

tag instance-attribute

tag: str

The leg's SIP tag — the offerer's from_tag (near) or the answerer's to_tag (far).

codec class-attribute instance-attribute

codec: Optional[str] = None

Negotiated audio codec name, when known.

remote_address class-attribute instance-attribute

remote_address: Optional[str] = None

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

local_address: Optional[str] = None

The engine's own media address toward this party ("host:port").

payload_type class-attribute instance-attribute

payload_type: Optional[int] = None

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

egress_ssrc: Optional[int] = None

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: int = 0

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

ssrc: Optional[int] = None

The inbound stream's SSRC (RFC 3550), when measured.

packets_lost class-attribute instance-attribute

packets_lost: Optional[int] = None

Cumulative network packets lost inbound (RFC 3550 §6.4.1), when measured.

loss_percent class-attribute instance-attribute

loss_percent: Optional[float] = None

Inbound network packet loss as a percentage, when measured.

jitter_ms class-attribute instance-attribute

jitter_ms: Optional[float] = None

Inbound interarrival jitter in milliseconds (RFC 3550 §6.4.1).

rtt_ms class-attribute instance-attribute

rtt_ms: Optional[float] = None

Engine↔peer round-trip time in milliseconds, when a reception report yielded one.

mos_average class-attribute instance-attribute

mos_average: Optional[float] = None

Mean ITU-T G.107 MOS across the call, when measured.

mos_min class-attribute instance-attribute

mos_min: Optional[float] = None

Lowest MOS across the call.

mos_max class-attribute instance-attribute

mos_max: Optional[float] = None

Highest MOS across the call.

mos_basis class-attribute instance-attribute

mos_basis: Optional[str] = None

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

text_packets: Optional[int] = None

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

text_characters: Optional[int] = None

T.140 characters received on the text stream.

text_missing_markers class-attribute instance-attribute

text_missing_markers: Optional[int] = None

U+FFFD loss markers inserted in the text stream (RFC 4103 §5.3).

text_recovered_from_redundancy class-attribute instance-attribute

text_recovered_from_redundancy: Optional[int] = None

Text packets recovered from RFC 4103 redundancy.

from_extra classmethod

from_extra(role: str, extra: Dict[str, str]) -> 'MediaLeg'

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.

carrier_id instance-attribute

carrier_id: str

The carrier_id the LCR API returned for this route.

status instance-attribute

status: int

Final SIP status this carrier gave, or the synthesized one on timeout.

elapsed_ms instance-attribute

elapsed_ms: int

Milliseconds from dialing this carrier to that status.

dialed instance-attribute

dialed: bool

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.