ADR-0010 - Kamailio CDR emit pattern: file-tail + Python sidecar to NATS
- Status: Accepted (2026-05-15)
- Deciders: Massimo Bagnoli, Claude (sintesi Architect+TLC+DevOps)
- Implementation tasks: TASK-78+ signaling Kamailio, dopo Trigger #2
- Supersedes: nessuno
- Superseded by: ADR-0016 per l'implementazione concreta della pipeline CDR e ADR-0019 per il placement NATS sul tier stateful.
Context
ADR-0007 fissa NATS JetStream come bus durevole della pipeline CDR, con
stream AKIRA_CDR e subject di ingresso cdr.raw. Resta da fissare il pattern
con cui Kamailio consegna a NATS i record prodotti dal modulo acc_json.
Il requisito operativo principale e' che il signaling path non venga bloccato
da problemi NATS, database o worker downstream. I CDR sono critici per
fatturazione e troubleshooting, ma non richiedono latenza real-time nel senso
del call setup. Una latenza sub-secondo tra hangup e publish su cdr.raw e'
accettabile se in cambio il sistema resta durabile, debuggabile e semplice da
operare.
Vincoli: Kamailio deve restare vicino ai moduli upstream standard, il publish non deve introdurre blocking I/O nel processo SIP, il percorso deve essere ispezionabile con strumenti ordinari e l'idempotenza downstream e' gia' prevista da TASK-24 e TASK-25.
Decision
Adottiamo il pattern file-tail + Python sidecar.
Kamailio usa acc_json per scrivere record JSONL su disco. Un processo
separato, kam-cdr-bridge, legge il file in tail, mantiene un offset
persistente e pubblica ogni record su NATS JetStream subject cdr.raw.
Architecture
Kamailio acc_json
|
+-- /var/log/kamailio/acc/cdr.jsonl
logrotate copytruncate ogni 5 min o 50MB
|
+-- kam-cdr-bridge (apps/kam-cdr-bridge/)
|
+-- asyncio + aiofiles tail
+-- offset persistente in /var/lib/akira/kam-cdr-bridge/offset
+-- fsync offset ogni 100 linee o al flush temporizzato
+-- nats-py async publish su subject cdr.raw
+-- buffer overflow su disco quando NATS non e' raggiungibile
+-- metriche Prometheus e health endpoint
Il sidecar e' un confine di isolamento: un bug o una pausa nel publish non deve compromettere Kamailio. Kamailio continua a scrivere file locali; il bridge assorbe la ritrasmissione e il backlog.
Kamailio config excerpt
loadmodule "acc_json.so"
modparam("acc_json", "log_file", "/var/log/kamailio/acc/cdr.jsonl")
modparam("acc_json", "log_flag", 1)
modparam("acc_json", "render_backslash_escaped_strings", 1)
modparam("acc_json", "log_facility", "LOG_LOCAL0")
Mandatory fields nel CDR emit
Il collector Kamailio DEVE popolare almeno questi campi prima di emettere il
CDR su NATS JetStream subject cdr.raw:
| Campo | Source Kamailio | Note |
|---|---|---|
call_id | $ci | UUID call leg. |
started_at | $Ts su INVITE | RFC3339 UTC. |
answered_at | $Ts su 200 OK | Nullable se la chiamata non ha risposta. |
ended_at | $Ts su BYE/CANCEL o final response | Mandatory. |
sip_response_code | $rs / last reply status | INT 100-699. |
cause_q850 | campo nativo q850_cause da accounting o $hdr(Reason) parsato | MANDATORY se presente. NULL se assente o non parseable. |
originator_id | $avp(s:orig_id) | Da lookup htable. |
terminator_id | $avp(s:term_id) | Da lookup htable. |
src | $fu From URI user | CLI normalizzata E.164. |
dst | $rU R-URI user | DNIS post-rewrite. |
codec | $avp(s:codec) | Da SDP negoziato. |
Parser cause_q850
Il valore preferito e' il campo Q.850 nativo esposto dall'accounting
Kamailio, se disponibile nel record acc_json come q850_cause. In assenza
del campo nativo, il collector puo' parsare l'header RFC 3326
Reason: Q.850;cause=N;text="...".
La priority chain del collector e':
q850_causenativo Kamailio, se presente e valido.- Header
Reason: Q.850;cause=N, se presente e parseable. NULL, se entrambe le sorgenti sono assenti o invalide.
Il collector non deve derivare cause_q850 da sip_response_code nell'MVP.
Implementazione attesa nel sidecar:
def extract_cause_q850(record: dict) -> int | None:
native = record.get("q850_cause")
if isinstance(native, int) and 0 < native < 256:
return native
if isinstance(native, str) and native.isdigit():
value = int(native)
if 0 < value < 256:
return value
reason = record.get("reason_header") or record.get("Reason")
if reason:
match = re.search(r"Q\.850\s*;\s*cause\s*=\s*(\d+)", reason)
if match:
value = int(match.group(1))
if 0 < value < 256:
return value
return None
# Kamailio config snippet
if (is_present_hf("Reason")) {
$var(reason) = $hdr(Reason);
# Match Q.850 cause: "Reason: Q.850;cause=16"
if ($var(reason) =~ "Q\.850.*cause=([0-9]+)") {
$var(q850) = $(var(reason){re.subst,/^.*cause=([0-9]+).*$/\1/});
$avp(s:cause_q850) = $var(q850);
}
}
Se l'header Reason e' assente, non contiene Q.850, o il valore non e'
parseable, cause_q850 = NULL. Il collector non deve usare 0 come default:
0 non e' una cause Q.850 valida per il CDR applicativo.
Mappatura SIP -> Q.850
La mappatura SIP -> Q.850 da RFC 3398 e' utile come riferimento diagnostico,
ma non e' abilitata nell'MVP come sorgente automatica di cause_q850: il
mapping non e' sempre reversibile e puo' introdurre falsi positivi nella FAS
detection. Il collector deve quindi estrarre Q.850 da q850_cause o
Reason: Q.850;cause=N; se questi dati mancano, il valore resta NULL.
| SIP | Q.850 cause | Significato | Uso MVP |
|---|---|---|---|
| 486 | 17 | User Busy | Riferimento diagnostico, non fallback automatico. |
| 487 | 16 | Call cancelled / normal clearing | Riferimento diagnostico, non fallback automatico. |
| 480 | 18 | No User Responding | Riferimento diagnostico, non fallback automatico. |
| 503 | 41 | Temporary Failure | Riferimento diagnostico, non fallback automatico. |
| 408 | 102 | Recovery on Timer Expiry | Riferimento diagnostico, non fallback automatico. |
Un eventuale flag cause_q850_source con valori come native e derived
resta materia di ADR/task futuro, se in produzione emergera' un use case NOC
per fallback best-effort. Per l'MVP il CDR traccia solo il valore
cause_q850, senza source tracking e senza derivazione da SIP code.
Sidecar runtime requirements
- Python 3.12 con ambiente
uv. - Dipendenze:
aiofiles,nats-py>=2.0,prometheus-client,structlog. - Servizio systemd:
kam-cdr-bridge.service. - Policy systemd:
Restart=always,RestartSec=5s. - Limiti iniziali: 256MB RAM, 1 CPU core.
- Health endpoint:
:8080/health. - Health condition:
last_processed_tssotto 10 secondi con NATS raggiungibile.
Offset and delivery semantics
Il bridge persiste un offset locale dopo il publish. L'offset viene fsyncato ogni 100 linee o al flush temporizzato, non a ogni singolo record.
Questa scelta privilegia throughput e basso I/O. Il failure mode accettato e' un possibile duplicato se il processo crasha dopo il publish ma prima del fsync offset. La duplicazione attesa e' limitata alla finestra di flush.
La garanzia end-to-end e' quindi at-least-once verso la pipeline CDR. La
deduplica e' responsabilita' dei consumer e dello storage: TASK-24 introduce
l'idempotenza dell'ingest CDR, TASK-25 consuma da NATS JetStream e ack-a dopo
persistenza, la unique key call_id + started_at resta la safety net.
Disk buffering
Se NATS e' down o il publish fallisce, il sidecar non scarta record. Mantiene backlog su disco con hard limit iniziale 5GB. Al raggiungimento del limite, il servizio deve esporre stato unhealthy e incrementare un contatore di failure; la policy di drop o stop deve essere decisa nel task implementativo, ma non deve impattare il processo Kamailio.
Il file sorgente resta soggetto a logrotate: copytruncate, rotazione ogni 5
minuti o a 50MB.
Consequences
Positive
- Zero impatto sul signaling: nessuna chiamata NATS dal processo Kamailio.
- Uso di moduli standard:
acc_jsonupstream, senza modulo C custom. - Durabilita' locale: se NATS e' down, il file JSONL resta su disco.
- Debug semplice: gli operatori possono ispezionare record con
tailejq. - Replay operativo: un file CDR archiviato puo' essere ri-letto dal bridge o da tooling dedicato.
- Backpressure naturale: il backlog si manifesta come crescita file o buffer disco, non come blocco SIP.
- Deploy indipendente: il sidecar puo' essere aggiornato senza rebuild di Kamailio.
Negative
- Latenza aggiuntiva: 100-500ms stimati da hangup a publish, dipendenti da polling/flush e carico I/O.
- Doppio I/O locale: Kamailio scrive e il bridge legge lo stesso record.
- Duplicati possibili: crash tra publish e fsync offset puo' ripubblicare fino alla finestra di flush.
- Un processo in piu': monitoring e runbook devono coprire anche il sidecar.
- Rotazione da testare:
copytruncatee offset persistente richiedono test specifici per evitare salti o reread eccessivi.
Alternatives considered
A1 - In-process NATS client
Kamailio invia direttamente a NATS da un modulo Lua o C custom.
Pro: latenza minima, nessun file intermedio, meno processi.
Contro: complessita' di build e manutenzione, rischio di bloccare o crashare il signaling path su bug client o problemi NATS, buffering locale da implementare dentro Kamailio, replay piu' difficile.
Verdict: rejected per Fase 2. Da rivalutare in Fase 4 solo se la latenza CDR diventa un problema misurato e il team accetta un modulo custom.
A2 - Kamailio acc_db + worker Python su Postgres
Kamailio scrive CDR tramite acc_db in una tabella Postgres; un worker legge
la tabella e pubblica su NATS.
Pro: persistenza immediata in database, idempotenza naturale via primary key, query e troubleshooting SQL.
Contro: doppia scrittura Postgres, carico extra sul DB, polling aggiuntivo, accoppiamento tra signaling e database.
Verdict: rejected. ADR-0007 separa appositamente il buffer CDR dal database di destinazione.
A3 - Syslog UDP + rsyslog to NATS
Kamailio emette accounting su syslog; rsyslog o un gateway inoltra verso NATS.
Pro: sfrutta componenti gia' presenti in molte installazioni Linux, pochi cambi applicativi, configurazione familiare agli operatori.
Contro: UDP puo' perdere messaggi, offset tracking debole, mapping verso NATS non standard, replay e deduplica meno controllabili.
Verdict: rejected. Il CDR path deve essere trattato come data pipeline, non come log best-effort.
Implementation references
- ADR-0007: pipeline CDR su NATS JetStream, stream
AKIRA_CDR, subjectcdr.rawe subject setcdr.>. - TASK-24: endpoint ingest CDR e idempotenza su
call_id + started_at. - TASK-25: consumer NATS JetStream e persistenza downstream.
- TASK-43: alerting Prometheus/Alertmanager.
- TASK-78+: installazione signaling Kamailio e configurazione
acc_json. apps/kam-cdr-bridge/: app sidecar da introdurre nel task implementativo.
Monitoring & success metrics
kam_cdr_bridge_processed_total: cresce sotto traffico.kam_cdr_bridge_publish_failures_total: rate sotto 0.01% dei publish.kam_cdr_bridge_backlog_bytes: sotto 100MB in carico normale.kam_cdr_bridge_lag_seconds: p95 sotto 2s.- Alert warning:
kam_cdr_bridge_backlog_bytes > 1GBper 10 minuti. - Alert critical: health endpoint unhealthy per oltre 2 minuti.
- Uptime sidecar target: 99.9% per quarter; restart manuali sotto 1/mese.
Open questions
- Multi-instance Kamailio: in Fase HA decidere sidecar per nodo o aggregatore centrale. La preferenza iniziale e' un sidecar per nodo, ma e' differita.
- Evoluzione formato CDR: i campi su
cdr.rawdevono essere additive-only. Un breaking change richiede subject versionato, ad esempiocdr.raw.v2. - Policy esatta al raggiungimento del limite 5GB del buffer disco: fail-closed
del sidecar o drop controllato con alert critico. Da fissare nel task
kam-cdr-bridge.