Skip to content

fix(otel): ship each trace's spans in one report through its owning request context#214

Draft
lucleray wants to merge 1 commit into
mainfrom
luc/span-context-routing
Draft

fix(otel): ship each trace's spans in one report through its owning request context#214
lucleray wants to merge 1 commit into
mainfrom
luc/span-context-routing

Conversation

@lucleray

@lucleray lucleray commented Jul 7, 2026

Copy link
Copy Markdown
Member

Problem

Traces from long-running invocations (e.g. Vercel Workflows) are missing most spans from the first part of the run: the trace looks blank early on, with all step spans bunched into the last few seconds. The work happens the whole time; the spans are genuinely lost.

Root cause (client side)

  1. The BatchSpanProcessor schedules flushes with a bare setTimeout, which runs outside the request's AsyncLocalStorage. The runtime exporter then resolves no request context and silently acked + dropped every mid-run batch. Only the final flush, triggered via waitUntil (in-context), survived.
  2. The BatchSpanProcessor queue is shared by all concurrent requests on an instance, so a flush ships other invocations' spans through the wrong request's telemetry channel, where they are not reliably persisted.

Fix

  • Capture the owning request context per trace id at root-span start (always in-request), bounded against never-ending traces.
  • The exporter accumulates spans of registered traces per trace and ships the whole buffer in a single report when the trace is finalized from its own request's waitUntil. Untracked traces keep shipping immediately through the ambient context.
  • Spans that cannot be attributed are retained and re-attempted instead of silently acked.
  • The processor drains the BatchSpanProcessor queue in-context on span end (debounced on OTEL_BSP_MAX_EXPORT_BATCH_SIZE / OTEL_BSP_SCHEDULE_DELAY) so long runs cannot overflow maxQueueSize. Off-Vercel behavior is unchanged.

Verified on a long-running workflow reproduction: with this change all step spans consistently survive across repeated runs (previously only the last few seconds' spans did).

Relationship to #213

Same problem; this PR keeps its in-context flush and retain-instead-of-drop ideas, and adds per-trace attribution + single-report delivery, which testing showed is required for reliability on warm instances.

Testing

  • Unit tests for accumulation/finalize semantics, per-trace separation, retain/re-ship paths, in-context drain triggers, and registration lifecycle.
  • type-check, eslint, full unit suite pass. Bundle size limits bumped slightly (main was within ~2KB / ~100 bytes of them).

@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
otel-site Skipped Skipped Open in v0 Jul 8, 2026 1:39pm

Comment on lines +114 to +116
for (const finalizer of traceFinalizers) {
finalizer(traceId);
}

@vercel vercel Bot Jul 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct for...of iteration over a Set in finalizeTrace causes build failure TS2802 under the build-types tsc invocation, which ignores tsconfig.json's ES2021 target.

Fix on Vercel

…equest context

Long-running invocations (e.g. Vercel Workflows) lose most spans from the
first part of the run: the trace looks blank early on, with all step spans
bunched into the last few seconds.

Root cause, in layers:

1. The BatchSpanProcessor's scheduled flush runs in a bare setTimeout,
   outside the request's AsyncLocalStorage, so the runtime exporter
   resolved no request context and silently dropped every mid-run batch.
2. The BSP queue is shared by all concurrent requests on the instance, so
   even an in-context flush ships other invocations' spans through a
   foreign telemetry channel, where they are not reliably persisted
   (verified empirically: per-trace re-routing alone still lost most
   spans).
3. Only a single report delivered near the end of the request is reliably
   persisted end to end (verified empirically: mid-run reports through
   the correct owning context still did not land; a single report at the
   end always does).

Fix:

- Capture the request context per trace id at root-span start (always
  in-request), bounded by a hard cap against never-ending traces.
- The exporter ACCUMULATES spans of registered traces into per-trace
  buffers instead of reporting mid-run; untracked traces keep shipping
  immediately through the ambient context; unattributable spans are
  retained and re-attempted instead of silently acked.
- When the trace's final waitUntil flush runs (inside the owning
  request), finalizeTrace ships the whole buffer in a single report
  through the owning context and drops the registration.
- The composite processor drains the BSP queue in-context on span end
  (debounced on OTEL_BSP_MAX_EXPORT_BATCH_SIZE / OTEL_BSP_SCHEDULE_DELAY)
  so long runs cannot overflow its maxQueueSize. Off-Vercel behavior is
  unchanged.

Verified on a long-running workflow reproduction: with this change all
step spans consistently survive across repeated runs (previously only
the last stretch of the trace did).
@lucleray lucleray force-pushed the luc/span-context-routing branch from 051c6e6 to 1a9cd87 Compare July 8, 2026 13:39
lucleray added a commit that referenced this pull request Jul 8, 2026
…ontexts

Variation of the accumulate-and-ship-once approach (#214) that also
supports exporting spans DURING the invocation, not only at its end.

Empirical finding behind the design: payloads composed of a SINGLE trace
that belongs to an active request are the shape the delivery pipeline
handles reliably. What poisoned mid-run delivery before was payload
mixing: the BatchSpanProcessor's queue is shared by all concurrent
requests, so its flushes shipped mixed-trace payloads whose delivery was
non-deterministic, and stale retained spans leaking into active payloads
recreated the same poisoning even with client-side routing.

The exporter now enforces strict per-trace payload discipline:

- Every flush groups incoming spans by their own trace id and ships each
  trace's spans as their own single-trace payload, through the telemetry
  channel captured for that trace at root-span start (ambient fallback
  for untracked traces). Traces never share a payload.
- Spans with no reachable channel are buffered per trace (bounded,
  oldest dropped with a warning) and retried on the next flush; the
  trace's finalize (from the owning request's waitUntil) ships whatever
  is left.
- The composite processor still drains the BSP queue in-context on span
  end (debounced on OTEL_BSP_MAX_EXPORT_BATCH_SIZE /
  OTEL_BSP_SCHEDULE_DELAY) so long runs cannot overflow maxQueueSize,
  and registers/finalizes the per-trace context as in #214.

Streaming is opt-in via VERCEL_OTEL_STREAM_SPANS=1 and not recommended
yet: parts of the platform-side delivery pipeline currently only
reliably persist batches delivered near the end of the request, so the
default remains accumulate-and-ship-once. Once that is addressed,
flipping the flag gives mid-run streaming without loss; spans then leave
the process every flush interval, bounding client memory on long runs.
lucleray added a commit that referenced this pull request Jul 8, 2026
…ontexts

Variation of the accumulate-and-ship-once approach (#214) that also
supports exporting spans DURING the invocation, not only at its end.

Empirical finding behind the design: payloads composed of a SINGLE trace
that belongs to an active request are the shape the delivery pipeline
handles reliably. What poisoned mid-run delivery before was payload
mixing: the BatchSpanProcessor's queue is shared by all concurrent
requests, so its flushes shipped mixed-trace payloads whose delivery was
non-deterministic, and stale retained spans leaking into active payloads
recreated the same poisoning even with client-side routing.

The exporter now enforces strict per-trace payload discipline:

- Every flush groups incoming spans by their own trace id and ships each
  trace's spans as their own single-trace payload, through the telemetry
  channel captured for that trace at root-span start (ambient fallback
  for untracked traces). Traces never share a payload.
- Spans with no reachable channel are buffered per trace (bounded,
  oldest dropped with a warning) and retried on the next flush; the
  trace's finalize (from the owning request's waitUntil) ships whatever
  is left.
- The composite processor still drains the BSP queue in-context on span
  end (debounced on OTEL_BSP_MAX_EXPORT_BATCH_SIZE /
  OTEL_BSP_SCHEDULE_DELAY) so long runs cannot overflow maxQueueSize,
  and registers/finalizes the per-trace context as in #214.

Streaming is opt-in via VERCEL_OTEL_STREAM_SPANS=1 and not recommended
yet: parts of the platform-side delivery pipeline currently only
reliably persist batches delivered near the end of the request, so the
default remains accumulate-and-ship-once. Once that is addressed,
flipping the flag gives mid-run streaming without loss; spans then leave
the process every flush interval, bounding client memory on long runs.
lucleray added a commit that referenced this pull request Jul 8, 2026
…ontexts

Variation of the accumulate-and-ship-once approach (#214) that also
supports exporting spans DURING the invocation, not only at its end.

Empirical finding behind the design: payloads composed of a SINGLE trace
that belongs to an active request are the shape the delivery pipeline
handles reliably. What poisoned mid-run delivery before was payload
mixing: the BatchSpanProcessor's queue is shared by all concurrent
requests, so its flushes shipped mixed-trace payloads whose delivery was
non-deterministic, and stale retained spans leaking into active payloads
recreated the same poisoning even with client-side routing.

The exporter now enforces strict per-trace payload discipline:

- Every flush groups incoming spans by their own trace id and ships each
  trace's spans as their own single-trace payload, through the telemetry
  channel captured for that trace at root-span start (ambient fallback
  for untracked traces). Traces never share a payload.
- Spans with no reachable channel are buffered per trace (bounded,
  oldest dropped with a warning) and retried on the next flush; the
  trace's finalize (from the owning request's waitUntil) ships whatever
  is left.
- The composite processor still drains the BSP queue in-context on span
  end (debounced on OTEL_BSP_MAX_EXPORT_BATCH_SIZE /
  OTEL_BSP_SCHEDULE_DELAY) so long runs cannot overflow maxQueueSize,
  and registers/finalizes the per-trace context as in #214.

Streaming is opt-in via VERCEL_OTEL_STREAM_SPANS=1 and not recommended
yet: parts of the platform-side delivery pipeline currently only
reliably persist batches delivered near the end of the request, so the
default remains accumulate-and-ship-once. Once that is addressed,
flipping the flag gives mid-run streaming without loss; spans then leave
the process every flush interval, bounding client memory on long runs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant