# Recordings in the console

> How the recordings screen merges CDR rows with storage objects, why some rows have no participants, and how signed playback, filtering and deletion behave.

The recordings screen lists recorded audio for the active organisation.
It does not read from a single recordings table. It merges two sources —
call detail records that carry a `recording_url`, and objects listed from
the recording bucket — then applies one shared filter predicate to the
merged set. Understanding that merge explains most of the screen's
surprising behaviour.

## Where the list comes from: CDRs plus bucket objects

`cdr.recordings` runs two lookups in parallel for the active org:

| Source | What it returns |
| ------ | --------------- |
| `cdrs` table | Every row for this tenant where `recording_url` is not empty, collapsed to one row per `call_id`. |
| Recording bucket | Objects listed for this org, each carrying a `callSid`, object key, size and last-modified time. |

Because a single call can produce several CDR rows, the CDR side groups
by `call_id` first. Within a group the screen shows:

- `max(starting_time)` as the timestamp,
- `max(duration)` and `max(recording_size_bytes)`,
- the latest-by-`starting_time` value for `caller`, `callee`,
  `direction`, `agent` and `recording_url`.

The two sides are then merged into a map keyed by call id. Bucket objects
are applied **after** the CDR rows, so when a call appears in both, the
bucket object wins: its `recording_url` becomes
`s3://<bucket>/<object-key>` and its `sizeBytes` becomes the displayed
file size. The call metadata (participants, direction, duration, agent)
still comes from the CDR lookup.

For every bucket object, the procedure also queries `cdrs` for that
tenant restricted to the listed call ids, to recover participant metadata
that the object itself does not carry.

## Rows without CDR attribution, and shared-bucket safety

An object in the bucket knows its call id, its size and when it was last
modified. It does not know who was on the call. That metadata only exists
in `cdrs`. So two outcomes are possible when no matching CDR row exists
for the tenant:

**The object came from the org's own storage.** The row is still shown.
Caller and callee fall back to empty strings and render as `—`,
`direction` and `duration` are null, and the timestamp falls back to the
object's last-modified time rather than a call start time. This is what a
recording with blank participants means: audio exists in storage, but no
CDR for this tenant references that call id yet — for example because the
CDR has not been written, or because it was aged out of the `cdrs` table
while the object survived in the bucket.

**The object came from a shared bucket.** Objects whose `source` is
`shared` and which have no `cdrs` row for this tenant are dropped
entirely. There is no proof the call belongs to the active org, and the
procedure will not surface an unattributed object across tenants. The CDR
row is the tenant proof. If a shared-bucket recording is missing from the
list, the missing piece is the CDR attribution, not the object.

## Signed playback and download URLs

The grid never links directly to a storage object. When you press play or
download, the console resolves `recording_url` first:

- A URL that does not start with `s3://` — a bring-your-own destination —
  is passed through unchanged.
- An `s3://` URI is exchanged for a presigned GET by calling
  `storage.signedUrl` with the org id and the call id.

Presigned URLs from that call are valid for one hour. The console
requests one per click and does not persist it, because the URL expires.
A URL that you copy out of the network tab or out of the audio element
will stop working once that hour is up; re-open the screen and click
again to mint a fresh one.

Play hands the resolved URL to the shared player with the call id as the
track id, so pressing play on the row that is already loaded toggles
pause instead of re-resolving. Download resolves the same way and saves
the file as `<call_id>.wav`.

## Filtering, paging and how the summary cards are computed

Every filter is applied server-side, over the merged set, before paging:

| Control | Input | Behaviour |
| ------- | ----- | --------- |
| Date range picker | `fromMs` / `toMs` | Compared against the row's `starting_time`. Relative tokens such as `now-7d` are rounded to the minute so the query key is stable. |
| Direction | `direction` | Exact match on `inbound` or `outbound`. "All directions" sends nothing. |
| Search box | `search` | Case-insensitive substring over call id, caller and callee, joined. Debounced 300 ms in the UI. |
| Agent (Advanced Filters) | `agent` | Case-insensitive substring over the CDR agent attribution. Committed on **Apply**, not as you type. |

Rows that survive the predicate are sorted newest first by
`starting_time`, then sliced with `offset` and `limit`. `limit` defaults
to 200 and the input schema accepts 1–500.

The four cards at the top are computed by the server over the **filtered
set as a whole**, not over the returned page:

- **Total Recordings** — the count of filtered rows, the same number as
  `total` in the footer's "Showing N of M recordings in range".
- **Total Duration** — the sum of every row's duration, treating null as
  zero.
- **Storage Used** — the sum of every row's `recording_size_bytes`.
- **Avg Duration** — total duration divided by the number of rows with a
  duration greater than zero, rounded. Rows with no duration, including
  unattributed bucket objects, are excluded from the divisor but still
  counted in Total Recordings.

That divisor is why Total Duration ÷ Total Recordings will not always
equal Avg Duration.

The query refetches every 60 seconds while the screen is open.

## The features.recording entitlement gate

The screen reads the org's entitlement claims and checks
`claims.features.recording`. The list renders unless the claims are
loaded **and** the flag is explicitly `false`, in which case the screen
is replaced by an upsell panel and no recordings query result is shown.

The gate deliberately fails open in two cases:

- entitlement data has not loaded yet, and
- entitlement data is `null`, meaning the entitlement service is not
  configured for this deployment.

So an unconfigured entitlement service never hides recordings from a
tenant that has them.

## Deleting a recording

Each row carries a delete control in the actions column. In the screen as
shipped, that control has no handler attached: clicking it does not call
a procedure, does not remove the object from the bucket, and does not
change the CDR. Nothing is deleted, and the row returns on the next
refetch. Treat the recordings grid as read-only until a delete procedure
is wired to it.

## Related

- [Telemetry](/platform/telemetry) — the `recording_url` column in the CDR schema
- [Telephony Metrics](/glossary/metrics)
- [Authentication](/concepts/authentication) — how short-lived signed access is minted
