npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@purposeinplay/payload-audit-log

v0.1.3

Published

Audit logging plugin for Payload CMS 3 — tracks create/update/delete across collections and globals, plus auth events.

Downloads

241

Readme

@purposeinplay/payload-audit-log

npm version license: MIT

Audit logging plugin for Payload CMS 3 — tracks create/update/delete across collections and globals, plus auth events.

Table of contents

Features

  • Track create, update, delete operations on collections and globals
  • Log user login events with IP tracking (see Login tracking)
  • Field-level change diffs with old/new values — relationship-aware, no-op updates are not logged
  • Optional document snapshots (previous and current versions), size-capped — off by default
  • Sensitive field stripping — passwords, tokens, and sessions are never stored
  • Cascade guard — suppress noise from relationship-triggered updates
  • Retention cleanup as a scheduled Payload job task — daily at 04:00 on the default queue (see Cleanup task)
  • Immutable logs — create/update/delete API access is always denied
  • Admin-only read access by default, with customizable access control
  • Non-fatal by design — audit-write failures are logged via payload.logger and never block the original operation
  • Zero environment variables — configured entirely through plugin options

Requirements

  • Payload ^3.0.0 (peer dependency)
  • Node.js >= 20
  • ESM only — the package ships "type": "module"; no CommonJS build

This plugin is server-side only: it adds no admin UI components, so there is no payload generate:importmap step and no next/react peer dependency.

Installation

pnpm add @purposeinplay/payload-audit-log
# or
npm install @purposeinplay/payload-audit-log
# or
yarn add @purposeinplay/payload-audit-log

Quick start

Add the plugin to your payload.config.ts:

import { buildConfig } from 'payload'
import { auditLogPlugin } from '@purposeinplay/payload-audit-log'

export default buildConfig({
  // ...
  plugins: [
    auditLogPlugin({
      // include your auth collection here if you want login events logged
      collections: ['users', 'pages', 'media'],
      globals: ['site-config', 'navigation'],
      trackLogin: true,
      retention: { days: 90 },
    }),
  ],
})

Only the slugs listed in collections / globals are tracked — omitting both means nothing is logged.

Migration requirement

This plugin injects an audit-logs collection into your Payload config. If your database adapter uses migrations (Postgres, SQLite), generate and run one after adding or updating the plugin:

npx payload migrate:create
npx payload migrate

The migrate:create command will prompt you about enum/table conflicts (create vs. rename). Choose create for all audit-log-related items unless you are intentionally renaming from an existing table.

Scheduling requires a consumer migration

This version schedules its cleanup task, and that turns on Payload's job scheduling for your whole config. Adopting it without the matching database migration stalls every job on the default queue.

Payload flips jobs.scheduling = true during config sanitization as soon as any task declares a schedule, and injects the payload-jobs-stats global. From then on handleSchedules reads that global on every autoRun tick, before jobs.run. If the payload_jobs_stats table does not exist, that read throws, jobs.run is never reached, and the consumer's entire default queue stops draining — every minute, with nothing else in the config having changed.

What you must do:

  1. Ship the plugin bump and the migration in the same release. After upgrading, run npx payload migrate:create and npx payload migrate. The migration must create payload_jobs_stats (wild consumers additionally need the payload_jobs.meta column).

  2. Keep the schedule on a queue you actually drain. handleSchedules skips every queue the running autoRun config does not drain unless it is invoked with allQueues, so the default schedule targets default. If you point retention.schedule at a dedicated queue, make sure something drains that queue.

  3. A dedicated worker must pass --handle-schedules. Payload's bin only calls handleSchedules when the flag is present:

    payload jobs:run --handle-schedules
    # or, to only queue scheduled jobs:
    payload jobs:handle-schedules

    In-process jobs.autoRun handles schedules on its own; a separate worker process does not, unless you pass the flag.

At boot (onInit) the plugin runs three checks and logs what it finds: it reads the payload-jobs-stats global and logs an error naming this migration when that fails; it selects the payload-jobs meta field, which enabling scheduling also adds (jobs.stats), so a missing payload_jobs.meta column is reported the same way; and it warns when nothing in jobs.autoRun drains the queue the cleanup is scheduled onto — that case is a warning because a dedicated --handle-schedules worker is invisible from inside the process. It never throws and never creates tables, so a missing migration is loud but not fatal to boot. retention: false registers neither the task nor the schedule and leaves jobs.scheduling untouched; retention: { days, schedule: [] } registers the task unscheduled, for consumers that want to queue it themselves without turning scheduling on — that case logs a one-line warning at init, since nothing will delete old rows.

How it works

The plugin injects an audit-logs collection and attaches Payload hooks (afterChange, afterDelete, afterLogin) to the collections and globals you specify. The plugin's hooks are prepended, so they run before any hooks you define yourself. When a tracked operation occurs, the hook:

  1. Identifies the user and extracts the client IP from x-forwarded-for / x-real-ip headers
  2. Strips sensitive fields from document snapshots
  3. Computes a field-level diff (for updates) — updates with no meaningful change are not logged
  4. Creates an immutable audit log entry via req.payload.create (same transaction as the original operation, overrideAccess: true)

Operations without req.user are attributed to a system actor rather than skipped (see System writes). Login events use the authenticated user returned by the hook itself. Globals only get an afterChange hook (action is always update).

If writing an audit entry fails, the error is logged via req.payload.logger.error and the original operation proceeds unaffected.

Options

All options are optional (AuditLogPluginOptions):

| Option | Type | Default | Description | |---|---|---|---| | enabled | boolean | true | Enable/disable the plugin entirely. When false, the plugin makes no changes other than recording its options under config.custom.auditLog | | collections | string[] | [] | Collection slugs to track. Omitted = no collections tracked | | globals | string[] | [] | Global slugs to track (via afterChange only) | | collectionSlug | string | 'audit-logs' | Slug of the injected audit-logs collection | | authCollectionSlug | string | 'users' | Auth collection slug — used for the user relationship field and login-hook attachment | | trackLogin | boolean | true | Log user login events via afterLogin hook. Requires the auth collection to be listed in collections — see Login tracking | | allowCascading | boolean | false | Log cascading writes to other collections/documents triggered within the same request (see Cascade guard) | | retention | { days: number; schedule?: { cron: string; queue: string }[] } \| false | { days: 90, schedule: [{ cron: '0 4 * * *', queue: 'default' }] } | Registers and schedules the audit-log-cleanup job task. Read Scheduling requires a consumer migration before adopting. Set false to register neither | | storeSnapshots | boolean \| { collections: string[] } | false | Store the full previousVersion/currentVersion snapshots on creates and updates. Off by default — they are ~2/3 of a row's bytes and changes is always kept. { collections: [...] } matches a collection slug or a global slug. Deletes always snapshot | | excludeFields | string[] | [] | Additional field names to strip — merged with the built-in sensitive-field list, so they are removed from both change diffs and the previousVersion/currentVersion snapshots | | jsonMaxBytes | number | 524288 (512 KB) | Max serialized size for the changes diff and for the previousVersion/currentVersion snapshots (see What gets logged) | | titleFieldMap | Record<string, string> | { users: 'email', media: 'filename' } | Per-collection field to use as documentTitle; merged over the built-in map (see Document titles) | | auditSystemWrites | boolean | true | Audit writes performed without an authenticated request — job runs, scripts, ai-translate bulk translation. Attributed to system with a null user (see System writes). false restores the pre-0.1.3 behaviour of skipping them | | auditAutoTranslate | boolean | false | Log writes originating from the sibling ai-translate plugin (tagged source: 'ai-translate'). Off by default so machine-generated locale fan-out doesn't drown the log | | access.read | Access | admin-only | Override read access for the audit-logs collection |

Login tracking

The afterLogin hook is only attached when the auth collection (authCollectionSlug, default 'users') is also listed in collections. With trackLogin: true but 'users' missing from collections, no login events are logged:

auditLogPlugin({
  collections: ['users', 'pages'], // 'users' present → logins are logged
  trackLogin: true,
})

Login entries record action: 'login' with type: 'security', the user relationship, and the client IP — no snapshots or diffs.

What gets logged

Each audit log entry records (all fields read-only in the admin UI):

  • authorEmail — email of the user who performed the action (falls back to user ID)
  • actioncreate, update, delete, or login
  • typeinfo, audit, warning, error, or security (writes use audit, logins use security)
  • collectionSlug / globalSlug — which collection or global was affected
  • documentId / documentTitle — the affected document
  • locale — locale context if applicable
  • ipAddress — client IP address (see Security notes)
  • sourceuser, ai-translate, or system (default user)
  • changes — diff of changed fields ({ field: { old, new } }); null for creates and deletes
  • previousVersion / currentVersion — full document snapshots, only when storeSnapshots is enabled — except deletes, which always store previousVersion regardless of the option: a delete has no diff to fall back on, and it is one row per deletion rather than the per-update bulk that made snapshots expensive. There is no opt-out, so a bulk delete of 500 documents writes 500 audit rows each carrying a snapshot (richText summarized, sensitive fields stripped, capped at jsonMaxBytes)
  • user — relationship to the auth collection (authCollectionSlug)

richText fields

A Lexical diff would otherwise embed the full old and new node trees — 422 KB of raw JSON for one field on a real document. Instead, richText values are compared on the raw trees (so nothing is missed) but recorded as a compact summary on each side:

{ "body": {
  "old": { "__richText": true, "text": "Welcome to…", "truncated": false, "bytes": 118442 },
  "new": { "__richText": true, "text": "Welcome back to…", "truncated": false, "bytes": 118501 }
} }

text is the extracted plain text, capped at 2000 characters per side (truncated says whether it was cut, including when the walk hit the cap and skipped later nodes); bytes is the UTF-8 size of the tree it replaced, so the reduction is visible in the row itself. A hash was rejected as the representation: it answers "did this change", not "what did it say", which is the question an audit row exists to answer. The same replacement is applied to richText nested inside blocks and arrays, and to stored previousVersion/currentVersion snapshots — otherwise the trees the diff no longer carries would come straight back in the snapshot.

Size caps

Both changes and the snapshots larger than jsonMaxBytes are replaced with { __truncated: true, __originalSize, partial } (the first chunk of the serialized value); values that fail to serialize are stored as { __error: 'Failed to serialize value' }. Sizes are UTF-8 bytes — what the column actually costs — and partial is cut on a code-point boundary, so an emoji straddling the cut never becomes a lone surrogate that Postgres jsonb would reject.

In the admin UI the collection appears under the System group, labeled Audit Log(s), with default columns authorEmail, action, collectionSlug, globalSlug, documentTitle, locale, createdAt.

Document titles

documentTitle is resolved per collection:

  1. The field from titleFieldMap if set for that slug (built-ins: usersemail, mediafilename)
  2. Otherwise the first non-empty of title, name, slug, label, email, filename
  3. Otherwise the document id as a string
auditLogPlugin({
  collections: ['products'],
  titleFieldMap: { products: 'sku' },
})

System writes

Not every write carries an authenticated request. Anything driven by Payload's persisted jobs — ai-translate bulk translation and its retries above all — runs on a req with no user, and those writes used to be dropped from the audit log entirely: the machine writes that most need a record left none.

They are now recorded with:

  • authorEmailsystem, or system (triggered by <email>) when the originating context carried the email of the human who started the run. The plugin reads that from the aiTranslateTriggeredByEmail request-context key, which @purposeinplay/payload-ai-translate sets on its locale writes; the key is matched by name only, so there is no dependency on that package, and any other producer can set it (a plain email string).
  • sourcesystem, or ai-translate when the write also carries the aiTranslateInternal flag.
  • usernull. The relationship is only set for a real authenticated user.

Everything else behaves as it does for a user edit: the cascade guard, auditAutoTranslate gating, diffs, snapshots and size caps all apply unchanged. Set auditSystemWrites: false to go back to skipping these writes.

Cascade guard

By default (allowCascading: false), the first operation in a request that actually produces a log entry is marked as the audit source (per-request, via req.context) — no-op updates never claim the slot. Later operations in the same request are logged only if they target the same collection and document as that source; writes to a different collection or document (e.g. Payload's relationship cascades) are suppressed to reduce noise. Set allowCascading: true to log every change.

The guard applies uniformly to collection creates, updates, and deletes as well as global updates, and also handles ai-translate integration: writes flagged with req.context.skipAutoTranslate are always suppressed; writes flagged req.context.aiTranslateInternal (set by the ai-translate plugin) are suppressed unless auditAutoTranslate: true, in which case they are logged with source: 'ai-translate'.

Sensitive fields

The plugin recursively strips the following fields (including inside nested objects and arrays) from both diffs and snapshots:

hash, salt, password, token, secret, sessions, lockUntil, loginAttempts, resetPasswordToken, resetPasswordExpiration, lastLogin, lastLoginAt, emailVerified, emailVerificationToken, emailVerificationExpiration, forgotPasswordToken, forgotPasswordExpiration, totp, _verificationToken

Any fields in your excludeFields option are merged into this list (and therefore also stripped from snapshots, not just diffs).

System fields (id, createdAt, updatedAt, createdBy, updatedBy, _status) are excluded from diffs but retained in snapshots.

Access control

By default, only users with an admin role can read audit logs. Override this with the access.read option:

auditLogPlugin({
  access: {
    read: ({ req }) => req.user?.roles?.includes('auditor') ?? false,
  },
})

Create, update, and delete access is always denied — audit logs are immutable.

Cleanup task

When retention is configured (the default), the plugin registers a job task with slug audit-log-cleanup in config.jobs.tasks and schedules it — daily at 04:00 on the default queue. The task deletes entries older than the configured number of days and retries 3 times so a transient failure does not skip the day's pass.

Each invocation is bounded: it deletes in batches of 500 and stops after 50 batches, returning { deletedCount, hasMore }. hasMore: true means the cap was reached and the next scheduled run continues — relevant on the first run after adopting scheduling, which meets the whole accumulated backlog.

Drain rate: 50 × 500 = 25,000 rows per pass, so 25,000 rows per day on the default daily schedule. For scale, the backlog measured on a wild production dump (2026-09-02) was 2,204 rows past the 90-day threshold — one pass, about 11 seconds. A backlog bigger than the cap takes ceil(rows / 25000) days to clear: 100,000 rows is 4 days, 1,000,000 is 40. If you shorten retention.days on a large table, expect the catch-up to span days, or queue the task by hand a few times to get through it faster. Rows are located with pagination: false, depth: 0, select: { id: true } (no per-batch COUNT(*), ids only) and deleted one by one outside the job's request, so a caller-supplied transaction can never grow to cover the entire pass.

Override the schedule per consumer:

auditLogPlugin({
  retention: {
    days: 90,
    schedule: [{ cron: '0 4 * * *', queue: 'default' }],
  },
})

Something has to drain that queue — in-process autoRun:

jobs: {
  autoRun: [{ cron: '* * * * *', queue: 'default' }],
}

or a dedicated worker, which must pass --handle-schedules (Payload's bin only calls handleSchedules with that flag):

payload jobs:run --handle-schedules

Before adopting, read Scheduling requires a consumer migration — the payload_jobs_stats table is mandatory.

You can still queue a run by hand:

await payload.jobs.queue({ task: 'audit-log-cleanup', input: {} })
await payload.jobs.run()

Set retention: false to register neither the task nor the schedule.

Security notes

  • IP extraction — the client IP is taken from the last entry of x-forwarded-for (the one appended by the proxy closest to your server; assumes a trusted reverse proxy), validated against IPv4/IPv6 formats to prevent log injection, with x-real-ip as fallback; otherwise null.
  • Audit entries are written with overrideAccess: true on the same request/transaction as the original operation.
  • GraphQL is disabled on the audit-logs collection, and lockDocuments is off (audit writes never contend for document locks).

Exports

The package ships a single entry point:

import { auditLogPlugin } from '@purposeinplay/payload-audit-log'
import type {
  AuditLogEntry,
  AuditLogPluginOptions,
  AuditLogScheduleConfig,
  ChangeFormatterOptions,
  StoreSnapshotsOption,
} from '@purposeinplay/payload-audit-log'

There is no ./client or other subpath — the plugin has no admin UI components and no client/server split. No environment variables are read.

License

MIT

Part of the purposeinplay/payload-plugins monorepo. Issues and contributions: GitHub issues.