Group OTLP spans by trace and make task spans self-describing - #260
Conversation
|
The first CI run went red on What was redThe core job died at 83 s with its output stopping mid-word inside Two things were ruled out rather than assumed:
That forced a different question, and it found the actual bug. The bugCI installs
It fired. With
The workspace half was wider and had no pin at all: The fixStop trusting a dependency for a safety property. Containment is verified against the real path, on the containing directory rather than the file — a link crossing is a directory boundary, and a symlinked file is not an escape at all, because Differential on 1.4.0: reverting only Cost measured, not asserted (interleaved min-of-5, three trials): a typical output tree (100 files / 5 dirs) goes 0.4–0.6 ms → 0.5–0.7 ms, inside noise; a large one (5000 files / 50 dirs) goes 11.7–14.0 ms → 14.2–18.2 ms — about 50 realpath calls on an operation that already costs ~13 ms and runs once per task. No Recorded, not fixedThree things are written up in the decision log rather than acted on here, because each is a separate decision:
Gates from the root: fmt/lint 0, core 2633 / 0 on both 1.3.11 and 1.4.0, cloud 1286 / 0, vx-otel 44 / 0. Generated by Claude Code |
|
CI is green on Correction to my own diagnosis above, pushed as So the truncation is the logs API, not the process, and it says nothing about how a run ended. "The red job printed none" was never evidence: the That also puts this repo's 2026-07-30 "killed process, not a failing assertion" entry in doubt, since its stated evidence was the same absent summary. Not asserted wrong — that run can't be re-examined — but flagged in the log, along with the rule I violated: before concluding anything from where a CI log ends, check where a passing run's log ends. Generated by Claude Code |
The receiver flattened every resourceSpans scope and attributed every vx.task span to the first vx.run span it found. A collector batching two runs therefore produced ONE run carrying the other workspace's tasks while the second run vanished — and a collector splitting one run across two exports had the task-only half refused with a 400, losing the tasks outright. Both shapes are what a collector does by design: it batches across producers and re-batches by size and time. Spans now group by trace id, so batched runs can never borrow each other's tasks. And a task span carries its own run id, workspace id and run start, so a task arriving ahead of its header is stored through the same incremental ingestTask path the native wire already uses. The run start is the load-bearing part: it is the storage key's partition base, so a stranded task derives the SAME (started_at, run_id, project, task) key its header would have given it, and the two arrival orders converge on one row rather than duplicating. Refusal narrows accordingly: a payload is a 400 only when it carries neither a root span nor a task span that names its run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019x8vExRpvPDkvwcDZ7wKDE
8bb3940 to
02b790d
Compare
A hostile audit of the OTLP receiver that landed in #259, aimed
deliberately at the code the previous wave had just written. Three
confirmed defects, each reproduced before it was fixed.
The defects
HIGH — a batched export merged two runs into one. The decoder
flattened every
resourceSpansscope into one list and attributedevery
vx.taskspan to the firstvx.runspan it found. A collectorbatching two workspaces' runs into one export therefore stored one
run carrying the other workspace's tasks, and the second run
vanished entirely. Measured on a two-run payload:
runs recorded: 1,and workspace B's task rows sitting under workspace A's header.
This is not an exotic shape — batching across producers is what a
collector is for, and the guide already told people to put one in the
middle.
HIGH — a split run lost its tasks with a 400. A collector
re-batches by size and time, so one run can arrive as two exports. The
half carrying only task spans had no root, and the receiver refused the
whole payload. The tasks were dropped and the exporter had no reason to
retry (it got a 4xx, not a 5xx).
Root cause of both: task spans were not self-describing. A
vx.taskspan named its task and nothing about the run it belonged to,so the decoder had no way to attribute it except positionally.
The fixes
Spans now group by trace id before decoding, so batched runs can
never borrow each other's tasks — one trace is one run, which is what
the trace id means.
And a task span now carries
cicd.pipeline.run.id,vx.workspace.idand
vx.task.run_started_at, so a task that arrives ahead of itsheader is stored anyway, through the same incremental
ingestTaskpaththe native
/v1/ingest/taskendpoint already uses.The run start is the load-bearing third field and the reason this
converges rather than duplicating: it is the storage key's partition
base, so a stranded task derives the same
(started_at, run_id, project, task)key its header would have givenit. Header-then-tasks and tasks-then-header land on one row either way
— verified end-to-end by splitting a run across two exports and
asserting the task count does not double.
Refusal narrows accordingly: a payload is a 400 only when it carries
neither a root span nor a task span that names its run. Empty and
malformed payloads are still refused exactly as before.
Verification
Three differentials, each isolating one fix, every restore verified
back to a green baseline:
vx.task.run_started_atfrom the encoderThe batched-export and split-run cases are pinned as real e2e —
a live platform, the real
OtelSink, two workspaces, asserting eachrun's tasks land under its own workspace and that a replayed half
stores nothing new.
Gates from the root: fmt/lint 0, core 2631 / 0 (
git diff src/empty), cloud 1286 / 0 across 58 suites (38 skip = the browser
suites, which need playwright + a built
ui/distthis containerlacks), vx-otel 44 / 0, docs site 168 pages with a zero-broken-link
crawl over both edited pages.
No
CACHE_VERSION/SCHEMA/ migration / wire-version bump —vx.task.run_started_atis an additive attribute, and an olderexporter's payloads still decode through the root-span path unchanged.
Docs
Both pages claimed "a payload without a root span is refused", which
this makes false. Corrected in place, and the OTel guide now states
what a collector in the middle actually does and why both shapes are
handled.
Generated by Claude Code