Skip to main content

Call logs

configVersion: lyno/v1
kind: System
spec:
dataDir: /var/lib/lyno
cdr:
enabled: true
retention: 8760h # one year; zero keeps them forever

Call logging is off by default and needs a dataDir. There is no per-tenant override: a system either keeps a record of its calls or it does not, and letting one tenant opt out of being logged is not a feature anyone wants to explain.

The layout

/var/lib/lyno/acme/cdr/
2026/07/26/20260726T143012Z-3f9ac1d2.json one record per call
2026/07/26/index.jsonl one compact line per call
inflight/20260726T143012Z-3f9ac1d2.json a call still in progress

The day directory is the tenant's local date, so a Dutch operator asking for yesterday's calls does not get a day that splits at 02:00.

One file per call, not an append-only log

A call record has to survive the machine losing power halfway through being written. One record is one write, one fsync and one rename, so a crash leaves an orphan nothing reads rather than a truncated line a parser has to guess about.

It also makes the two operations a data protection authority asks for trivial: erasing one call is one delete, and dropping a day of retention is one recursive delete.

Listing would be slow against thousands of files, so every day directory also carries an index.jsonl — one compact line per call, appended at commit and regenerable from the records themselves.

Records are written directly and synchronously, never over the event bus. The bus drops events when a subscriber falls behind, which is right for a dashboard and wrong for a billing record.

In-flight markers and crash recovery

A marker is written under inflight/ once a call is answered, and removed when the record is committed. At startup — before the listeners open — anything left there is promoted to a real record with incomplete: true and cause: incomplete, ending at the marker's last modification time.

A marker whose record was already committed only gets its index line repaired.

What a record contains

Field
id, tenant, vIdentity.
directioninbound, outbound or internal.
call_id, dialog_idThe SIP identifiers, as fields — never as path elements.
from, from_name, to, did, trunk, source, extensionWho and how.
start, answer, endanswer is absent when the call was never answered.
duration_ms, bill_msbill_ms is zero unless the call was answered.
cause, sip_status, reasonHow it ended.
steps[]Every dialplan step: id, type, skipped, at, result, error.
legs[]Every leg rung: target, kind, start, answer, end, sip_status, won, cause.
digits[]DTMF the caller sent, with the step it arrived in.
answered_byWhich extension picked up.
incompleteTrue for a record recovered from a marker.

cause comes from a closed set, so a report can group by it: answered, no_answer, busy, rejected, no_route, cancelled, failed, error, incomplete.

Digits collected by a voicemail_check step are deliberately not recorded — that step collects a PIN.

The queue, recording and voicemail fields are always empty

A record declares fields linking it to a queue, a recording and a voicemail message, and the index line carries rec and vm booleans derived from them. Nothing in the call path ever fills them in. The link runs the other way too — the cdr field in a recording's sidecar is empty for the same reason.

Correlate by time and call_id instead.

Retention

cdr.retention is applied by a janitor that runs every 24 hours, with the first pass at startup. It removes whole day directories older than the cutoff, deciding from the directory name alone, and tidies up empty months and years. A directory whose name is not a date is left alone rather than guessed at.

Zero keeps records forever.

Reading them

Over the management API with the cdr:read scope:

curl -H "Authorization: Bearer $T" \
'http://127.0.0.1:8080/v1/cdr?tenant=acme&direction=inbound&answered=false&limit=50'

curl -H "Authorization: Bearer $T" \
http://127.0.0.1:8080/v1/cdr/acme/20260726T143012Z-3f9ac1d2
Filter
tenantRequired in effect for a tenant-scoped token.
from, toRFC 3339. to before from is a 400.
directioninbound, outbound, internal.
numberSubstring across from, to and did.
answeredtrue or false.
trunk, extensionExact.
cursor, limitNewest first; limit defaults to 100, caps at 1000.

Listing reads only the index files and skips whole day directories outside the range, so a year of history is cheap to page through.

Or read them off disk — the format is stable enough to jq:

jq -r '[.start, .direction, .from, .to, .cause, .bill_ms] | @tsv' \
/var/lib/lyno/acme/cdr/2026/07/26/index.jsonl

The dashboard is not the call log

The dashboard's call history lives in memory and resets when the process restarts. It reports what just happened; the call log is the record. They are built from different sources on purpose.

Reference

Key-by-key schema: lyno.yamlcdr.