Adds the canonical JSONL sink surface consuming TurnEvent records that ADR-0039 made uniform across main and stub paths. One deterministic line per turn; redact-by-default trust boundary; opt-in content emission; runtime auto-emits on attached sink. Trust boundary (CLAUDE.md): - Metadata-only by default — no surfaces or input tokens emitted. include_content=True opt-in at attachment time. - Path fixed at construction for JsonlFileSink; no user-controlled paths interpreted at emit time. - Sink errors propagate — telemetry failures should surface, not silently drop audit signal. Determinism: - sort_keys=True; compact separators. Same event → byte-identical line. - No implicit wall-clock; timestamps caller-provided. - Field set fixed; missing TurnEvent attrs fall back to safe defaults. API: - serialize_turn_event(event, **kwargs) -> dict (pure) - format_turn_event_jsonl(event, **kwargs) -> str (pure, deterministic) - TurnEventSink Protocol; JsonlBufferSink; JsonlFileSink - ChatRuntime.attach_telemetry_sink(sink, *, include_content=False) - _emit_turn_event invoked after both turn_log.append sites Wire format (alphabetised, always present): cycle_cost_total, dialogue_role, ethics_pack_id, ethics_runtime_checkable_count, ethics_upheld, ethics_violated, flagged, hedge_injected, identity_pack_id, refusal_emitted, safety_pack_id, safety_runtime_checkable_count, safety_upheld, safety_violated, stub_path, turn, vault_hits, versor_condition. Conditional: identity_* (when score present), surface / walk_surface / articulation_surface / input_tokens (when include_content=True), timestamp (when provided). Files: - chat/telemetry.py (new) — serializer, formatter, sinks - chat/runtime.py — attach + emit + post-append calls - tests/test_telemetry_sink.py (new) — 29 tests - docs/decisions/ADR-0040-telemetry-sink.md (new) Verification: - Combined pack-layer + telemetry suite: 199 green (was 170 after ADR-0039; +29) - CLI suites unchanged: smoke 67, runtime 19, cognition 121 - core eval cognition: intent 100%, versor_closure 100% (baseline)
192 lines
6.6 KiB
Python
192 lines
6.6 KiB
Python
"""ADR-0040 — structured-logging sink for turn-event audit.
|
|
|
|
Consumes ``TurnEvent`` records that ADR-0039 makes uniform across
|
|
main and stub paths. Emits one JSON-line per turn with deterministic
|
|
field ordering, suitable for log aggregation, replay, and offline
|
|
audit pipelines.
|
|
|
|
Trust boundary (per CLAUDE.md):
|
|
|
|
* **Metadata-only by default.** Surface text and input tokens are
|
|
redacted unless the caller explicitly opts in via
|
|
``include_content=True``. Audit needs counts, ids, and flags —
|
|
not raw content — and the redact-by-default stance prevents
|
|
accidental PII leakage when sinks point at shared log stores.
|
|
* **No implicit wall-clock.** Timestamps are caller-provided so
|
|
emission is reproducible under replay. The runtime never reaches
|
|
for ``datetime.now()`` here.
|
|
* **Append-only file paths.** ``JsonlFileSink`` opens the target in
|
|
append mode and never truncates. Path is fixed at construction;
|
|
the sink does not interpret user-controlled paths at emit time.
|
|
* **Idempotent flush.** Each ``emit()`` flushes immediately so a
|
|
crashed turn loop still has its prior turns durable on disk.
|
|
|
|
See ``docs/decisions/ADR-0040-telemetry-sink.md``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import IO, Protocol
|
|
|
|
|
|
_UNKNOWN_DOMAIN_SURFACE = "I don't know — insufficient grounding for that yet."
|
|
|
|
|
|
# ---------- pure serializer ----------
|
|
|
|
|
|
def serialize_turn_event(
|
|
event,
|
|
*,
|
|
safety_pack_id: str = "",
|
|
ethics_pack_id: str = "",
|
|
identity_pack_id: str = "",
|
|
include_content: bool = False,
|
|
timestamp: str | None = None,
|
|
) -> dict[str, object]:
|
|
"""Produce a JSON-safe audit dict from a ``TurnEvent``.
|
|
|
|
Pack ids are passed as kwargs because ``TurnEvent`` does not
|
|
carry them — the runtime knows them. Fields are typed
|
|
deliberately at the boundary so an upstream change to
|
|
``TurnEvent`` doesn't silently break the wire format; missing or
|
|
differently-typed values fall back to safe defaults.
|
|
"""
|
|
verdicts = getattr(event, "verdicts", None)
|
|
out: dict[str, object] = {
|
|
"turn": int(getattr(event, "turn", 0)),
|
|
"safety_pack_id": str(safety_pack_id),
|
|
"ethics_pack_id": str(ethics_pack_id),
|
|
"identity_pack_id": str(identity_pack_id),
|
|
"refusal_emitted": bool(getattr(verdicts, "refusal_emitted", False)),
|
|
"hedge_injected": bool(getattr(verdicts, "hedge_injected", False)),
|
|
"versor_condition": float(getattr(event, "versor_condition", 0.0)),
|
|
"vault_hits": int(getattr(event, "vault_hits", 0)),
|
|
"cycle_cost_total": float(getattr(event, "cycle_cost_total", 0.0)),
|
|
"flagged": bool(getattr(event, "flagged", False)),
|
|
"stub_path": getattr(event, "walk_surface", "") == _UNKNOWN_DOMAIN_SURFACE,
|
|
"dialogue_role": str(getattr(event, "dialogue_role", "")),
|
|
}
|
|
safety = getattr(event, "safety_verdict", None)
|
|
if safety is not None:
|
|
out["safety_violated"] = sorted(
|
|
getattr(safety, "violated_boundaries", ()) or ()
|
|
)
|
|
out["safety_runtime_checkable_count"] = int(
|
|
getattr(safety, "runtime_checkable_count", 0)
|
|
)
|
|
out["safety_upheld"] = bool(getattr(safety, "upheld", True))
|
|
ethics = getattr(event, "ethics_verdict", None)
|
|
if ethics is not None:
|
|
out["ethics_violated"] = sorted(
|
|
getattr(ethics, "violated_commitments", ()) or ()
|
|
)
|
|
out["ethics_runtime_checkable_count"] = int(
|
|
getattr(ethics, "runtime_checkable_count", 0)
|
|
)
|
|
out["ethics_upheld"] = bool(getattr(ethics, "upheld", True))
|
|
identity_score = getattr(event, "identity_score", None)
|
|
if identity_score is not None:
|
|
out["identity_alignment"] = float(
|
|
getattr(identity_score, "alignment", 0.0)
|
|
)
|
|
out["identity_flagged"] = bool(getattr(identity_score, "flagged", False))
|
|
out["identity_deviation_axes"] = sorted(
|
|
getattr(identity_score, "deviation_axes", ()) or ()
|
|
)
|
|
if include_content:
|
|
out["input_tokens"] = list(getattr(event, "input_tokens", ()))
|
|
out["surface"] = str(getattr(event, "surface", ""))
|
|
out["walk_surface"] = str(getattr(event, "walk_surface", ""))
|
|
out["articulation_surface"] = str(
|
|
getattr(event, "articulation_surface", "")
|
|
)
|
|
if timestamp is not None:
|
|
out["timestamp"] = str(timestamp)
|
|
return out
|
|
|
|
|
|
def format_turn_event_jsonl(event, **kwargs) -> str:
|
|
"""Serialize a turn event as one deterministic JSONL line.
|
|
|
|
Field order is alphabetical (``sort_keys=True``) so two emissions
|
|
of the same logical event produce byte-identical lines. No
|
|
trailing newline — the sink owns line termination.
|
|
"""
|
|
payload = serialize_turn_event(event, **kwargs)
|
|
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
# ---------- sink protocol ----------
|
|
|
|
|
|
class TurnEventSink(Protocol):
|
|
"""Minimal sink contract.
|
|
|
|
Sinks receive one already-serialized JSONL line per turn. The
|
|
runtime calls ``emit()`` after each ``turn_log.append()`` — see
|
|
``ChatRuntime._emit_turn_event``.
|
|
"""
|
|
|
|
def emit(self, line: str) -> None: ...
|
|
|
|
|
|
# ---------- concrete sinks ----------
|
|
|
|
|
|
@dataclass
|
|
class JsonlBufferSink:
|
|
"""In-memory sink that captures every emitted line.
|
|
|
|
Useful for tests, replay diffing, and small-volume audit where
|
|
persistence is the caller's responsibility.
|
|
"""
|
|
|
|
lines: list[str] = field(default_factory=list)
|
|
|
|
def emit(self, line: str) -> None:
|
|
self.lines.append(line)
|
|
|
|
|
|
class JsonlFileSink:
|
|
"""Append-only JSONL file sink with eager flush.
|
|
|
|
The path is fixed at construction. Each ``emit()`` flushes
|
|
immediately so a crashed runtime still has its prior turns
|
|
durable on disk. Supports context-manager usage.
|
|
"""
|
|
|
|
def __init__(self, path: str | Path) -> None:
|
|
self._path = Path(path)
|
|
self._fh: IO[str] | None = None
|
|
|
|
def emit(self, line: str) -> None:
|
|
if self._fh is None:
|
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._fh = self._path.open("a", encoding="utf-8")
|
|
self._fh.write(line)
|
|
self._fh.write("\n")
|
|
self._fh.flush()
|
|
|
|
def close(self) -> None:
|
|
if self._fh is not None:
|
|
self._fh.close()
|
|
self._fh = None
|
|
|
|
def __enter__(self) -> "JsonlFileSink":
|
|
return self
|
|
|
|
def __exit__(self, *exc_info) -> None:
|
|
self.close()
|
|
|
|
|
|
__all__ = [
|
|
"JsonlBufferSink",
|
|
"JsonlFileSink",
|
|
"TurnEventSink",
|
|
"format_turn_event_jsonl",
|
|
"serialize_turn_event",
|
|
]
|