Documentation

Activity Logs

Record plugin events

Record your plugin's own operations in the VitNode Activity Logs with c.get('audit').record(), inside the same transaction as the change, with before/after values and translated labels.

The Content Engine audits its own writes. Your plugin's own tables are your job, but only barely: call c.get('audit').record() from the API route that makes the change. VitNode takes the actor, IP address and request id from the request. You describe the operation.

In this guide, the site-notes plugin's create note route writes to the admin log.

Before you begin

Finish Permissions. It adds the site_notes table, the AdminCP route at admin/notes and a temporary Create a test note button. This guide changes that route.

Record the operation in the same transaction

Replace the handler of createNoteRoute. Write the entry in the same transaction as the change. A rolled-back insert then leaves no entry behind, and a failed audit write fails the insert instead of quietly losing the trail.

plugins/site-notes/src/api/modules/admin/notes/create.route.ts
export const createNoteRoute = buildRoute({
  adminStaffPermission: { module: 'notes', permission: 'can_create' },
  route: {
    // unchanged
  },
  handler: async (c) => {
    const values = c.req.valid('json')
    const note = await c.get('db').transaction(async (tx) => {
      const [created] = await tx.insert(site_notes).values(values).returning({
        id: site_notes.id,
        title: site_notes.title,
        body: site_notes.body,
      })

      await c.get('audit').record(
        {
          action: 'note.created',
          target: { type: 'note', id: created.id, label: created.title },
          changes: c.get('audit').diff({
            before: null,
            after: created,
            include: ['title', 'body'],
          }),
        },
        { tx },
      )

      return created
    })

    return c.json(note, 201)
  },
})

pluginId defaults to the plugin of the route, so the entry is filed under site-notes. area defaults to admin on an AdminCP request, public for a signed-in member and system otherwise. Pass area: 'moderator' from moderator routes so the entry lands in the moderator log, and scope when the action belongs to a moderation scope.

Describe the changes

diff() (also exported as auditDiff from @vitnode/core/lib/audit/diff) compares two objects and keeps only the fields that changed. before: null describes a creation, after: null a deletion:

c.get('audit').diff({
  before: { title: 'News', slug: 'news', viewCount: 10 },
  after: { title: 'Announcements', slug: 'announcements', viewCount: 11 },
  exclude: ['viewCount'],
})
// { title: { from: 'News', to: 'Announcements' },
//   slug: { from: 'news', to: 'announcements' } }

Prefer include for anything security-related. An allowlist cannot leak a column added later. Fields named like passwords, tokens, secrets, API keys, cookies or recovery codes are always stored as { redacted: true }. The entry says they changed, never what to. Rich-text documents are stored as a marker, because their content belongs to revisions.

Declare the action and its label

Declare your actions so the Activity Logs action filter offers them before they first happen. Create src/api/audit-actions.ts:

plugins/site-notes/src/api/audit-actions.ts
import { defineAuditActions } from '@vitnode/core/lib/audit/actions'

export const siteNotesAuditActions = defineAuditActions([
  { action: 'note.created', targetType: 'note' },
])

Pass it to buildApiPlugin in src/config.api.ts. The snippet is an excerpt, so keep the rest of the file:

plugins/site-notes/src/config.api.ts
import { siteNotesAuditActions } from './api/audit-actions'

export const siteNotesApiPlugin = () =>
  buildApiPlugin({
    manifest,
    auditActions: siteNotesAuditActions, 
    // ...
  })

Add the label to src/locales/en.json, inside the site-notes object:

plugins/site-notes/src/locales/en.json
{
  "site-notes": {
    "audit": {
      "actions": {
        "note": { "created": "Created note" }
      }
    }
  }
}

The label key is {pluginId}.audit.actions.{action}, with the dots of the action nesting. Without a label, the UI makes the action id readable ("Note created"). An undeclared action is still recorded and listed. It just won't show up in the action filter.

Check the result

Press Create a test note as the unrestricted administrator, then open AdminCP → Staff → Administrators → Logs. The newest entry reads Created note under your plugin, with the note's title as the target. Click it, and Changes shows Title and Body going from Empty to the new values.

Background work and failures

  • In a cron job or queue task, entries are attributed to the system automatically. To add context to your own block of work, wrap it in withAuditContext from @vitnode/core/api/lib/audit/context, for example with { metadata: { trigger: 'import' } }.
  • A malformed action or target type throws AuditLogError. That is a bug in your code, so VitNode never swallows it. For a non-critical operation outside a transaction, pass { bestEffort: true } to log a failed database write to the technical log instead of failing the request.