Skip to content

fix(instrumentation): langchain association properties context cleanup#4055

Open
kamilamin123 wants to merge 4 commits intotraceloop:mainfrom
kamilamin123:fix/4054-langchain-association-properties-token
Open

fix(instrumentation): langchain association properties context cleanup#4055
kamilamin123 wants to merge 4 commits intotraceloop:mainfrom
kamilamin123:fix/4054-langchain-association-properties-token

Conversation

@kamilamin123
Copy link
Copy Markdown

@kamilamin123 kamilamin123 commented Apr 25, 2026

closes #4054

  • I have added tests that cover my changes.
  • If adding a new instrumentation or changing an existing one, I've added screenshots from some observability platform showing the change.
  • PR name follows conventional commits format: feat(instrumentation): ... or fix(instrumentation): ....
  • (If applicable) I have updated the documentation accordingly.

What changed

  • track the association_properties context attachment token on LangChain SpanHolder
  • detach tracked holder tokens during span cleanup and before replacing an existing holder for the same run_id
  • preserve the metadata context token when _create_llm_span() swaps the holder token to suppression context
  • add lifecycle regression tests covering normal cleanup and duplicate run_id replacement

Why

Issue #4054 identified that _create_span() attaches association_properties into OpenTelemetry context without retaining a token to detach later. That can leave stale metadata in context after the span ends or when a holder is overwritten.

Impact

This fixes a context leak in the LangChain instrumentation so downstream spans and logs do not inherit stale association_properties from earlier runs.

Root cause

_create_span() called context_api.attach(context_api.set_value(...)) for association_properties but dropped the returned token. Since the token was never stored on the span holder, there was no matching detach during _end_span() or holder replacement.

Validation

  • python3 -m compileall packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain packages/opentelemetry-instrumentation-langchain/tests/test_context_token_lifecycle.py
  • PYTHONPATH=packages/opentelemetry-instrumentation-langchain ./.venv/bin/python -m pytest -q --noconftest packages/opentelemetry-instrumentation-langchain/tests/test_context_token_lifecycle.py
    • result: 5 passed

Summary by CodeRabbit

  • Bug Fixes
    • Improved trace-context cleanup so association metadata and suppression tokens are properly detached when spans end or when span identifiers are reused, preventing stale metadata from leaking across operations.
  • Tests
    • Added regression tests validating lifecycle and cleanup of association metadata (including LLM-related spans) during span replacement and termination.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 25, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a5a2c3a9-c7a1-4acf-8e9a-93eafc0c2775

📥 Commits

Reviewing files that changed from the base of the PR and between a05063e and facc477.

📒 Files selected for processing (1)
  • packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py

📝 Walkthrough

Walkthrough

Adds tracking and cleanup for OpenTelemetry association_properties context tokens: tokens are stored on SpanHolder, detached when spans end or when a SpanHolder is replaced for the same run_id, and span-creation flows updated to avoid orphaned tokens.

Changes

Cohort / File(s) Summary
Context token lifecycle updates
packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py
Capture and store the token returned by context_api.attach for association_properties; centralized detach helper _detach_holder_contexts added; _end_span detaches tokens for child holders and run holder and only ends spans when not already ended; _create_span detaches any existing holder for same run_id before overwriting; _create_llm_span relies on _create_span for duplicate-holder detachment and preserves prior association_properties_token when replacing holders.
SpanHolder schema
packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py
Adds optional association_properties_token: Any = None to SpanHolder to hold the association_properties context token for lifecycle management.
Tests for token lifecycle
packages/opentelemetry-instrumentation-langchain/tests/test_context_token_lifecycle.py
Adds _association_properties() test helper and regression tests asserting association_properties tokens are detached on span end and when a SpanHolder is replaced (including LLM span replacement cases).

Sequence Diagram(s)

sequenceDiagram
    participant Handler as TraceloopCallbackHandler
    participant SpanHolder as SpanHolder
    participant ContextAPI as context_api
    participant Span as Span

    rect rgba(200,200,255,0.5)
    Handler->>SpanHolder: request/create span (run_id, metadata)
    end

    rect rgba(200,255,200,0.5)
    Handler->>ContextAPI: attach(association_properties=...)
    ContextAPI-->>Handler: association_properties_token
    Handler->>SpanHolder: store association_properties_token
    end

    rect rgba(255,200,200,0.5)
    Handler->>Span: end span
    Handler->>SpanHolder: detach association_properties_token (and span token)
    ContextAPI-->>Handler: detached
    end

    rect rgba(255,255,200,0.5)
    Handler->>SpanHolder: replace existing holder for same run_id
    Handler->>ContextAPI: detach previous association_properties_token (if any)
    ContextAPI-->>Handler: detached
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped through spans and tokens tight,

I stored the bits that hid from sight.
When runs conclude, I tug the thread,
Detach the traces, clear the spread.
🥕 Clean context, hop — all's right!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main fix: preventing association_properties context from leaking by implementing proper cleanup.
Linked Issues check ✅ Passed Changes fully address issue #4054: token tracking added to SpanHolder, detachment implemented in _end_span and _create_span, and holder replacement cleanup added.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the association_properties context leak; no unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@kamilamin123 kamilamin123 changed the title fix langchain association properties context cleanup fix(instrumentation): langchain association properties context cleanup Apr 25, 2026
Copy link
Copy Markdown

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

🧹 Nitpick comments (2)
packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py (1)

208-217: Pre-existing: child holders are not cleaned up here.

Outside the scope of this PR, but worth noting: when _end_span recursively ends child spans (lines 209–213), it does not call _detach_holder_contexts on them or del them from self.spans. If a parent ends before its children, child token/association_properties_token will continue to leak. Consider a follow-up to apply the same lifecycle handling to child holders.

🤖 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 208 - 217, The _end_span method currently ends child spans (in the
loop over self.spans[run_id].children) but does not call _detach_holder_contexts
nor remove the child entries from self.spans, which can leak child holder tokens
(e.g., token and association_properties_token); update _end_span so that for
each child_id you: retrieve the child holder entry (self.spans[child_id]), call
self._detach_holder_contexts(...) for that child holder, end the child_span only
if not already ended, and then delete self.spans[child_id] before continuing,
preserving the existing behavior for the parent span and final cleanup of
self.spans[run_id].
packages/opentelemetry-instrumentation-langchain/tests/test_context_token_lifecycle.py (1)

202-235: Good duplicate-run_id replacement coverage; consider adding the LLM variant.

This nicely covers _create_span replacement semantics. Optional follow-up: add a parallel test that exercises _create_llm_span twice with the same run_id and metadata= set, asserting that both suppression and association_properties are cleared after _end_span. Currently test_duplicate_run_id_leaks_suppression_token only checks suppression, and test_duplicate_run_id_replaces_association_properties only goes through _create_span, so the combined LLM-with-metadata duplicate path isn't directly asserted.

Note: first_span here is intentionally never .end()-ed to focus the assertion on context-token cleanup; a brief inline comment to that effect (beyond the existing "sanity" assertion) would prevent future readers from "fixing" it.

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

In
`@packages/opentelemetry-instrumentation-langchain/tests/test_context_token_lifecycle.py`
around lines 202 - 235, Add a new test mirroring
test_duplicate_run_id_replaces_association_properties but using the
handler._create_llm_span API: call handler._create_llm_span twice with the same
run_id and different metadata, assert the first LLM span remains open (add a
short inline comment that the first span is intentionally not ended), verify
that association metadata from the first span is cleared after creating the
replacement (via _association_properties()), verify suppression-token behavior
as in test_duplicate_run_id_leaks_suppression_token, then call handler._end_span
on the second span and assert _association_properties() is empty and suppression
is cleared; reference handler._create_llm_span, handler._end_span,
_association_properties, and test_duplicate_run_id_leaks_suppression_token when
locating where to add this test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py`:
- Around line 208-217: The _end_span method currently ends child spans (in the
loop over self.spans[run_id].children) but does not call _detach_holder_contexts
nor remove the child entries from self.spans, which can leak child holder tokens
(e.g., token and association_properties_token); update _end_span so that for
each child_id you: retrieve the child holder entry (self.spans[child_id]), call
self._detach_holder_contexts(...) for that child holder, end the child_span only
if not already ended, and then delete self.spans[child_id] before continuing,
preserving the existing behavior for the parent span and final cleanup of
self.spans[run_id].

In
`@packages/opentelemetry-instrumentation-langchain/tests/test_context_token_lifecycle.py`:
- Around line 202-235: Add a new test mirroring
test_duplicate_run_id_replaces_association_properties but using the
handler._create_llm_span API: call handler._create_llm_span twice with the same
run_id and different metadata, assert the first LLM span remains open (add a
short inline comment that the first span is intentionally not ended), verify
that association metadata from the first span is cleared after creating the
replacement (via _association_properties()), verify suppression-token behavior
as in test_duplicate_run_id_leaks_suppression_token, then call handler._end_span
on the second span and assert _association_properties() is empty and suppression
is cleared; reference handler._create_llm_span, handler._end_span,
_association_properties, and test_duplicate_run_id_leaks_suppression_token when
locating where to add this test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 315a3ecc-0694-4e9d-8238-30c3281b8542

📥 Commits

Reviewing files that changed from the base of the PR and between 3efa0eb and 417a9c2.

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

Copy link
Copy Markdown

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

🧹 Nitpick comments (2)
packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py (2)

208-222: Minor: detach-vs-end ordering is asymmetric between children and parent.

For each child, tokens are detached before child_span.end(); for the parent (run_id), tokens are detached after span.end(). Functionally equivalent here (span finalization doesn't depend on the active OTel context), but the asymmetry is easy to misread. Consider detaching the parent's tokens before span.end() for symmetry, or document the intent.

♻️ Optional consistency tweak
-        if span.end_time is None:  # avoid warning on ended spans
-            span.end()
-        self._detach_holder_contexts(self.spans[run_id])
+        self._detach_holder_contexts(self.spans[run_id])
+        if span.end_time is None:  # avoid warning on ended spans
+            span.end()
🤖 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 208 - 222, The _end_span method detaches context tokens for
children before ending their spans but detaches the parent's tokens after
calling span.end(), creating an asymmetric ordering; update _end_span to call
self._detach_holder_contexts(self.spans[run_id]) before calling span.end()
(matching the child flow) so the parent span's contexts are detached prior to
span.end(), keeping the logic consistent for run_id, span, and using the
existing _detach_holder_contexts and spans structures.

466-471: Stale comment — the described action no longer happens here.

The comment claims the old holder is captured and detached before _create_span runs, but no such capture/detach exists at this call site anymore — that responsibility has been moved into _create_span via the new _detach_holder_contexts(old_holder) block. As written, the comment will mislead future maintainers into expecting (or re-adding) detach logic here.

📝 Suggested rewrite
-        # Capture and detach the old holder BEFORE _create_span runs.
-        # _create_span unconditionally overwrites self.spans[run_id], which
-        # would make the old SpanHolder (and its suppression token) unreachable.
-        # Detaching here, before the overwrite, ensures we clean up the original
-        # suppression token from a duplicate run_id and that the new suppression
-        # is layered on top of the clean baseline context.
+        # _create_span detaches any prior holder's tokens (including a stale
+        # suppression token from a duplicate run_id) before overwriting
+        # self.spans[run_id], so the new suppression below layers on top of a
+        # clean baseline context.
         span = self._create_span(
🤖 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 466 - 471, The existing comment is stale: it says the old
SpanHolder is captured and detached before _create_span runs, but that logic was
moved into _create_span via the new _detach_holder_contexts(old_holder) call;
update the comment around the call site so it no longer claims a pre-detach
occurs here, instead succinctly noting that _create_span is responsible for
detaching any existing SpanHolder (via _detach_holder_contexts) and that it will
overwrite self.spans[run_id], so callers should rely on _create_span to manage
suppression tokens and context cleanup; reference SpanHolder, _create_span,
_detach_holder_contexts, self.spans, and run_id in the revised comment.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py`:
- Around line 208-222: The _end_span method detaches context tokens for children
before ending their spans but detaches the parent's tokens after calling
span.end(), creating an asymmetric ordering; update _end_span to call
self._detach_holder_contexts(self.spans[run_id]) before calling span.end()
(matching the child flow) so the parent span's contexts are detached prior to
span.end(), keeping the logic consistent for run_id, span, and using the
existing _detach_holder_contexts and spans structures.
- Around line 466-471: The existing comment is stale: it says the old SpanHolder
is captured and detached before _create_span runs, but that logic was moved into
_create_span via the new _detach_holder_contexts(old_holder) call; update the
comment around the call site so it no longer claims a pre-detach occurs here,
instead succinctly noting that _create_span is responsible for detaching any
existing SpanHolder (via _detach_holder_contexts) and that it will overwrite
self.spans[run_id], so callers should rely on _create_span to manage suppression
tokens and context cleanup; reference SpanHolder, _create_span,
_detach_holder_contexts, self.spans, and run_id in the revised comment.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2c03cec5-b12a-4049-9f43-c2796eeb1fd4

📥 Commits

Reviewing files that changed from the base of the PR and between 417a9c2 and 3aea188.

📒 Files selected for processing (2)
  • packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py
  • packages/opentelemetry-instrumentation-langchain/tests/test_context_token_lifecycle.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/opentelemetry-instrumentation-langchain/tests/test_context_token_lifecycle.py

Copy link
Copy Markdown

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

🧹 Nitpick comments (2)
packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py (2)

198-222: Helper + _end_span cleanup logic looks correct.

LIFO order is preserved for the common case: _create_span attaches association_properties_token first (line 298) then the span context token (line 336), so detaching token before association_properties_token in _detach_holder_contexts is the right order for both non-LLM and LLM spans (where token becomes the suppression token attached on top of association_properties_token). Children are also drained before the parent, which keeps the context stack consistent with the attach order under normal nesting.

One minor hygiene note: _detach_holder_contexts doesn't null out span_holder.token / span_holder.association_properties_token after a successful detach. Today every call site either deletes the holder or overwrites it immediately, so this is safe — but if a future change ever re-invokes the helper on the same holder, the same tokens would be detached twice. _safe_detach_context swallows the resulting error, so it's just defensive cleanup, not a correctness issue.

♻️ Optional: clear tokens after detach to make the helper idempotent
     def _detach_holder_contexts(self, span_holder: SpanHolder | None) -> None:
         """Detach any tracked context tokens stored on a span holder."""
         if span_holder is None:
             return

         if span_holder.token:
             self._safe_detach_context(span_holder.token)
+            span_holder.token = None
         if span_holder.association_properties_token:
             self._safe_detach_context(span_holder.association_properties_token)
+            span_holder.association_properties_token = None
🤖 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 198 - 222, The helper _detach_holder_contexts may detach the same
context token twice if called again on the same SpanHolder; after calling
self._safe_detach_context for span_holder.token and
span_holder.association_properties_token, set span_holder.token = None and
span_holder.association_properties_token = None to clear the references and make
_detach_holder_contexts idempotent; update the function in callback_handler.py
where SpanHolder is handled (and ensure related flows in _end_span that delete
holders still work) so subsequent calls won't attempt to detach already-cleared
tokens.

495-526: Redundant current_holder.token = None — the holder is replaced on the very next statement.

At line 515 you overwrite self.spans[run_id] with a brand-new SpanHolder, so mutating current_holder.token to None first has no observable effect (and the only other reference, current_holder.association_properties_token, is read after this point, but the token field isn't). It's harmless but a small footgun: it implies current_holder keeps living, when in fact it's discarded. Consider dropping the assignment for clarity.

The rest of the swap (detach the span context token from _create_span, attach suppression on top, then carry forward the freshly-attached association_properties_token into the replacement holder) is correct — the assoc-props token stays referenced exactly once so _end_span can detach it later.

♻️ Drop the dead mutation
         current_holder = self.spans.get(run_id)
         if current_holder is not None and current_holder.token is not None:
             self._safe_detach_context(current_holder.token)
-            current_holder.token = None
🤖 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 495 - 526, The assignment current_holder.token = None is redundant
because you immediately replace self.spans[run_id] with a new SpanHolder; remove
that line to avoid misleading mutation. In callback_handler.py inside the block
that calls self._safe_detach_context(current_holder.token) and then constructs
SpanHolder(...,
association_properties_token=(current_holder.association_properties_token if
current_holder else None), ...), delete the current_holder.token = None
statement so only the detach occurs and the association_properties_token is
still read from current_holder for the new SpanHolder (no other changes to
_safe_detach_context or SpanHolder construction required).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py`:
- Around line 198-222: The helper _detach_holder_contexts may detach the same
context token twice if called again on the same SpanHolder; after calling
self._safe_detach_context for span_holder.token and
span_holder.association_properties_token, set span_holder.token = None and
span_holder.association_properties_token = None to clear the references and make
_detach_holder_contexts idempotent; update the function in callback_handler.py
where SpanHolder is handled (and ensure related flows in _end_span that delete
holders still work) so subsequent calls won't attempt to detach already-cleared
tokens.
- Around line 495-526: The assignment current_holder.token = None is redundant
because you immediately replace self.spans[run_id] with a new SpanHolder; remove
that line to avoid misleading mutation. In callback_handler.py inside the block
that calls self._safe_detach_context(current_holder.token) and then constructs
SpanHolder(...,
association_properties_token=(current_holder.association_properties_token if
current_holder else None), ...), delete the current_holder.token = None
statement so only the detach occurs and the association_properties_token is
still read from current_holder for the new SpanHolder (no other changes to
_safe_detach_context or SpanHolder construction required).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cca33ac5-457a-4bd8-be6f-f1ede9a7eef2

📥 Commits

Reviewing files that changed from the base of the PR and between 3aea188 and a05063e.

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

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: langchain: _create_span leaks association_properties context token

1 participant