Topic · Cookbook

Audit-Logging Cookbook

Sechs produktionsreife Rezepte für Security-Audit-Logging und Telemetrie. Vom Dev-Setup (Stderr) über Compliance-Audit (NDJSON + logrotate) bis SIEM-Forwarding (Syslog) und Distributed-Tracing (OTLP-Collector). Trait-Surface, LogLevel-Skala und Sink-Architektur in der Audit-Logging Reference.

6 Rezepte · Stderr/NDJSON/Syslog/OTLP/Fan-Out/Scrub Targets: SIEM · Loki · Splunk · Jaeger · Tempo · Honeycomb Crates: zerodds-security-logging, zerodds-observability-otlp

1 · Stderr (Dev-Default)

Schnellster Einstieg — kein File-Handling, kein Network. Geht direkt auf stderr und wird von journalctl/docker logs/kubectl logs eingesammelt.

use zerodds_security::logging::LogLevel;
use zerodds_security_runtime::SecurityBundle;
use zerodds_security_logging::StderrLoggingPlugin;

let sink = StderrLoggingPlugin::with_level(LogLevel::Warning);

let security_bundle = SecurityBundle::builder()
    .logging_plugin(Box::new(sink))
    .build();

▶ Runnable example: rust-audit-securitybundle-realpart

Output-Format:

2026-05-15T10:22:33Z CRITICAL [0102030405060708090a0b0c0d0e0f10] auth.handshake.failed: cert chain rejected
2026-05-15T10:22:34Z WARNING  [aabbccddeeff0011...] pki.cert.soft_expiry: cert expires in 12 days

Severity-Cutoff über with_level(...). Default ohne Argument: LogLevel::Warning (Emergency–Warning passieren, Notice–Debug verworfen). Für Dev-Setup mit Trace-Logs:

let sink = StderrLoggingPlugin::with_level(LogLevel::Debug);  // alles loggen

Production-Tipp: Stderr ist OK für stateless Container mit Sidecar-Collector (Fluent Bit, Vector, Promtail). Für Audit-Compliance brauchst du Persistenz + Retention — siehe Recipe 2 (NDJSON).

2 · NDJSON-File + logrotate

Compliance-tauglicher Persistenz-Sink. Eine Zeile = ein Event = ein JSON-Objekt. Direkt jq-/Loki-/Splunk-tauglich. Rotation extern via logrotate(8).

Code

use zerodds_security_logging::JsonLinesLoggingPlugin;
use zerodds_security::logging::LogLevel;

let sink = JsonLinesLoggingPlugin::open(
    "/var/log/zerodds/audit.ndjson",
    LogLevel::Notice,   // Notice + Warning + Error + Critical + Alert + Emergency
)?;

▶ Runnable example: rust-audit-loggingplugin

systemd-Unit + Pfad-Permissions

# /etc/systemd/system/zerodds-publisher.service
[Service]
ExecStartPre=/usr/bin/install -d -m 0750 -o zerodds -g audit /var/log/zerodds
User=zerodds
Group=audit
# Audit-File darf nur vom Service geschrieben, vom Audit-Reader gelesen werden.

logrotate-Config

# /etc/logrotate.d/zerodds-audit
/var/log/zerodds/audit.ndjson {
    daily
    rotate 90        # 90 Tage Retention (anpassen auf Compliance-Window)
    compress         # gz
    delaycompress    # neueste rotated File bleibt für 1 Tag uncomprimiert
    missingok
    notifempty
    create 0640 zerodds audit
    copytruncate     # ZeroDDS kennt kein SIGHUP-Reopen — copytruncate vermeidet File-Handle-Bruch
}

Warum copytruncate? JsonLinesLoggingPlugin hält ein File-Handle offen. Ein normaler Rotate (mv + create) ließe das alte File-Handle weiter in die neue inode schreiben. copytruncate kopiert die alte Datei und truncated das Original — Handle bleibt valid, Daten landen im richtigen File. Trade-off: kurzes Race-Fenster, wenige Bytes können doppelt landen. Für Audit-Compliance akzeptabel.

3 · Fan-Out — Stderr (Operator) + NDJSON (Auditor)

Operator will journalctl -f live mitlesen, Auditor will eine append-only JSONL-Datei. Beide sehen die gleichen Events — wichtig für Forensik (Operator-Log und Audit-Log dürfen nicht divergieren).

use zerodds_security_logging::{
use zerodds_security_runtime::SecurityBundle;
    StderrLoggingPlugin,
    JsonLinesLoggingPlugin,
    FanOutLoggingPlugin,
};
use zerodds_security::logging::LogLevel;

let stderr_sink = StderrLoggingPlugin::with_level(LogLevel::Warning);
let audit_sink  = JsonLinesLoggingPlugin::open(
    "/var/log/zerodds/audit.ndjson",
    LogLevel::Notice,
)?;

let fanout = FanOutLoggingPlugin::new()
    .with(stderr_sink)    // Operator-Tier (Warning-Cutoff)
    .with(audit_sink);    // Audit-Tier (Notice-Cutoff)

let security_bundle = SecurityBundle::builder()
    .logging_plugin(Box::new(fanout))
    .build();

Jeder Sink hat seinen eigenen Severity-Cutoff. Im Beispiel sehen Operator und Auditor unterschiedliche Subset-Tiefen — Operator sieht Warnings aufwärts (knapper), Auditor sieht Notice aufwärts (vollständiger). Wichtig: kein Sink darf Emergency verlieren — beide Cutoffs müssen darunter liegen.

Konsistenz-Garantie: Fan-Out broadcastet synchron in Reihenfolge — wenn Sink 1 blockiert, blockiert Sink 2 auch. Das ist by-design (keine Re-Ordering-Risiken), aber: jeder Sink darf nicht länger blockieren, als der Tick warten kann. Für 100-1000 Events/s ist das mit lokaler Datei + stderr unkritisch.

4 · Syslog → SIEM (Splunk, Graylog, rsyslog)

Enterprise-SIEM-Integration. Syslog-UDP an einen lokalen rsyslog/syslog-ng-Daemon, der dann zum SIEM forwarded.

Code

use zerodds_security_logging::SyslogLoggingPlugin;
use zerodds_security::logging::LogLevel;

let sink = SyslogLoggingPlugin::connect(
    "127.0.0.1:514".parse()?,  // Lokaler rsyslog (SocketAddr)
    "zerodds",                 // App-Name im Syslog-Stream
    "edge-01",                 // Hostname im Syslog-Stream
    LogLevel::Notice,
)?;

▶ Runnable example: rust-audit-syslog

rsyslog-Config (Forwarder)

# /etc/rsyslog.d/40-zerodds.conf
# ZeroDDS taggt mit "zerodds" und LOCAL0-Facility — separate Datei + Forward.
local0.*  /var/log/zerodds-syslog.log
local0.*  @@siem.internal:6514;RSYSLOG_SyslogProtocol23Format
# @@ = TCP, einfaches @ = UDP. Für SIEM-Forward TCP + TLS empfohlen.

# Audit-trail-Flag: niemals droppen
$ActionResumeRetryCount -1

Splunk-Forwarder (Alternative)

Splunk-Universal-Forwarder liest die NDJSON-Datei aus Recipe 2 direkter und sauberer als Syslog. Empfehlung wenn Splunk vorhanden: NDJSON + Forwarder statt Syslog-UDP (höhere Granularität, JSON-Felder bleiben strukturiert).

UDP-Caveat: Syslog-UDP ist lossy — Datagramme >1024 Byte können fragmentiert / verworfen werden. Für Audit-Compliance ist das ein Risiko. Bevorzugt: lokaler rsyslog + TLS-Forward zum SIEM (kein UDP über Netz), oder direkt Recipe 2 (NDJSON) + Filebeat/Forwarder.

5 · OTLP-Collector (Jaeger, Tempo, Honeycomb)

Distributed-Tracing-Layer. Separater Pfad vom Security-Audit — OTLP ist operative Telemetrie. Liefert Spans (Discovery-Phasen, Reader-Match-Latency), Histogramme (Sample-Size, Publish-Throughput) und Events (Component-Lifecycle).

Code

use std::time::Duration;
use zerodds_observability_otlp::{OtlpConfig, OtlpExporter};

let cfg = OtlpConfig {
    host: "otel-collector.internal".into(),
    port: 4318,
    service_name: "zerodds-publisher".into(),
    service_version: env!("CARGO_PKG_VERSION").into(),
    timeout: Duration::from_secs(5),
};

let exporter = std::sync::Arc::new(OtlpExporter::new(cfg));

// Im Tick / pro Sample:
exporter.add_span(Span { /* ... */ });
exporter.add_histogram(Histogram { /* ... */ });

// Background-Flusher (z.B. tokio Interval):
let flush_handle = exporter.clone();
tokio::spawn(async move {
    let mut tick = tokio::time::interval(Duration::from_secs(10));
    loop {
        tick.tick().await;
        if let Err(e) = flush_handle.flush() {
            eprintln!("otlp flush failed: {e:?}");
        }
    }
});

▶ Runnable example: rust-audit-otlp

Jaeger-compose-Setup

# docker-compose.yml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.94.0
    command: ["--config=/etc/otel-collector.yaml"]
    ports:
      - "4318:4318"   # OTLP/HTTP
    volumes:
      - ./otel-collector.yaml:/etc/otel-collector.yaml:ro

  jaeger:
    image: jaegertracing/all-in-one:1.55
    ports:
      - "16686:16686"   # Jaeger-UI
# otel-collector.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

exporters:
  jaeger:
    endpoint: jaeger:14250
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [jaeger]

Audit ≠ Telemetrie. Auditor liest NDJSON-File aus Recipe 2 — nicht Jaeger. OTLP ist für Operator-Dashboard, SLO-Monitoring und Performance-Investigation. Beide Schichten parallel laufen lassen.

6 · Sensitive-Data-Scrub

Audit-Logs landen in Standard-Storage (NDJSON-File, Syslog, SIEM) — sie dürfen keine Geheimnisse enthalten. LoggingPlugin::log bekommt (level, participant, category, message). Was wo passen darf:

FeldWas rein darfWas niemals rein darf
participantParticipant-GUID (16 Byte Identity-Reference)Identity-Token, Cert-Inhalt, Private-Key-Bytes
categoryBekannte Dot-Notation-KategorieFree-form User-Input (Injection-Risiko)
messageCert-Subject (CN, OU), Cert-Fingerprint, Topic-Name, Endpoint-HandleSymmetric-Keys, Shared-Secrets, Nonces, Sample-Payloads, PII (User-IDs, IBANs)

Scrub-Wrapper-Pattern

Wenn ein dritt-Plugin Custom-Events emittiert (z.B. ein eigener Auth-Plugin der Tokens loggt), Wrap es:

use zerodds_security::logging::{LogLevel, LoggingPlugin};

pub struct ScrubbingLogger<P: LoggingPlugin> {
    inner: P,
}

impl<P: LoggingPlugin> LoggingPlugin for ScrubbingLogger<P> {
    fn log(&self, level: LogLevel, participant: [u8; 16], category: &str, message: &str) {
        // Replace bekannte Geheimnis-Patterns.
        let scrubbed = scrub_secrets(message);
        self.inner.log(level, participant, category, &scrubbed);
    }

    fn plugin_class_id(&self) -> &str {
        self.inner.plugin_class_id()
    }
}

fn scrub_secrets(msg: &str) -> String {
    use std::sync::OnceLock;
    use regex::Regex;
    static RE_HEX: OnceLock<Regex> = OnceLock::new();
    let re_hex = RE_HEX.get_or_init(|| {
        // 64+ hex chars = wahrscheinlich Key/Nonce/Hash
        Regex::new(r"\b[0-9a-fA-F]{64,}\b").unwrap()
    });
    re_hex.replace_all(msg, "[REDACTED-HEX]").into_owned()
}

▶ Runnable example: rust-audit-scrubbing

Compliance-Checkliste

  • Kein Plaintext-Sample-Payload — wenn ein Crypto-Failure-Event den Payload loggt, ist das eine PII/Geheim-Leakage.
  • Keine Shared-Secrets — Authentication-Handshake-Failures dürfen niemals den Shared-Secret im Message-Body haben (cert-fingerprints ok, secret-bytes nie).
  • Cert-Subjects ja, Cert-Bodies nein — CN, OU, expiry-date sind audit-relevant; ganze PEM-Blocks im Log sind redundant + leakage-risk.
  • Periodischer Audit-Log-Review — automatisierter Scan mit jq + Regex auf bekannten Secret-Patterns (Hex-Strings >64 Char, Base64-Blocks >100 Char).

Trust-Boundary: Audit-Log landet im SIEM/Auditor-Storage. Wer Zugriff darauf hat, sieht alles. Behandle den Audit-Log als quasi-public innerhalb der Organisation — die Operator-Klasse hat Zugriff, also gehört kein Material rein, das nicht für Operator OK ist.

Topic · Cookbook

Audit Logging Cookbook

Six production-ready recipes for security audit logging and telemetry. From the dev setup (stderr) through compliance audit (NDJSON + logrotate) to SIEM forwarding (syslog) and distributed tracing (OTLP collector). Trait surface, LogLevel scale and sink architecture in the Audit Logging Reference.

6 recipes · stderr/NDJSON/syslog/OTLP/fan-out/scrub Targets: SIEM · Loki · Splunk · Jaeger · Tempo · Honeycomb Crates: zerodds-security-logging, zerodds-observability-otlp

1 · Stderr (dev default)

The fastest start — no file handling, no network. Goes straight to stderr and is collected by journalctl/docker logs/kubectl logs.

use zerodds_security::logging::LogLevel;
use zerodds_security_runtime::SecurityBundle;
use zerodds_security_logging::StderrLoggingPlugin;

let sink = StderrLoggingPlugin::with_level(LogLevel::Warning);

let security_bundle = SecurityBundle::builder()
    .logging_plugin(Box::new(sink))
    .build();

▶ Runnable example: rust-audit-securitybundle-realpart

Output format:

2026-05-15T10:22:33Z CRITICAL [0102030405060708090a0b0c0d0e0f10] auth.handshake.failed: cert chain rejected
2026-05-15T10:22:34Z WARNING  [aabbccddeeff0011...] pki.cert.soft_expiry: cert expires in 12 days

Severity cutoff via with_level(...). Default without an argument: LogLevel::Warning (Emergency–Warning pass, Notice–Debug dropped). For a dev setup with trace logs:

let sink = StderrLoggingPlugin::with_level(LogLevel::Debug);  // log everything

Production tip: stderr is fine for stateless containers with a sidecar collector (Fluent Bit, Vector, Promtail). For audit compliance you need persistence + retention — see recipe 2 (NDJSON).

2 · NDJSON file + logrotate

A compliance-grade persistence sink. One line = one event = one JSON object. Directly jq/Loki/Splunk-ready. Rotation external via logrotate(8).

Code

use zerodds_security_logging::JsonLinesLoggingPlugin;
use zerodds_security::logging::LogLevel;

let sink = JsonLinesLoggingPlugin::open(
    "/var/log/zerodds/audit.ndjson",
    LogLevel::Notice,   // Notice + Warning + Error + Critical + Alert + Emergency
)?;

▶ Runnable example: rust-audit-loggingplugin

systemd unit + path permissions

# /etc/systemd/system/zerodds-publisher.service
[Service]
ExecStartPre=/usr/bin/install -d -m 0750 -o zerodds -g audit /var/log/zerodds
User=zerodds
Group=audit
# the audit file may only be written by the service, read by the audit reader.

logrotate config

# /etc/logrotate.d/zerodds-audit
/var/log/zerodds/audit.ndjson {
    daily
    rotate 90        # 90 days retention (adjust to the compliance window)
    compress         # gz
    delaycompress    # the newest rotated file stays uncompressed for 1 day
    missingok
    notifempty
    create 0640 zerodds audit
    copytruncate     # ZeroDDS has no SIGHUP reopen — copytruncate avoids a file-handle break
}

Why copytruncate? JsonLinesLoggingPlugin keeps a File handle open. A normal rotate (mv + create) would let the old file handle keep writing into the new inode. copytruncate copies the old file and truncates the original — the handle stays valid, data lands in the right file. Trade-off: a short race window, a few bytes may land twice. Acceptable for audit compliance.

3 · Fan-out — stderr (operator) + NDJSON (auditor)

The operator wants to read journalctl -f live, the auditor wants an append-only JSONL file. Both see the same events — important for forensics (the operator log and the audit log must not diverge).

use zerodds_security_logging::{
use zerodds_security_runtime::SecurityBundle;
    StderrLoggingPlugin,
    JsonLinesLoggingPlugin,
    FanOutLoggingPlugin,
};
use zerodds_security::logging::LogLevel;

let stderr_sink = StderrLoggingPlugin::with_level(LogLevel::Warning);
let audit_sink  = JsonLinesLoggingPlugin::open(
    "/var/log/zerodds/audit.ndjson",
    LogLevel::Notice,
)?;

let fanout = FanOutLoggingPlugin::new()
    .with(stderr_sink)    // operator tier (Warning cutoff)
    .with(audit_sink);    // audit tier (Notice cutoff)

let security_bundle = SecurityBundle::builder()
    .logging_plugin(Box::new(fanout))
    .build();

Each sink has its own severity cutoff. In the example, operator and auditor see different subset depths — the operator sees Warnings upward (tighter), the auditor sees Notice upward (more complete). Important: no sink may lose Emergency — both cutoffs must be below it.

Consistency guarantee: fan-out broadcasts synchronously in order — if sink 1 blocks, sink 2 blocks too. That's by design (no re-ordering risks), but: no sink may block longer than the tick can wait. For 100-1000 events/s this is uncritical with a local file + stderr.

4 · Syslog → SIEM (Splunk, Graylog, rsyslog)

Enterprise SIEM integration. Syslog UDP to a local rsyslog/syslog-ng daemon that then forwards to the SIEM.

Code

use zerodds_security_logging::SyslogLoggingPlugin;
use zerodds_security::logging::LogLevel;

let sink = SyslogLoggingPlugin::connect(
    "127.0.0.1:514".parse()?,  // local rsyslog (SocketAddr)
    "zerodds",                 // app name in the syslog stream
    "edge-01",                 // hostname in the syslog stream
    LogLevel::Notice,
)?;

▶ Runnable example: rust-audit-syslog

rsyslog config (forwarder)

# /etc/rsyslog.d/40-zerodds.conf
# ZeroDDS tags with "zerodds" and the LOCAL0 facility — separate file + forward.
local0.*  /var/log/zerodds-syslog.log
local0.*  @@siem.internal:6514;RSYSLOG_SyslogProtocol23Format
# @@ = TCP, a single @ = UDP. For a SIEM forward, TCP + TLS is recommended.

# audit-trail flag: never drop
$ActionResumeRetryCount -1

Splunk forwarder (alternative)

The Splunk universal forwarder reads the NDJSON file from recipe 2 more directly and cleanly than syslog. Recommendation when Splunk is present: NDJSON + forwarder instead of syslog UDP (higher granularity, JSON fields stay structured).

UDP caveat: syslog UDP is lossy — datagrams >1024 bytes can be fragmented / dropped. For audit compliance that's a risk. Prefer: a local rsyslog + TLS forward to the SIEM (no UDP over the network), or recipe 2 (NDJSON) + Filebeat/forwarder directly.

5 · OTLP collector (Jaeger, Tempo, Honeycomb)

The distributed-tracing layer. A separate path from the security audit — OTLP is operational telemetry. Provides spans (discovery phases, reader-match latency), histograms (sample size, publish throughput) and events (component lifecycle).

Code

use std::time::Duration;
use zerodds_observability_otlp::{OtlpConfig, OtlpExporter};

let cfg = OtlpConfig {
    host: "otel-collector.internal".into(),
    port: 4318,
    service_name: "zerodds-publisher".into(),
    service_version: env!("CARGO_PKG_VERSION").into(),
    timeout: Duration::from_secs(5),
};

let exporter = std::sync::Arc::new(OtlpExporter::new(cfg));

// in the tick / per sample:
exporter.add_span(Span { /* ... */ });
exporter.add_histogram(Histogram { /* ... */ });

// background flusher (e.g. a tokio interval):
let flush_handle = exporter.clone();
tokio::spawn(async move {
    let mut tick = tokio::time::interval(Duration::from_secs(10));
    loop {
        tick.tick().await;
        if let Err(e) = flush_handle.flush() {
            eprintln!("otlp flush failed: {e:?}");
        }
    }
});

▶ Runnable example: rust-audit-otlp

Jaeger compose setup

# docker-compose.yml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.94.0
    command: ["--config=/etc/otel-collector.yaml"]
    ports:
      - "4318:4318"   # OTLP/HTTP
    volumes:
      - ./otel-collector.yaml:/etc/otel-collector.yaml:ro

  jaeger:
    image: jaegertracing/all-in-one:1.55
    ports:
      - "16686:16686"   # Jaeger UI
# otel-collector.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

exporters:
  jaeger:
    endpoint: jaeger:14250
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [jaeger]

Audit ≠ telemetry. The auditor reads the NDJSON file from recipe 2 — not Jaeger. OTLP is for the operator dashboard, SLO monitoring and performance investigation. Run both layers in parallel.

6 · Sensitive-data scrub

Audit logs land in standard storage (NDJSON file, syslog, SIEM) — they must contain no secrets. LoggingPlugin::log receives (level, participant, category, message). What may go where:

FieldWhat may go inWhat must never go in
participantparticipant GUID (16-byte identity reference)identity token, cert content, private-key bytes
categorya known dot-notation categoryfree-form user input (injection risk)
messagecert subject (CN, OU), cert fingerprint, topic name, endpoint handlesymmetric keys, shared secrets, nonces, sample payloads, PII (user IDs, IBANs)

Scrub-wrapper pattern

If a third-party plugin emits custom events (e.g. your own auth plugin logging tokens), wrap it:

use zerodds_security::logging::{LogLevel, LoggingPlugin};

pub struct ScrubbingLogger<P: LoggingPlugin> {
    inner: P,
}

impl<P: LoggingPlugin> LoggingPlugin for ScrubbingLogger<P> {
    fn log(&self, level: LogLevel, participant: [u8; 16], category: &str, message: &str) {
        // replace known secret patterns.
        let scrubbed = scrub_secrets(message);
        self.inner.log(level, participant, category, &scrubbed);
    }

    fn plugin_class_id(&self) -> &str {
        self.inner.plugin_class_id()
    }
}

fn scrub_secrets(msg: &str) -> String {
    use std::sync::OnceLock;
    use regex::Regex;
    static RE_HEX: OnceLock<Regex> = OnceLock::new();
    let re_hex = RE_HEX.get_or_init(|| {
        // 64+ hex chars = probably a key/nonce/hash
        Regex::new(r"\b[0-9a-fA-F]{64,}\b").unwrap()
    });
    re_hex.replace_all(msg, "[REDACTED-HEX]").into_owned()
}

▶ Runnable example: rust-audit-scrubbing

Compliance checklist

  • No plaintext sample payload — if a crypto-failure event logs the payload, that's a PII/secret leak.
  • No shared secrets — authentication handshake failures must never carry the shared secret in the message body (cert fingerprints OK, secret bytes never).
  • Cert subjects yes, cert bodies no — CN, OU, expiry date are audit-relevant; whole PEM blocks in the log are redundant + a leak risk.
  • Periodic audit-log review — an automated scan with jq + regex for known secret patterns (hex strings >64 chars, base64 blocks >100 chars).

Trust boundary: the audit log lands in SIEM/auditor storage. Whoever has access to it sees everything. Treat the audit log as quasi-public within the organisation — the operator class has access, so nothing belongs in it that isn't OK for an operator to see.