Audit Log Patterns

8 min readUpdated 27 Apr 2026

Every significant admin and member action in VeloCMS writes to the audit_logs collection via the auditLog() helper in src/lib/audit/log.ts. The write is non-blocking — the originating action succeeds regardless of whether the audit write succeeds. This design choice means audit infrastructure never degrades user-visible operations, at the cost of occasional missed entries under extreme failure conditions.

The auditLog() helper

src/lib/audit/log.ts (usage examples)
import { auditLog } from "@/lib/audit/log";

// Basic usage — fire-and-forget (do NOT await in hot paths)
void auditLog({
  actor: { id: session.user.id, type: "user" },
  tenantId: session.tenantId,
  action: "post.published",
  target: { type: "post", id: post.id, name: post.title },
});

// With metadata (e.g. changed fields diff)
void auditLog({
  actor: { id: session.user.id, type: "user" },
  tenantId: session.tenantId,
  action: "settings.updated",
  target: { type: "site_settings", id: settings.id },
  metadata: { changed_fields: ["blog_name", "description"] },
});

// System-initiated (cron job, webhook handler)
void auditLog({
  actor: { id: "cron", type: "system" },
  tenantId: null, // platform-level event
  action: "subscription.upgraded",
  target: { type: "tenant", id: tenantId, name: tenantSlug },
});
typescript

Actor types

Every audit entry has an actor — who or what triggered the event. The actor_type field is a three-value enum.

actor_typeWhoExample
userBlog owner or team member (users collection)Admin publishing a post, team member deleting media
memberReader/subscriber (blog_members collection)Member updating their account, member canceling subscription
systemAutomated action (cron, webhook, API key)Stripe webhook upgrading a plan, cron archiving old jobs

Action verb registry

The AUDIT_ACTIONS constant in src/lib/pocketbase/schemas/audit-log.ts is the canonical registry of action verbs. All verbs follow the format {resource}.{verb}. Using the registry makes log queries predictable and enables structured filtering in the audit log UI.

src/lib/pocketbase/schemas/audit-log.ts (verb registry excerpt)
export const AUDIT_ACTIONS = [
  // Posts
  "post.created", "post.updated", "post.published", "post.deleted", "post.archived",
  // Comments
  "comment.submitted", "comment.approved", "comment.rejected", "comment.spam", "comment.deleted",
  // Settings
  "settings.updated",
  // Team
  "team.member_invited", "team.member_removed", "team.role_changed",
  // Media
  "media.uploaded", "media.deleted", "media.metadata_updated",
  // API Keys
  "apikey.created", "apikey.revoked",
  // Membership / Billing
  "subscription.upgraded", "subscription.downgraded", "subscription.canceled",
  // GDPR
  "gdpr.data_export_requested", "gdpr.data_deleted",
  // ... (see full list in the source file)
] as const;

export type AuditAction = (typeof AUDIT_ACTIONS)[number];
typescript

IP hashing for privacy

Raw IP addresses are never stored. The hashIp() function in src/lib/audit/log.ts computes HMAC-SHA256 of (AUDIT_LOG_SALT + rawIp) and stores the first 16 hex characters. The salt (from the AUDIT_LOG_SALT environment variable, defaults to velocms-audit-default-salt) prevents rainbow-table attacks against the short hash prefix. The extractIpHash() function reads the x-forwarded-for header in Next.js server context and returns the hash.

src/lib/audit/log.ts (IP hashing)
export function hashIp(rawIp: string): string {
  const salt = process.env.AUDIT_LOG_SALT || "velocms-audit-default-salt";
  return createHash("sha256")
    .update(salt + rawIp)
    .digest("hex")
    .slice(0, 16); // 16-char prefix — enough for dedup, not enough to reverse
}
typescript

Tenant scoping

The tenant_id field is nullable. Non-null entries are tenant-scoped events visible in the tenant's audit log UI. Null entries are platform-level events (GDPR deletions, deploy hooks, Stripe billing events). The audit_logs collection sits in the GLOBAL_ALLOWLIST in multi-instance.ts — it is intentionally not in TENANT_SCOPED_COLLECTIONS, because the platform admin needs to read all entries. Tenant-level access is gated by the PocketBase API rules using tenant_id.owner_id checks.

Non-blocking write contract

auditLog() catches all errors internally and logs them via the VeloCMS logger (log.error). It never re-throws. The function signature returns Promise<void> but callers use void auditLog() rather than awaiting. This contract means: audit entries are best-effort. In testing environments where POCKETBASE_ADMIN_EMAIL is unset, the function logs a warning and returns immediately rather than crashing.

src/lib/audit/log.ts (non-blocking contract)
export async function auditLog(input: AuditLogInput): Promise<void> {
  try {
    // ... write to audit_logs collection
  } catch (err) {
    // Non-blocking — audit failures MUST NOT break the originating action
    log.error("audit log: write failed (non-fatal)", {
      error: err instanceof Error ? err.message : "unknown",
    });
  }
}

// Batch variant — all entries fire in parallel, errors isolated
export async function auditLogBatch(inputs: AuditLogInput[]): Promise<void> {
  await Promise.allSettled(inputs.map(auditLog));
}
typescript

Retention

Audit logs are retained indefinitely in the current implementation. A retention cron that deletes entries older than a configurable number of days is planned for a future sprint. Until then, PocketBase's SQLite file grows with every audit entry. For high-volume deployments (many tenants, frequent writes), monitor the pb_data volume and plan accordingly. A reasonable retention policy is 90 days for tenant events and 365 days for platform-level events.

Data model

FieldTypeDescription
actor_idstring | nullID of the user or member, null for system
actor_typeuser | member | systemAuth collection the actor belongs to
tenant_idstring | nullTenant scope, null = platform-level
actionstringVerb from AUDIT_ACTIONS or custom string
target_typestring | nullResource type (post, settings, media, etc.)
target_idstring | nullPocketBase record ID of the resource
target_namestring | nullDenormalized human-readable name
ip_hashstring | null16-char HMAC-SHA256 prefix of source IP
user_agentstring | nullTruncated User-Agent (max 256 chars)
metadataJSON | nullArbitrary context (changed fields diff, etc.)

Reference

FileResponsibility
src/lib/audit/log.tsauditLog(), auditLogBatch(), hashIp(), extractIpHash()
src/lib/pocketbase/schemas/audit-log.tsAUDIT_ACTIONS registry, AuditAction type, Zod schemas
pb/pb_migrations/ (audit_logs migration)PocketBase collection schema + API rules