Skip to content

πŸ› Bug Report: Orphaned context_api.attach() Calls Corrupt OpenTelemetry Context StackΒ #3526

@michaelknopf

Description

@michaelknopf

Which component is this bug for?

Langchain Instrumentation

πŸ“œ Description

The LangChain instrumentation library has multiple code paths where context_api.attach() is called without a corresponding context_api.detach(), leaving orphaned contexts on the stack. This corrupts the OpenTelemetry context, causing subsequent code to lose access to the active trace context.

Impact

After LangChain/LangGraph execution completes, trace.get_current_span() returns an ended span (with is_recording() == False) instead of the parent span that should be active. This causes:

  1. Missing trace IDs in logs: Logging systems that check span.is_recording() before adding trace context see False and skip adding trace_id/span_id
  2. Broken span hierarchy: Child spans created after LangChain execution may attach to the wrong parent
  3. Inconsistent tracing: The same application has some logs with trace context and others without

Locations of Orphaned Attaches

File: opentelemetry/instrumentation/langchain/callback_handler.py

1. _create_span() method (lines 263-269)

if metadata is not None:
    current_association_properties = (
        context_api.get_value("association_properties") or {}
    )
    try:
        context_api.attach(  # ❌ No detach, token not saved
            context_api.set_value(
                "association_properties",
                {**current_association_properties, **sanitized_metadata},
            )
        )
    except Exception:
        pass

2. on_chain_end() method (lines 462-471)

self._end_span(span, run_id)
if parent_run_id is None:
    try:
        context_api.attach(  # ❌ No detach, token not saved
            context_api.set_value(
                SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, False
            )
        )
    except Exception:
        pass

Workaround We're Using

We implemented a context manager that saves the OpenTelemetry context before calling LangChain/LangGraph and restores it afterward:

from contextlib import contextmanager
from collections.abc import Generator
from opentelemetry import context as otel_context

@contextmanager
def preserve_otel_context() -> Generator[None]:
    """
    Preserve OpenTelemetry context across operations that may detach it.
    """
    token = otel_context.attach(otel_context.get_current())
    try:
        yield
    finally:
        otel_context.detach(token)

Usage:

with preserve_otel_context():
    result = await agent.ainvoke(...)
# Context is now correctly restored

This workaround doesn't fix the library's context leaks but allows us to restore the correct parent span after execution.

πŸ‘Ÿ Reproduction steps

  1. Set up OpenTelemetry with LangChain instrumentation:
from opentelemetry import trace
from opentelemetry.instrumentation.langchain import LangchainInstrumentor

LangchainInstrumentor().instrument()
tracer = trace.get_tracer(__name__)
  1. Create a parent span and invoke a LangChain chain inside it:
with tracer.start_as_current_span("parent_operation"):
    # Before LangChain
    span_before = trace.get_current_span()
    print(f"Before: {span_before.name}, recording={span_before.is_recording()}")

    # Invoke any LangChain chain or LangGraph
    result = await chain.ainvoke(input_data)

    # After LangChain
    span_after = trace.get_current_span()
    print(f"After: {span_after.name}, recording={span_after.is_recording()}")
  1. Observe the output:
Before: parent_operation, recording=True
After: some_langchain_span, recording=False  # ❌ Wrong span, already ended

πŸ‘ Expected behavior

After LangChain/LangGraph execution completes:

  • trace.get_current_span() should return the parent span (parent_operation)
  • The parent span should have is_recording() == True
  • Subsequent logging or span creation should use the correct parent context

πŸ‘Ž Actual Behavior with Screenshots

FYI I am including logs instead of screenshots.

After LangChain/LangGraph execution, the context points to an ended LangChain instrumentation span instead of the parent:

[TRACE DEBUG] Before ainvoke - Span: _Span(name="case_worker.process", context=SpanContext(trace_id=0x1c60dc422d09a853510b4c8f688decc7, span_id=0x7349095e5220669a, trace_flags=0x01, trace_state=[], is_remote=False)), Recording: True, Trace ID: 1c60dc422d09a853510b4c8f688decc7

[TRACE DEBUG] Inside preserve_otel_context, before ainvoke - Span: _Span(name="case_worker.process", context=SpanContext(trace_id=0x1c60dc422d09a853510b4c8f688decc7, span_id=0x7349095e5220669a, trace_flags=0x01, trace_state=[], is_remote=False)), Recording: True, Trace ID: 1c60dc422d09a853510b4c8f688decc7

# After LangGraph ainvoke() returns:
[TRACE DEBUG] Inside preserve_otel_context, after ainvoke - Span: _Span(name="judge.task", context=SpanContext(trace_id=0x1c60dc422d09a853510b4c8f688decc7, span_id=0x12ed093291f1de81, trace_flags=0x01, trace_state=[], is_remote=False)), Recording: False, Trace ID: None

# With our workaround, context is restored:
[TRACE DEBUG] After exiting preserve_otel_context - Span: _Span(name="case_worker.process", context=SpanContext(trace_id=0x1c60dc422d09a853510b4c8f688decc7, span_id=0x7349095e5220669a, trace_flags=0x01, trace_state=[], is_remote=False)), Recording: True, Trace ID: 1c60dc422d09a853510b4c8f688decc7

Key observations:

  1. After ainvoke(), the current span changed from case_worker.process to judge.task
  2. The judge.task span has Recording: False (it was ended)
  3. Without our workaround, subsequent logs would have no trace_id because the span is not recording

πŸ€– Python Version

3.13.7

πŸ“ƒ Provide any additional context for the Bug.

Versions

  • opentelemetry-instrumentation-langchain: 0.50.1
  • opentelemetry-api: 1.29.0
  • langchain-core: 0.3.29
  • langgraph: 0.2.63

πŸ‘€ Have you spent some time to check if this bug has been raised before?

  • I checked and didn't find similar issue

Are you willing to submit PR?

Yes I am willing to submit a PR!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions