open-telemetry / open-telemetry/opentelemetry-python-genai
langchain: spans are not nested — every step becomes its own root span/trace (async), and attach/detach raises "was created in a different Context"
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 39
- Forks
- 63
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 175
Description
Component: opentelemetry-instrumentation-genai-langchain (1.2b0.dev) + opentelemetry-util-genai
Type: bug
Summary
When the LangChain callback handler is driven by an async LangGraph run (graph.ainvoke, and/or sync tools/models invoked under an async graph via a thread-pool), the emitted spans are not nested. Each GenAI step — invoke_workflow, every chat, every execute_tool — is exported as a separate root span with its own trace id (parentId = null). A single user interaction fragments into N one-span traces instead of one connected trace.
At runtime OpenTelemetry also logs:
Failed to detach context
ValueError: <Token var=<ContextVar name='current_context' ...>> was created in a different Context
Root cause
Parent/child linkage relies entirely on the ambient OTel context via attach/detach, and the LangChain parent_run_id — which the handler already receives and stores — is never used to set the span parent.
util/opentelemetry-util-genai/.../_invocation.py:
def _start(self, attributes=None):
self.span = self._tracer.start_span( # no context= -> parents from CURRENT contextvar
name=self._span_name, kind=self._span_kind, attributes=attributes,
)
self._span_context = set_span_in_context(self.span)
self._context_token = attach(self._span_context) # attach on THIS callback
...
def _finish(self, error=None):
...
detach(context_token) # detach on a LATER, different callback
self.span.end()
start_span() is called with no context=, so it takes whatever is in the ambient contextvars at that instant as the parent. attach() happens in on_*_start and the matching detach() happens in on_*_end — a different callback invocation.
instrumentation/.../callback_handler.py receives parent_run_id on every callback but only feeds it to bookkeeping (add_invocation_state, _find_nearest_agent) — it is never turned into a parent span context:
self._invocation_manager.add_invocation_state(run_id, parent_run_id, llm_invocation)
Because the start/finish attach/detach pair is split across separate callbacks, and LangGraph runs nodes as their own asyncio tasks (and dispatches sync models/tools to thread-pool workers), the contextvar set by a parent's attach() is not visible in the child callback's execution context. So:
- the child's
start_spansees an empty context → new root span → new trace, and - the later
detach()runs in a differentContextthan theattach()→ValueError: ... created in a different Context→ "Failed to detach context".
attach/detach are LIFO, single-call-stack primitives; using them across separate, possibly-concurrent callbacks that hop asyncio tasks / threads is not sound. (Compare traceloop's opentelemetry-instrumentation-langchain, which keeps its own run_id → span map and explicitly threads the parent via start_span(context=...); under the identical async graph it produces one correctly nested trace.)
Steps to reproduce
Minimal supervisor graph with two react-agent workers, instrumented with LangChainInstrumentor, run with graph.ainvoke:
from opentelemetry.instrumentation.genai.langchain import LangChainInstrumentor
LangChainInstrumentor().instrument(tracer_provider=provider)
# ... build a StateGraph supervisor + weather/finance create_react_agent nodes ...
graph = builder.compile()
await graph.ainvoke({"messages": [{"role": "user",
"content": "What's the weather in Paris, and convert 100 USD to EUR?"}]})
provider.shutdown()
(Full ~140-line repro available; it's a standard supervisor + create_react_agent weather/finance graph.)
Observed
- async (
graph.ainvoke):Failed to detach contextlogged; exported spans are allparentId=null, each in its own trace id. One interaction → many single-span traces. - Span scope:
opentelemetry.util.genai.handler.
Expected
One trace per top-level invocation, with chat / execute_tool / agent spans nested under the workflow/agent span — regardless of sync vs. async execution.
Suggested fix
Don't depend on ambient attach/detach for parent linkage across callbacks. Instead, look up the parent invocation by parent_run_id, and pass its span context explicitly:
parent = self._invocation_manager.get_invocation(parent_run_id)
parent_ctx = parent._span_context if parent else None
span = tracer.start_span(name=..., kind=..., context=parent_ctx, attributes=...)
This makes nesting independent of which asyncio task / thread the callback fires on, and removes the cross-Context detach error. (Also see #491, which is the same attach/detach context-lifetime fragility surfacing via streaming.)
Environment
opentelemetry-instrumentation-genai-langchain1.2b0.devopentelemetry-util-genai(matching)- langgraph V1.x (
create_react_agent), langchain-openai, Python 3.14
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with util/opentelemetry-util-genai/.../_invocation.py and instrumentation/.../callback_handler.py, then run the minimal async LangGraph reproduction described in the issue. Trace parent_run_id through invocation state and verify that the async graph produces one nested trace without cross-Context detach errors.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- observability
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 52/100