How-to
Audit log schema design
An audit event is a public interface. Once a customer builds an alert on an action name, renaming it is a breaking change on their side, not a refactor on yours. This guide is about getting the schema right on the first pass, because the second pass is expensive.
The shape of an event
Every useful audit event answers four questions: who did it, what did they do, to what, and when. Everything else is context that helps during an incident but is not part of the contract.
| Field | Holds | Notes |
|---|---|---|
| actor | Who performed the action | A user, an API key, an agent or the system itself |
| action | What was done | Past tense, stable forever, dot-namespaced |
| targets | What it was done to | An array, because one action can touch several things |
| occurredAt | When it happened | The time of the action, not the time you sent the event |
| organizationId | Whose log it belongs in | Missing this makes the event invisible to the customer |
| context | Where it came from | IP address, user agent, request ID |
| metadata | Everything else | Free-form, and where most mistakes live |
Naming actions
Use resource.past_tense_verb, namespaced by area. Past tense matters: an audit log records what happened, and an action named in the imperative reads like an instruction that may or may not have succeeded.
Record successes. If you also record failed attempts — and for authentication you should — give them their own action name rather than a success flag, so an alert on sign-in failures does not require reading a boolean.
| Good | Poor | Why |
|---|---|---|
| user.signed_in | login | Namespaced and unambiguous |
| user.sign_in_failed | user.signed_in (failed: true) | Alertable without inspecting fields |
| organization.member_removed | delete_user | Says which relationship changed |
| role.permission_granted | update_role | Names the actual change |
| vault.object_decrypted | read | Meaningless without a namespace |
| api_key.created | key_event | An event type is not a category |
Actors that are not people
A great many audit events are not performed by a human. Scheduled jobs, API keys, directory syncs and agents all act, and an event that attributes their work to a person is worse than one that admits it does not know.
Type the actor explicitly, and where an agent acts for somebody, record both: the agent as the actor and the person as the principal. During an incident, being able to distinguish what somebody did from what something did on their behalf is exactly the question being asked.
1{ "actor": { "type": "user", "id": "user_01HQZX8N4T", "name": "avery@foo-corp.example" } }23{ "actor": { "type": "api_key", "id": "key_01HQZX8N4T", "name": "billing-worker" } }45{ "actor": { "type": "system", "id": "directory_sync", "name": "Directory Sync" } }67{8 "actor": { "type": "agent", "id": "agent_01HQZX8N4T", "name": "Support Assistant" },9 "principal": { "type": "user", "id": "user_01HQZX8N4T" }10}
What belongs in metadata
The test is whether a field helps answer a question during an incident. Which plan a subscription moved from and to: yes. The entire subscription record: no.
The second test is whether it would be a problem if the field appeared in a customer's SIEM, in a support screenshot and in an export sitting on somebody's laptop. Audit logs are copied more widely than any other data your product holds.
- Include: the before and after values of what changed, the request ID, the reason if one was given, and the identifiers of anything else affected.
- Exclude: passwords, tokens, session values, decrypted secrets, full personal records, and anything you would not paste into a support ticket.
- Be careful with: free-text fields written by users, which can carry personal data you did not intend to store, and error messages, which sometimes carry credentials from the layer below.
Redacting an audit log after the fact is difficult by design — the record is append-only, which is the property that makes it trustworthy. Deciding what not to write is the only reliable control.
Changing the schema later
You will need to add fields; you should almost never remove or rename them. Treat the action name and the shape of actor and targets as frozen, and put anything you are unsure about into metadata, where additions are safe.
When an action genuinely must change meaning, introduce a new name and emit both for a period, rather than redefining the old one. A customer's alert firing on a subtly different meaning is worse than one that stops firing, because the second is noticed.
1import { Paycux } from "@paycux/node";23const paycux = new Paycux(process.env.PAYCUX_API_KEY);45// Tek giris noktasi: her cagri ayni sozlesmeden gecer.6export async function audit(input: {7 organizationId: string;8 action: string;9 actor: { type: string; id: string; name?: string };10 targets: { type: string; id: string; name?: string }[];11 occurredAt?: string;12 context?: { location?: string; userAgent?: string; requestId?: string };13 metadata?: Record<string, string | number | boolean>;14}) {15 await paycux.auditLogs.createEvent({16 ...input,17 occurredAt: input.occurredAt ?? new Date().toISOString(),18 });19}
Which events to emit
Start with what a security reviewer will ask for, then add what your own support team keeps guessing at. That list is shorter than instrumenting everything and considerably more useful.
- Authentication: sign-in, sign-in failure, sign-out, MFA enrolment and reset, session revocation.
- Access changes: role granted or revoked, member added or removed, invitation sent and accepted.
- Configuration: SSO connection created or deactivated, directory connected, API key created or revoked.
- Data movement: export created, bulk delete performed, secret decrypted, integration connected.
- Anything irreversible in your own product, whatever it is.