Plugins
Audit Logs

Configuration

The full option surface, from per-collection opt-in to the audit-logs collection itself.

Top level

OptionTypeDefaultDescription
collectionsRecord<CollectionSlug, AuditOptions>{}Which collections to audit. Opt-in.
globalsRecord<GlobalSlug, GlobalAuditOptions>{}Which globals to audit. Opt-in.
defaults{ createdBy?, lastModifiedBy? }noneDefaults applied to every automatic audit field.
anonymizeRecord<Slug, AnonymizeFunction>nonePer-entity value redaction. See anonymization.
drafts'ignore' | 'log''ignore'Default draft handling for versioned entities.
normalizeRelationshipsbooleantrueReduce relationship values to plain ids in diffs and snapshots.
multiTenancytrue | MultiTenancyConfigoffSee multi-tenancy.
impersonationbooleandetectedAdd the impersonator field. On when @10x-media/impersonation is in config.plugins, in any order. false keeps the field out even then; true adds it without the plugin, for code that sets req.user._impersonation itself.
retentionDataRetentionConfigoffSee data retention.
logsobjectsee belowThe audit-logs collection and its view.
debugbooleanfalseWith retention set, adds buttons to the view that queue the retention jobs. Development only.
disabledbooleanfalseStop all behaviour, keep the schema. See below.
translationsTranslationsOptionnoneSee i18n.

Per collection

AuditOptions is true (both features, defaults) or an object:

posts: {
  auditFields: true,   // true | false | { createdBy?, lastModifiedBy? }
  auditLog: true,      // true | false | CollectionAuditLogConfig
  auth: true,          // true | false | { login?, forgotPassword?, failedLogin? }
}

auth only means anything on a collection with auth enabled in Payload. It logs logins and password resets, and like everything else here it is off until asked for.

failedLogin is the exception to true covering everything: it stays off unless named, because it is a write an anonymous caller can trigger. See refused logins.

An omitted key is off. auditFields: true without auditLog gives you the two columns and no log; auditLog: true without auditFields gives you the log and no extra columns.

CollectionAuditLogConfig

OptionTypeDefaultDescription
operationsArray<'create' | 'update' | 'delete'>all threeWhich operations produce an entry.
excludeFieldsstring[][]Paths kept out of the diff entirely. An update that only touches these writes no entry.
drafts'ignore' | 'log'inherits, 'ignore' at the root'log' records every draft save, autosave included.
shouldLogShouldLogFunctionnoneLast word on whether to write. Runs after the diff.
snapshotOnCreatebooleanfalseStore the whole created document instead of a null-to-value diff per field.
snapshotOnDeletebooleanfalseStore the whole document on delete. What makes recovery possible.

shouldLog receives { req, operation, doc, previousDoc, diff, changedPaths } and returning false (or a promise of it) skips the entry. diff and changedPaths are already normalized, and are empty for create and delete:

shouldLog: ({ req, operation, changedPaths }) => {
  // Let a sync job write without filling the log with its own bookkeeping.
  if (operation === 'update' && req.payloadAPI === 'local') {
    const noise = new Set(['syncedAt', 'externalId'])
    return changedPaths.some((path) => !noise.has(path))
  }
  return true
},

shouldLog runs on every write to the collection and is awaited before the entry is written, so anything slow in it slows down every save. It may be async, but a database lookup per write is rarely worth it.

Per global

GlobalAuditOptions has the same shape minus the parts that cannot apply. Globals are update-only, so operations, snapshotOnCreate and snapshotOnDelete are not accepted.

The audit-logs collection

payload.config.ts
auditLogs({
  logs: {
    hidden: false,
    ipAddress: false,
    userAgent: false,
    group: true,
    payloadAPIs: ['MCP'],
    access: { read: ({ req }) => Boolean(req.user) },
    view: { defaultLimit: 50, path: '/change-history' },
    override: (collection) => ({ ...collection, admin: { ...collection.admin, group: 'Ops' } }),
  },
})
OptionTypeDefaultDescription
hiddenbooleantrueHide the collection from admin navigation. The custom view is the intended way in.
ipAddressbooleantrueStore the requester's IP.
userAgentbooleantrueStore the requester's user agent.
groupboolean | { contextKey?: string }offAdd an indexed group field, filled from req.context[contextKey]. Default key auditGroup.
payloadAPIs(string | { label, value })[]noneExtra payloadAPI values to label in the view, on top of REST, GraphQL and local.
eventTypes(string | { label, value })[]noneCustom event types to offer in the view's Event filter, and how to label them there and on the row badge. See eventTypes.
access{ create?, read?, update?, delete? }all deniedAccess for the collection's own REST and GraphQL endpoints.
viewfalse | { path?, defaultLimit?, access?, forceWhere?, components? }onThe custom view. See the admin view. components.customEvents renders custom events, see custom events.
override(collection) => CollectionConfignoneModify the generated collection. The slug is always forced back to audit-logs, and the content-lock exemption is always kept.

All four access rules default to denying everything. The plugin's own writes never go through them, so the log fills up regardless; the rules only govern the public API. Open read if something outside the admin needs to query entries.

payloadAPIs

payloadAPI records which API the audited request came in through, and is stored as free text: core sets REST, GraphQL or local, and a plugin may set anything else, the way @payloadcms/plugin-mcp sets MCP. Any value is recorded without configuration; this option labels the ones you expect in the view.

logs: {
  payloadAPIs: ['MCP', { label: 'Server-side', value: 'local' }],
}

A bare string is used as its own label. An entry naming a built-in value relabels it in place rather than adding a duplicate.

eventTypes

The plugin cannot know which eventType values your custom events use, so the view's Event filter offers the ones you list here, plus every type that has a renderer in view.components.customEvents. Any other type can still be typed into the filter.

logs: {
  eventTypes: ['order_paid', { label: 'Refund', value: 'order_refunded' }],
}

A bare string is used as its own label. The label also replaces the raw identifier on the row badge.

group

Correlates the entries produced by one workflow. Set the key on req.context before triggering operations, and pass the same req down so Payload's local API carries the context with it:

hooks: {
  beforeChange: [
    ({ req }) => {
      req.context.auditGroup = `import-${runId}`
    },
  ],
}

req.context lives for one request. Work that crosses a request boundary (a queued job, a webhook callback) starts with a fresh req, so set the value again at the start of each one.

Content locks

With @10x-media/content-lock installed, the audit-logs collection stays writable during every lock. It exempts itself with custom: { contentLock: { exempt: true } }, applied after override, so an override cannot drop it.

An entry follows a write the lock already let through, such as a login or a write to a collection the lock leaves open, and a rejected entry would fail that write with it. Custom events are recorded as usual, and the retention jobs can still delete and stamp entries.

No window can freeze an exempt collection: individual selection does not offer audit-logs, and a content-lock group that names it fails the build.

disabled

disabled: true stops every hook and job but leaves the audit-logs collection and the audit fields in the schema.

This is deliberately weaker than the disabled flag in the other plugins here, which return the config untouched. Removing the collection would make the next migration drop the table and its columns, so a per-environment switch would quietly become a destructive schema change. If you want the schema gone too, remove the plugin.

On this page