Recipe: Write an Audit Event
Add a new event to the audit log in three small steps. All audit writes are server-side; clients can never write audit rows.
Add a new event to the audit log in three small steps. All audit writes are server-side; clients can never write audit rows.
1. Add the action to the catalog
In
convex/audit/validators.ts,
add a literal in three places (they are kept in lockstep on purpose):
// auditActionValidator union
v.literal("workspace.record_exported"),
// AuditAction type union
| "workspace.record_exported"
// AUDIT_ACTION_CATEGORY map
"workspace.record_exported": "organization",Pick a category from: auth, organization, member, billing,
security, admin, system.
2. Call the helper from your mutation
Inside the mutation that performs the change — in the same transaction —
call writeAuditEvent:
import { writeAuditEvent } from "./audit/events";
await writeAuditEvent(ctx, {
action: "workspace.record_exported",
// result defaults to "success"
actorUserId: actor.appUserId,
actorAuthUserId: actor.authUserId,
actorEmail: actorUser?.email ?? undefined,
organizationId: actor.organizationId,
targetType: "organization",
targetId: actor.organizationId,
summary: `Exported ${count} records`,
metadata: { count }, // flat scalars only
});Resolve the actor server-side from your existing context (e.g. the
requireAppPermission actor, or resolveAuditActor(ctx) in
convex/audit/actor.ts). Never
accept actor ids from the client.
From an action, HTTP action, or scheduled job
Those contexts have no ctx.db. Call the internal mutation instead:
await ctx.runMutation(internal.audit.events.recordAuditEvent, {
action: "workspace.record_exported",
summary: "Exported records",
});Rules
- Never put secrets, tokens, codes, hashes, passwords, or raw payloads in
metadata. The helper redacts known-sensitive keys, but the call site is your first line of defense. See Audit Log. - Keep
summaryshort and human-readable — it is the admin table's main column. metadatais a flat record ofstring | number | boolean | nullonly.