Documentation

Activity Logs

Activity Logs reference

Reference for c.get('audit').record(), auditDiff, withAuditContext, defineAuditActions, the core_audit_logs fields, the Activity Logs API routes and their limits.

This page lists every server-side Activity Logs API in @vitnode/core, plus the stored fields, the admin routes and their limits.

c.get('audit').record(input, options?)

Writes one entry. Server-only. Returns the entry id, or null when a withAuditContext frame dropped it (an enclosing operation records it as a whole) or a best-effort write failed.

NameTypeRequiredDefaultDescription
actionstringYesLowercase letters, digits and _, joined by dots, at most 150 characters: note.created
target{ type, id?, label? }Yestype like note or blog.category (at most 100 characters); id a number or string, stored as text (cut at 64); label kept after deletion (cut at 255)
area'admin' | 'moderator' | 'public' | 'system'NoSee belowWhich staff log the entry belongs to: moderator goes to the moderator log, every other area to the admin log
changesRecord<string, { from?, to?, redacted? }>NonullUsually from diff(); sanitized and capped at 50 fields and 16 KB
metadataRecord<string, unknown>No{}Small, non-personal facts; sanitized and capped at 16 KB
pluginIdstringNoThe route's pluginWhich plugin the entry belongs to
scope{ family, id }NonullThe moderation scope the action was about, such as { family: 'forum', id: 4 }

Without area, a withAuditContext frame's area applies, then admin on an AdminCP request, public for a signed-in member and system otherwise.

OptionTypeDefaultDescription
txDrizzle transactionWrite with the change's transaction: both commit and roll back together
bestEffortbooleanfalseWithout tx: log a database failure to core_logs instead of throwing

The actor, the actor's name, the IP address and the request id are always taken from the request (or a trusted withAuditContext frame), never from input. A member writing in the moderator area is recorded as staff. Malformed input throws AuditLogError from @vitnode/core/api/lib/audit/write, even with bestEffort.

Values are sanitized before they are stored: strings are cut at 500 characters, arrays and objects at 50 items, nesting at 4 levels, errors keep only their name, and sensitive keys are redacted.

auditDiff({ before, after, include?, exclude? })

From @vitnode/core/lib/audit/diff, also c.get('audit').diff(). Universal. Returns only the fields whose value changed, or null.

  • before: null describes a creation, after: null a deletion. A missing field counts as null.
  • Dates compare by instant; objects and arrays by value.
  • Rich-text documents become "[rich text]".
  • Sensitive field names become { redacted: true }: passwords, hashes, tokens, secrets, credentials, cookies, API, access and private keys, recovery codes.
  • include compares only the listed fields; exclude skips fields.

withAuditContext(c, frame, body)

From @vitnode/core/api/lib/audit/context. Server-only. Applies trusted context to every entry body writes on c and returns what body returns. Frames nest; the innermost wins.

Frame fieldDescription
actorAttribute entries to this actor: AUDIT_SYSTEM_ACTOR for background work
areaArea for entries that do not name one
correlationIdGroup the entries; defaults to one id per request
metadataMerged into each entry's metadata; the entry's own keys win
scopeModeration scope for entries that do not name one
interceptRewrite each entry before it is written, or return null to drop it

Cron jobs and queue tasks already run inside a system frame with { actor: AUDIT_SYSTEM_ACTOR, area: 'system' } and a trigger in the metadata.

import {
  AUDIT_SYSTEM_ACTOR,
  withAuditContext,
} from '@vitnode/core/api/lib/audit/context'

await withAuditContext(
  c,
  {
    actor: AUDIT_SYSTEM_ACTOR,
    area: 'system',
    metadata: { trigger: 'import' },
  },
  async () => await importNotes(c),
)

defineAuditActions(actions)

From @vitnode/core/lib/audit/actions. Universal. Validates { action, targetType }[] for buildApiPlugin({ auditActions }) and throws when an action or target type is not a valid identifier, or an action is declared twice. Declaring is optional. It only adds the actions to the action filter. Labels come from {pluginId}.audit.actions.{action} in the plugin's messages.

Stored fields (core_audit_logs)

ColumnNotes
pluginId, action, areaPlain text: readable after the plugin is removed
actorType, actorId, actorNamestaff, api (a member) or system; actorId becomes null when the account is deleted, actorName stays
targetType, targetId, targetLabelNo foreign key: entries outlive their target
changes, metadataSanitized JSON
contentTypeId, revisionIdContent Engine entries
scopeFamily, scopeIdModeration scope
ipAddress, correlationIdSensitive; ipAddress is null for system entries

Indexed for newest-first paging, actor, plugin, action, area, one target's history and scope, each with createdAt.

API routes

The admin routes live in core's admin/activity-logs module. Every one needs an AdminCP session.

Method and pathPermissionReturns
GET /activity_logs can_viewKeyset page, newest first
GET /{id}can_viewEntry with changes, revision diff and an AdminCP link; 404 when missing
GET /entitycan_view, or can_view on the content typeOne target's history with changes; 403 otherwise
GET /filterscan_viewDeclared actions and installed plugins
GET /exportcan_exportCSV, at most 10,000 rows
GET /settingscan_view{ retentionDays }
PUT /settingscan_manage_retention{ retentionDays }: 0 (forever), 30, 90, 180, 365 or 730

List filters: staff (moderator keeps the moderator area, admin every other area), pluginId, area, actorId, action, targetType, targetId, from and to (YYYY-MM-DD, UTC, inclusive), search (target label, action id and actor name), cursor and first (1-100, default 25). The export takes the same filters without cursor and first. /entity takes pluginId, targetType and targetId (all required), plus cursor and first.

Read one record's history from an AdminCP screen with fetcher:

import { fetcher } from '@vitnode/core/tanstack/fetcher'

const response = await fetcher({
  plugin: '@vitnode/core',
  method: 'get',
  module: 'admin/activity-logs',
  path: '/entity',
  args: {
    query: { pluginId: 'site-notes', targetType: 'note', targetId: '1' },
  },
})
if (!response.ok)
  throw new Error(`Activity history answered ${response.status}`)
const { edges, pageInfo } = await response.json()

Pass pageInfo.endCursor as cursor to read older entries while pageInfo.hasNextPage is true. To show it in the AdminCP without writing the UI, render ActivityHistory.