Design: OTLP Trace Ingestion
Context
ADR-0015 decided that Cairn accepts OTLP/HTTP trace exports and translates them into
the native SPEC-0004 run/span tree at ingest. The forcing evidence was the July 2026
stress test: a harness sharing its run reached for OpenTelemetry unprompted, then
garbled the hand-translation into Cairn's shape (placeholder durations, children hours
outside their parents). The ecosystem's instrumentation — agent frameworks, gen_ai.*
semantic conventions — already speaks OTel; ADR-0015 moves the risky translation step
from every agent into one tested adapter. ADR-0009's refusal to store OTLP verbatim
stands; this spec is the border crossing, not a second model.
Constraints inherited:
- ADR-0012 / ADR-0003 — one core service; the adapter is a thin translation layer over the same ingest methods REST uses, which is what guarantees validation parity.
- SPEC-0004 — containment, timing validity, bounds, output spill, and the incremental open→append→close lifecycle the trace_id mapping rides on.
- ADR-0004 — bearer auth, which stock OTLP exporters can supply via headers.
- ADR-0007 — provenance; an OTLP-ingested run records how it arrived and the
service.namethat sent it.
Goals / Non-Goals
Goals
- A stock OTLP/HTTP exporter with an endpoint URL and bearer header produces a shareable, valid Cairn run — live (batched appends) or after the fact.
- One category-mapping table, server-side, deterministic, never vocabulary-rejecting.
- Absolute validation parity with native ingest — no laxer side door.
Non-Goals
- OTLP/gRPC (HTTP's two encodings cover the exporters that matter; gRPC is additive later if demanded).
- Metrics and logs signals; span events/links; span status (joins ADR-0009's errored-run "try next").
- Round-tripping a run back out as OTLP; the translation is lossy by design.
- Trace-context propagation, sampling, or any APM/query ambition — unchanged from ADR-0009.
Decisions
SpanKind-lowercased fallback category
Choice: When no explicit cairn.category, no gen_ai.* mapping, and no
protocol-attribute rule matches, the category is the span's SpanKind lowercased
(internal, client, …), landing in SPEC-0004's open set and its hashed-color path.
Rationale: Deterministic, honest (it encodes "OTel span with no richer signal"), and visually coherent — the five kinds get five stable colors instead of one grey lump, and a reader learns them once.
Alternatives considered:
- Fallback to
tool: lies about non-tool spans and pollutes the tool-call count's natural companion category. - Fallback to
meta: buries real work in the overhead color. - Reject unmapped spans: violates ADR-0009's open-set principle and would make the adapter stricter about vocabulary than native ingest, inverting the actual risk.
trace_id keys the incremental lifecycle
Choice: First sight of a trace_id opens a run; later exports append; close is the
native close or the abandoned-run policy. Multi-trace batches fan out to one run per
trace.
Rationale: OTLP exporters batch and stream; the native incremental path already has exactly those semantics, so the mapping buys live OTLP capture without new lifecycle machinery. Fan-out (rather than rejecting multi-trace batches) matches what stock batch processors actually emit.
Alternatives considered:
- One run per export request: shatters a streamed trace into fragments.
- Rejecting multi-trace requests: fights the OTLP batch processor's default behavior.
Started-at is first-seen-earliest; earlier arrivals reject
Choice: started_at = earliest span start in the first batch; a later append
carrying an earlier span rejects.
Rationale: Offsets are persisted run-relative (SPEC-0004); rebasing them on a late root span would rewrite history under annotations. Exporters emit roots late (a root closes last) — but its start is carried in the first batch's child spans in practice; where it is not, the rejection message tells the exporter to send the root first (the CLI import path sorts, so this bites only exotic live streams).
Both OTLP/HTTP encodings, no gRPC
Choice: application/x-protobuf and application/json on one endpoint.
Rationale: Protobuf is what SDK exporters default to; JSON is what agents and scripts hand-produce. gRPC adds a server dependency for no observed client.
Architecture
sequenceDiagram
participant Exp as OTLP/HTTP exporter
participant OTLP as otlp adapter
participant Core as Core ingest (SPEC-0004 rules)
participant DB as PostgreSQL
Exp->>OTLP: POST /v1/otlp/v1/traces (batch 1, trace T)
OTLP->>OTLP: decode, group by trace_id, translate spans
OTLP->>Core: open run for T + append spans
Core->>Core: containment, ≥1ms, bounds, category rules
Core->>DB: INSERT run + spans (tx)
OTLP-->>Exp: OTLP success response
Exp->>OTLP: POST (batch 2, trace T)
OTLP->>Core: append to T's run
Core->>DB: INSERT spans (tx)
Note over Core,DB: close via native close call<br/>or abandoned-run policy
Risks / Trade-offs
- Semantic-convention drift → the
gen_ai.*conventions are young and moving; the mapping table will need curation. Mitigated by rule 1 (cairn.categorywins, a stable escape hatch) and rule 4 (a deterministic fallback that never rejects). - Abandoned-run dependency → live OTLP export has no close call, so OTLP runs lean on the abandoned-run auto-close policy that SPEC-0004's design already lists as open. This spec makes answering it urgent rather than optional.
- Protobuf decode surface → a new parser on an authenticated but hostile-input boundary. Mitigated by body-size limits before buffering, decode-error sentinels, and fuzzing the decode path in CI.
- Lossy translation disappoints OTel purists → events, links, status, resource topology dropped. Deliberate: documented in the ADR, and the alternative (verbatim storage) was re-rejected there.
- trace_id collision across principals → trace_ids are client-chosen; the mapping is scoped per owning principal so one tenant's trace_id can never append into another's run.
Migration Plan
Additive: a new endpoint and adapter package; no schema change (translated spans are ordinary spans). Ship behind the same feature review as any new surface; no rollback concern beyond removing the route.
Open Questions
- What is the abandoned-run auto-close timeout, and is it OTLP-specific or global (inherited SPEC-0004 open question, now load-bearing)?
- Should
gen_aitoken-usage attributes populate the run's token count when present? - Is a
partial_successOTLP response ever appropriate (e.g. multi-trace fan-out where one trace validates and another does not), or does Cairn's atomicity always reject the whole request?