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.2

Published

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

Downloads

357

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
  • Full document snapshots (previous and current versions), size-capped
  • Sensitive field stripping — passwords, tokens, and sessions are never stored
  • Cascade guard — suppress noise from relationship-triggered updates
  • Retention cleanup as a registered Payload job task (you schedule it — 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.

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)

Anonymous operations (no req.user) are skipped for collection and global hooks. 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 } \| false | { days: 90 } | Registers the audit-log-cleanup job task with this threshold. Set false to skip registering the task | | 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 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) | | 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 (deletes store only previousVersion)
  • user — relationship to the auth collection (authCollectionSlug)

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' }.

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' },
})

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. The task deletes entries older than the configured number of days, processing them in batches of 500, and returns { deletedCount }.

The plugin does not schedule or run the task for you. Queue and run it yourself, e.g.:

// queue a run (e.g. from a cron endpoint or script)
await payload.jobs.queue({ task: 'audit-log-cleanup', input: {} })
await payload.jobs.run()

or configure Payload's jobs autorun in your config:

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

Set retention: false to skip registering the task entirely.

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,
  ChangeFormatterOptions,
} 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.