Skip to content

Audit log ​

Every tools/call through every endpoint produces one audit record in Postgres: who called what, where it went, how long it took, how it ended. It is the answer to "what happened?", and the reconciliation source for an application layer that meters calls.

The same log also holds the few management actions that need a durable trace. Today that is one: an export made with include_secrets=true writes a record with action export.secrets_included, naming the admin who made it. A management record has kind: management; it names no endpoint, bundle or tool, its status is success, and it never holds a secret value. Every tool-call record has kind: tool_call and no action.

What is recorded ​

FieldMeaning
kindtool_call for a call through an endpoint, management for a management action.
actionWhat a management record says happened, such as export.secrets_included; null on a tool-call record.
endpoint_idThe endpoint's id, which survives a rename.
endpoint_name, bundle_name, server_nameWhere the call arrived and where it was routed, as named at the time; empty on a management record.
tool_name, presented_tool_nameThe upstream tool and the name the client used, including any override.
user_id, api_key_id, auth_methodThe caller and how they authenticated.
session_idThe MCP session for handshake-era clients.
idempotency_keyThe metering idempotency key of the invocation, the one the metering hook received, the same on every leg of a paused and resumed call. null for a call denied before metering, such as an unknown tool or a caller not entitled to the endpoint.
statussuccess, error, denied, or input_required when a call paused for the client's input and continues on its retry.
denied_reasonWhy a denied call was refused: unknown_or_disabled_tool, forbidden (a valid credential not entitled to the endpoint), or a metering decision (the hook's own reason, verbatim).
duration_msTime spent on the upstream call; null when the call was denied before reaching it.
error_messageTruncated failure detail. A call that could not reach its upstream — including a per_user server the caller has not connected — is status: error with error_message: upstream_not_connected, not denied, so a dashboard filtering on denied will not see it.
created_atWhen the record was written.

Tool arguments and results are not stored. Neither are authentication failures or rate-limit rejections; those go to the logs and the metrics, not the audit log. A tools/call with a valid credential on an endpoint it is not entitled to is recorded as denied with reason forbidden; other requests refused that way are not.

Records are written asynchronously after the response, so auditing never adds latency to a call. The writer batches records, and a failed write is retried with backoff for a minute before it is given up. Every record that cannot be written is logged with its idempotency_key, so a metering ledger can tell which invocation it was:

Log eventWhen
audit.queue_overflowThe in-process queue (1000 records) was full when the record was submitted
audit.write_failedPostgres refused the write for longer than the retry period
audit.lost_on_shutdownThe record was still queued when the 10-second shutdown drain ran out

Overflows and given-up writes are also counted in bundlemcp_audit_dropped_total. A process killed outright loses its queue without a log line; see Reconciling with a metering hook.

Querying ​

The Audit page filters by time range, kind, endpoint, bundle, server, tool, caller and status, and Export CSV downloads the whole filtered result, not only the loaded page. A management record shows its action where a tool call shows its tool. The API takes the same filters:

bash
curl -s "https://gw.example.com/api/v1/audit-records?endpoint_name=assistant&status=error&created_from=2026-09-01T00:00:00Z&limit=100" \
  -H "Authorization: Bearer $SERVICE_KEY"
Parameter
created_from, created_toISO 8601 timestamps.
kindtool_call or management; both when omitted.
endpoint_name, bundle_name, server_name, tool_nameExact match on the denormalised names.
user_id, api_key_idCaller filters.
statusOne of the four statuses.
idempotency_keyThe legs of one metered invocation.
offset, limitPagination, default 50, maximum 500.

Results are listed newest first and paginated like every other list route, with items, total, offset and limit. GET /api/v1/audit-records:export takes the same filters (no pagination) and answers the whole result as CSV, one row per record with every field above, streamed so a large export does not load into memory.

Retention ​

A purge job deletes records older than audit_retention_days (a runtime setting, default 90). It runs every 15 minutes on one replica at a time, reads the setting on every run, so a change applies without a restart, and deletes in chunks so no long transaction holds the table. 0 keeps everything.

If you meter calls and reconcile against the audit log, the retention must be longer than your reconciliation window. Pull records out through the export route on a schedule if you need them beyond that.

Reconciling with a metering hook ​

For every call whose pre-call metering decision succeeded, an audit record with a terminal status eventually exists. A debit with no matching record means the gateway died between the pre-call and post-call hooks, and the application layer can refund it out of band. The details are in Metering hook.