Call Detail Records (CDR)
Use this page to understand what is in a Call Detail Record (CDR), when it is emitted, and how to query it. A CDR is a single structured log record (logType='cdr') that summarises one call/conversation end-to-end. It is intended for reporting, troubleshooting, and analytics — a single JSON object per call that consolidates the data otherwise scattered across in-memory state, OTel spans, structured logs, and the Conversation row.
For the broader log schema, SigNoz queries, and per-service log-level variables, see Monitoring in SigNoz. For call-flow events on TelSys / TelPro / RTPEngine, see Application and call-flow logging.
Scope
v1 emits CDRs only. Sending them to a remote HTTP server is a follow-up task and is not covered here.
The CDR is emitted at StasisEnd for every SIP caller leg whose call actually ends:
| Branch | CDR emitted? |
|---|---|
| Non-resumable cleanup | Yes |
| Resumable fallback cleanup (pause not supported / no app config found) | Yes |
ExternalMedia channel_end (UnicastRTP/…, WebSocket/…) | No |
Resumable pause success (cleanupChannelResourcesPartial) | No |
CDR emission is wrapped in a defensive try/catch, so a build failure never blocks cleanupChannelResources.
Filterable shape
Each CDR is a single OTel log record with eventName='cdr' (filterable as attributes.logType == 'cdr'). Every field on the entry is also flattened into the OTel attributes map, so a flat attributes.status == "error" query works without nested-path syntax.
The same record is persisted to Postgres AppLog with logType='cdr' and the full JSON in the data JSONB column. Reuse the existing AppLog indexes on (conversationId, timestamp) and (logType, timestamp).
Fields
Correlation IDs
| Field | Source |
|---|---|
channelId | ARI SIP channel UUID |
conversationId | Conversation.id (DB row) |
callId | X-Call-ID from SIP headers (resumable session id) |
traceId | Active OTel trace; falls back to the X-TRACE-ID header |
parentSpanId | Active OTel span at hangup time |
appId | Resolved FlowDefinition / app |
teamId | Call metadata teamId; falls back to the Conversation row |
flowDefinitionId, flowVersion | Call metadata, falling back to the Conversation row |
sessionId | channelData.conversationId (cross-service runtime owner id placeholder) |
These match the identifiers used across TelWeb, TelPhi, and SigNoz — see Monitoring in SigNoz → Structured logging schema.
Timing
| Field | Source |
|---|---|
startedAt | channelData.startedAt (epoch ms) |
endedAt | Date.now() at emission |
durationSeconds | (endedAt - startedAt) / 1000 |
Participants
| Field | Source |
|---|---|
callerNumber | channelData.fullCallerNumber (unredacted; redaction handled downstream) |
calledNumber | channel.dialplan.exten |
PII redaction (caller / called / transcript text) is handled by the existing redactForLogs pipeline — see PII redaction. No new redaction rules are required for the CDR.
Status / failure
status is an enum derived from the call-end path:
| Value | When |
|---|---|
completed | Caller or bot hangup, normal clearing |
transferred | Last flow-engine node exited with transfer or transfer_completed |
abandoned | Channel ended during setup (cancelPendingChannelSetup returned true) |
error | finalizeFlowEngineConversationOnHangup reported an error |
failure carries the supporting detail:
| Sub-field | Source |
|---|---|
hangupCause | Asterisk channel.hangupcause |
category | Derived from hangupCause — normal_clearing, busy, no_answer, transfer, error |
errorType | FlowEngineError when a node recorded a structured error |
failureCode | Most recent nodeHistory[].error.code → last exitReason → hangupCause → null |
Bot / service context
| Field | Source |
|---|---|
botName | flowEngineContext.getAllCallMetadata().botName |
botDisplayName | botDisplayName; falls back to botName |
flowName | Call metadata flowName |
flowVersionLabel | Call metadata flowVersionLabel |
botOutcome.botOperationResult | extractBotOperationParams(callMetadata) → success/failure |
botOutcome.botOperationData | Same as above (arbitrary payload, e.g. { intent: 'order_status' }) |
botOutcome.lastNodeExitReason | Last nodeHistory[].exitReason |
botOutcome.lastErrorCode | Last nodeHistory[].error.code |
Provider / service identifiers
provider.providerMode is either unified (one provider/model) or modular (separate STT / LLM / TTS components).
Unified mode
| Field | Source |
|---|---|
unifiedProvider | providerInstance.provider |
unifiedModel | providerInstance.model |
Modular mode
| Field | Source |
|---|---|
sttProvider | sttComponent.provider |
llmProvider, llmModel | llmComponent.{provider,model} |
ttsProvider | ttsComponent.provider |
currentSttIndex, currentTtsIndex | Active fallback-chain index at hangup time |
sttChain, ttsChain | Configured provider fallback chain |
ttsMediaCacheBackend | none | media | local |
tobiConversationId is populated when the call used the Vodafone TOBi managed LLM. hasRecording reflects whether channelData.callRecorder was set.
Latency / KPIs
| Field | Source |
|---|---|
durationSeconds | Same as in Timing |
rtpMediaSeconds | Best-effort: rtpSentStats.bytes * 8 / 64000 (G.711 μ-law approximation) |
llmInputTokens, llmOutputTokens, llmTotalTokens | usage.totals.* |
totalLatencyMs | Aggregate latency across realtime turns |
ttsProviders[] | Per-provider rollup from usage.runtime.tts.providers[] — provider, model, requests, chars |
llmModels[] | Per-model rollup from usage.runtime.llm.models[] — provider, model, requests, in/out tokens |
The per-usage-rollup detail is what makes CDRs usable for cost dashboards and per-provider SLI/SLO reporting.
Schema
schemaVersion: "1.0" — bump this when adding, removing, or renaming fields. Consumers can branch on it.
Querying
SigNoz (live OTel)
Filter by record type:
service.name = "telphi" AND attributes.logType = "cdr"
Add a status filter to find errored calls:
service.name = "telphi" AND attributes.logType = "cdr" AND attributes.status = "error"
Or trace a single call:
attributes.channelId = "<channel-uuid>"
attributes.callId = "<x-call-id>"
attributes.conversationId = "<conversation-uuid>"
Postgres (AppLog)
CDR rows land in AppLog with the full payload in data JSONB:
SELECT
data->>'callId' AS call_id,
data->>'status' AS status,
data->>'durationSeconds' AS duration_s,
data->>'callerNumber' AS caller,
data->>'appId' AS app_id,
data->'latency'->>'llmTotalTokens' AS llm_tokens,
"createdAt"
FROM "AppLog"
WHERE "logType" = 'cdr'
ORDER BY "createdAt" DESC
LIMIT 50;
Filter on a specific bot or team:
SELECT data
FROM "AppLog"
WHERE "logType" = 'cdr'
AND "appId" = 'app-uuid'
AND data->>'status' = 'error'
AND "createdAt" > NOW() - INTERVAL '24 hours';
The AppLog schema already indexes (logType, timestamp), (conversationId, timestamp), and (appId, timestamp) — see Schema overview.
PII and retention
CDRs flow through the same redactForLogs pipeline as every other structured log, so:
callerNumberandcalledNumberare redacted whenENABLE_PII_LOGGINGis off (and again at the OTel collector'stransform/pii).- Transcript-derived fields (none on the CDR itself) inherit the existing redaction rules.
CDR retention is the same as AppLog retention, which is governed by the platform's standard log retention policy. If you need a longer horizon for analytics, export to your warehouse from AppLog or wait for the follow-up "send CDR to remote server" feature.
Limitations / not yet
- No remote HTTP sender. CDRs are emitted and persisted only. Sending to an external collector is a separate task.
- No new Prisma model. The CDR lives in
AppLog.dataJSONB. If you need strongly-typed columns or a dedicated retention horizon, that is a follow-up schema change. - No new dashboard / alert. Filter in SigNoz using the queries above; build dashboards against
AppLogonce you have a representative data window. - Synchronous emission. CDR build runs inline at StasisEnd; it is small (single JSON, no network) and bounded by try/catch, but if the build ever grows heavy, move it behind a Tasker job (the
PERSIST_CONVERSATION_SPAN_TREEpattern is the template). - Bot / fallback tracking beyond
usage.runtime.*. If a flow engine emits new structured events about provider fallbacks (distinct from usage rollups), extend the CDR builder to surface them — don't add ad-hoc fields without bumpingschemaVersion.
Where to look
| Surface | Best for |
|---|---|
SigNoz Logs filtered by attributes.logType = 'cdr' | Live, cross-call search by status, trace id, app id. |
Postgres AppLog filtered by logType='cdr' | Historical reporting, joins with Conversation, retention control. |
| Monitoring in SigNoz | Schema keys, log-level env vars, dashboards, PII redaction. |
| Application and call-flow logging | Per-event TelSys / TelPro / RTPEngine logs. |
| SIP trunk log reference | Inbound SIP trunk health messages when calls fail before reaching TelSys. |