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, v | Identity. |
direction | inbound, outbound or internal. |
call_id, dialog_id | The SIP identifiers, as fields — never as path elements. |
from, from_name, to, did, trunk, source, extension | Who and how. |
start, answer, end | answer is absent when the call was never answered. |
duration_ms, bill_ms | bill_ms is zero unless the call was answered. |
cause, sip_status, reason | How 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_by | Which extension picked up. |
incomplete | True 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.
queue, recording and voicemail fields are always emptyA 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 | |
|---|---|
tenant | Required in effect for a tenant-scoped token. |
from, to | RFC 3339. to before from is a 400. |
direction | inbound, outbound, internal. |
number | Substring across from, to and did. |
answered | true or false. |
trunk, extension | Exact. |
cursor, limit | Newest 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.yaml → cdr.