Audit log
The complete list of the 17 audited admin actions, what each row records, how to search and export the log, and what is deliberately not recorded.
Last updated 2026-08-06
Summary#
Every mutation made through an /api/admin/* endpoint writes one row to the admin audit log: who did it, what they did, what they did it to, and a short detail string. Seventeen distinct actions exist and they are listed in full below. Reads are not recorded, and two mutations sit outside the audited path. The log is exposed on the System Health screen and on the Customers screen, and it can be exported to CSV.
Overview#
The audit log answers "who changed this, and when" for the staff console. It is written by a single helper that every admin mutation calls after its work succeeds. That write is best-effort: if it fails, the action it was recording still stands. The design choice is deliberate. An audit failure must never roll back a customer fix, so the log is a record of intent rather than a transactional guarantee.
The table holds five fields plus an auto-incrementing id.
| Field | Type | Meaning |
|---|---|---|
id | integer | Auto-increment. The log is ordered by this, newest first |
ts | integer | Unix seconds, not milliseconds |
actor | text | The verified admin email that made the change |
action | text | One of the seventeen values below |
target | text | What was changed. Usually an email, a key or an id |
meta | text | A short detail string, or empty |
actor is the email the worker resolved from the caller's token, not a value the browser supplied, so it cannot be spoofed by editing the request body.
The 17 audited actions#
Seventeen places in the worker write an audit row. Every one of them writes it after the change has been applied.
| # | action | Written by | target | meta | Role needed |
|---|---|---|---|---|---|
| 1 | issue.resolved or issue.unresolved | Resolving or reopening an error issue. The status becomes part of the action name | Issue fingerprint | none | admin |
| 2 | blog.status | Moderating a customer post | Post id | The new status | owner |
| 3 | config.delete | Deleting a configuration key | The key | none | owner |
| 4 | config.set | Saving a configuration key | The key | The value, first 120 characters | owner |
| 5 | jobs.run | Pressing Run due now | The job key | none | owner |
| 6 | cache.clear | Clearing cached provider data | Tool id, or all | "<n> rows" | owner |
| 7 | cache.setall | Applying one lifetime to every tool | "<n> days" | none | owner |
| 8 | social.disconnect | Revoking a customer's social channel | Customer email | Platform, or platform:accountId | owner |
| 9 | customer.set_plan | Changing a customer's plan | Customer email | The plan | owner |
| 10 | customer.reset_usage | Zeroing this month's usage | Customer email | The month key | owner |
| 11 | customer.refund_credits | Refunding credits | Customer email | The amount | owner |
| 12 | customer.suspend | Suspending a customer | Customer email | none | owner |
| 13 | customer.reactivate | Lifting a suspension | Customer email | none | owner |
| 14 | customer.remove_seat | Removing a team member | Customer (owner) email | The member email | owner |
| 15 | admin.add | Adding an admin | Admin email | role=<role> | owner |
| 16 | admin.update | Suspending, activating, promoting or demoting an admin | Admin email | role=<r or -> status=<s or -> | owner |
| 17 | admin.remove | Removing an admin | Admin email | none | owner |
That is seventeen call sites and eighteen possible action strings, because row 1 writes either issue.resolved or issue.unresolved depending on which button was pressed. When you filter by action, customer. matches rows 9 to 14, admin. matches 15 to 17, and config. matches 3 and 4.
Note: config.set stores the first 120 characters of the value it wrote. That is a convenience for reading history, not a secret store. Do not put credentials in configuration values.
What is not audited#
| Not recorded | Why it matters |
|---|---|
| Every read endpoint | Loading a customer's record, exporting subscribers, opening the Run Log and reading contact messages leave no trace. There is no record of who looked at what |
| Resolving a workflow failure | The endpoint sits outside /api/admin/* and writes no audit row. It does set resolved_at, so the time survives but the actor does not |
| Anything done in Messages or Bug Reports | Those screens talk to Supabase directly from the browser. Marking read, marking responded and deleting are unaudited |
| Sign-in and sign-out | Authentication events live in Supabase, not here. The admin_users row does carry a last_seen_at stamp updated on every verified request |
| Changes made outside the console | A direct database write, an environment-variable change or a deploy produces no row |
Reading the log#
On System Health#
Admin console → Operations → System Health → Recent admin actions
| Control | Behavior | |||
|---|---|---|---|---|
| Header count | (<loaded> of <total>). The total respects the active filters | |||
actor field | Substring match, case-insensitive. Press Enter or Filter | |||
action field | Substring match. customer. finds every customer mutation | |||
| Filter | Re-runs from the first page with the current filters | |||
| Load more | Fetches the next 50 rows. Hidden once everything is loaded | |||
| Export CSV | Writes the loaded rows to metricvault-audit-<date>.csv | |||
| Columns | `When | Actor | Action | Target, with meta` appended after the target |
| Empty state | No admin actions recorded. |
The CSV header is When (unix),Actor,Action,Target,Meta. The When column is raw unix seconds rather than a formatted date, so a spreadsheet needs a conversion before it reads as a timestamp.
On a customer record#
Admin console → Operations → Customers → Recent actions on this customer
Loading a customer also queries the audit log filtered to that email as the target, most recent 25 rows. It is the fastest way to answer "has anyone touched this account". The empty state is No recorded admin actions on this customer.
Because the filter matches the target column, actions where the customer is the target appear (plan changes, refunds, suspensions, seat removals, social disconnects). Actions where their email only appears in meta, such as a seat removal recorded against a workspace owner, are found by searching the owner instead.
Query limits#
| Parameter | Default | Bounds |
|---|---|---|
limit | 50 | 1 to 500 |
offset | 0 | 0 or greater |
actor filter | none | Substring, lower-cased before matching |
action filter | none | Substring, case-sensitive |
target filter | none | Substring, lower-cased before matching |
since filter | none | Unix seconds, matched against ts |
Rows are always returned newest first by id. The console's own page size is 50.
Retention and deletion#
Nothing prunes this table. There is no retention window, no cleanup job, and no endpoint that deletes audit rows. The console cannot clear its own history, which is the property you want from an audit trail. Growth is bounded in practice by the fact that only mutations are recorded, and the console is used by a small number of staff.
If the log ever needs trimming, it has to be done directly against the database by someone with that access. Doing so leaves no record, by definition.
Failure behavior#
| Situation | Result |
|---|---|
| The audit write fails | The action it recorded still succeeded. No error is surfaced |
MONITOR_DB is unbound | Nothing is recorded, and every admin screen is already failing for the same reason |
| Two admins act at the same time | Both rows are written. The auto-increment id orders them |
| An action affects zero rows | It is still audited. A refund against a customer with no usage row records customer.refund_credits and reports success |
That last row is worth remembering during an investigation: an audit entry proves somebody pressed the button, not that anything changed. Confirm the effect on the customer record itself. See Customer lookup and billing.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.