Ship the audit trail to your SIEM
The audit trail leaves the gateway as OpenTelemetry logs on an
instrumentation scope of its own, pistra/audit. That scope is the
integration. A collector can send audit records somewhere with
retention and access control while operational logs go to the place you
tail during an incident.
Nothing here is a separate export path or a second exporter to
configure. The trail rides the same OTEL_* environment as every other
signal, and the local copy is always written regardless, so a
collector outage costs you the fan-out, not the trail.
Turn it on
Section titled “Turn it on”Run this command:
$ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 pistra -config pistra.yamlThat one variable turns on traces, logs and metrics together.
OTEL_LOGS_EXPORTER selects logs alone, and OTEL_SERVICE_NAME names
the resource. On Kubernetes it goes in the chart’s env:
env: - name: OTEL_EXPORTER_OTLP_ENDPOINT value: http://otel-collector.monitoring:4318 - name: OTEL_SERVICE_NAME value: pistraAudit records do not share the operational log’s delivery. Operational logs go through a batch processor, which drops on a full queue and on a crash, right for them, and disqualifying for evidence. Audit gets a synchronous exporter. By the time an admin mutation returns, its record has either left the process or been counted as lost.
What arrives
Section titled “What arrives”One record per event. The body is the canonical record, the event name is the event, and the chain fields ride as attributes:
{ "v": 1, "time": "2026-09-01T08:14:25.805088Z", "event": "guardrail.decision", "outcome": "denied", "actor": { "kind": "anonymous", "name": "" }, "target": "chat_completions", "path": "/v1/chat/completions", "rule": "no-cards", "controls": ["acme:AI-07", "eu-ai-act:art-50"], "side": "request", "status": 403, "entities": { "pii/CREDIT_CARD": 1 }, "schedule": "sync", "enforced": true, "deferred": []}Note what is not there. The card that triggered this is not in the record and cannot be recovered from it. Guardrail records name entity types and count them. An audit record carrying the PII a rule just refused to forward would put the payload in the one system most likely to be replicated onward.
Note also enforced and deferred, which exist so that a reader cannot
draw a conclusion the gateway cannot support. enforced: false means
policy’s verdict was recorded but not acted on. deferred names
detectors that contributed only what they already knew. A record
without them would read as a complete, enforced inspection.
Every field is in the audit trail reference, along with the events, the outcomes and the attribute keys.
Route it to the right place
Section titled “Route it to the right place”A collector filters on attributes and the scope, never on the body, which is opaque to it. Two pipelines off one receiver, split on the scope name:
receivers: otlp: protocols: http:
processors: filter/audit: error_mode: ignore log_conditions: - scope.name != "pistra/audit" filter/operational: error_mode: ignore log_conditions: - scope.name == "pistra/audit"
exporters: otlphttp/siem: endpoint: https://siem.example/otlp otlphttp/logs: endpoint: http://loki.monitoring:4318
service: pipelines: logs/audit: receivers: [otlp] processors: [filter/audit] exporters: [otlphttp/siem] logs/operational: receivers: [otlp] processors: [filter/operational] exporters: [otlphttp/logs]The filter processor drops what its condition matches, so
filter/audit keeps only the audit scope and filter/operational
keeps everything else. scope.name is the OTTL path for the
instrumentation scope, and both processors come from the collector’s
contrib distribution.
Send audit somewhere a drop is noticed. The two are separated because they want different destinations. Operational logs want a place that is cheap and lossy, and audit records want one where retention is enforced and deletion is privileged. Sending both to the same store gets you the first property for both.
Notice when it does not arrive
Section titled “Notice when it does not arrive”The trail is shipped over OTLP, but whether it arrived is a Prometheus
question, /metrics on the metrics_listen listener, or the same
registry pushed as OTLP:
$ curl -s localhost:9464/metrics | grep '^pistra_audit_export_failures_total'pistra_audit_export_failures_total 0Every record the collector refuses is counted here and reported to the text log. Alert on any value above zero, not on a rate. This is the one series where a single event matters, because each count is a record the SIEM does not have and whose only remaining copy is the node’s own output.
That is also the recovery. The local copy is always written, so a collector outage is repaired by shipping the node’s log for the window rather than by accepting a hole. See watch the gateway for the alerting, and the metrics reference for the series itself.
Query it
Section titled “Query it”Four attributes are duplicated out of the body so a collector can route
and a SIEM can index without parsing JSON: event, outcome,
actor.kind and actor.name. The event name is set on the record
itself as well.
The fifth is controls, the governance control identifiers the
deciding rule declared, and it makes a compliance question answerable
in one query:
attributes["controls"] contains "eu-ai-act:art-50"That returns every decision made under that control, in the order the rule declared them, without joining against the configuration that was in force at the time. It is present only on records whose rule declared any. See connect your AI governance platform for where the identifiers come from.
The queries worth saving first are the ones nobody thinks to write
until they need them: event = "secret.write",
event = "suspension.change", event = "cluster.member.remove", and
event = "admin.authz", a caller you know, doing something they may
not.
Verify what the SIEM actually holds
Section titled “Verify what the SIEM actually holds”A trail nobody has checked is a trail nobody can rely on, and the copy
worth checking is the one in the SIEM rather than the one on the node.
The body and the audit.* attributes are a chain entry, so an export
can be turned back into one:
$ jq -c ' .resourceLogs[].scopeLogs[] | select(.scope.name == "pistra/audit") | .logRecords[] | (reduce .attributes[] as $a ({}; .[$a.key] = ($a.value.stringValue // $a.value.intValue))) as $at | {v: 1, node: $at["audit.node"], kid: $at["audit.kid"], seq: ($at["audit.seq"]|tonumber), prev: $at["audit.prev"], hash: $at["audit.hash"], sig: $at["audit.sig"], record: (.body.stringValue|fromjson)}' export.json > trail.log
$ pistra audit verify trail.logNODE KID ENTRIES SEQ ORIGIN CLOSED SIGNED STATUSgw-a da3b27d1a322d9c0 7 1..7 genesis true true ok1 other lines ignoredverifiedThat is the round trip the design is for. The records the SIEM stored
verify under the node’s own key, independently of the gateway, the
cluster and the collector that carried it. If your SIEM exports some
other shape, the mapping is the same six attributes and the body. See
verify the audit trail for what verify
then tells you, and for the witnessed heads that catch a trail cut at
the end.
See also
Section titled “See also”- Verify the audit trail,
pistra audit verify, what each finding means, and witnessed heads - The audit trail, why the trail is a separate signal from the log, and what is deliberately not recorded
- Audit trail reference, every event, field and attribute
- Connect your AI governance platform, where
controlscomes from, and the platform reading this trail back