The activity log is the tenant-facing record of what happened in your org. It is separate from the gateway’s own operational telemetry — metrics, traces, CDRs and HEPv3 capture all describe the TeleQuick gateway and target Prometheus / OTLP / ClickHouse / Homer. The activity log describes your org’s activity, is scoped to a single org, and is what the console Logs screen reads through 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
Rows are stored in ClickHouse as 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:
Rows carry no unique id. Byte-identical lines do occur in the same millisecond — two 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:
  • histogram is stacked by level: each point has separate info, warn and error counts for its bucket.
  • stepSec is 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.
  • bucket is a ClickHouse timestamp string with the same no-timezone caveat as ts.
  • totals is the sum of bySource, 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
So a partial call SID matches nothing on the 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 carries nextBeforeMs:
  • If more rows exist beyond this page, nextBeforeMs is the ts_ms of the last (oldest) row on the page. Pass it as beforeMs to get the next, older page.
  • If this page is the end of the window, nextBeforeMs is null.
The server asks for 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 OFFSET re-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.
Because a re-query against a live log returns different rows, keep pages you have already fetched if you want “previous” to show what it showed before. The console caches fetched pages for that reason and only issues a new request when stepping past the newest page it holds.

Tailing with afterMs and the gap flag

To follow new lines, pass afterMs 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.
  • gap is only ever true when afterMs was 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 without afterMs or beforeMs), then resume tailing from its newest ts_ms.
Two client-side conventions worth copying from the console, since neither is enforced by the API:
  • 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.query limits 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.query returns { rows: [], nextBeforeMs: null, gap: false }.
  • logs.overview returns empty histogram, bySource and totals, but still returns a populated budget and stepSec.
The failure is recorded server-side with the tenant and procedure name. This shape is deliberate — the UI gets a quiet empty state instead of a banner — but it means “no activity” and “the query broke” look identical to the caller. If a window you expect to be busy comes back empty, that is worth reporting rather than re-trying forever.
  • Telemetry — the gateway’s own metrics, traces, CDRs and SIP capture
  • Authentication — the API key scopes that reach the control plane