fix(tracing): mint a genuine root span for a caller-chosen trace_id - #101
Open
Alan-Marx wants to merge 5 commits into
Open
fix(tracing): mint a genuine root span for a caller-chosen trace_id#101Alan-Marx wants to merge 5 commits into
Alan-Marx wants to merge 5 commits into
Conversation
added 2 commits
August 10, 2026 11:26
Langfuse.observe/start_observation(trace_id:) built a synthetic, never-exported parent span (TraceId.to_span_context) just to carry the requested trace ID onto the real span. OTel still recorded that span as having a parent, so Langfuse's ingestion (parentObservationId IS NULL) permanently misclassified it as a non-root child — spans reached Langfuse fine, but the turn's whole trace tree had no discoverable root in the dashboard. Root-caused against the actual OTel SDK source: TracerProvider only consults its id_generator for spans with no valid parent context (Tracer#start_root_span forces exactly that). Fixed by minting a true, parentless root via start_root_span while pinning what generate_trace_id returns for that one call (TraceId.pin_generation_to), instead of faking a parent to smuggle the trace_id through. Checked upstream (langfuse-python/langfuse-js) and the real Langfuse issue tracker (langfuse/langfuse#12896, #14868) before committing to this shape: the official SDKs keep the same synthetic-parent trick and compensate with an internal.is_app_root attribute computed by their SpanProcessor. That attribute never clears the stored parentObservationId, so those traces still don't satisfy the public Observations v2 API's root filter. Minting a real root sidesteps that class of gap entirely rather than adding an attribute-based workaround. Pinning is Fiber-local (Fiber[], not Thread.current[]=) so it stays correct once request handling moves off Puma onto a fiber-based scheduler (async, Falcon) — Thread.current[]= doesn't inherit into a fiber spawned mid-call, which Fiber[] does correctly. Added the regression test the previous suite was missing: nothing asserted parent_span_id was actually nil for the trace_id case, only that trace_id matched — which the old buggy implementation also satisfied.
Alan-Marx
force-pushed
the
fix/root-span-trace-id-parent
branch
from
August 10, 2026 18:34
405297b to
7112a29
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 7112a29. Configure here.
added 3 commits
August 10, 2026 12:23
start_root_span forces `Context.empty`, which silently dropped attributes set via Langfuse.propagate_attributes (and baggage) for a trace_id: root, since Langfuse::SpanProcessor#on_start reads them off the parent context OTel hands it. Every other observation path (explicit parent_span_context, implicit ambient parent) preserves ambient context by layering onto Context.current instead, so this was an inconsistency introduced by the root-span fix, not an intentional design choice. Fixed by building a context equal to Context.current but with only the "current span" slot overridden to Span::INVALID (via the same context_with_span helper create_child_span already uses), instead of delegating to Tracer#start_root_span. TracerProvider#internal_start_span derives root-ness solely from that slot's validity, so this still produces a genuine parentless root and still lets the pinned id_generator supply the trace ID — it just stops discarding everything else riding along in the ambient context. Extracted create_child_span and create_root_otel_span out of start_observation to keep it under the line limit and give each otel_span construction path a name.
…span entry That behavior never shipped in a released version — it was only broken within this same unreleased branch, between the previous commit and this one — so it doesn't warrant its own Fixed bullet.
Matches create_child_span's naming.
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.

TL;DRstart_observation/observewith an explicittrace_id:now mint a genuine parentless root span instead of faking one via a synthetic, never-exported parent, so the resulting trace is correctly recognized as a root in Langfuse — including by the public Observations v2 API, where the equivalent gap in the official SDKs (langfuse/langfuse#14868) is still open as of this writing.Whytrace_id:was implemented by building a syntheticSpanContext(TraceId.to_span_context) purely to carry the requested trace ID, then creating the real span as its child. OTel still recorded that span as having a parent, so Langfuse's ingestion (parentObservationId IS NULL) permanently misclassified it as a non-root child — the span data reached Langfuse fine, but the trace's root was never discoverable in the dashboard or via the Observations v2 API's root filter.This is the same family of bug tracked upstream against the official SDKs:
langfuse.internal.as_root/is_app_rootattribute, which broke root detection in the v4 preview UI. That's been fixed.is_app_rootset correctly, ingestion never nulls out the phantomparentObservationId, so the public Observations v2 API's only root filter (parentObservationId is null) still never matches these observations. Its own description calls out #12896 explicitly: "added SDK app-root detection, but not public-API root exposure."So the official SDKs'
is_app_rootattribute is a UI-only patch over the real defect, not a fix for it — the storedparentObservationIdis still wrong. Rather than port that same partial workaround into Ruby, this fixes the root cause:TracerProvideronly consults itsid_generatorfor spans with no valid parent context, so we start the span under a context whose "current span" slot is forced toSpan::INVALIDwhile pinning whatgenerate_trace_idreturns for that one call (TraceId.pin_generation_to), instead of smuggling the trace ID through a fake parent. The resulting span has no parent at all, so there's noparentObservationIdto null out in the first place —langfuse-rbdoesn't inherit #14868's gap.Pinning is Fiber-local (
Fiber[], notThread.current[]=) so it stays correct if request handling ever moves off a thread-per-request server onto a fiber-based scheduler (async, Falcon) —Thread.current[]=doesn't inherit into a fiber spawned mid-call, whichFiber[]does correctly.Update: Cursor Bugbot flagged that the first version of this PR used
Tracer#start_root_spandirectly, which forceswith_parent: Context.empty— that discarded attributes set viaLangfuse.propagate_attributes(and baggage) for atrace_id:root, sinceLangfuse::SpanProcessor#on_startreads them off whatever parent context OTel hands it. Every other observation path in this SDK (explicitparent_span_context:, implicit ambient parent) preserves ambient context by layering ontoContext.current, so this was an inconsistency introduced by the root-span fix, not an intentional "roots don't inherit propagation" design choice. Fixed by building the root context asContext.currentwith only the "current span" slot overridden toSpan::INVALID, instead of delegating tostart_root_span—internal_start_spanderives root-ness solely from that slot's validity, so this still produces a genuine parentless root and still lets the pinnedid_generatorsupply the trace ID, it just stops discarding everything else riding along in the ambient context.ChecklistRelated to langfuse/langfuse#14868 (open upstream, cross-SDK; this PR avoids the underlying defect entirely for langfuse-rb) and langfuse/langfuse#12896 (closed; same root cause, different symptom)
What changedLangfuse.start_observation/.observe: whentrace_id:is given, start a real root span with generation pinned to that trace ID, instead of creating a child of a synthetic parentSpanContext.lib/langfuse/otel_setup.rb: installLangfuse::TraceIdas theTracerProvider'sid_generator, so pinned trace IDs actually reach OTel's root-span path.Langfuse::TraceId: replacedto_span_context(private, built a fake parentSpanContext) withpin_generation_to(Fiber-local pinning ofgenerate_trace_id's return value) plus thegenerate_trace_id/generate_span_idmethods required by OTel'sid_generatorcontract.create_child_spanandcreate_root_spanout ofstart_observation— each owns oneotel_spanconstruction path (explicit parent, pinned trace ID, plain root) and keeps the method under the line-count limit.create_root_spanbuilds its root context asContext.currentwith only the "current span" slot overridden toSpan::INVALID, rather than delegating toTracer#start_root_span(which forcesContext.emptyand drops propagated attributes/baggage — see the Bugbot update above).parent_span_idis actually nil for thetrace_id:case (the previous suite only assertedtrace_idmatched, which the old buggy implementation also satisfied), and attributes set viaLangfuse.propagate_attributesstill apply to atrace_id:root.CHANGELOG.md: one[Unreleased]entry covering the root-span fix, including the propagated-attributes preservation — the latter never shipped broken in a released version, so it's folded into the same bullet rather than listed as its own fix.Validation