Skip to content

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:

  1. 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.
  2. 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.

  • otlpmetric reads only DimMetric fields.
  • otlplog emits one record per content kind, with AttrRecord fields as log attributes and Content as the body.
  • otlptrace builds 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.