Skip to content

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.

Run this command:

Terminal window
$ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 pistra -config pistra.yaml

That 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: pistra

Audit 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.

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.

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.

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:

Terminal window
$ curl -s localhost:9464/metrics | grep '^pistra_audit_export_failures_total'
pistra_audit_export_failures_total 0

Every 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.

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.

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:

Terminal window
$ 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.log
NODE KID ENTRIES SEQ ORIGIN CLOSED SIGNED STATUS
gw-a da3b27d1a322d9c0 7 1..7 genesis true true ok
1 other lines ignored
verified

That 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.