feat(audit): add OpenTelemetry AuditSink (closes #245)
Your AI's guard dog to stop it from going rogue.
Brought to you by:
doberman
Originally created by: Maqbool61
Implements the OpenTelemetry AuditSink described in issue [#245]. Ships redacted decision records to any OTLP/HTTP-compatible collector (Grafana Alloy, OpenTelemetry Collector, Honeycomb, Datadog Agent, etc.) using the same isolation discipline as the webhook sink (#233).
src/doberman/storage/otel_sink.py — OtelAuditSink implementationtests/unit/test_otel_sink.py — 27 unit tests covering all required invariantsdocs/audit_otel.md — collector setup docs with a working docker exampleemit() is enqueue-only on the decision path — returns in O(1), no network I/O, no blocking, no exception propagation/v1/logs, JSON)drop_count — never grows unbounded.doberman/audit_otel.yaml (endpoint, optional auth_env, timeout_s, queue_max). Absent or malformed config → sink is completely inert, zero network I/Otimestamp, verdict, tool, reason_codes, explanation, session_id) — filtered both in emit() and again in the payload builder as defence-in-depth. Adds no fields of its ownurllib.request, json, threading, queue) — no opentelemetry-sdk dependency requiredemit() never blocks or raises into the decision path (worker paused, timed)is_active == False, zero threads started, urlopen never calledAKIAIOSFODNN7SYNTHETIC) never appears in any exported payloadresourceLogs / scopeLogs / logRecords shapeCloses [#245]
Originally posted by: Maqbool61
Fixing those failing tests you will see more commits from my side.
Originally posted by: Maqbool61
All three points addressed. close() now uses _state_lock/_active/_stop_event mirroring WebhookAuditSink exactly, with six lifecycle tests. OtelAuditSink is wired into emit_to_sinks() via the same module-level cache pattern as the webhook sink, with one test proving a record reaches it through the real path. Endpoint validation now uses _is_loopback() and rejects non-http(s) schemes, with nine tests covering all cases.
Originally posted by: Maqbool61
All points from the review addressed.
Allowlist / fixtures
_ALLOWED_FIELDSis now derived frombuild_record()instorage/log.py:ts,action_type,risk,final_verdict,decided_layer,reason_codes,auth_result,session_id.The four stale names (
timestamp,verdict,tool,explanation) are gone.The
_record()test helper is replaced by_build_record()which mirrors the exact shapebuild_record()emits, including all non-exportable fields (action_id,agent_role,target_path_class,auth_required,elevation_id,entity_id). The suite now validatesthe sink against the real producer, not against itself.
Two guard tests make this durable:
test_allowed_fields_are_subset_of_build_record_keys— every name in_ALLOWED_FIELDSmustexist in a real
_build_record()output. A future rename inlog.pybreaks this test insteadof silently emptying the export.
test_old_field_names_are_gone— asserts the stale names are not in_ALLOWED_FIELDS.On the judgment call: included
decided_layerandauth_result— both are inbuild_record(),both are redaction-safe.
Docs / loopback
docs/audit_otel.mdexample updated fromlocalhost:4318tootel-collector.internal:4318.Added one paragraph explaining why loopback is rejected: an accidental loopback config silently
discards every export; a non-loopback host makes the misconfiguration visible.
Originally posted by: fu351
Merged — this round landed it. Deriving
_ALLOWED_FIELDSfrom whatbuild_record()actually emits, mirroring the webhook sink's_state_lock/_activelifecycle instead of inventing a second pattern, and proving the wiring through the realemit_to_sinks()path were exactly the right three moves. The stale-names canary test (timestamp/verdict/tool/explanationmust never reappear) is a nice touch — that is the bug class this review was about, now permanently fenced.Full disclosure: I pushed one commit (a499ebf) to your branch before merging, all remnants of the field rename. The OTLP payload builder still read
safe.get("timestamp"), which no longer exists, sotimeUnixNanosilently used wall-clock time instead of the record'sts. The module docstring and the docs table still listed the old field names. A fewWebhookAuditSinkdocstrings had been dropped along the way, which I restored. I also pointed the allowlist subset test at the realstorage.log.build_record()— not just this file's mirror of it — so a future rename in log.py fails the suite instead of silently emptying the export. That is the same failure mode you just fixed, now closed for good.If you want the next one: [#192] (level-5) — define the shared JSON-output contract across the CLI's JSON commands, in docs + tests. After what you just went through with field names, you are exactly the right person to pin that contract down. Yours if you would like it.
Related
Tickets:
#192Ticket changed by: fu351