cdr.recordings procedure
merges two sources — the cdrs table in ClickHouse and an object listing of
the recording bucket — de-duplicates them by call id, then filters, sorts, and
pages the merged set server-side.
What the list is built from
cdr.recordings runs two lookups in parallel for the requested org:
Because a call can appear as several
cdrs rows, the CDR side collapses them
per call_id: max(starting_time), max(duration), max(recording_size_bytes),
and argMax(..., starting_time) for caller, callee, direction, agent,
and recording_url. You see one row per call, built from its latest CDR write.
For every object that the listing returned, the procedure then looks the call id
back up in cdrs for the same tenant to borrow call metadata. The merged map is
keyed by call id, and the storage-derived entry is written last — so when a call
exists in both sources, the row you see carries:
recording_urlrewritten tos3://<managed-bucket>/<object-key>recording_size_bytestaken from the object listing- everything else (participants, start time, duration, direction, agent) from the CDR lookup
Tenant attribution and why some objects never appear
Objects from the listing carry asource. Objects whose source is shared
live in a bucket that is not exclusive to one tenant, so the object key alone is
not proof of ownership. For those, the CDR lookup is the attribution check:
- Shared-bucket object with a matching
cdrsrow for this tenant — the row is included, using the CDR metadata. - Shared-bucket object with no
cdrsrow for this tenant — the row is dropped. It is not rendered greyed out, and it is not counted intotal. TeleQuick would otherwise have no way to prove the audio belongs to the org asking for it, so the object is treated as invisible rather than exposed cross-tenant. - Object from a non-shared source — kept even with no CDR row, because the listing itself is already scoped to the org.
cdrs row under this tenant with a matching
call_id.
Reading a row with missing metadata
A storage-discovered row with no CDR metadata is populated from the object alone. Those rows look incomplete on purpose:
So a row whose timestamp is minutes or hours after you expect is showing when
the object was last written to the bucket. Treat the timestamp column as a call
start time only for rows that have participants and a duration.
One side effect matters for filtering: a row with no
starting_time at all is
treated as timestamp 0 by the range predicate, which puts it before any
fromMs you set. Such a row only appears when the time range has no lower
bound.
Filtering, search, and paging
All filtering happens on the server, over the merged set, before paging. The screens send filter state as query input and re-render whatever comes back — they do not post-filter rows in the browser.
The surviving rows are sorted newest first by
starting_time, then sliced by
offset/limit. The response carries:
rows— the current pagetotal— the count of rows that passed the filters, which is what drives the pager and the “N recordings in range” subtitlestats—total_recordings,total_duration,total_bytes, andavg_durationcomputed across the whole filtered set, not just the page
total is post-filter, widening the date range or clearing the search
box changes the page count as well as the rows.
The two screens drive the same procedure differently:
Changing any filter resets the pager to the first page on both screens.
Filters the API supports but these screens do not send
cdr.recordings accepts an agent input — a case-insensitive substring match
on the CDR agent attribution. Neither recordings screen exposes a control for
it, so it is always unset from the console. Call the procedure directly if you
need to narrow recordings to one agent.
The Voice AI screen additionally leaves direction and search unset. If you
need those, use the admin recordings screen or the procedure.
Playback and presigned download URLs
Nothing is fetched until you ask for it. Each row starts with a Load player / Play button; pressing it resolves that row’srecording_url into
something the browser can play:
- A URL that already starts with
http://orhttps://(a bring-your-own destination) is used as-is. - An
s3://URI (the managed bucket) is exchanged for a presigned GET by callingstorage.signedUrl({ orgId, callSid }). The procedure returns aurl, and that URL is valid for one hour.
<audio controls> element pointed at the resolved URL.
There is no HLS, no streaming session, and no request-header auth — the
presigned URL is the credential.
Resolved URLs are kept only in component state for the life of the screen. They
are never persisted, because they expire. Reload the page and the row goes back
to its unresolved state; press the button again to mint a fresh URL.
Download works the same way. The admin screen resolves the URL, then triggers a
browser download named <call_id>.wav. The Voice AI screen reuses the URL that
playback already resolved as a plain download link, so it only offers download
after you have loaded the player.
Two things follow from the one-hour lifetime:
- A download that starts near the end of the window can fail partway. Re-press the button to get a new URL.
- A presigned URL you copy out of the page is a bearer credential for that recording until it expires. Treat it accordingly.
Enabling recording for a workspace
The admin recordings screen is gated on thefeatures.recording entitlement
flag for the active org. The gate is deliberately fail-open:
To turn recording on for a workspace, move the org to a plan whose entitlement
claims include
features.recording. The flag controls this screen; it is not a
per-call switch, and the screen itself has no toggle. Once the flag is set,
recorded inbound and outbound calls appear here as the CDR pipeline and the
bucket listing pick them up.
The Voice AI recordings screen does not check the entitlement. It will render an
empty list rather than an upsell panel for an org without the flag.
Troubleshooting an empty or short list
Related
- Telemetry — the
recording_urlcolumn on the CDR schema - Authentication — API keys and short-lived tokens