Fundamentals of Product Audit Logs

mxkaske2 pts0 comments

The Audit Log Page — Five theses on product audit logs<br>Skip to the first thesisTwo readers01 Event model02 Write path03 What belongs04 Time & deletion05 Evidence

A schema is the most durable opinion in the system. Endpoints get rewritten, UIs get redesigned. A column on an append-only table is one you are still reading in four years, and a column you left out is a question you can never ask about the past.<br>Every field has to survive the same test: would a competent engineer plausibly do this the other way? If yes, the field is a decision and deserves its defense. If no, it is furniture.

type AuditEvent = {<br>id: string<br>sequence: number<br>workspace_id: string<br>actor: Actor<br>action: string<br>target: Target | null<br>affected_user: UserSnapshot | null<br>outcome: "allowed" | "denied"<br>denial_reason: string | null<br>changes: Changes | null<br>metadata: Record<br>origin: Origin<br>request_id: string<br>occurred_at: string<br>recorded_at: string

type Actor =<br>| { type: "user"; snapshot: UserSnapshot }<br>| { type: "api_key"; snapshot: ApiKeySnapshot; issued_by: UserSnapshot }<br>| { type: "system"; name: string }<br>| { type: "agent"; name: string; on_behalf_of: UserSnapshot | null }<br>| { type: "impersonation"; operator: UserSnapshot; subject: UserSnapshot }

type UserSnapshot = {<br>id: string // an erasure never reaches this one<br>email: string | null // null after an erasure, never before<br>name: string | null

type Changes = {<br>fields: string[] // what moved<br>before: Record | null // null when it did not exist<br>after: Record | null // null when it no longer does<br>17 fields carry an argument. workspace_id does not — it is plumbing, so it is not annotated. A field that cannot survive this treatment does not belong in the schema.

changesChanges | null<br>The diff, not just the verb

`monitor.updated` tells you something moved; it does not tell you that the interval went from 60s to 30s, or that the alert threshold was quietly raised past the point where anything would ever fire. `before` and `after` are narrowed to the fields that actually differ, so the row is a diff rather than a copy of the table. `changes.fields` is stored beside them because a JSON blob is readable and not indexable, and "every event that touched a role" has to be a query.<br>Instead<br>Rebuilding state by replaying every event before this one — which works right up until the first event you failed to log, and cannot be done at all for the row somebody deleted.

Completeness is not a property you can add later. A dashboard, a REST call, an MCP server, a Slack agent and a nightly cron are five doors into the same mutation. The only thing they share is the service that performs it. Emit the event anywhere else and you are maintaining a list of doors, which is wrong the week someone adds one.<br>So the event is written in the same transaction as the change it describes — not after it, and not by something watching the database. Usually that is one more INSERT next to the row you just changed. It becomes an outbox and a processor only when the log lives somewhere the transaction cannot reach. Either way: both commit, or neither does.

one transactionoutbox, when the log lives elsewherefire-and-forget HTTPdatabase triggers<br>dashboardREST APIMCP serverSlack agentcronevery mutation, whatever the doorservice layeremits the eventBEGINUPDATE monitors …INSERT audit_events …COMMIT — both, or neitheraudit_eventssame database, same commitoccurred_at = recorded_atthere is no second moment<br>One more INSERT, the same COMMIT, the same database. The event cannot survive a mutation that rolled back and the mutation cannot commit without it — which is the whole guarantee, bought without a second table or a process to operate. occurred_at and recorded_at are the same instant here, and most rows in a real log look exactly like this.

Draw the line between audit and telemetry yourself, or volume draws it for you: a check running every thirty seconds buries every role change you ever recorded. The test is not whether an event is interesting. It is whether someone might have to prove it happened.<br>The missing half is the refusals. An API key attempting a delete it has no scope for is the highest-signal row in the table — and it exists only if the authorization layer writes an event when it says no.

candidate eventverdict<br>A member's role changes from member to adminaudit<br>An HTTP check ran against api.example.com and returned 200 in 84mstelemetry<br>An API key was denied monitor.delete for want of a scopeaudit<br>A user opened the billing settings pagetelemetry<br>Someone exported the audit logaudit<br>A monitor's check interval changed from 60s to 30saudit<br>A monitor flipped from up to downcontestedIt is an observation about the world, not an action taken by anyone in your system.<br>There is no actor, so half the schema arrives empty — which is the tell. Keep it in incidents where it belongs, and log the human response (acknowledged, resolved) in the audit log instead. Some teams log both, and pay for it in a table where you cannot see the role...

string null event type usersnapshot audit

Related Articles