event.audit is a typed field on every wide event. Downstream queries filter on audit IS NOT NULL to materialise an audit dataset out of regular logs.
AuditFields type
interface AuditFields {
action: string // 'invoice.refund'
actor: {
type: 'user' | 'system' | 'api' | 'agent'
id: string
displayName?: string
email?: string
// For type === 'agent', mirrors evlog/ai fields:
model?: string
tools?: string[]
reason?: string
promptId?: string
}
target?: { type: string, id: string, [k: string]: unknown }
outcome: 'success' | 'failure' | 'denied'
reason?: string
changes?: { before?: unknown, after?: unknown, patch?: AuditPatchOp[] }
causationId?: string // ID of the action that caused this one
correlationId?: string // Shared by every action in one operation
version?: number // Defaults to 1
idempotencyKey?: string // Auto-derived; safe retries across drains
context?: { // Filled by auditEnricher
requestId?: string
traceId?: string
ip?: string
userAgent?: string
tenantId?: string
}
signature?: string // Set by signed({ strategy: 'hmac' })
prevHash?: string // Set by signed({ strategy: 'hash-chain' })
hash?: string
}
Action naming
action. Use noun.verb (invoice.refund, user.invite, apiKey.revoke). Past tense if the action already happened (invoice.refunded), present tense if withAudit() will resolve the outcome. Keep a small fixed dictionary in one file, because auditors and SIEM rules query on action, so a typo is a missing alert.A single dictionary file makes alerting straightforward:
export const AUDIT_ACTIONS = {
USER_INVITE: 'user.invite',
USER_REMOVE: 'user.remove',
USER_ROLE_CHANGE: 'user.role-change',
INVOICE_REFUND: 'invoice.refund',
API_KEY_REVOKE: 'apiKey.revoke',
} as const
defineAuditCatalog over a plain object dictionary: same noun.verb convention, but you get autocomplete on the action union and per-entry target type inference for free.Say who acted
actor.type: 'system' for cron jobs, queue workers, and background tasks; actor.type: 'api' for machine-to-machine calls authenticated by a token; actor.type: 'agent' for AI tool calls. Logging a synthetic 'user' for system actions is the single fastest way to fail an audit review.actor.type | When to use |
|---|---|
'user' | A human authenticated through your normal auth flow. |
'system' | Cron jobs, queue workers, scheduled tasks, internal background processes. |
'api' | Machine-to-machine calls from another service authenticated by a token. |
'agent' | AI tool calls (combine with evlog/ai fields like model, tools, promptId). |
Record how it ended
outcome | Meaning |
|---|---|
'success' | The action completed as requested. |
'failure' | The action was attempted but failed (downstream error, race condition, etc.). |
'denied' | The action was rejected by an authorisation check. |
'failure' and 'denied' are different things, and auditors care a lot about denied actions because they signal probing or misconfigured access controls. Always log denials (see Recording Events).
Make a retry safe
idempotencyKey is auto-derived from a hash of action, actor.id, target, and a coarse timestamp. The result: even if your drain retries an audit insert across a network blip, the duplicate row collapses on ON CONFLICT DO NOTHING. You don't have to think about it, since it is filled in for you.
Use the field as the primary key in Postgres / Bigtable / DynamoDB so retries stay safe by construction.
Tie an action to what caused it
| Field | Use case |
|---|---|
correlationId | Shared by every audit event that belongs to the same operation (e.g. one HTTP request that triggers a refund + an email + a webhook). |
causationId | The id of the previous audit event that caused this one. Useful for reconstructing chains of cascading actions. |
Most teams set correlationId to requestId. causationId is opt-in and only worth filling when a single user action triggers many internal audit events.