fix: bind the Langfuse tracer provider so spans always reach the exporter - #53
Open
milanagm wants to merge 2 commits into
Conversation
hassiebp
reviewed
Aug 24, 2026
Collaborator
There was a problem hiding this comment.
@milanagm The /dist directory should not be source controlled typically - is there a reason this is in here?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #51 (#51). This removes the failure mode the issue points at. See "Confirmation status" below
Problem
setupInstrumentationnever 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, logsparsed N turn(s), flushes without error and exits 0, while no span reaches Langfuse and no request reaches the ingestion endpoint.startObservationresolves its tracer viagetLangfuseTracerProvider(), which returns an explicitly bound provider if there is one and otherwise falls back to the OpenTelemetry global registry.registerGlobalrefuses the registration in two cases — the slot is already taken, or the API version does not match — and reports both only through the OpenTelemetrydiaglogger, which is a no-op unless configured. In either casestartObservationgets 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, andsetLangfuseTracerProviderwas never imported, so the bundler dropped it fromdist/index.mjsentirely. The SDK documents the explicit pairing as the way to bind a custom provider.Fix
Call
setLangfuseTracerProvider(provider)alongsideprovider.register().register()is still needed: it installs the AsyncLocalStorage context manager thatpropagateAttributesrelies on. Verified — droppingregister()still exports spans, butsessionIdandtagssilently 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
NodeTracerProviderregistering first inside the hook process — what an APM agent does viaNODE_OPTIONS: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/apicopy, Node 20/22/24 all export correctly, and with the OTeldiaglogger 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. Adiag-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.tsoccupies the global registry, runssetupInstrumentation, 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.mjsbecause 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.mjsshows ~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 issetLangfuseTracerProvider, previously tree-shaken out, plus its call site.pnpm run lint:distpasses.Out of scope
Two separate findings from this investigation, both worth their own issue:
hooks/hooks.jsonuses${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.