Skip to content

fix: detach root observations from the host application's ambient OpenTelemetry context - #122

Open
Eth3rnit3 wants to merge 3 commits into
simplepractice:mainfrom
Eth3rnit3:fix/root-observation-detach-ambient-context
Open

Eth3rnit3 wants to merge 3 commits into
simplepractice:mainfrom
Eth3rnit3:fix/root-observation-detach-ambient-context

Conversation

@Eth3rnit3

@Eth3rnit3 Eth3rnit3 commented Sep 9, 2026

Copy link
Copy Markdown

Problem

create_otel_span creates root observations with:

else
  # Create root span
  otel_tracer.start_span(name, start_timestamp: start_time)
end

Tracer#start_span implicitly parents the new span to OpenTelemetry::Context.current. That context is process-wide and provider-agnostic — it is not scoped to the TracerProvider the tracer came from. So this branch only produces an actual root span when nothing else is currently active.

In a host application that runs its own OpenTelemetry instrumentation (Rack, ActiveJob, Sidekiq…) on a separate provider — a very common setup — a root observation started inside an instrumented request or job silently becomes a child of that ambient span.

The consequence is only visible on the Langfuse side: Langfuse ingests the child, whose trace_id belongs to a root span that was exported to the application's own backend and never reaches Langfuse. The UI then renders an empty, unnamed trace wrapping the real observation. Grouping or filtering by trace name becomes unusable, since a large share of traces have no name at all.

This is distinct from the isolated tracer provider introduced in 0.8.0 (#77): that change isolates which spans are exported to Langfuse (and it works — thank you for it). This issue is about which span a new span is parented to, which context propagation decides independently of the provider.

Fix

Reset to OpenTelemetry::Context::ROOT for the duration of the root span creation only:

OpenTelemetry::Context.with_current(OpenTelemetry::Context::ROOT) do
  otel_tracer.start_span(name, start_timestamp: start_time)
end

Nesting is deliberately unaffected: once the span is started it becomes current again, so child observations keep attaching to it, and the caller's ambient context is restored on the way out. The child branch is untouched.

Tests

Three specs under .start_observation, covering the fix and the two properties a naive fix would break:

  • a root observation gets its own trace, not the ambient one (fails on main, passes with the change);
  • child observations still nest under the root;
  • the caller's ambient context is restored afterwards.

bundle exec rspec spec/langfuse_spec.rb → 100 examples, 0 failures. rubocop clean on both changed files.

Happy to adjust naming, comment length or spec placement to your preferences.


Note

Medium Risk
Changes core root-span parenting in mixed OpenTelemetry setups; incorrect detection of “foreign” vs Langfuse ambient spans could mis-nest traces, though the scope check and new specs narrow the blast radius.

Overview
Fixes root Langfuse observations being parented to the host app’s unrelated OpenTelemetry span when Rack/ActiveJob/Sidekiq (or another provider) already has Context.current set. Those roots showed up in Langfuse as orphan children under empty, unnamed traces.

Root span creation in create_otel_span now temporarily clears only the active span (invalid span in context) before start_span, so a new trace_id is generated without wiping propagate_attributes context values. If the ambient span is Langfuse’s own (ambient_langfuse_span? via tracer instrumentation scope), behavior is unchanged so nested observe / start_observation calls still attach.

Adds specs for a foreign ambient span: own trace_id, nested children, restored caller context, nested Langfuse observations, and propagated user.id / session.id.

Reviewed by Cursor Bugbot for commit 259943e. Bugbot is set up for automated code reviews on this repo. Configure here.

…l context

`create_otel_span` calls `otel_tracer.start_span(name)` for root
observations without touching the current context. `Tracer#start_span`
implicitly parents the new span to `OpenTelemetry::Context.current`, and
that context is process-wide and provider-agnostic -- it is not scoped to
the TracerProvider the tracer came from.

So when the host application runs its own OpenTelemetry instrumentation
(Rack, ActiveJob, Sidekiq...) on a separate provider, a root observation
started inside an instrumented request or job silently becomes a child of
that ambient span. Langfuse only ingests the child, pointing at a trace_id
whose root span was exported somewhere else entirely and never reaches
Langfuse -- the UI then shows an empty, unnamed trace wrapping the real
observation, and grouping by trace name becomes unusable.

This is not fixed by the isolated tracer provider introduced in 0.8.0:
that isolates which spans are *exported* to Langfuse, while this is about
which span a new span is *parented to*.

Resetting to `Context::ROOT` for the duration of the span creation makes
root observations genuinely rooted, whatever the caller's context.
Nesting is unaffected: once started, the span becomes current again, so
child observations keep attaching to it, and the caller's ambient context
is restored on the way out.

The regression spec covers all three properties: a root observation gets
its own trace, children still nest under it, and the ambient context is
left untouched.

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

Stale Bugbot comment from a previous run.

Comment thread lib/langfuse.rb
…butes

Resetting to `Context::ROOT` dropped every context value, not just the
parent span. `propagate_attributes` stores user_id, session_id, tags and
metadata as context values, and `SpanProcessor#on_start` reads them from
the parent context -- so a root observation opened inside the documented
`propagate_attributes` pattern silently lost its identity.

Setting the current span to `Span::INVALID` instead keeps the rest of the
context intact while still producing a real root: the SDK only inherits a
trace_id when the parent span context is valid
(`TracerProvider#internal_start_span`).

Added a regression spec asserting user.id / session.id survive on a root
observation started inside `propagate_attributes`; it fails with the
previous `Context::ROOT` approach.

Thanks to Cursor Bugbot for catching this.

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

Reviewed by Cursor Bugbot for commit 60ac94a. Configure here.

Comment thread lib/langfuse.rb
Detaching unconditionally broke legitimate nesting: `observe` makes its
observation the current span via `run_in_context`, and a nested
`Langfuse.observe` / `start_observation` relies on that ambient context to
attach. Every nested observation would have been exported as a
disconnected root -- experiment tasks and `observe`-instrumented helpers
included.

The detach now applies only when the ambient span is not Langfuse's own,
which the instrumentation scope identifies: spans opened by
`LANGFUSE_TRACER_NAME` are legitimate parents, everything else (host app
instrumentation, non-recording or remote spans) is foreign.

Added a regression spec asserting a nested observation keeps its parent's
trace_id; it fails without the guard.
@kxzk

kxzk commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

I checked this against langfuse-js and langfuse-python. Both preserve the active OpenTelemetry context, including with isolated providers. The JS SDK documents this explicitly. Both handle Langfuse application-root identification separately, and Ruby main already does the same.

I also reproduced two regressions in the current patch:

  • When sampling drops a Langfuse parent, that span has no instrumentation scope. The guard treats it as foreign. A nested observation can then start a new trace and be exported independently.
  • A third-party span can use Langfuse’s own provider. The guard still treats it as foreign, splitting an existing parent-child tree.

Both checks pass on main and fail with this PR.

Could you add a minimal reproduction of the empty or unnamed trace on current main, including the Ruby SDK and Langfuse server versions? That would help establish the cause before we change default parentage.

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