Skip to content

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:

  1. When (UTC, monotonic, millisecond resolution)
  2. Who (an opaque user id, not a PII string)
  3. What (an enum or a stable code, not free text)
  4. Where (an origin IANA timezone, for forensic reconstruction)
  5. 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:

  1. Reconstruction. If you ever need to answer "what time was it for the user when this happened?", the IANA timezone is sufficient.
  2. Anomaly detection. A user who logs in from UTC and then 5 minutes later from Asia/Tokyo is suspicious.
  3. 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 UPDATE or DELETE grants.
  • 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_tz is enough).
  • The user's local clock (it can be wrong; the server's UTC is the source of truth).

See also ​