Audit log timestamps
Audit logs are append-only records of "who did what when". The "when" column is the most important column, and it is also the easiest to get wrong.
The minimum bar
An audit log row should answer:
- When (UTC, monotonic, millisecond resolution)
- Who (an opaque user id, not a PII string)
- What (an enum or a stable code, not free text)
- Where (an origin IANA timezone, for forensic reconstruction)
- Why (optional, but recommended for sensitive actions)
The schema
sql
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL, -- UTC, monotonic
actor_id TEXT NOT NULL,
action TEXT NOT NULL, -- e.g. 'user.delete'
origin_tz TEXT NOT NULL, -- e.g. 'America/New_York'
meta JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX audit_log_occurred_at_idx ON audit_log (occurred_at DESC);
CREATE INDEX audit_log_actor_action_idx ON audit_log (actor_id, action, occurred_at DESC);Writing
ts
import atemporal from 'atemporal';
import { prisma } from './db';
export async function audit(opts: {
actorId: string;
action: string;
originTz?: string;
meta?: Record<string, unknown>;
}) {
const now = atemporal(); // UTC, ISO 8601 with milliseconds
await prisma.auditLog.create({
data: {
occurredAt: now.toDate(),
actorId: opts.actorId,
action: opts.action,
originTz: opts.originTz ?? atemporal.getDefaultTimeZone?.() ?? 'UTC',
meta: opts.meta ?? {},
},
});
}
// Usage
await audit({
actorId: 'user_42',
action: 'document.delete',
meta: { documentId: 'doc_123' },
});The origin_tz field
Storing the timezone of the actor's browser matters because:
- Reconstruction. If you ever need to answer "what time was it for the user when this happened?", the IANA timezone is sufficient.
- Anomaly detection. A user who logs in from
UTCand then 5 minutes later fromAsia/Tokyois suspicious. - Compliance. Some regulations (e.g. finance) require the "local time" of the action, not UTC.
Monotonicity
For audit logs, you usually want occurred_at to be monotonically increasing — i.e. row 2 happened after row 1, always. The TIMESTAMPTZ in Postgres gives you microsecond resolution; if you need strict monotonicity (e.g. for cryptographic chaining), use a SEQUENCE or a Lamport clock.
Tamper evidence
If your audit log is security-sensitive, consider:
- Append-only role in Postgres. No
UPDATEorDELETEgrants. - Hash chaining. Include
prev_hash = sha256(occurred_at || actor || action || meta)in the row, and verify on read. - External sink. Stream the same rows to S3 / GCS / BigQuery in near-real-time. The external copy is harder to tamper with.
What you do not need to log
- The user's IP (unless you also log the geo and have a reason).
- The user's locale string (the
origin_tzis enough). - The user's local clock (it can be wrong; the server's UTC is the source of truth).