Skip to content

Consumer integration guide

How third-party module authors emit chained log entries and contribute context to the audit trail.

There are two entry points:

  • Generic logger path. Anyone calling \Drupal::logger() reaches AuditTrailLogger through the standard PSR-3 pipeline. Set 'audit_trail' => TRUE (or rely on mode: auto on a chain that claims the channel) and the entry lands in the chain.
  • Orchestrator path. Consumer modules that audit their own business events call AuditTrailInterface::record(). It writes the chain row itself rather than through AuditTrailLogger, which is what lets it add the context contributor pipeline, the permanent retention tier, and the per-event dispatch controls. The other loggers then receive one ordinary PSR-3 call, marked as already chained so this module's own logger passes on it, and the entry reaches dblog once and the chain once.

Both paths resolve the same chains and ask the same filters. What differs is what the caller can hand over and what can be withheld from the other loggers, and each section below says so where it matters.

The audit_trail context key

The module reads one key out of a log context, audit_trail, and nothing else. Everything a caller says about the audit row goes under it, either as the key's whole value:

['audit_trail' => TRUE]       // chain this entry
['audit_trail' => FALSE]      // never chain this entry
['audit_trail' => 'finance']  // chain it, into the `finance` chain

or as a keyed array, for a caller that also names the row's action and resource:

['audit_trail' => [
  'chain' => TRUE,
  'action' => 'state_change',
  'resource' => 'node/' . $nid,
]]

The chain element is optional in that form: leave it out and the chain's own mode decides, exactly as it does for a call carrying no audit_trail key at all.

One key, because a log context is a namespace shared with every other logger and every other module that logs. action and resource land in columns the row's HMAC covers, and the chain statement decides whether the row exists at all, so reading them from names another module might be using for its own purposes would let a foreign log context withhold or forge part of an audit row.

Generic logger path

mode: flag (default): opt-in per call

Set 'audit_trail' => TRUE in the PSR-3 context array. Everything else is a normal log call.

\Drupal::logger('finance')->notice('@action on @resource', [
  // What audit_trail is told: chain this entry, and these are
  // the row's action and resource.
  'audit_trail' => [
    'chain' => TRUE,
    'action' => 'state_change',
    'resource' => 'node/' . $nid,
  ],

  // Ordinary message placeholders, for every logger.
  '@action' => 'state_change',
  '@resource' => 'node/' . $nid,
]);

Without the flag, the call still reaches dblog / syslog; it just does not appear in the chain.

mode: auto: channel-wide opt-in

If your subsystem produces only audit-worthy events on a dedicated channel, declare a chain that claims the channel and set mode: auto. Every call on that channel chains automatically; the per-call flag becomes optional.

# config/install/audit_trail.chain.finance.yml
status: true
id: finance
label: 'Finance audit'
mode: auto
channels:
  - finance

Then anywhere in the codebase:

\Drupal::logger('finance')->notice('Acte signed', [
  'audit_trail' => [
    'action' => 'state_change',
    'resource' => 'node/' . $nid,
  ],
]);

Use a dedicated channel for audit-worthy events (separate from the subsystem's debug / notice channel). Cleaner separation, smaller chain, lower review surface.

Universal opt-out

'audit_trail' => FALSE always wins, regardless of mode:

\Drupal::logger('cache_warmer')->info('Warmed @count entries', [
  '@count' => 200_000,
  'audit_trail' => FALSE,
]);

Use it for high-volume / low-value entries you don't want in the chain even when the chain's mode would normally claim them.

Orchestrator path: AuditTrailInterface::record()

For business events flowing through Drupal's entity API or a similar abstraction, route through the orchestrator:

$audit_trail = \Drupal::service(AuditTrailInterface::class);
$audit_trail->record(
  channel: 'finance',
  action: 'state_change',
  subject: new AuditTrailSubject(
    resource: 'node/' . $nid,
    live: $node,
  ),
  context: [
    'note' => 'Approved by management',
  ],
);

Three optional arguments follow context, all with defaults that suit most bridges: severity (RFC 5424, NOTICE by default), correlation_id to tie several entries to one operation, and chain_only: TRUE to keep this one event off dblog and syslog whatever the chain is configured to do. The interface docblock on AuditTrailInterface::record() documents each in full.

The orchestrator:

  1. Resolves the chain for the channel (see below).
  2. Walks enabled context contributors in ascending weight.
  3. Each contributor's applies() decides whether to run; passing contributors' contribute() returns a (permanent, transient) bucket payload.
  4. Merges every contributor's output per tier: last-write- wins on key conflicts so a higher-weight contributor can overwrite a lower-weight one's value.
  5. Writes the row through the chain writer with those buckets, then makes one \Drupal::logger($channel) call for the other sinks. That call carries the action and the resource, and neither bucket: see "What the other loggers are given" below.

Chain resolution and the default catch-all

record() resolves the target chain for a channel in this order:

  1. channels[] membership: an active chain that explicitly lists the channel in its channels[] claims it. An explicit claim outranks a chain merely named after the channel, so funnelling finance into an accounting chain works even when a finance chain also exists.
  2. Exact id match: otherwise, the active chain whose id equals the channel (the finance channel lands in the finance chain).
  3. An inactive chain still holds its channels: if no active chain answers for the channel but a chain that exists and is inactive is named after it or lists it, the entry is refused rather than chained anywhere else, and resolution stops here. This is the promise the chain form makes when its Active box is unchecked: existing rows stay queryable and verifiable, new chained writes are refused. Sending those entries to default instead would file them under another chain's filters, contributors and retention windows, which is not what making a chain inactive asks for. Deleting the chain is the other thing: with its configuration gone, nothing claims the channel any more and the catch-all below takes it.
  4. default fallback: if no chain claims the channel and none holds it, the row lands in the chain named default, provided one exists and is active. The default chain ships in config/install/, but it is an ordinary chain with no special status: an operator can disable or delete it. When no active default chain is present, this step resolves to nothing and the call chains no row (see below).

Either path can also name the chain outright, which skips all four steps. 'audit_trail' => 'finance' in the context targets the finance chain.

A name asks for one chain, and it is not a starting point for a search. If no active chain has that id, the entry is not chained at all, and the id is reported to the operator instead: on the audit_trail channel for a record() call, in PHP's error log for a plain logger call, which is the only sink that logger can reach. A typo therefore costs you the entry and tells you so, rather than filing it under some other chain.

No id is reserved, so the chain statement's whole vocabulary is: TRUE to chain, FALSE never to chain, or the id of the chain to chain into. Per-event chain-only dispatch is an argument of record() rather than a fourth value of this key, because that is the only path able to honor it (configuration.md § chain_only says how far it reaches).

Three consequences follow that surprise people:

  • mode does not gate record(). The flag / auto mode setting governs the generic logger path only (whether a \Drupal::logger() call without 'audit_trail' => TRUE chains). It is never consulted when resolving a record() call: once a chain resolves, record() writes the row regardless of that chain's mode.
  • A channel no chain has ever claimed still chains: as long as a default chain exists. Because of the step-4 fallback, calling record() on a channel that no chain claims by id or channels[] lands in the default chain rather than falling through to plain dblog. This is why entries appear in default for channels you never named in any chain's config. The fallback is not guaranteed, though: the default chain is removable, and if it has been disabled or deleted, record() resolves no chain at all: no chain row is written, and the event flows out to the regular dblog / syslog sinks instead (the same route an 'audit_trail' => FALSE call uses). To keep these events in a chain, either keep a default chain or give some chain the channel's id (or add it to its channels[]); to stop chaining them deliberately, route them through the generic logger path with 'audit_trail' => FALSE rather than record().
  • Making a chain inactive does not hand its channels to default. A channel the inactive chain claims stops chaining altogether (step 3), and its entries flow out to dblog / syslog like any other unchained event. To keep chaining them somewhere else, move the channel to another chain's channels[] rather than relying on the fallback.

The two-tier retention model

Every chained row carries two context columns with different retention shapes:

Tier Column Signed how Purgeable? Typical content
Permanent context_permanent Raw in the canonical payload Never Operator-attested PII-free metadata.
Transient context_transient Via hash to context_transient_hash Yes: NULLed at retention by the transient-purge cron run; the cleared range is attested by a transient-purge segment. Opt-out preserves the bytes into the archive NDJSON until file-purge Raw operational payload, possibly PII.

Generic logger callers always land in transient. That is the safe default, and on that path it is the only outcome: the logger writes a single bucket, so an _audit_trail_permanent key in a \Drupal::logger() context is not an attestation. It is another transient value under a name that suggests otherwise, and the transient-purge run will clear it like the rest.

Permanent is opt-in through AuditTrail::record(), where the operator attests the classification, either by writing a ContextContributor plugin or by passing an explicit _audit_trail_permanent payload. Anything else in the context array routes to transient as usual:

$audit_trail->record(
  channel: 'finance',
  action: 'state_change',
  subject: new AuditTrailSubject(resource: 'workflow/' . $workflow_id),
  context: [
    '_audit_trail_permanent' => [
      'workflow_id' => $workflow_id,
      'state_from' => 'draft',
      'state_to' => 'signed',
    ],
    'approver_uid' => $approver_uid,
  ],
);

Private routing keys (_audit_trail_*)

Any context key prefixed with _audit_trail_ is treated as private routing metadata, not row payload. AuditTrail::record() strips every _audit_trail_* key from the seed transient bucket, so on that path these keys never persist on the audit row. The framework uses the prefix to thread metadata between callers, the orchestrator, contributors, and the logger without polluting the chain.

These are a separate convention from the audit_trail key above, and both are collision-safe: one name the module claims, and a prefix nothing else uses. A call that both opts in and correlates therefore carries two keys, 'audit_trail' => TRUE and '_audit_trail_correlation_id' => $id.

The PSR-3 path does not do that strip. AuditTrailLogger removes audit_trail, exception, backtrace and _audit_trail_correlation_id, and everything else the caller passed lands in context_transient verbatim, prefix or no prefix. A bridge threading private metadata has to go through AuditTrail::record() for it to stay off the row.

Current callers / consumers of the convention:

Key Direction Purpose
_audit_trail_permanent caller to orchestrator Explicit opt-in to the permanent bucket (the only path for a caller to land data in context_permanent).
_audit_trail_correlation_id caller to logger Ties several entries to one operation from a \Drupal::logger() call. It lands in the row's correlation_id column rather than in the context, and it is the one key the logger does consume. record() takes a $correlation_id argument instead.
_audit_trail_entity_selected_fields bridge to contributor Field-name allowlist for the snapshot. The bridge owns the gating; the contributor extracts.
_audit_trail_entity_permanent_fields bridge to contributor Subset of selected fields routed to context_permanent.
_audit_trail_entity_record_config_values bridge to contributor Whether a configuration entity records its exported values at all, or only its name. Absent means no, so a caller that says nothing cannot record a credential by accident.
_audit_trail_entity_redact_config bridge to contributor The config_ignore patterns naming configuration whose value is recorded as REDACTED. Absent means the shipped patterns; an empty list switches the net off.
_audit_trail_already_chained orchestrator to logger Set by record() on the one log call it makes for the other sinks, so this module's logger does not write a second row for an event it has already chained. AuditTrailLogger::ALREADY_CHAINED; nothing else should set it.

New bridges and contributors: any private metadata you want to thread through the audit pipeline should use the _audit_trail_<scope>_<key> shape. The strip is a one-place contract, in AuditTrail::record(). The naming convention scopes your keys (_audit_trail_webdav_lock_token, _audit_trail_workflow_transition_reason) so peer bridges can't accidentally collide.

Keys that DO persist (no _audit_trail_ prefix, framework-emitted): - _v: wire-format version marker at the top of every SnapshotDelta bucket. Required by the renderer to recognize the bucket as a diff fragment. See architecture.md#snapshot-delta-bucket-format.

The module stamps two keys of its own onto the transient bucket. Both carry the _audit_trail_ prefix, and each is dropped by whatever writes it, whoever put it there. The prefix alone would not be enough: record() strips it, and the PSR-3 path deliberately does not (see below), so a key protected only by the strip is still the caller's to send on the path most callers use. Without this a row says the module observed something it did not: - _audit_trail_contributor_errors (AuditTrail::CONTRIBUTOR_ERRORS_KEY): stamped on rows where a contributor threw mid-event, so operators can grep context_transient LIKE '%_audit_trail_contributor_errors%' to surface affected rows. No contributor runs on the PSR-3 path, so the logger drops the key there rather than letting a caller answer that query. - _audit_trail_caller_supplied (ForensicStamp::CALLER_KEY): dropped by the stamp itself on both paths, since the stamp is its only writer. The forensic stamp overwrites uid, request_uri, ip and message_template with what the framework observed, and any caller value that differed is kept here rather than discarded, so the row shows both. The one value not kept is core's own copy of the request URI, because the stamp masks credentials out of the URI it records and keeping the unmasked copy would undo that. See architecture.md § "Forensic envelope".

The writer owns three keys of its own, and those do persist, in the permanent bucket, because they are the writer's statement about the row rather than anything a caller threaded through. The writer claims those three names and strips them from the permanent bucket it is handed, so a caller or a contributor using one of them cannot put a signed claim on the row that the module never made, and cannot overwrite one it did: _audit_trail_write_mode on a row that waited somewhere it could have been lost, _audit_trail_rolled_back on a row describing work that was undone, and _audit_trail_shortened holding what a value was cut down from, keyed by column, on a row where one did not fit its column. Each appears only on the rows it has something to say about. See configuration.md for the last one, which is the only one with a setting.

Writing a ContextContributor plugin

Plugins live under <your_module>/src/Plugin/ContextContributor/ and carry the #[ContextContributor] attribute:

namespace Drupal\my_module\Plugin\ContextContributor;

use Drupal\audit_trail\AuditTrailSubject;
use Drupal\audit_trail\Attribute\ContextContributor;
use Drupal\audit_trail\ContextContributor\ContextContributorBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;

#[ContextContributor(
  id: 'my_module_workflow_state',
  label: new TranslatableMarkup('Workflow state snapshot'),
  description: new TranslatableMarkup(
    'Records the workflow state transition (from / to) into the permanent bucket.'
  ),
  weight: 10,
)]
final class WorkflowStateContributor extends ContextContributorBase {

  public function applies(string $channel, string $action, AuditTrailSubject $subject, array $context, int $severity): bool {
    return $subject->live instanceof MyWorkflowEntity
      && in_array($action, ['transition', 'approve'], TRUE);
  }

  public function contribute(AuditTrailSubject $subject, string $action, array $context): array {
    /** @var MyWorkflowEntity $entity */
    $entity = $subject->live;
    return self::EMPTY + [
      'permanent' => [
        'workflow_id' => $entity->workflowId(),
        'state_from' => $entity->getOriginalState(),
        'state_to'   => $entity->getCurrentState(),
      ],
    ];
  }

}

Operators enable the plugin on a chain via the chain edit form (/admin/config/system/audit-trail/chains/<id>/edit > Context contributors).

Conventions worth knowing

Caller-supplied context goes to transient by default

When you pass ['note' => 'Approved by …'] to record() (no explicit _audit_trail_permanent), the orchestrator seeds the transient bucket with your context. Contributors then add their bucket payloads on top. If a contributor and the caller emit the same key, the contributor wins (last-write-wins in array_merge).

If you need a caller-supplied value to land in permanent, use the explicit _audit_trail_permanent payload shown above: the orchestrator preserves it without seeding transient from those keys.

An entry written during a save appears when the save commits

Most consumer writes happen inside a transaction, because saving a content entity opens one. Such an entry cannot be chained where it happens, so it is buffered and chained when the caller's outermost transaction resolves. Three consequences are worth knowing.

The entry is not readable until the transaction commits. Code that writes an audit entry and then queries audit_trail for it, still inside the same transaction, finds nothing. That includes a test: assert after the commit, not before it. Test frameworks do not wrap tests in a transaction, and an entity save commits its own, so a test that saves an entity and then asserts still sees its entry.

Precisely, the entry is chained when the Transaction object is released, not when commitOrRelease() returns: Drupal runs post-transaction callbacks from that object's destructor. Ordinary code never has to think about it, because the transaction is a local variable in the method doing the work and dies when that method returns. Code that holds one open in a property, or asserts on the next line after committing, does.

A failed chain write can no longer abort your transaction. By the time the entry is chained, your work is already durable, so there is nothing left to roll back. A consumer that deliberately re-throws a failed audit write to cancel its own change needs the inline setting, and with it the entries that setting loses under concurrency.

A rolled-back savepoint needs one call from you. See rolling a savepoint back. Nothing to do unless you use savepoints and roll one back.

Subject $live is ?object by design

AuditTrailSubject::$live is typed as ?object so any bridge can hand any payload (a Drupal entity, a Symfony event, a custom DTO). Contributors are the type-aware layer:

public function applies(string $channel, string $action, AuditTrailSubject $subject, array $context, int $severity): bool {
  // Always guard with instanceof before accessing $subject->live:
  // a sibling bridge might call record() with a different live
  // type, and your contributor would NULL-deref otherwise.
  return $subject->live instanceof MyExpectedType;
}

A contributor that doesn't instanceof-check is a bug waiting for the second bridge to ship.

What the other loggers are given

The two paths differ here, and the difference is the one that matters for anything sensitive.

AuditTrail::record() persists the buckets itself and then makes one plain log call for the other sinks. That call carries the action and the resource, under the same audit_trail key a caller would use, plus the marker saying the record is already chained. Nothing else: no transient items, no permanent payload, none of the caller's own _audit_trail_* keys. A contributor's entity snapshot therefore exists in context_permanent and context_transient and nowhere else, under audit_trail's retention alone.

A \Drupal::logger() call is the other way round. That call is a log call, so whatever context it carries is in dblog by the time this module sees it, and the chain row then gets its own copy of the same context in the transient bucket. audit_trail purges its copy on the configured window; dblog's row lives on dblog's row cap and a SIEM pipe on whatever that keeps. So if the payload is sensitive, either configure those retentions to match, or write the entry through record(), where the payload does not leave the chain.

Bridge implementers: opt diagnostics out of the chain

If you write a bridge that subscribes to upstream events and calls AuditTrail::record(), the bridge will also produce its own operational log lines: "failed to resolve rule for type X", "skipped entity Y because Z", etc. Those diagnostics route through Drupal's standard logger pipeline; if any chain claims your bridge's channel in mode: auto, your diagnostic noise will land in the chain alongside the real audit events.

Convention: pass 'audit_trail' => FALSE on every diagnostic log call inside a bridge. Operators almost never want "audit_trail_my_bridge skipped entity 42" landing in a tamper-evident chain.

\Drupal::logger('audit_trail_my_bridge')->warning(
  'Skipped entity @id: no rule matched.',
  ['@id' => $entity->id(), 'audit_trail' => FALSE],
);

The audit_trail module itself follows this convention for its internal diagnostics (secret-resolution warnings, lock- contention warnings, contributor-throw warnings).

Skipping audit for a specific entity save

For modules using the audit_trail_entity bridge: set _audit_trail_skip on the entity before ->save() to opt that specific save out of audit:

$node->_audit_trail_skip = TRUE;
$node->save();

Use sparingly: every silent skip is a hole in the audit trail.

Worked example: WebDAV PUT

namespace Drupal\my_module\EventSubscriber;

use Drupal\audit_trail\AuditTrailInterface;
use Drupal\audit_trail\AuditTrailSubject;
use Drupal\webdav\Event\WebDavResourceEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class MyWebDavSubscriber implements EventSubscriberInterface {

  public function __construct(
    private readonly AuditTrailInterface $auditTrail,
  ) {}

  public static function getSubscribedEvents(): array {
    return [WebDavResourceEvent::class => 'onWebDav'];
  }

  public function onWebDav(WebDavResourceEvent $event): void {
    if ($event->method !== 'PUT') {
      return;
    }
    $this->auditTrail->record(
      channel: 'finance',
      action: 'PUT',
      subject: new AuditTrailSubject(
        resource: 'webdav:' . $event->path,
        live: $event,
      ),
      context: [
        'hash_before' => $event->hashBefore,
        'hash_after'  => $event->hashAfter,
      ],
    );
  }

}

The chain row carries action = "PUT", resource = "webdav:files/acte/4". A future query answers "who PUT acte/4 between 14:00 and 14:30?" against the live rows or their archive.