Plugins
Audit Logs

Admin view

The browsable log at /admin/audit-logs, its filters, and how to scope or re-mount it.

The plugin registers a custom admin view at /admin/audit-logs. It lists entries newest first, one row per entry, and expands a row to show its diff, snapshot, metadata and request details.

The audit-logs collection itself is hidden from the navigation by default. The view is a better reading surface than a generic list: it renders the diff as a table rather than a JSON blob, and its filters know about the fields entries actually have. Set logs.hidden: false if you want the raw documents too.

payload.config.ts
auditLogs({
  logs: {
    view: {
      path: '/audit-logs',
      defaultLimit: 25,
      access: ({ req }) => Boolean(req.user),
    },
  },
})

Pass view: false to skip mounting it.

Rows

A row reads left to right as what happened, to what, by whom, and through what:

  • The event: a coloured badge for the operation, or the auth or custom event type under its label from logs.eventTypes.
  • The collection or global, by its label. It links to the document in a new tab, except for a delete, which leaves nothing to open.
  • The user, as a pill linking to them. When the entry was written under impersonation, the pill carries an impersonated mark that opens who was impersonating. A user who no longer exists shows as deleted.
  • Context badges: the API the request came through, the locale (not for auth events and deletes), and in the all-tenants view the tenant. The tenant's name filters the list to it; the arrow beside it opens the tenant.
  • How many fields changed, and when, in admin.dateFormat and the admin's language, to the second on hover.

Expanding a row shows the request details (IP, user agent, locale, group), the changed paths as links that add them to the Changed field filter, and the diff. A create or delete with a snapshot shows it as the same path and value table, and an auth or custom event its metadata, each with the raw JSON in a collapsible you can copy from. Custom events can render their own body instead. A row with none of this does not expand.

To show your own context on rows (badges, a popover on the user, sections in the expanded row) or filter by it, see Extending.

Filters

The filters sit in one row of pills, each showing its current value. A pill opens its editor in a panel below the row, one at a time, the way the list view opens its columns and filters. Changes are staged and applied together. Filters live in the URL, so any view state is a link you can share or bookmark.

Values inside one filter match any of them; the filters combine with AND.

FilterNotes
EventCreate, update and delete, then auth and custom events, each either as a whole ("All custom events") or by type. Custom types come from logs.eventTypes and the keys of view.components.customEvents; any other can be typed in.
CollectionCollections and globals, labelled from their config. Payload's own bookkeeping collections (payload-preferences, payload-locked-documents, payload-kv and the like) are not offered.
UserPick users from the list drawer, as a relationship field does, or type an id, which is how you find the work of a deleted account.
DatePresets for the last 24 hours, 7 days and 30 days fill the From date; From and To can also be set directly.
More filtersDocuments (picked from the drawer, or typed as an id for a deleted one), changed fields, groups, and the API an entry came through. Group is present when logs.group is enabled. API offers REST, GraphQL, local and your logs.payloadAPIs; any other value can be typed.

The collection name on a row links to the document the entry is about, and, when the entry has a group, the expanded row links to every other entry sharing it.

Two kinds of access

logs.view.access decides who can open the view. It returns a boolean and receives only req, so it cannot narrow which entries are visible.

logs.access governs the collection's REST and GraphQL endpoints and is unrelated. It denies everything by default:

payload.config.ts
auditLogs({
  logs: {
    view: { access: ({ req }) => req.user?.role === 'admin' },
    access: { read: ({ req }) => req.user?.role === 'admin' },
  },
})

The plugin's own writes never go through access control, so tightening either of these stops reads, never recording.

Being turned away

Payload skips its own auth redirect for custom admin views, so the plugin does it: opening the view without a session sends you to the login page, and opening it while signed in without permission sends you to the unauthorized page. Signing in returns you to the view with your filters intact, because the path and its query string travel along in redirect.

Scoping the view

To narrow what a view can ever show, use forceWhere. It is merged with AND into every request the view makes and the user cannot remove it:

payload.config.ts
auditLogs({
  logs: {
    view: { forceWhere: { relationTo: { equals: 'orders' } } },
  },
})

That is a lock, not a default filter. For a starting filter the user can clear, link to the view with query parameters instead.

Mounting more than one

forceWhere on logs.view configures the single built-in view. For several scoped views, or per-view access, mount the component yourself as an ordinary Payload admin view:

payload.config.ts
export default buildConfig({
  admin: {
    components: {
      views: {
        ordersAudit: {
          Component: {
            path: '@10x-media/audit-logs/rsc#AuditLogsView',
            serverProps: {
              pluginOptions,
              forceWhere: { relationTo: { equals: 'orders' } },
            },
          },
          path: '/audit-logs-orders',
        },
      },
    },
  },
})

pluginOptions must be the same object you passed to auditLogs(): the view reads logs.view and multiTenancy out of it.

A tenant-scoped view is registered for you when multi-tenancy is on. See multi-tenancy.

Debug buttons

With debug: true and retention configured, the view grows two buttons that queue the archive and delete jobs immediately instead of waiting for their cron. They post to an endpoint the plugin mounts only under debug, and any logged-in user can press them, so keep it out of production.

Changing the collection

logs.override receives the fully built audit-logs collection and returns your version. The slug is forced back afterwards, because the hooks, the view and the jobs all address it by name. So is the content-lock exemption in custom.contentLock; the rest of custom is yours.

payload.config.ts
auditLogs({
  logs: {
    override: (collection) => ({
      ...collection,
      fields: [...collection.fields, { name: 'source', type: 'text' }],
      hooks: {
        ...collection.hooks,
        afterChange: [
          ...(collection.hooks?.afterChange ?? []),
          async ({ doc }) => forwardToSiem(doc),
        ],
      },
    }),
  },
})

Removing a built-in field will not break writing: the entry is stored without it, on both adapters. It will break reading. Drop changedPaths and the changed-path filter goes with it, along with every query that depends on the index.

How entries are written

Entries normally go straight to the database adapter (payload.db.create) rather than through payload.create. The pipeline exists to validate input, check access and run hooks, and an audit entry needs none of that: the plugin builds every field itself, and the collection ships with no hooks of its own.

That changes if your override attaches hooks. A direct write would skip them, so the plugin checks the finished collection for hooks and falls back to payload.create when it finds any. You do not configure this, and the example above keeps working.

The gap is worth the branch. Over 200 000 documents on Mongo the audit overhead per document was a third of the pipeline's, and over 100 000 on Postgres it was under half. Memory matters more: the same runs through the pipeline retained 178 MB and 119 MB after a forced collection, writing directly retained under 2 MB. On a long migration that growth is what breaks first, well before write speed does.

createAuditEvent always takes the pipeline. It is one deliberate call per business event rather than a hot path, so hooks firing there is what a host expects.

On this page