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()reachesAuditTrailLoggerthrough the standard PSR-3 pipeline. Set'chain' => TRUE(or rely onmode: autoon 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:
- Resolves the active chain for the channel.
- Walks enabled context contributors in ascending weight.
- Each contributor's
applies()decides whether to run; passing contributors'contribute()returns a(permanent, transient)bucket payload. - 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.
- Dispatches to
\Drupal::logger($channel)with the merged transient items spread as plain keys and the merged permanent items under the private_audit_trail_permanentPSR-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>/edit →
Context 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.