# Reading the CDR grid

> How the CDR explorer deduplicates rows per call, what the filters and search box match, why the summary tiles and the pager total differ, and when to drop into ClickHouse.

The CDR explorer in the TeleQuick console is a paged view over the
`cdrs` table in ClickHouse, collapsed to one row per call. This page
explains what the grid shows, what it does not show, and where the
numbers come from, so that you can tell a data problem apart from a
display artifact.

For the underlying CDR schema and how rows get written, see
[Telemetry](/platform/telemetry).

## What one row represents

One row is one **call**, identified by `call_id` — not one CDR record
and not one call leg. The columns are:

| Column | Source field | Notes |
| ------ | ------------ | ----- |
| Call ID | `call_id` | The dedupe key. Truncated in the cell; hover for the full value. |
| Start Time | `starting_time` | Rendered in your browser's locale and timezone. Blank rows show `—`. |
| Caller → Callee | `caller`, `callee` | Two stacked lines plus a direction marker. Long carrier-format numbers and SIP URIs truncate; hover for the full value. |
| Duration | `duration` | Seconds, as stored. |
| Agent | `agent` | A single value. Blank for AI-answered calls — see below. |
| Status | `status` | `completed`, `failed`, or `no-answer`, with a colour dot. |
| Cost | `cost` | Per-second billing, as stored on the CDR. |

The direction marker under Caller → Callee (`↓ Inbound` / `↑ Outbound`)
comes from the same `direction` field the direction filter uses.

## Deduplication by call ID

The `cdrs` table is a plain MergeTree. MergeTree never collapses
identical rows on its own, so when `cdr_service` replays or re-emits a
record, the duplicate accumulates in the table. Nothing removes it at
write time.

The grid therefore deduplicates **at query time**. Rows are grouped by
`call_id` and collapsed with aggregates:

- `caller`, `callee`, `direction`, `status`, `agent` — `argMax(..., starting_time)`, i.e. the value from the latest-starting record for that call.
- `starting_time`, `ending_time`, `duration`, `cost` — `max(...)`.
- `id` — `any(...)`, used only as a React row key.

Two consequences worth knowing:

- **The row you see may not correspond to any single stored record.**
  It is a per-field reconciliation across every record carrying that
  `call_id`.
- **Filters run on the deduplicated values, not the raw rows.** The
  `WHERE` clause is applied outside the grouping, so a replayed record
  cannot pull a call into the window, or push it out, on its own.

A migration of `cdrs` to `ReplacingMergeTree` is queued as a follow-up.
Until then, query-time collapsing is the only thing making the grid
one-row-per-call — a hand-written `SELECT * FROM cdrs` will show you
the duplicates.

## The filter predicate: range, direction, status, search

Every filter is applied **server-side**, in one predicate. The grid
rows, the pager total, and the aggregate figures are all computed from
that same predicate, so they cannot disagree about which calls are in
scope.

| Control | Matches |
| ------- | ------- |
| Date range picker | `starting_time` of the **deduplicated** call, between the resolved `fromMs` and `toMs` bounds (inclusive on both ends). Either bound may be omitted, which leaves that side unbounded. |
| Direction | Exact match on `direction`: `inbound` or `outbound`. "All directions" sends no direction constraint. |
| Status | Exact match on `status`. "All statuses" sends no status constraint. |
| Search box | Case-insensitive **substring** match — the row is kept if the needle appears anywhere in `call_id`, `caller`, `callee`, **or** `agent`. |

Notes on the search box specifically:

- It is a substring match, not a prefix match and not a fuzzy match. A
  partial Call ID or a partial number both work.
- It is case-insensitive on every one of the four fields.
- It is a single needle. Typing two terms searches for that whole
  string, including the space, not for either term.
- Input is trimmed and debounced before it is sent, so the grid settles
  a moment after you stop typing rather than on each keystroke.
- Because `agent` is one of the matched fields, searching an agent name
  will not surface AI-answered calls — their `agent` field is empty.

Relative range tokens such as `now-7d` / `now` are resolved to epoch
milliseconds and rounded to the minute (`from` down, `to` up) before
the request goes out. That rounding is what keeps the query key stable
between renders; the view refetches on its own interval so a relative
window stays live without the range shifting on every tick.

Changing the org, the range, the direction, the status, or the search
term resets you to the first page.

## Summary tiles vs the range total

These two readouts are computed over different sets, and that is
deliberate:

- **The four summary tiles** — Total Calls, Total Duration, Total Cost,
  Avg Call Length — are computed in the browser from **the rows
  currently loaded**, which is at most one page. Total Calls is
  labelled "loaded window" for exactly this reason.
- **The pager line** — "Showing *x*–*y* of *N* in range" — uses `N`
  from the server, which counts **every deduplicated call matching the
  predicate** across the whole range, not just the loaded page.

So on a range with more matches than fit on one page, the Total Calls
tile will read lower than the pager total, and Total Duration and Total
Cost describe only the page in front of you. Narrow the range or
tighten the filters until the range total fits on one page if you need
the tiles to describe the whole selection.

Two smaller behaviours in the same footer:

- With no matches, the footer reads "No records in the selected range"
  and the pager is hidden entirely — a "1 / 1" control over an empty
  table is noise.
- While a fetch is in flight the footer reads "Loading…" and the
  Previous/Next buttons are disabled. The previous page's rows stay on
  screen as placeholder data rather than blanking the grid.

## AI and human handlers on the same call

The `agent` column on `cdrs` holds a **single** value, and it is empty
for AI-answered calls. That is why the Agent cell renders `—` on calls
an AI agent handled: there is nothing in that field to show. It is not
a lookup failure.

The real handler information lives in `events_raw`, which records a row
**per segment** with a `handler_kind` of `'llm'` or `'human'`, a
`handler_id`, a `handler_display_name`, and `talk_time_sec`. The list
procedure enriches each page from that table, keyed on `call_id` (the
same id space as `cdrs`), producing per call:

- the AI handler id and display name, plus summed AI talk time
- the human handler id and display name, plus summed human talk time

Within each kind, the id and display name come from the
latest-finishing segment (`argMaxIf` on `seg_stop`), while talk time is
summed across all segments of that kind. A call that was transferred
from an AI agent to a person therefore carries both sides, with talk
time split between them.

A call with no matching `events_raw` segment simply gets nulls for the
split — an absent segment is not an error.

This enrichment is deliberately scoped to the `call_id`s on the current
page, which keeps it to one extra round-trip per page rather than a
join across the whole range.

## Dropping into ClickHouse for queries the grid cannot express

The grid exposes a fixed predicate: range, direction, status, and the
four-field search. Anything outside that shape — grouping, joins
against `events_raw` beyond the built-in handler split, arbitrary
aggregate windows, or columns the grid does not render — belongs in
ClickHouse directly.

**Query ClickHouse** in the header calls `admin.launchClickhouse` with
the currently active org. The procedure builds a launch URL scoped to
**your user id and that org id**, and the console opens it in a new tab
with `noopener`. The hand-off carries the org you are looking at, not
an account-wide grant, and it carries your identity rather than a
shared credential. The button is disabled while you have no active org
selected and while the launch request is in flight.

When you write queries there, remember the deduplication rule above:
`cdrs` holds raw, potentially duplicated records. Group by `call_id`
and reduce with `argMax(..., starting_time)` / `max(...)` if you want
numbers that agree with the grid.

## Related

- [Telemetry](/platform/telemetry) — the CDR schema and how rows reach ClickHouse
- [Telephony Metrics](/glossary/metrics) — definitions for the rates you compute from CDRs
