Passa al contenuto principale

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:

CampoSource KamailioNote
call_id$ciUUID call leg.
started_at$Ts su INVITERFC3339 UTC.
answered_at$Ts su 200 OKNullable se la chiamata non ha risposta.
ended_at$Ts su BYE/CANCEL o final responseMandatory.
sip_response_code$rs / last reply statusINT 100-699.
cause_q850campo nativo q850_cause da accounting o $hdr(Reason) parsatoMANDATORY 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 userCLI normalizzata E.164.
dst$rU R-URI userDNIS 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':

  1. q850_cause nativo Kamailio, se presente e valido.
  2. Header Reason: Q.850;cause=N, se presente e parseable.
  3. 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.

SIPQ.850 causeSignificatoUso MVP
48617User BusyRiferimento diagnostico, non fallback automatico.
48716Call cancelled / normal clearingRiferimento diagnostico, non fallback automatico.
48018No User RespondingRiferimento diagnostico, non fallback automatico.
50341Temporary FailureRiferimento diagnostico, non fallback automatico.
408102Recovery on Timer ExpiryRiferimento 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_ts sotto 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_json upstream, senza modulo C custom.
  • Durabilita' locale: se NATS e' down, il file JSONL resta su disco.
  • Debug semplice: gli operatori possono ispezionare record con tail e jq.
  • 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: copytruncate e 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, subject cdr.raw e subject set cdr.>.
  • 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 > 1GB per 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.raw devono essere additive-only. Un breaking change richiede subject versionato, ad esempio cdr.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.