From cd8844dedfe951e57311c87cf6c180cb395008bb Mon Sep 17 00:00:00 2001 From: Dvir Rezenman Date: Sun, 10 May 2026 14:19:59 +0300 Subject: [PATCH 1/2] fix(langchain): remove orphaned context_api.attach() in on_chain_end --- .../instrumentation/langchain/callback_handler.py | 10 ---------- packages/traceloop-sdk/uv.lock | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py b/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py index 55a18aafd0..af99da8c8a 100644 --- a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py +++ b/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py @@ -657,16 +657,6 @@ def on_chain_end( span.set_attribute(SpanAttributes.GEN_AI_TASK_OUTPUT, output_json) self._end_span(span, run_id) - if parent_run_id is None: - try: - context_api.attach( - context_api.set_value( - SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, False - ) - ) - except Exception: - # If context reset fails, it's not critical for functionality - pass @dont_throw def on_chat_model_start( diff --git a/packages/traceloop-sdk/uv.lock b/packages/traceloop-sdk/uv.lock index 9adc48aa2a..541627a4b9 100644 --- a/packages/traceloop-sdk/uv.lock +++ b/packages/traceloop-sdk/uv.lock @@ -2296,7 +2296,7 @@ dev = [ ] test = [ { name = "litellm", specifier = ">=1.71.2,<2" }, - { name = "openai-agents", specifier = ">=0.6.9" }, + { name = "openai-agents", specifier = ">=0.14.2" }, { name = "opentelemetry-sdk", specifier = ">=1.38.0,<2" }, { name = "pytest", specifier = ">=8.2.2,<9" }, { name = "pytest-asyncio", specifier = ">=1.0.0,<2" }, From 226d5dd8f2f29d26a4c1439e75dbc2322cb5317e Mon Sep 17 00:00:00 2001 From: Dvir Rezenman Date: Thu, 14 May 2026 11:36:37 +0300 Subject: [PATCH 2/2] add regression tests --- .../tests/test_context_token_lifecycle.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/packages/opentelemetry-instrumentation-langchain/tests/test_context_token_lifecycle.py b/packages/opentelemetry-instrumentation-langchain/tests/test_context_token_lifecycle.py index fbaca9f2b1..09c1a86e5c 100644 --- a/packages/opentelemetry-instrumentation-langchain/tests/test_context_token_lifecycle.py +++ b/packages/opentelemetry-instrumentation-langchain/tests/test_context_token_lifecycle.py @@ -238,6 +238,102 @@ def test_duplicate_run_id_replaces_association_properties(handler): ) +# --------------------------------------------------------------------------- +# Issue #3526 — orphaned context_api.attach() in on_chain_end corrupts context stack +# --------------------------------------------------------------------------- + +def test_on_chain_end_does_not_leak_context_frame(handler): + """ + Regression test for issue #3526. + + Before the fix, on_chain_end() contained an orphaned context_api.attach() call + for root chains (parent_run_id=None): + + context_api.attach(context_api.set_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, False)) + + No token was saved, so the frame was *never detached*. The OTel context stack + grew by one entry on every root chain completion — permanently polluting the + context for the lifetime of the thread/task. + + Observable effect: after on_chain_end the key is False (explicitly set) rather + than None (absent/unset), and it stays False for all subsequent work in the + same execution context. + + The fix removes the orphaned attach entirely — _end_span() already detaches the + suppression token stored in the SpanHolder, which restores the pre-chain context. + """ + chain_run_id = uuid4() + llm_run_id = uuid4() + + assert context_api.get_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY) is None, ( + "precondition: suppression key must be absent before test" + ) + + # Simulate on_chain_start for a root chain + handler.on_chain_start( + serialized={"name": "TestChain"}, + inputs={"input": "hello"}, + run_id=chain_run_id, + parent_run_id=None, + ) + + # Simulate an LLM call inside the chain (sets suppression) + handler._create_llm_span(llm_run_id, chain_run_id, "gpt-4", LLMRequestTypeValues.CHAT) + + assert _suppression_active(), "sanity: suppression must be active during LLM span" + + # End the LLM span + llm_span = handler.spans[llm_run_id].span + handler._end_span(llm_span, llm_run_id) + + # End the root chain — this is where the orphaned attach fires in the buggy code + handler.on_chain_end( + outputs={"output": "result"}, + run_id=chain_run_id, + parent_run_id=None, + ) + + # The suppression key must be truly absent (None), not just falsy (False). + # Bug present → value is False (orphaned attach pushed False onto the stack and never popped) + # Bug fixed → value is None (key is absent; _end_span restored the baseline context) + raw_value = context_api.get_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY) + assert raw_value is None, ( + f"Issue #3526 not fixed: SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY is " + f"{raw_value!r} after on_chain_end, expected None. " + "The orphaned context_api.attach() in on_chain_end pushed False onto the context " + "stack without saving a token, so it was never detached. " + "Fix: remove the orphaned attach — _end_span() already restores the context." + ) + + +def test_on_chain_end_context_stack_does_not_accumulate(handler): + """ + Complementary check for issue #3526: running N root chains must not grow the + context stack. Detected by checking the suppression key stays None after each + completion and never becomes False from a previous chain's leaked frame. + """ + for i in range(3): + chain_run_id = uuid4() + + handler.on_chain_start( + serialized={"name": f"Chain{i}"}, + inputs={"input": "x"}, + run_id=chain_run_id, + parent_run_id=None, + ) + handler.on_chain_end( + outputs={"output": "y"}, + run_id=chain_run_id, + parent_run_id=None, + ) + + raw_value = context_api.get_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY) + assert raw_value is None, ( + f"Issue #3526: after chain #{i + 1}, SUPPRESS key is {raw_value!r} " + f"(expected None). Each root on_chain_end leaked a context frame." + ) + + def test_duplicate_llm_run_id_replaces_association_properties(handler): """ Replacing an LLM span holder must clear the prior metadata context and suppression.