Skip to content

fix(langchain): detach orphaned context_api.attach() calls that corrupt OTel context#3807

Open
saivedant169 wants to merge 2 commits intotraceloop:mainfrom
saivedant169:fix/langchain-orphaned-context-attach
Open

fix(langchain): detach orphaned context_api.attach() calls that corrupt OTel context#3807
saivedant169 wants to merge 2 commits intotraceloop:mainfrom
saivedant169:fix/langchain-orphaned-context-attach

Conversation

@saivedant169
Copy link

@saivedant169 saivedant169 commented Mar 16, 2026

Fixes #3526

Description

The LangChain instrumentation has two code paths where context_api.attach() is called without a corresponding context_api.detach(), leaving orphaned contexts on the OpenTelemetry stack. After LangChain execution completes, trace.get_current_span() returns an ended span instead of the parent span, breaking downstream trace context propagation and log correlation.

Root cause

1. _create_span() — association_properties token lost

# BEFORE: token never saved, never detached
context_api.attach(
    context_api.set_value("association_properties", {...})
)

Fix: Save the token and store it in a new SpanHolder.association_properties_token field. Detach it in _end_span() alongside the span token.

2. on_chain_end() — redundant attach without detach

# BEFORE: orphaned attach to reset suppression key
context_api.attach(
    context_api.set_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, False)
)

Fix: Removed entirely. The suppression token is already properly detached by _end_span() via SpanHolder.token, so this second attach was both redundant and harmful.

Changes

  • span_utils.py: Added association_properties_token field to SpanHolder dataclass
  • callback_handler.py: Save association_properties attach token and detach in _end_span()
  • callback_handler.py: Remove redundant orphaned attach in on_chain_end()

Testing

  • ruff check and ruff format pass on all changed files
  • The first commit is a pre-existing formatting fix (ruff format applied to the two files); the second commit contains the actual bug fix

Summary by CodeRabbit

  • Refactor
    • Streamlined span lifecycle management for improved context handling
    • Enhanced context propagation and detachment mechanisms for LangGraph instrumentation
    • Consolidated code expressions for better maintainability and readability
    • Refined span state capture and token management processes

…pt OTel context

Two orphaned context_api.attach() calls were leaving stale contexts on
the OpenTelemetry stack, causing trace.get_current_span() to return
ended spans after LangChain execution.

1. _create_span(): association_properties attach token was never saved
   or detached. Now stored in SpanHolder.association_properties_token
   and detached in _end_span().

2. on_chain_end(): redundant attach to reset SUPPRESS key — already
   handled by _end_span() detaching SpanHolder.token. Removed.

Fixes traceloop#3526
@CLAassistant
Copy link

CLAassistant commented Mar 16, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link

coderabbitai bot commented Mar 16, 2026

📝 Walkthrough

Walkthrough

This pull request fixes orphaned context_api.attach() calls that corrupt the OpenTelemetry context stack. The changes properly track and detach context tokens for association properties, ensuring the context is restored after LangChain/LangGraph execution completes.

Changes

Cohort / File(s) Summary
Context Token Management
packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py
Introduced association_properties_token tracking to save and detach context attachments during span lifecycle. Modified _create_span() to save attachment tokens and _end_span() to detach both span and association tokens. Removed problematic context-reset pattern in on_chain_end() that re-attached suppression flags without cleanup. Consolidated inline expressions for readability without changing behavior.
Public API Extension
packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py
Added association_properties_token field to SpanHolder dataclass to support token lifecycle management. Applied minor formatting consolidations and simplified conditional extractions across utility functions while preserving functionality.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A context leak? Not on our watch today!
Tokens attached now have their proper way—
Detach and restore, the dance is complete,
No orphaned spans, the context so sweet! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.05% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main fix: saving and detaching orphaned context_api.attach() calls to prevent OTel context corruption.
Linked Issues check ✅ Passed The PR successfully addresses both issues identified in #3526: saves association_properties token in _create_span() for later detachment in _end_span(), and removes the redundant attach in on_chain_end().
Out of Scope Changes check ✅ Passed The PR introduces only code changes necessary to fix the context leak issues identified in #3526; minor formatting cleanups are incidental to the bug fix.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can use your project's `pylint` configuration to improve the quality of Python code reviews.

Add a pylint configuration file to your project to customize how CodeRabbit runs pylint.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py (1)

368-385: ⚠️ Potential issue | 🟠 Major

Fix double-encoding of OpenAI-style tool call arguments.

Lines 373-385 incorrectly handle pre-serialized arguments from OpenAI API responses. When tool_args comes from function.arguments (the fallback path), it's already a JSON string. Applying json.dumps() to a string wraps it in additional quotes/escapes, producing invalid span attributes. Check the type before serializing:

Suggested fix
-        _set_span_attribute(
-            span,
-            f"{tool_call_prefix}.arguments",
-            json.dumps(tool_args, cls=CallbackFilteredJSONEncoder),
-        )
+        serialized_args = (
+            tool_args
+            if isinstance(tool_args, str) or tool_args is None
+            else json.dumps(tool_args, cls=CallbackFilteredJSONEncoder)
+        )
+        _set_span_attribute(span, f"{tool_call_prefix}.arguments", serialized_args)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py`
around lines 368 - 385, _in _set_chat_tool_calls, pre-serialized OpenAI
function.arguments (a JSON string) is being passed through json.dumps which
double-encodes it; update the logic in _set_chat_tool_calls to check the type of
tool_args and only call json.dumps(tool_args, cls=CallbackFilteredJSONEncoder)
when tool_args is not a str (i.e., when it is a dict/list), otherwise pass the
string verbatim to _set_span_attribute for f"{tool_call_prefix}.arguments"; use
the existing CallbackFilteredJSONEncoder only for non-string objects and keep
using _set_span_attribute to set the final attribute.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py`:
- Around line 459-465: The code is overwriting the SpanHolder in _create_span(),
losing previously saved span and metadata attachment tokens; instead of
replacing self.spans[run_id] with a new SpanHolder, add a suppression_token
field to the existing SpanHolder (or construct the holder with suppression_token
set) and set that field when _safe_attach_context() returns the suppression
token; update SpanHolder to include suppression_token and modify _end_span() to
detach tokens in reverse attach order: first suppression_token, then the span
attachment token, then association_properties_token so all context attachments
are properly unwound.

---

Outside diff comments:
In
`@packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py`:
- Around line 368-385: _in _set_chat_tool_calls, pre-serialized OpenAI
function.arguments (a JSON string) is being passed through json.dumps which
double-encodes it; update the logic in _set_chat_tool_calls to check the type of
tool_args and only call json.dumps(tool_args, cls=CallbackFilteredJSONEncoder)
when tool_args is not a str (i.e., when it is a dict/list), otherwise pass the
string verbatim to _set_span_attribute for f"{tool_call_prefix}.arguments"; use
the existing CallbackFilteredJSONEncoder only for non-string objects and keep
using _set_span_attribute to set the final attribute.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f74a231e-4bad-4a06-864a-f4fd7074d97f

📥 Commits

Reviewing files that changed from the base of the PR and between 93786d9 and 5df3c51.

📒 Files selected for processing (2)
  • packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py
  • packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py

Comment on lines +459 to 465
token = context_api.attach(context_api.set_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, True))
except Exception:
# If context setting fails, continue without suppression token
token = None

self.spans[run_id] = SpanHolder(
span, token, None, [], workflow_name, None, entity_path
)
self.spans[run_id] = SpanHolder(span, token, None, [], workflow_name, None, entity_path)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -name "callback_handler.py" -path "*/instrumentation/langchain/*" | head -5

Repository: traceloop/openllmetry

Length of output: 175


🏁 Script executed:

wc -l packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py

Repository: traceloop/openllmetry

Length of output: 178


🏁 Script executed:

# Find SpanHolder class definition and key methods
rg "class SpanHolder" -A 10 packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py

Repository: traceloop/openllmetry

Length of output: 47


🏁 Script executed:

# Find _create_span method
rg "def _create_span" -A 30 packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py

Repository: traceloop/openllmetry

Length of output: 1434


🏁 Script executed:

# Find _create_llm_span method
rg "def _create_llm_span" -A 40 packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py

Repository: traceloop/openllmetry

Length of output: 1617


🏁 Script executed:

# Find _end_span method
rg "def _end_span" -A 20 packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py

Repository: traceloop/openllmetry

Length of output: 921


🏁 Script executed:

# Find SpanHolder namedtuple/class definition
rg "SpanHolder" packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py | head -20

Repository: traceloop/openllmetry

Length of output: 343


🏁 Script executed:

# Get more context around _create_span to see where the holder is stored
rg "def _create_span" -A 60 packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py | tail -40

Repository: traceloop/openllmetry

Length of output: 2025


🏁 Script executed:

# Check around line 450-465 to see the exact code
sed -n '440,475p' packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py

Repository: traceloop/openllmetry

Length of output: 1398


🏁 Script executed:

# Find SpanHolder definition/import
rg -B 5 "SpanHolder" packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py | grep -A 10 "class SpanHolder\|namedtuple.*SpanHolder\|SpanHolder ="

Repository: traceloop/openllmetry

Length of output: 47


🏁 Script executed:

# Get the complete _create_span method
sed -n '194,250p' packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py

Repository: traceloop/openllmetry

Length of output: 2595


🏁 Script executed:

# Check what imports are at the top
head -50 packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py | grep -i spanhold

Repository: traceloop/openllmetry

Length of output: 80


🏁 Script executed:

# Look for SpanHolder in the models/types files
find packages/opentelemetry-instrumentation-langchain -name "*.py" -exec grep -l "SpanHolder" {} \;

Repository: traceloop/openllmetry

Length of output: 374


🏁 Script executed:

# Find SpanHolder definition in patch.py or span_utils.py
rg "class SpanHolder|SpanHolder\s*=" packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py

Repository: traceloop/openllmetry

Length of output: 82


🏁 Script executed:

# Check patch.py
rg "class SpanHolder|SpanHolder\s*=" packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/patch.py

Repository: traceloop/openllmetry

Length of output: 47


🏁 Script executed:

# Look for imports in callback_handler.py
head -100 packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py | grep -i import

Repository: traceloop/openllmetry

Length of output: 1379


🏁 Script executed:

# Get the full SpanHolder class definition from span_utils.py
rg "class SpanHolder" -A 20 packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py

Repository: traceloop/openllmetry

Length of output: 624


🏁 Script executed:

# Also check if SpanHolder is a dataclass or namedtuple
sed -n '1,100p' packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py | grep -A 20 "SpanHolder"

Repository: traceloop/openllmetry

Length of output: 1470


🏁 Script executed:

# Get complete _create_span method to see if it stores the holder
sed -n '194,250p' packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py

Repository: traceloop/openllmetry

Length of output: 2595


🏁 Script executed:

# Check the end of _create_span where it returns
sed -n '220,250p' packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py

Repository: traceloop/openllmetry

Length of output: 1544


🏁 Script executed:

# Find where _create_span stores the SpanHolder to self.spans
rg "def _create_span" -A 80 packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py | head -100

Repository: traceloop/openllmetry

Length of output: 3585


🏁 Script executed:

# Search for all lines where self.spans[run_id] is assigned in _create_span
sed -n '257,335p' packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py

Repository: traceloop/openllmetry

Length of output: 3514


Don't replace the SpanHolder after _create_span() has saved its tokens.

_create_span() attaches the span context via _safe_attach_context() and stores the attachment token in the holder. When metadata is present, it also attaches and stores the association_properties_token. However, line 464 overwrites that holder with a new SpanHolder that replaces the span attachment token with only the suppression token and loses the metadata attachment token. When _end_span() runs, it cannot unwind the original span attachment or the metadata attachment—only the suppression token—leaving stale context attachments on the OpenTelemetry stack.

Suggested direction
-        try:
-            token = context_api.attach(context_api.set_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, True))
-        except Exception:
-            # If context setting fails, continue without suppression token
-            token = None
-
-        self.spans[run_id] = SpanHolder(span, token, None, [], workflow_name, None, entity_path)
+        try:
+            suppression_token = context_api.attach(
+                context_api.set_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, True)
+            )
+        except Exception:
+            suppression_token = None
+
+        self.spans[run_id].suppression_token = suppression_token

SpanHolder will need a dedicated suppression_token field, and _end_span() should detach in reverse attach order: suppression token, span token, then association_properties_token.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py`
around lines 459 - 465, The code is overwriting the SpanHolder in
_create_span(), losing previously saved span and metadata attachment tokens;
instead of replacing self.spans[run_id] with a new SpanHolder, add a
suppression_token field to the existing SpanHolder (or construct the holder with
suppression_token set) and set that field when _safe_attach_context() returns
the suppression token; update SpanHolder to include suppression_token and modify
_end_span() to detach tokens in reverse attach order: first suppression_token,
then the span attachment token, then association_properties_token so all context
attachments are properly unwound.

@saivedant169
Copy link
Author

Thanks for the thorough analysis @coderabbitai.

The point about _create_llm_span() overwriting the SpanHolder (line 464) is valid, but it's pre-existing behavior that was in the code before this PR — my changes only fix the two orphaned context_api.attach() calls reported in #3526. The _create_llm_span overwrite issue is a separate concern and should be tracked in its own issue if maintainers want it addressed.

The Sourcery review approved the changes. Happy to address any maintainer feedback.

@coderabbitai
Copy link

coderabbitai bot commented Mar 16, 2026

Tip

For best results, initiate chat on the files or code changes.

@saivedant169 Thank you for the clarification — that's a fair point. The _create_llm_span() SpanHolder overwrite is indeed pre-existing behavior and falls outside the scope of this PR, which is correctly focused on fixing the two orphaned context_api.attach() calls from #3526.

Would you like me to open a separate GitHub issue to track the _create_llm_span() SpanHolder overwrite concern so it doesn't get lost? I can include a backlink to this PR and the relevant context.

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.

🐛 Bug Report: Orphaned context_api.attach() Calls Corrupt OpenTelemetry Context Stack

2 participants