Audit Logs
Record entity changes and security events with @repo/audit and the admin audit view.
@repo/audit records who did what to which entity - admin actions, billing changes, and auth security events - into the audit_logs table. It has no config flag: audit logging is always-on infrastructure, gated only by read access (platform and org admins) and a retention window.
What you get
- Three write paths: request-scoped, system, and a low-level insert that never throws.
- Type-checked actions: pass an
AUDIT_ACTIONSconstant, never a raw string. - Two read paths:
GET /admin/audit-logs(all logs,adminGuard) andGET /audit(caller's org only,orgAdminGuard). - Entity enrichment: rows resolve to live user/org names, so the UI shows readable labels even after the record changes.
- Automatic retention: a weekly job prunes rows past the cutoff.
Recording an entry
In a request handler, build a request-scoped logger that pre-fills actor, IP, and user-agent, then call .log(action, entityType, entityId, options?).
import { AUDIT_ACTIONS, createAuditContext } from "@repo/audit";
const auditCtx = createAuditContext(c.req.raw, session); // actorType from session role
await auditCtx.log(AUDIT_ACTIONS.USER_DELETED, "user", userId);| Function | Use when | actorType |
|---|---|---|
createAuditContext(req, session).log(...) | Inside a request handler | "admin" or "user" (from session role) |
auditSystem(action, entityType, entityId, metadata?) | Background jobs and webhooks (no request) | "system" |
audit(entry) | Lowest-level insert; never throws (failures are logged) | from entry |
Actions and metadata
AUDIT_ACTIONS (in packages/audit/src/types.ts) is the single source of truth for what can be audited.
| Group | Example constants |
|---|---|
| Admin | USER_BANNED, USER_UNBANNED, USER_DELETED, USER_ROLE_CHANGED, ORG_DELETED |
| Billing | PLAN_SET, PLAN_CLEARED, CREDITS_ADDED, CREDITS_REMOVED, CREDITS_SET, AUTO_TOPUP_TRIGGERED, PRODUCT_QUANTITY_SET |
| Auth security | LOGIN_SUCCESS, PASSWORD_CHANGED, TWO_FACTOR_ENABLED, TWO_FACTOR_DISABLED |
| Org management | MEMBER_INVITED, MEMBER_ROLE_CHANGED, MEMBER_REMOVED |
| API keys | API_KEY_CREATED, API_KEY_REVOKED, API_KEY_USED |
entityType is "user" | "organization"; actorType is "user" | "admin" | "system" | "api_key". The metadata JSON column is free-form, but typed shapes are exported for structured detail.
| Shape | Fields |
|---|---|
BillingChangeMetadata | before/after (plan, credits), reason, source |
BanMetadata | reason, expiresAt |
RoleChangeMetadata | before, after |
MemberMetadata | memberId, memberEmail, role |
Reading logs
Two routes read the table. Both paginate and enrich rows via enrichLogsWithEntityDetails (adds entityName, entityImage, entityEmail from the live user/org).
| Route | File | Guard | Scope | Filters | Sort |
|---|---|---|---|---|---|
GET /admin/audit-logs | packages/api/src/routes/internal/admin/audit-logs.ts | adminGuard (platform admin role) | All logs | action, entityType, entityId, actorId, startDate/endDate | timestamp, action, or entityType (sortOrder asc/desc) |
GET /audit | packages/api/src/routes/internal/audit.ts | orgAdminGuard (org owner/admin) | Caller's orgId only | action, entityType | timestamp desc (fixed) |
Five indexes back these queries - four composite (entity, org, action, actor, each paired with timestamp) plus a standalone audit_logs_timestamp_idx.
Retention
A weekly maintenance job deletes rows older than cacheConfig.retentionDays.auditLogs (default 90 days); adjust it in packages/config/src/cache.ts. See Background jobs for the scheduler.
Frequently asked questions
Why is there no feature flag to disable audit logging?
It is compliance and security infrastructure, expected always-on. The only knobs are who can read logs (platform admins see all via adminGuard; org owners/admins see their own org via orgAdminGuard) and the retention window.
Do I need to wrap audit() calls in try/catch?
No. All write paths route through audit(), which catches and logs failures so a logging error never breaks the caller's flow.
Why do old entries still show correct names after a user is renamed or deleted?
The audit row stores only IDs; the view runs enrichLogsWithEntityDetails at read time to attach the current display name, image, and email.