Skip to content

ADR-0026: Optional OTLP Span Export of the Planning Hierarchy

  • Status: Accepted. Implemented in lib/otlp.cjs; off by default. With telemetry.otlp.enabled: false nothing in that module runs.
  • Date: 2026-07-30
  • Supersedes: None
  • Related: ADR-0001 (No-Daemon), ADR-0002 (Zero Runtime Deps), ADR-0021 (the spawns being traced), ADR-0022 (the token-cost problem this measures)

Context and Problem Statement

nubos-pilot records one JSONL line per agent spawn under .nubos-pilot/metrics/, with agent, tier, resolved model, phase, plan, task, timestamps, token counts, status and error. Every number an operator wants is already there.

The shape is wrong for the questions people actually ask. "Which slice was expensive, and which agent inside it" is a question about a tree, and the JSONL keeps the nodes while throwing away the edges. metrics-aggregate can sum per phase, but reconstructing milestone → slice → task → spawn requires re-deriving the hierarchy from ids at read time, in every consumer.

Meanwhile the hierarchy is a span tree. A milestone has a start and an end and contains slices; a slice contains tasks; a task contains agent spawns with durations and token counts. Nothing needed inventing — only mapping.

The obvious move is to adopt an observability platform. That is where this ADR has to be careful, because the obvious move violates two accepted invariants at once: a platform brings a database and a collector (ADR-0001) and an SDK (ADR-0002).

Decision Drivers

  • The edges are the missing information. Any solution that does not preserve the hierarchy solves nothing.
  • [ADR-0001] No daemon and [ADR-0002] Zero runtime dependencies are not negotiable for a default-path feature.
  • Egress is irreversible. Spans sent to a third party may be retained and indexed regardless of what happens locally. The safe operation must be the one you get by accident.
  • Re-export must be idempotent. An operator will run the export more than once. Accumulating a duplicate trace per run makes the feature useless within a day.
  • Honesty about time. A planning artefact has no clock of its own. Whatever is emitted must not imply measurements that were never taken.

Considered Options

  • A: Bundle a Langfuse client. Rejected. Brings an SDK, and pins nubos-pilot to one vendor's API surface.
  • B: Run a local collector as part of nubos-pilot. Rejected outright by ADR-0001.
  • C: Extend metrics-aggregate with a tree-shaped local report. Solves the local case with no new surface, but produces a bespoke format with no viewer, no retention and no cross-project comparison.
  • D: Emit the OTLP wire format and let the operator point it anywhere. Chosen.

Decision Outcome

Chosen: Option D — emit OTLP/HTTP with a JSON body, built from node:https.

This is the wire format, not a client. OTLP/HTTP JSON is one POST; there is no SDK, no daemon and no dependency, and the operator points telemetry.otlp.endpoint at Langfuse, an OpenTelemetry collector, or anything else that speaks OTLP. Agent spans carry the OpenTelemetry GenAI semantic conventions (gen_ai.operation.name, gen_ai.agent.name, gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens), which is what lets a generic backend render this without knowing what nubos-pilot is. nubos-pilot's own dimensions sit under a nubos.* namespace so they cannot collide with a future convention key.

Derived ids, not random

A span's id is a SHA-256 of its stable identifier (project/milestone|slice|task|spawn-key). Re-exporting a milestone therefore produces the same ids and the backend updates one trace instead of accumulating a new one per run. Math.random() would make every export a fresh trace.

One trace per milestone, not per project: a project-wide trace spans weeks, and every backend renders that unusably.

A retry of the same agent on the same task is disambiguated by run_id, because without it two attempts collapse onto one span id and the second silently overwrites the first.

A unit with no observed time is omitted, never fabricated

Structural spans (milestone, slice, task) have no clock. They inherit min(start)/max(end) from timed descendants. A slice whose tasks never ran has no honest interval, and stamping it with now would make an unrun slice indistinguishable from an instantaneous one. Such units are skipped and the skip is counted in the stats — reported, not silent.

In-flight work maps to span status UNSET, not OK. Claiming OK for something still running is a small lie that a dashboard amplifies into a chart.

A record naming no task attaches to its milestone rather than being dropped: an unattributed spawn still cost tokens.

Egress is opt-in twice over

  • dry-run, write and stats work unconditionally, because inspecting your own data locally is not egress.
  • send requires telemetry.otlp.enabled: true and an endpoint. --endpoint alone cannot switch it on.
  • endpoint defaults to null rather than localhost:4318, so enabled is not the only thing standing between a private project and an unintended send.
  • An empty span set reports a no-op instead of posting an empty envelope, and a non-2xx rejects rather than being swallowed into a silent success.

The default verb is dry-run. The safe operation is the one you get by accident; the sending verb has to be named.

Consequences

Good, because:

  • The planning structure becomes the trace structure, which is a genuinely differentiating view — the hierarchy is nubos-pilot's own and no generic LLM-observability tool has it.
  • Cost attribution per slice and per agent becomes a query rather than a script.
  • Zero cost when off, and no vendor lock: the same export feeds any OTLP backend.

Bad, because:

  • The operator owns the backend. There is no bundled UI, so "turn on telemetry" is a multi-step task involving software nubos-pilot does not ship.
  • Spans are only as good as the metrics records. A workflow that skips metrics record is invisible here, and the workflow-missing-metrics lint is a warning rather than an error.
  • The GenAI conventions are still evolving. Attribute names may need a migration, and gen_ai.system is inferred from the model id only where the prefix is unambiguous — guessing it wrong is worse than omitting it, because a dashboard groups by it.

Implementation note worth keeping

Millisecond→nanosecond conversion uses BigInt. The product exceeds 2^53, so float arithmetic silently loses precision on every span — a bug that would produce plausible, subtly wrong timings rather than an error.

More Information

  • Implementation: lib/otlp.cjs, CLI bin/np-tools/otlp-export.cjs (verbs dry-run | write | send | stats).
  • Hierarchy read: lib/planning-tree.cjs, shared with the roadmap-graph renderer (ADR-0027) so the two views cannot disagree about the same project.
  • Config: telemetry.otlp.{enabled, endpoint, headers, service_name, timeout_ms}.