Skip to main content
Activity logs tell you who did what, and when — without the data. The audit trail adds the missing half: for every create, update and delete Forest performs through your agent, it records the field-level before/after values into a SQL database you own, and the record’s Activity tab replays them. It has two halves:
  • Capture & storage — built into the agent. It instruments every collection that has a primary key through the collection hooks (so it is datasource-agnostic: SQL, Sequelize, Mongo, ActiveRecord, Mongoid…) and writes one row per changed record into a SQL database you provide. A collection without one is skipped — there is nothing to file its rows under, and Node.js logs a warning naming it.
  • The Activity tab — in Forest, on each record’s detail page. It replaces the previous activity tab: it lists the record’s activity logs and enriches each one with the field-level diff captured by your agent.
The audit trail is off until you configure a database for it. No connection is opened, no route is exposed, and no hook is installed on your collections.It requires an agent version that ships the audit trail (@forestadmin/agent 1.110.4 or later for Node.js, forest_admin_agent 1.46.3 or later for Ruby) and a PostgreSQL database. The audit database is independent from your data: you can audit a Mongo datasource and store the trail in Postgres.

Enable it on your agent

Use @forestadmin/agent 1.110.4 or later. There is nothing to install: the agent ships what the audit trail needs, PostgreSQL driver included, even if your main datasource is not SQL. Pass an auditTrail option to createAgent.
Gating it behind an environment variable lets you enable the audit trail per environment:
The connection opens and pending migrations run during agent.start(), so a bad connection string or a failed migration fails at startup, not on the first audited write.

Masking sensitive fields

A redacted field still produces an audit entry when it changes — the change is recorded, its value is not:
Redaction applies to smart action form values too, keyed by collection.

What gets stored

One row per audited change, in forest.audit_logs: The identity columns are copied from the caller at write time, not looked up live from the current user record: a row reflects who acted then, not who holds that user id today — a renamed, deleted or reassigned account doesn’t rewrite history. previous_values / new_values hold only what actually changed: nested objects and arrays of objects are diffed structurally, so a change to one sub-field of a JSON column records that leaf alone rather than the whole document. A key present on only one side (a nested field that was added or removed) is simply omitted from the other side rather than filled with a placeholder. Only writable columns are audited — read-only, computed and database-managed fields are never written by Forest, so they never appear. The table is created and evolved through versioned migrations tracked in a dedicated {table_name}_migration table (e.g. audit_logs_migration), scoped per table name so several audit stores sharing one schema never share migration state. On Postgres they run under an advisory lock, so several agent instances booting at once apply them one after another instead of racing.

Write protocol

Every row goes through two phases: it is inserted as pending before the write runs, carrying whatever is already known at that point (identity, record_id where it exists yet, the before-values); once the write resolves, that same row is updated to status: done with the values the write actually produced. This is what the critical option controls:
  • critical: false (default) — a failure recording the pending row is logged and swallowed; the write proceeds exactly as it would without the audit trail, just without an entry for it.
  • critical: true — the same failure instead refuses the write: nothing happens, no compensating action is taken.
A row left pending means its write may or may not have landed — whatever would have flipped it to done never ran, for any reason (a crash, a dropped connection, the process being killed mid-write). That is deliberately left as-is rather than deleted or backfilled: its presence is itself the evidence that a write was attempted and never confirmed. A write that matches a record but changes nothing leaves no row pending either way — the write itself resolved. On Node.js its row is confirmed to done with previous_values/new_values both empty; on Ruby the pending row is deleted, so a no-op write leaves no entry at all.

Correlation with activity logs

The agent generates one id per request, exposes it to the client in the X-Forest-Correlation-Id response header, and stores it as correlation_key on every row written during that request. That id is what lets Forest join an activity log (“who triggered what”) to the audit rows (“which fields changed”). The Node.js agent and the Rails integration set up the header and its CORS exposure for you. A standalone Ruby agent does not: mount ForestAdminAgent::Http::CorrelationIdMiddleware yourself, otherwise the id is missing from responses and a pooled thread hands its previous request’s key to the next one.

Smart actions

Running a smart action records the invocation itself: one row per targeted record, or one row attached to no record (an empty record_id) for a global action or a select-all bulk run. operation is action, or action_failed when it raised or resolved with an error result. action_name records which action ran. previous_values holds the form the operator submitted (redacted per redact); new_values holds a summary of what the action answered once it resolved — its type, plus whichever of a message, a redirect path or a webhook’s method/URL apply to that result. A result’s HTML, a webhook’s headers/body (which routinely carry credentials) and a file result’s contents are deliberately dropped — none of that belongs in an audit table, and a URL’s own credentials or query string are stripped before it is stored. Writes the action performs through Forest — context.collection.update(...), create, delete — go through the same hooks as any other write and produce their usual field-level rows, sharing the action’s correlation_key.
A write that does not go through Forest’s data layer is not audited. An action doing a direct ORM write (Customer.find(id).update!(...), myModel.save()) is invisible to the agent: only the invocation row above exists, with no field-level diff. Audit your existing actions before promising full coverage.

The Activity tab

Each record’s detail page keeps a single Activity tab — the new one replaces the previous activity tab — showing a chronological, paginated timeline of every change made to that record.
  • An entry with audit data renders as a card with field-level diffs and an operation badge — Created, Updated, Deleted, Action or Action failed.
  • An entry without it (an older log, or an agent with no audit trail configured) falls back to a plain activity card.
  • Action entries show the submitted form values rather than a before/after diff — a failed action changed nothing, so showing its inputs as an “after” state would be a lie.
  • The filter panel narrows by author, activity type and date range, offers free-text search across the recorded values, and the sort toggle switches between newest-first (default) and oldest-first. All are applied server-side, so they filter the whole history, not the current page.
  • The author filter’s options are populated from meta.availableUsers — no separate request needed.
The tab is present on every record, whether or not the audit trail is on. Field-level diffs only appear for agents that have the audit trail configured; otherwise the timeline shows activity logs alone.
Bulk and global actions do not appear in a record’s timeline. They target no single record, so the agent records them with an empty record_id, which no record-scoped query matches.

Project Activity Logs page

The project-level Activity Logs page uses the same timeline across every collection: the project’s activity logs, enriched with the audit rows of every collection you can read. It offers the same filters and the same newest-first / oldest-first toggle as a record’s Activity tab, and each entry links to its record. A record that has since been deleted is shown crossed out, and one outside your scope is named without a link, since neither can be opened.
  • Who can open the page is the team’s Activity Logs setting, as before. The agent does not add a gate of its own.
  • What it shows follows the rule in Who sees the recorded values: if you can read a collection and your team has no scope on it, you see its rows’ values here as on each record’s tab.
  • A collection your team has a record-level scope on has no audit trail on this page, whatever the scope’s condition, even one every record passes. A scope can’t be evaluated across the whole timeline without reading every record it mentions. The collection’s activity logs still appear, without diffs, since activity logs are not filtered by roles or scopes, and each carries a Scoped tag saying why it has no audit trail. Each record’s audit trail stays available on its own Activity tab.
  • The page needs an agent that serves the cross-collection route: Node.js from 1.110.4 and Ruby from 1.46.3. On an older agent, or one without the audit trail configured, the page shows the activity logs alone.

HTTP routes

When the audit trail is configured, the agent exposes these routes. They sit behind Forest’s authentication and require read permission on the target collection. The Activity tab is their consumer; you can also query them directly. Both correlation routes are scoped to a single record: they require collection and recordId (query params on GET, body on POST) and answer 400 without them. meta.availableUsers — [{ id, firstName, lastName, email }, ...] — is only included when the request has no explicit page[number]; any later page omits the key entirely rather than sending an empty array, so a client is expected to keep the list it saw on the first fetch. The per-record history route accepts these optional filters, all combining with AND: Dates are read as wall-clock time in the request timezone and converted to a UTC instant before querying. A bare day snaps to the start of the day for startDate and to 23:59:59.999 for endDate; a datetime without seconds is completed the same way. A malformed date or timezone returns 400; non-numeric userIds tokens are dropped. Pagination follows JSON:API — page[number] is 1-based, page[size] defaults to 20 and is capped at 100 — and sort accepts -timestamp (newest first, the default) or timestamp. Ties on equal timestamps fall back to insertion order, so paging stays deterministic.

The timeline route

GET /forest/_audit-trail serves the project’s Activity Logs page. It takes the same userIds, operations, startDate, endDate, timezone and search filters as the per-record route, but no fields, since a column name means nothing across collections. It is paged by cursor rather than by page number, because the page merges it with the activity logs and no count spans both:
  • sort=timestamp walks oldest first; anything else walks newest first. Ties follow the row id in the same direction.
  • before (newest first) or after (oldest first) is an inclusive ISO 8601 instant, and excludeIds lists the row ids already served at it, so rows sharing a timestamp are never skipped or repeated. The bound for the other direction is ignored. The bound and the date filter on the same side combine, and the tighter one wins.
  • meta.cursor holds the next page’s { before | after, excludeIds }, and is null on the last page.
  • meta.availableUsers is sent on the first page only, when neither before nor after is given.
The capability payload announces the route as canUseAuditTrailTimeline, and the oldest-first walk as canSortAuditTrailTimeline: the front only asks an agent that announces them.

Searching

search matches, case-insensitively, as a substring:
  • action_name
  • user_first_name, user_last_name, user_email
  • the keys and the scalar values of previous_values and new_values, at any depth — searching Lyon finds {"address":{"city":"Lyon"}}
It never matches operation, correlation_key, record_id, collection, status or timestamp — those are machine identifiers a user would never search, and matching them would produce confusing hits. A redacted value can never match a search for the real value: redact replaces it before the row is ever written, so the real value was never in the database to find. The mask itself is ignored too, so searching redacted does not reveal which rows hold a masked value. The match runs against the serialized JSON text of previous_values/new_values, not a structural walk of the parsed value — cheap, and correct for matching keys and scalar values. The term is escaped the way the values were, so 5" finds the row holding 5", and a bare quote can’t match the JSON structure. One thing remains inherent to the approach: a punctuation-only term (,, :, {) matches almost any row whose diff has more than one key, since those characters are JSON structure rather than content.

Reconstructing a past state

The /state route rebuilds a record by taking it as it stands now and undoing every entry recorded strictly after the given timestamp — an entry stamped exactly at it counts as part of that state. It only restores audited columns: read-only, computed and database-managed fields were never recorded, so they cannot be reconstructed. A pending row (see Write protocol) is excluded from the replay: its write may not have landed, so including it could revert a mutation that never actually happened.

Who sees the recorded values

If you can read a record, you can read its audit trail on its Activity tab. There is no separate admin gate on the values: your role’s read permission and your record-level scopes decide, as they do for the record itself. The project’s Activity Logs page applies the same rule but leaves out every collection your team has a scope on; see Project Activity Logs page. A scope can’t be evaluated against a record that no longer exists, so a deleted record’s history stays readable to anyone who can read the collection — seeing what was deleted is much of the point of an audit trail. The column values its rows captured are tested against your scope instead, and withheld where they would have failed it. The row itself is always returned: that something happened, by whom and when, stays visible.
  • A delete row keeps its previous values, and a create row its new values, only if they pass your scope. An update row’s two sides are a partial diff, so each side is tested on its own values and kept only if it passes.
  • A side that can’t answer your scope is withheld rather than guessed at: a scope on a read-only column (never captured), on a relation, or on a value stored redacted. A captured null never passes a negated condition such as status != 'private', as in the database.
  • The primary key is filled from the row’s own id where the row doesn’t capture it. On an update’s previous side, a redacted primary key is not filled, since a writable key may have moved: that side is withheld from a scope on the key.
  • An id freed and taken again has two lives. When a record is deleted, or its primary key moves, and another record takes the same id, being able to read the current record says nothing about the earlier one. The rows from before the id was taken go through the same test as a deleted record’s, even though a record you can read holds the id now.
  • The record is read twice, before and after the audit rows are, and the values are withheld if either read found it gone. An id taken by a record you cannot read answers 404.
  • Action rows are not tested. An action or action_failed row holds the form values submitted to the action and a result summary, not column values, so your scope has nothing to test. It is returned as recorded, even on a deleted record outside your scope.
Every route that serves captured column values applies this: the per-record history (where search and fields then match the served values, so the count and the author list can’t reveal a withheld value either), /state (a reconstruction that fails your scope returns null) and both correlation routes. That holds from Node.js 1.110.4 and Ruby 1.46.3.

Limitations

  • Only writes going through Forest are audited. Changes made by your own code outside the Forest data layer, by another application, or directly in the database leave no trace. Audit trail coverage is not database-level coverage.
  • A concurrent overwrite can stale previous_values. The hooks bracket a write as separate calls and the data layer exposes no lock (it spans SQL, Mongo, HTTP APIs), so two writes racing on the same record snapshot the same prior state. new_values is always exact and no row is ever lost — only the before image of an overlapping write can be stale. Exact before-images under concurrency need database triggers or CDC.
  • A record-level scope protects a deleted record’s values, not the fact that it changed. Anyone who can read the collection sees that a deleted record was touched, by whom and when; the values are tested against their scope. See Who sees the recorded values, including the agent versions this needs.
  • By default, a broken audit store doesn’t block a write. A failure recording the pending entry is logged and the write proceeds unaudited — set critical: true to refuse the write instead; see Write protocol.
  • Auditing costs extra queries. Updates and deletes snapshot the record before writing, so each mutating request adds database round-trips.
  • A bulk update or delete audits at most 1000 records per operation. Matching more logs a warning and audits only the first 1000, unless critical: true is set, in which case the whole operation is refused instead of running partially audited.
  • A smart action’s selection is capped at the same 1000. Naming only a subset of a wider selection would misstate what the action covered, so instead of truncating, an over-cap run is recorded as one entry attached to no record (the same way a global or select-all run already is) — or refused before it runs when critical: true is set.
  • A record whose primary key moved starts its history at the move. An update that changes a writable primary key is filed under the record’s new id, and the rows written before stay under the old one, which the record’s timeline no longer reads. Both agents behave this way. Following a record across the move is left out until there is a need for it; tell us if you have one.
See also Audit & Activity Logs for the activity log itself — including its export and its public API.