logs.query and logs.overview.
Both procedures pin the tenant server-side from the authenticated org.
Every row you can read belongs to the active org; there is no cross-org
query shape.
What lands in the activity log
The log carries one line per notable thing that happened in your org:- call lifecycle
- agent activity
- meetings
- streams
- VPN sessions
- webhook deliveries
- quota notices
telequick.tenant_log_event and
rolled up per minute into telequick.tenant_log_1m_q. The row
table backs logs.query; the rollup backs logs.overview.
Event schema
logs.query returns rows with exactly these fields:
Two things to handle on the client:
ts has no timezone suffix. It is a ClickHouse DateTime string.
new Date("2026-01-04 02:24:16.259") is parsed as local time by some
engines. Normalise before parsing:
Agent invoked a tool lines at 02:24:16.259 is a real
case. A content hash therefore collides, and React will warn on duplicate
keys and highlight the wrong row. Stamp your own local identity on each row
as it enters your buffer and keep it stable for as long as the row lives
there.
The fields blob
fields is a string, not an object. It contains a JSON object with
whatever structured detail the emitting source attached to that event; the
key set is not fixed and varies by event_type. Parse defensively and
treat “unparseable” and “empty object” the same way — as no detail:
fields is not searchable. The search input matches message,
event_type and call_sid only.
Sources and event types
source identifies the subsystem that emitted a line; event_type names
the specific event within that subsystem. The set of both is open — new
sources and event types appear without a schema change — so do not hardcode
a list.
To discover which sources are actually producing lines, read
logs.overview. Its bySource array is derived from the rollup over the
requested window and gives per-source events and bytes counts, ordered
by event count descending. A source with no activity in the window does not
appear. This is exactly how the console populates its source filter, which
is why the dropdown changes as you change the window.
Rows with an empty source are reported as unknown in bySource.
When you pass sources to logs.query, each entry must match the
procedure’s name pattern, and you may pass at most 8.
Volume, levels and the one-minute rollup
logs.overview returns the volume histogram and window totals:
histogramis stacked by level: each point has separateinfo,warnanderrorcounts for its bucket.stepSecis the bucket width the server chose. It caps the histogram at roughly 120 points regardless of window size, and is always a whole number of minutes with a floor of 60 seconds. A 15-minute window buckets at one minute; a 7-day window buckets much coarser.bucketis a ClickHouse timestamp string with the same no-timezone caveat asts.totalsis the sum ofbySource, so it counts events and bytes across the whole window.
totals.events comes from the rollup and describes the entire window.
The row count of any single logs.query page is bounded by that call’s
limit. The two numbers answer different questions and will not match.
Admission budget
logs.overview reports the per-minute admission budget that applies to
your org, alongside the volume it is being compared against:
The two defaults above are the deployment defaults; the effective values
come from the
TENANT_LOG_EVENTS_PER_MIN and TENANT_LOG_BYTES_PER_MIN
deployment settings, so always read them from budget rather than assuming
them. The budget has two dimensions because either can bind first: a flood
of short lines exhausts the event budget, while a smaller number of lines
carrying large fields blobs exhausts the byte budget.
The console renders budget.eventsPerMin next to the window totals so the
histogram can be read against it, and quota notices themselves arrive as
lines in the log — so the record of a budget event is visible in the same
stream you are reading.
These two procedures read and report the budget. They do not enforce it;
enforcement happens at ingest, upstream of anything documented here.
Retention
Activity log rows are retained for 30 days.logs.overview reports this as
budget.retentionDays, and the console states it on the Logs screen.
windowMinutes accepts up to 10080 (7 days), which is the longest window
the console exposes. Anything older than retention is gone from the row
table regardless of the window or cursor you ask for.
Query windows and filters
The window is evaluated relative to server “now”, not to your cursor. Both
beforeMs and afterMs narrow within the window; neither escapes it.
If you page back far enough that the cursor leaves the window, the window
is what stops you.
search is a single term applied as an OR across three places:
- a case-insensitive substring match on
message - a case-insensitive substring match on
event_type - an exact match on
call_sid
call_sid leg — it only hits
if the fragment happens to appear in a message. Paste the whole SID.
Rows come back newest first (ts DESC) in every mode, including tails.
Cursor paging with nextBeforeMs
Paging is by timestamp cursor, not offset. Each response carriesnextBeforeMs:
- If more rows exist beyond this page,
nextBeforeMsis thets_msof the last (oldest) row on the page. Pass it asbeforeMsto get the next, older page. - If this page is the end of the window,
nextBeforeMsisnull.
limit + 1 rows internally and returns at most limit,
which is how it knows whether a further page exists without a second query.
Cursor paging gives you next/previous and no jump-to-page-7. That is the
correct shape for a live log rather than a limitation:
- Seeking by timestamp costs the same for the 2nd page as for the 200th,
whereas
OFFSETre-scans everything it skips. - It stays correct while the log is being written. With
OFFSET, rows arriving at the head shift every later page and the reader silently sees duplicates.
Tailing with afterMs and the gap flag
To follow new lines, passafterMs set to the newest ts_ms you already
hold. The response contains only what landed since — a quiet interval costs
an empty rows array instead of re-sending the head page. The console polls
this every two seconds with limit: 500.
gap is the important part. On a tail request the rows returned are the
newest since afterMs. If that request fills its limit, there are
older rows between afterMs and the oldest row returned that you will never
be handed by continuing to tail — the burst outran your poll. gap: true
says so explicitly.
gapis only evertruewhenafterMswas supplied and the result hit the limit.- On
gap: true, do not stitch the rows onto your buffer. That leaves a silent hole. Discard and re-fetch the head page (the same request withoutafterMsorbeforeMs), then resume tailing from its newestts_ms.
- Only the head page streams. On an older page the reader is looking at a fixed window; new arrivals must not push it around.
- Cap your buffer. Nothing in
logs.querylimits how much you accumulate across polls. The console caps at 50,000 rows and trims from the old end; beyond that the answer is a narrower window or a filter, not more rows in the browser.
Empty results are not always empty
If the underlying ClickHouse query fails, both procedures return a well-formed empty answer rather than an error:logs.queryreturns{ rows: [], nextBeforeMs: null, gap: false }.logs.overviewreturns emptyhistogram,bySourceandtotals, but still returns a populatedbudgetandstepSec.
Related
- Telemetry — the gateway’s own metrics, traces, CDRs and SIP capture
- Authentication — the API key scopes that reach the control plane