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 'chain' => 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::event(). The orchestrator dispatches through the same logger but adds the context contributor pipeline — pre-bucketed payloads, three-tier retention, declarative configuration via the chain entity edit form.

Generic logger path

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

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

\Drupal::logger('finance')->notice('@action on @resource', [
  // Marker telling audit_trail to land this entry in the chain.
  'chain' => TRUE,

  // Conventional audit-shape keys (see "Conventions" below).
  'action'   => 'state_change',
  'resource' => 'node/' . $nid,
  '@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', [
  '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

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

\Drupal::logger('cache_warmer')->info('Warmed @count entries', [
  '@count' => 200_000,
  'chain' => 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::event()

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->event(
  channel: 'finance',
  action: 'state_change',
  subject: new AuditTrailSubject(
    resource: 'node/' . $nid,
    live: $node,
  ),
  context: [
    'note' => 'Approved by management',
  ],
);

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. Dispatches to \Drupal::logger($channel) with the merged transient items spread as plain keys and the merged permanent items under the private _audit_trail_permanent PSR-3 key.

Chain resolution and the default catch-all

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

  1. Exact id match — the chain whose id equals the channel (the finance channel lands in the finance chain).
  2. channels[] membership — a chain that explicitly lists the channel in its channels[] claims it.
  3. default fallback — if no chain matches either way, 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).

Two consequences follow that surprise people:

  • mode does not gate event(). The flag / auto mode setting governs the generic logger path only (whether a \Drupal::logger() call without 'chain' => TRUE chains). It is never consulted when resolving an event() call — once a chain resolves, event() writes the row regardless of that chain's mode.
  • A channel no chain claims still chains — as long as a default chain exists. Because of the step-3 fallback, calling event() 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, event() resolves no chain at all — no chain row is written, and the event flows out to the regular dblog / syslog sinks instead (the same passthru fanout a 'chain' => 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 'chain' => FALSE rather than event().

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 canonical() Never Operator-attested PII-free metadata.
Transient context_transient Via hash → context_transient_hash Yes — NULLed at retention by the transient-purge cron pass; 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's the safe default. Permanent is explicit-opt-in only — the operator has to attest 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:

\Drupal::logger('finance')->notice('Acte signed', [
  'chain' => TRUE,
  '_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. The logger strips every _audit_trail_* key from the context before encoding context_transient, so 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.

Current callers / consumers of the convention:

Key Direction Purpose
_audit_trail_permanent caller → orchestrator Explicit opt-in to the permanent bucket (the only path for a caller to land data in context_permanent).
_audit_trail_entity_selected_fields bridge → contributor Field-name allowlist for the snapshot. The bridge owns the gating; the contributor extracts.
_audit_trail_entity_permanent_fields bridge → contributor Subset of selected fields routed to context_permanent.

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 — see AuditTrailLogger::bucketContext(). 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. - _contributor_errors — orchestrator-stamped marker on rows where a contributor threw mid-event. Lives in transient so operators can grep context_transient LIKE '%_contributor_errors%' to surface affected rows.

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(AuditTrailSubject $subject, string $action, array $context): 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>/editContext contributors).

Conventions worth knowing

Caller-supplied context goes to transient by default

When you pass ['note' => 'Approved by …'] to event() (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.

Subject $live is mixed 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(AuditTrailSubject $subject, string $action, array $context): bool {
  // Always guard with instanceof before accessing $subject->live —
  // a sibling bridge might call event() 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.

Bucket payloads propagate to dblog / syslog / SIEM

The orchestrator passes the full merged context (transient items as plain keys, permanent items under _audit_trail_permanent) through LoggerFactory, which dispatches to every registered logger — audit_trail itself, dblog, syslog, third-party SIEM pipes. Those sinks have their own retention; the transient bucket's auto-purge contract applies to audit_trail only.

Practical implication. PII routed to transient for audit_trail's purge cycle still gets logged verbatim into dblog and syslog. Configure dblog row TTL / external SIEM retention to match if PII flow matters.

Bridge implementers: opt diagnostics out of the chain

If you write a bridge that subscribes to upstream events and calls AuditTrail::event(), 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 'chain' => 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(), 'chain' => FALSE],
);

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

Suppressing 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->event(
      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 or archived chain.