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.
What one row represents
One row is one call, identified bycall_id — not one CDR record
and not one call leg. The columns are:
The direction marker under Caller → Callee (
↓ Inbound / ↑ Outbound)
comes from the same direction field the direction filter uses.
Deduplication by call ID
Thecdrs 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.
- 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
WHEREclause is applied outside the grouping, so a replayed record cannot pull a call into the window, or push it out, on its own.
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.
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
agentis one of the matched fields, searching an agent name will not surface AI-answered calls — theiragentfield is empty.
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
Nfrom the server, which counts every deduplicated call matching the predicate across the whole range, not just the loaded page.
- 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
Theagent 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
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_ids 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 againstevents_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 — the CDR schema and how rows reach ClickHouse
- Telephony Metrics — definitions for the rates you compute from CDRs