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'audit_trail' => 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::record(). It writes the chain row itself rather than throughAuditTrailLogger, 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:
- Resolves the chain for the channel (see below).
- 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.
- 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:
channels[]membership: an active chain that explicitly lists the channel in itschannels[]claims it. An explicit claim outranks a chain merely named after the channel, so funnellingfinanceinto anaccountingchain works even when afinancechain also exists.- Exact id match: otherwise, the active chain whose id
equals the channel (the
financechannel lands in thefinancechain). - 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
defaultinstead 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. defaultfallback: if no chain claims the channel and none holds it, the row lands in the chain nameddefault, provided one exists and is active. Thedefaultchain ships inconfig/install/, but it is an ordinary chain with no special status: an operator can disable or delete it. When no activedefaultchain 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:
modedoes not gaterecord(). Theflag/automodesetting governs the generic logger path only (whether a\Drupal::logger()call without'audit_trail' => TRUEchains). It is never consulted when resolving arecord()call: once a chain resolves,record()writes the row regardless of that chain'smode.- A channel no chain has ever claimed still chains: as long
as a
defaultchain exists. Because of the step-4 fallback, callingrecord()on a channel that no chain claims by id orchannels[]lands in thedefaultchain rather than falling through to plain dblog. This is why entries appear indefaultfor channels you never named in any chain's config. The fallback is not guaranteed, though: thedefaultchain 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 regulardblog/syslogsinks instead (the same route an'audit_trail' => FALSEcall uses). To keep these events in a chain, either keep adefaultchain or give some chain the channel's id (or add it to itschannels[]); to stop chaining them deliberately, route them through the generic logger path with'audit_trail' => FALSErather thanrecord(). - 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 todblog/sysloglike any other unchained event. To keep chaining them somewhere else, move the channel to another chain'schannels[]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.