Documentation

Activity logs

How VitNode records every Content Engine write in the Activity Logs automatically, inside the write's transaction, and how to tune or disable it per content type with the audit option.

Every content type is audited out of the box, no code required. Creating, editing, deleting, duplicating, publishing, hiding, restoring, scheduling and every translation change land in the Activity Logs with the actor, the record, the changed fields and, for an editorial content type, the revision it created. This page covers what gets recorded and how to tune it.

What an entry contains

FieldValue
PluginThe content type's plugin, from the content registry, never from the request
Actioncontent.created, content.updated, content.translation_published...
TargetThe content type id (blog.category), the record id and its title at the time
Changed fieldsThe canonical paths the write moved (seo.title, categories)
ChangesWithout editorial: bounded before/after values of the changed scalar fields. With it: none, the revision holds them
RevisionThe revision the write created, shown as a diff against the previous one
LocaleFor translation operations

Delivery events (delivery_slug_changed, delivery_redirect_created) do not get entries of their own. The slug change is part of the content.updated entry. The area follows the request: admin from the AdminCP, public for a member's own write, moderator from a report or approval, system for scheduled work.

Why entries are written in the transaction

Content events fire after a write commits, which is too late for a listener to keep the audit trail atomic. So the Content Engine writes the entry inside the write's transaction: beside the revision for an editorial content type, and in the plain service's transaction otherwise. A rolled-back write leaves no entry, and a write whose entry cannot be stored does not commit.

This holds for the generated AdminCP routes, the public write API, approvals, moderation actions and your own code calling model.service(c) or model.editorialService(c), because they all go through the same services.

One operation, one entry

Some operations write more than one thing. The Content Engine records the operation the person asked for:

OperationEntries
Create a localized record with its translationsOne content.created, labelled with the default translation's title, listing the locales
Publish a localized recordOne content.published listing the languages it published
Duplicate a recordOne content.duplicated on the copy, with the source id
Save shared fields and a translation togetherOne entry per part that moved, sharing a request id

Scheduled publishing

The system runs a scheduled publish or unpublish, so its entry has the System actor and area: system. Whoever booked it travels in the metadata:

{
  "action": "content.published",
  "actorType": "system",
  "metadata": {
    "scheduledByUserId": 12,
    "scheduleId": 40,
    "trigger": "scheduled_task"
  }
}

Booking and cancelling the schedule are entries of their own (content.scheduled, content.schedule_cancelled) attributed to the admin. The execution runs in the schedule's claim transaction, so a retried run never records twice.

Tune or disable it

Auditing is on by default. Pass audit to defineContentType to keep noisy fields out of the recorded changes, or false to turn it off.

plugins/shop/src/content/product.ts
import { defineContentType, field } from '@vitnode/core/content'

export const productContentType = defineContentType({
  id: 'shop.product',
  tableName: 'shop_products',
  fields: {
    name: field.text({ required: true, maxLength: 200 }),
    stock: field.number({ integer: true, required: true }),
  },
  // Stock moves with every order; the entry still says the product changed.
  audit: { excludeFields: ['stock'] }, 
})
OptionTypeDefaultDescription
excludeFieldsfield names[]Never named or diffed in an entry; a group name covers its nested fields. The write itself is still recorded. An unknown field name throws when the content type is defined.
trackChangesbooleantruefalse records the operation only, without changed fields or values.

audit: false records nothing for the content type. Use it only for high-volume, non-sensitive content. Sensitive field names (passwords, tokens, secrets, API keys) are redacted no matter what you set here.

See a record's history in the AdminCP

Every content list has an Activity row action for admins with can_view on the content type. It opens the record's entries, newest first, with their changes, for any content type, editorial or not. Don't mix it up with History, which shows what the content itself used to be.

Activity dialog of a blog category listing Updated a translation of Category with an en badge, Updated Category and Created Category, each with its actor, AdminCP area and time, expandable changes on the updates and the Color field named on the creation

To show the same history on your own AdminCP screen, render ActivityHistory inside RouteMessages with the Activity Logs messages and your plugin's content labels:

plugins/blog/src/views/admin/category-activity.tsx
import { RouteMessages } from '@vitnode/core/tanstack/i18n'
import { ActivityHistory } from '@vitnode/core/views/activity-logs/activity-history'

export const CategoryActivity = ({ categoryId }: { categoryId: number }) => (
  <RouteMessages
    namespaces={['core.activity_logs', 'core.global', '@vitnode/blog.content']}
  >
    <ActivityHistory
      pluginId="@vitnode/blog"
      targetId={categoryId}
      targetType="blog.category"
    />
  </RouteMessages>
)

ActivityHistory reads GET /admin/activity-logs/entity, so it shows entries only to admins allowed to read that record's history. RouteMessages suspends while the messages load, so render it inside a Suspense boundary with a skeleton fallback.