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 active chain for the channel.
  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.

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.