Skip to content

fix(tracing): mint a genuine root span for a caller-chosen trace_id - #101

Open
Alan-Marx wants to merge 5 commits into
simplepractice:mainfrom
Alan-Marx:fix/root-span-trace-id-parent
Open

fix(tracing): mint a genuine root span for a caller-chosen trace_id#101
Alan-Marx wants to merge 5 commits into
simplepractice:mainfrom
Alan-Marx:fix/root-span-trace-id-parent

Conversation

@Alan-Marx

@Alan-Marx Alan-Marx commented Aug 10, 2026

Copy link
Copy Markdown

TL;DR

start_observation/observe with an explicit trace_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.

Why

trace_id: was implemented by building a synthetic SpanContext (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/langfuse#12896 (closed) — the Python SDK wasn't reliably setting the internal langfuse.internal.as_root/is_app_root attribute, which broke root detection in the v4 preview UI. That's been fixed.
  • langfuse/langfuse#14868 (still open) — even with is_app_root set correctly, ingestion never nulls out the phantom parentObservationId, 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_root attribute is a UI-only patch over the real defect, not a fix for it — the stored parentObservationId is still wrong. Rather than port that same partial workaround into Ruby, this fixes the root cause: TracerProvider only consults its id_generator for spans with no valid parent context, so we start the span under a context whose "current span" slot is forced to Span::INVALID while pinning what generate_trace_id returns 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 no parentObservationId to null out in the first place — langfuse-rb doesn't inherit #14868's gap.

Pinning is Fiber-local (Fiber[], not Thread.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, which Fiber[] does correctly.

Update: Cursor Bugbot flagged that the first version of this PR used Tracer#start_root_span directly, which forces with_parent: Context.empty — that discarded attributes set via Langfuse.propagate_attributes (and baggage) for a trace_id: root, since Langfuse::SpanProcessor#on_start reads them off whatever parent context OTel hands it. Every other observation path in this SDK (explicit parent_span_context:, implicit ambient parent) preserves ambient context by layering onto Context.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 as Context.current with only the "current span" slot overridden to Span::INVALID, instead of delegating to start_root_spaninternal_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.

Checklist

  • Has label
  • Has linked issue
  • Tests added for new behavior
  • Docs updated (if user-facing)

Related 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 changed

  • Langfuse.start_observation/.observe: when trace_id: is given, start a real root span with generation pinned to that trace ID, instead of creating a child of a synthetic parent SpanContext.
  • lib/langfuse/otel_setup.rb: install Langfuse::TraceId as the TracerProvider's id_generator, so pinned trace IDs actually reach OTel's root-span path.
  • Langfuse::TraceId: replaced to_span_context (private, built a fake parent SpanContext) with pin_generation_to (Fiber-local pinning of generate_trace_id's return value) plus the generate_trace_id/generate_span_id methods required by OTel's id_generator contract.
  • Extracted create_child_span and create_root_span out of start_observation — each owns one otel_span construction path (explicit parent, pinned trace ID, plain root) and keeps the method under the line-count limit.
  • create_root_span builds its root context as Context.current with only the "current span" slot overridden to Span::INVALID, rather than delegating to Tracer#start_root_span (which forces Context.empty and drops propagated attributes/baggage — see the Bugbot update above).
  • Added regression tests: parent_span_id is actually nil for the trace_id: case (the previous suite only asserted trace_id matched, which the old buggy implementation also satisfied), and attributes set via Langfuse.propagate_attributes still apply to a trace_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

bundle exec rspec
# 1347 examples, 0 failures
# Line Coverage: 96.86% (2372 / 2449)

bundle exec rubocop
# no offenses detected

Alan Marx 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
Alan-Marx force-pushed the fix/root-span-trace-id-parent branch from 405297b to 7112a29 Compare August 10, 2026 18:34

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread lib/langfuse.rb Outdated
Alan Marx 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.
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