Skip to content

fix: bind the Langfuse tracer provider so spans always reach the exporter - #53

Open
milanagm wants to merge 2 commits into
langfuse:mainfrom
milanagm:lfe-15354-stop-hook-exits-cleanly-and-reports-parsed-n-turns-but-no-trace
Open

fix: bind the Langfuse tracer provider so spans always reach the exporter#53
milanagm wants to merge 2 commits into
langfuse:mainfrom
milanagm:lfe-15354-stop-hook-exits-cleanly-and-reports-parsed-n-turns-but-no-trace

Conversation

@milanagm

@milanagm milanagm commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Refs #51 (#51). This removes the failure mode the issue points at. See "Confirmation status" below

Problem

setupInstrumentation never told Langfuse which tracer provider to use. It relied on an implicit fallback, which can refuse silently. When it refuses, the hook parses a rollout, logs parsed N turn(s), flushes without error and exits 0, while no span reaches Langfuse and no request reaches the ingestion endpoint.

startObservation resolves its tracer via getLangfuseTracerProvider(), which returns an explicitly bound provider if there is one and otherwise falls back to the OpenTelemetry global registry. registerGlobal refuses the registration in two cases — the slot is already taken, or the API version does not match — and reports both only through the OpenTelemetry diag logger, which is a no-op unless configured. In either case startObservation gets a no-op tracer, so every span is non-recording: clean parse, clean flush, zero network traffic, zero errors.

The reliance is original: provider.register() has been the only wiring since the initial commit, and setLangfuseTracerProvider was never imported, so the bundler dropped it from dist/index.mjs entirely. The SDK documents the explicit pairing as the way to bind a custom provider.

Fix

Call setLangfuseTracerProvider(provider) alongside provider.register(). register() is still needed: it installs the AsyncLocalStorage context manager that propagateAttributes relies on. Verified — dropping register() still exports spans, but sessionId and tags silently disappear from traces.

Confirmation status

Reproduced end-to-end through real Codex sessions (codex-cli 0.148.0, macOS, Langfuse Cloud), with a genuine second NodeTracerProvider registering first inside the hook process — what an APM agent does via NODE_OPTIONS:

arm bundle registry hook output trace in Langfuse
baseline old free parsed + uploaded arrived
second registrant old occupied parsed + uploaded never arrived
second registrant new occupied parsed + uploaded arrived
regression check new free parsed + uploaded arrived

The second row is the issue title verbatim: the Stop hook exits cleanly, reports parsed 1 turn(s), Codex shows "Stop Completed" — and the ingestion endpoint is never hit.

What is not established is that this was the trigger on the reporter's machine. On macOS the bug does not occur on its own: the installed bundle carries a single @opentelemetry/api copy, Node 20/22/24 all export correctly, and with the OTel diag logger enabled the registration reports success. One reported detail also does not fit this mechanism: in the induced failure the sidecar is still written, while the issue reports no sidecar. A diag-enabled run on the reporter's machine would settle which gate, if any, closes there.

The fix is worth landing regardless, because it removes the dependency on the registry rather than patching a symptom.

Tests

plugins/tracing/test/instrumentation.test.ts occupies the global registry, runs setupInstrumentation, converts a fixture rollout and asserts the turn span reaches the plugin's own span processor. It fails without the fix and passes with it — verified in both directions.

A source-level assertion would not cover this: the call was previously tree-shaken out of dist/index.mjs because nothing imported it, so a test that only greps the source would stay green while the shipped bundle is broken.

Note on the dist diff

dist/index.mjs shows ~700 changed lines. The module set is identical (340 regions before and after); the bundler only reordered them because the new import changed the module graph. The only new content is setLangfuseTracerProvider, previously tree-shaken out, plus its call site. pnpm run lint:dist passes.

Out of scope

Two separate findings from this investigation, both worth their own issue:

  1. Windows hooks may never spawn. hooks/hooks.json uses ${CODEX_HOME:-$HOME/.codex}, POSIX parameter expansion. Measured: run through a shell it expands and exits 0; spawned as argv without a shell, Node fails in the module loader before any plugin code runs. This masks the bug above wherever hooks are spawned without a shell.
  2. A silently dropped turn is still marked uploaded. The sidecar is written even when the export produced no request, so the turn is never retried once the tracer problem is fixed.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@CodeCLS
CodeCLS requested a review from hassiebp August 24, 2026 11:44

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@milanagm The /dist directory should not be source controlled typically - is there a reason this is in here?

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.

2 participants