Architecture¶
/opt/agent-sessions/<namespace>/**/*.jsonl
│
▼
internal/source discovery, tailing, per-file byte-offset checkpointing
│
├── internal/decode/claude ──┐
└── internal/decode/codex ──┤ raw records -> decoder events
▼
internal/reduce events -> unified model
│
internal/model Session / Turn / Item (THE SEAM)
│
internal/redact bounding, structural handling
│
┌──────────────────────────────┼──────────────────────────────┐
▼ ▼ ▼
sink/otlpmetric sink/otlplog sink/otlptrace
└──────────────────────────────┼──────────────────────────────┘
▼
localhost:4318 (camden Alloy) -> Grafana Cloud
internal/attr sits beside all three sinks and owns every name and every field's destination. internal/selfobs, internal/profile (drift) and internal/health observe the pipeline itself.
internal/source¶
Walks the namespace roots, tracks each file by identity and size, and reads forward from a checkpointed byte offset.
- Files grow. A client checkpoints a transcript at most once every five minutes and on session end, so a file is appended to for the life of a session. Re-read from the stored offset, never from the start.
- A file's absence proves nothing. Hot retention prunes at 90 days after the NFS copy succeeds.
- Never write into the tree. No touch, no rename, no lock file. The checkpoint lives in
/var/lib/codingagent2otel/. - Identify by content prefix, not by name. A name can be reused; codexlb2otel learned this the hard way when an archive path was recreated as an unrelated capture.
- A partial trailing line is not an error. Stop at the last complete line and resume there.
internal/decode¶
Two decoders, no shared code beyond the event type. Each turns raw records into ordered decoder events and reports anything it does not recognise as unhandled_record_types rather than dropping it silently.
Claude: one record per message, parentUuid chain, no native timing. Codex: nested type + payload.type, native timing, duplicated item representations that must be deduplicated on item id.
See transcript formats.
internal/model - the frozen seam¶
Both decoders produce this and all three sinks consume it. Freeze it before any parallel work starts; changing it later invalidates every lane at once.
Three entities: Session, Turn, Item.
Two rules that are the whole point of the model:
- Provenance travels with the value. Every duration and every timing carries a source (
native,derived,absent). Every optional measurement is a pointer, so a measured zero and an absent measurement stay distinguishable. - Two sources describing one idea stay two fields. Never collapse them and never let one overwrite the other.
internal/attr¶
Every metric name, every attribute key and every field's destination. Adding an instrument means adding a constant here first. The dashboard generator validates against this package, so a metric that is not declared here cannot be graphed.
Sinks¶
Three independent sinks over one shared OTLP connection. A sink failure is counted and does not stall the pipeline or the other sinks.
otlpmetricreads onlyDimMetricfields.otlplogemits one record per content kind, withAttrRecordfields as log attributes andContentas the body.otlptracebuilds the span tree, using Codex's native trace id where present.
Drift¶
internal/profile induces a schema signature from a scan and diffs it against the committed baseline. The baseline is content-free by construction and a test proves it. Findings are reported by severity; breaking drift fails ca2probe, and the running daemon reports findings as a gauge rather than exiting.
Codex record shapes vary by CLI version and a whole record type can disappear between versions without any error. An absent record type is a finding.
codex-lb enrichment (optional, off by default)¶
A Codex token_usage_record.response_id matches the resp_... id in codex-lb's conversation archive, verified live against same-hour data. When enabled, the enricher adds engine, proxy, cost and account context to a Codex turn.
It is optional on purpose. codex-lb is being retired, model_provider already varies between codex-lb and openai within a namespace, and the archive retains only recent hours while the transcripts go back 90 days hot and forever cold. Every signal must be useful with this disabled.