# Tenant activity logs

> The per-org activity log behind the console Logs screen: event schema, sources, levels, the per-minute admission budget, retention, and how windows, filters, cursor paging and the tail gap flag work.

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:

| Field         | Type                     | Meaning                                                                 |
| ------------- | ------------------------ | ----------------------------------------------------------------------- |
| `ts`          | string                   | ClickHouse `DateTime` rendered as `YYYY-MM-DD HH:MM:SS[.mmm]`.          |
| `ts_ms`       | number \| string         | The same instant as Unix milliseconds. This is the paging cursor value. |
| `source`      | string                   | Which subsystem emitted the line. May be empty.                         |
| `level`       | `info` \| `warn` \| `error` | Severity.                                                            |
| `event_type`  | string                   | The machine-readable event name.                                        |
| `message`     | string                   | The human-readable line shown in the console.                           |
| `call_sid`    | string                   | Set when the line belongs to a call. Empty otherwise.                   |
| `session`     | string                   | Set when the line belongs to a session. Empty otherwise.                |
| `resource_id` | string                   | The stream, agent, meeting or other resource the line refers to.        |
| `fields`      | string                   | A JSON object, serialised. See below.                                   |

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:

```ts
const d = new Date(ts.includes("T") ? ts : ts.replace(" ", "T") + "Z");
```

**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:

```ts
function parseFields(fields: string): unknown {
  try {
    const v = JSON.parse(fields);
    return v && typeof v === "object" && Object.keys(v as object).length > 0 ? v : null;
  } catch {
    return null;
  }
}
```

`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:

```ts
const overview = await logs.overview.query({ orgId, windowMinutes: 60 });
// {
//   histogram: [{ bucket, info, warn, error }, ...],
//   bySource: [{ source, events, bytes }, ...],
//   totals:   { events, bytes },
//   budget:   { eventsPerMin, bytesPerMin, retentionDays },
//   stepSec
// }
```

- `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:

| Field           | Default                        | Dimension                     |
| --------------- | ------------------------------ | ----------------------------- |
| `eventsPerMin`  | `3000`                         | Events admitted per minute.   |
| `bytesPerMin`   | `2 * 1024 * 1024` (2 MiB)      | Bytes admitted per minute.    |
| `retentionDays` | `30`                           | See [Retention](#retention).  |

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

```ts
const page = await logs.query.query({
  orgId,
  windowMinutes: 60,
  levels: ["warn", "error"],
  sources: ["..."],
  search: "sid_",
  limit: 200,
});
// { rows, nextBeforeMs, gap }
```

| Input           | Accepted                                               | Default |
| --------------- | ------------------------------------------------------ | ------- |
| `windowMinutes` | integer, `5`–`10080`                                   | `60`    |
| `levels`        | up to 3 of `info`, `warn`, `error`                     | all     |
| `sources`       | up to 8 source names                                   | all     |
| `search`        | string, up to 200 characters                           | none    |
| `limit`         | integer, `1`–`500`                                     | `100`   |
| `beforeMs`      | positive integer, Unix ms                              | none    |
| `afterMs`       | positive integer, Unix ms                              | none    |

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`.

```ts
let beforeMs: number | undefined;
for (;;) {
  const res = await logs.query.query({ orgId, windowMinutes: 1440, limit: 200, beforeMs });
  handle(res.rows);
  if (!res.nextBeforeMs) break;
  beforeMs = res.nextBeforeMs;
}
```

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`.

```ts
const res = await logs.query.query({ orgId, windowMinutes, limit: 500, afterMs: newestMs });
if (res.gap) {
  // hole in the stream — discard and re-seed from the head page
} else if (res.rows.length) {
  newestMs = Number(res.rows[0].ts_ms); // rows are newest-first
  prepend(res.rows);
}
```

`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.

## Related

- [Telemetry](/platform/telemetry) — the gateway's own metrics, traces, CDRs and SIP capture
- [Authentication](/concepts/authentication) — the API key scopes that reach the control plane
