audit_log
table, scoped to one org, and the console reads them back through tRPC. This
page explains what each field means, what the action vocabulary covers, and how
to page through the log outside the console.
Two procedures expose the same table:
What gets recorded
An entry is written for a change to a tenant-scoped resource: the insert, update or delete of a configuration row, a hydration of Postgres state into the runtime cache, and credential writes and removals. Each entry names the actor, the action, the resource it touched, and — where the writer supplied them — the resource state before and after the change. The log is per-org.admin.auditList requires an orgId and filters on
org_id; access is additionally constrained server-side by row-level security,
so a caller only ever reads rows for orgs it is a member of.
Rows are returned newest-first (admin.auditList orders by ts descending).
Anatomy of an entry
admin.auditList selects these columns and returns them unmodified:
The response envelope is:
total is an exact count of the rows matching the current filters, not the
number returned in this page.
Actors: users and the system fallback
Three fields describe who acted, and they are populated independently:actor_user_id— set when a console user performed the change.actor_label— a display string for the actor. Prefer it in your UI; it is what the console shows.- Neither set — the change did not originate from a signed-in user. Both
console screens fall back to rendering the actor as
systemin that case.
actor_label, then a shortened
actor_user_id, then system. Reproduce that order if you render the log
yourself, because a row can carry a label without a user id, or a user id
without a label.
adminAudit.options returns an actors array of { id, label } for the org,
built from the actors that actually appear in the log. Feed its id values back
as actorUserId to filter. Rows with no actor_user_id cannot be selected that
way — the system actor has no id to filter on.
Resource types
The portal’s resource filter offers this fixed set:
The filter is an exact-match equality on
resource_type, so a value must be
spelled exactly as it was written. The Voice AI screen instead populates its
dropdown from adminAudit.options.resourceTypes, which reflects the resource
types that are actually present in that org’s log — use that if you want a list
that tracks the data rather than a hardcoded vocabulary.
Actions
hydrate entries are not user edits in the usual sense — they record that the
source-of-truth rows were re-published to the runtime. Trunk transfer rules work
this way: Postgres holds the priority-ordered list, and every write re-fetches
the full list for the trunk and rewrites the Redis key that the runtime’s
transfer tool reads. So a single rule edit can produce both a write action and a
hydrate action.
Credential writes get their own two actions rather than being folded into
insert / update / delete, which lets you filter for credential activity on
its own. The portal colour-codes any action containing credential as a
warning.
Action colours in the portal table:
The action filter is also an exact equality match. The portal’s dropdown lists
the six values above;
adminAudit.options.actions returns the distinct actions
present in the org’s log, so an action written by a newer service shows up there
before it is added to any hardcoded list.
Before and after payloads
before and after are opaque JSON columns. The procedures select them
directly and return them as stored — no shaping, diffing, filtering or masking
happens in the read path. What you get is exactly what the writer of the row
recorded.
Consequences worth planning for:
- Either side can be absent. Both console screens only render a payload when
it is non-
null, and the Voice AI timeline only makes an entry expandable when at least one of the two is present. Aninserttypically has no meaningfulbefore; adeletetypically has noafter; some actions record neither. - The shape is per-writer. There is no common envelope across resource
types. Do not assume a field exists in
afterbecause it existed for another resource type. - Treat the payloads as sensitive.
vendor_credsis an audited resource type, and the read path performs no redaction of its own. If you expose the log in your own surface, apply whatever masking your policy requires at that layer rather than relying on the API to have removed anything.
Request context: IP, user agent and request ID
ip, user_agent and request_id describe the request that caused the change,
and are each independently nullable — a change made by a non-interactive actor
may carry none of them.
request_id is the most useful of the three for investigation: it is the id of
the control-plane request that produced the row, so it is the value to line up
against your own request logs when you need the full context around a change.
The portal shows all three in the detail panel; the Voice AI timeline shows
ip inline next to the actor.
Retention and immutability
The log is append-only. Rows are written once and are never edited or deleted through these surfaces — there is no update or delete procedure foraudit_log,
and the console states this on the page itself. A correction therefore appears as
a new entry, not as a modification of the original.
Neither procedure applies an expiry or trims old rows, and neither exposes a
retention setting. The only bound in the read path is the time filter you pass:
adminAudit.list accepts a since timestamp, and the Voice AI screen’s All
window computes since from a 3650-day lookback rather than omitting the filter.
If you need a guaranteed retention horizon for a compliance requirement, confirm
it against your contract — it is not expressed in the API.
Reading the log over the API
The log is readable outside the console through the same tRPC procedures the console calls.admin.auditList input:
The Voice AI variant takes a different shape, including free-text search and an
actor filter:
{ rows, total }. If the underlying query fails,
admin.auditList throws a tRPC INTERNAL_SERVER_ERROR carrying the database
error message.
Because orgId is required and the query is additionally scoped by row-level
security, there is no cross-org read: to audit several orgs, call once per org.
Filtering and pagination
- Empty means unfiltered. The console maps its “All resources” / “All
actions” selections to omitting the field entirely. Send
undefined, not an empty string, for a filter you do not want applied. - Filters combine with AND.
resourceTypeandactionnarrow the same query;totalreflects the filtered count, so it changes as you change filters. - Reset the page when a filter changes. Both screens set the page back to the first page whenever a filter changes, because an offset that was valid for the wider result set usually points past the end of the narrower one.
- Offset vs page.
admin.auditListis offset-based (offset = page * limit);adminAudit.listis 1-based page numbering. Do not mix the two conventions. - Page count. With
admin.auditList, derive the number of pages fromtotaland yourlimit; the exact count makes that reliable without a separate count call. - Maximum page size is 500. Requesting more is rejected by input validation.
For a full export, iterate
offsetrather than raisinglimit.
adminAudit.list, via since. When
you compute that timestamp on the client, memoise it — deriving it from a bare
Date.now() on every render changes the query key each time and the query
refetches continuously.
Related
- Authentication — API keys and relay tokens; the credentials whose use these entries record
- Telemetry — metrics, traces and CDRs, which cover call traffic rather than admin actions