From 394b297706c5b3daff7630cafafd22eedf9266bc Mon Sep 17 00:00:00 2001 From: bgodlin <37313677+bgodlin@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:23:57 +0000 Subject: [PATCH 1/2] fix(langfuse): shutdown client on session finalize to avoid interpreter-teardown TypeError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The langfuse plugin never called client.shutdown(), relying on the SDK's atexit handler. That fires during interpreter finalization, after opentelemetry.trace.Span is torn down to None — use_span's isinstance(span, Span) raises TypeError, surfaced as 'Exception ignored in: ' on quit. Register on_session_finalize to call client.shutdown() while the interpreter is alive. --- plugins/observability/langfuse/__init__.py | 28 ++++++++++++++++++++++ plugins/observability/langfuse/plugin.yaml | 1 + tests/plugins/test_langfuse_plugin.py | 3 ++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/plugins/observability/langfuse/__init__.py b/plugins/observability/langfuse/__init__.py index 31904d47e3052..2bdb7f4fa4c36 100644 --- a/plugins/observability/langfuse/__init__.py +++ b/plugins/observability/langfuse/__init__.py @@ -1125,6 +1125,33 @@ def on_post_tool_call(*, tool_name: str = "", args: Any = None, result: Any = No ) +def on_session_finalize(**_: Any) -> None: + # Explicitly shut down the Langfuse client at the true session boundary + # (CLI exit, /new, /reset) while the interpreter is still alive. The + # Langfuse SDK registers its own atexit shutdown handler, but that runs + # during interpreter finalization — by then module globals (notably + # opentelemetry.trace.Span) may already be torn down to None, and the + # SDK's span-finalization path (use_span → isinstance(span, Span)) + # raises "TypeError: isinstance() arg 2 must be a type" which surfaces + # as a noisy "Exception ignored in: " traceback on quit. + # Calling shutdown() here flushes pending spans and joins the background + # export threads while all modules are intact; the SDK's atexit handler + # then becomes a no-op (it checks _shutdown and unregisters itself). + client = _get_langfuse() + if client is None: + return + # Finish any in-flight traces first so their spans are flushed by + # _finish_trace's own client.flush() before we tear down the client. + with _STATE_LOCK: + keys = list(_TRACE_STATE.keys()) + for key in keys: + _finish_trace(key) + try: + client.shutdown() + except Exception as exc: # pragma: no cover - fail-open + _debug(f"langfuse shutdown failed: {exc}") + + def register(ctx) -> None: # Register for both hook name variants so the plugin works across # Hermes versions. pre_api_request / post_api_request fire per API @@ -1135,3 +1162,4 @@ def register(ctx) -> None: ctx.register_hook("post_llm_call", on_post_llm_call) ctx.register_hook("pre_tool_call", on_pre_tool_call) ctx.register_hook("post_tool_call", on_post_tool_call) + ctx.register_hook("on_session_finalize", on_session_finalize) diff --git a/plugins/observability/langfuse/plugin.yaml b/plugins/observability/langfuse/plugin.yaml index 18f1c6245d3d5..add0aa1ae3612 100644 --- a/plugins/observability/langfuse/plugin.yaml +++ b/plugins/observability/langfuse/plugin.yaml @@ -12,3 +12,4 @@ hooks: - post_llm_call - pre_tool_call - post_tool_call + - on_session_finalize diff --git a/tests/plugins/test_langfuse_plugin.py b/tests/plugins/test_langfuse_plugin.py index d18724b66074d..469a9819e345c 100644 --- a/tests/plugins/test_langfuse_plugin.py +++ b/tests/plugins/test_langfuse_plugin.py @@ -25,11 +25,12 @@ def test_manifest_fields(self): data = yaml.safe_load((PLUGIN_DIR / "plugin.yaml").read_text()) assert data["name"] == "langfuse" assert data["version"] - # All six hooks the plugin implements. + # All seven hooks the plugin implements (six lifecycle + shutdown). assert set(data["hooks"]) == { "pre_api_request", "post_api_request", "pre_llm_call", "post_llm_call", "pre_tool_call", "post_tool_call", + "on_session_finalize", } # Required env vars are the user-facing HERMES_ prefixed keys. assert "HERMES_LANGFUSE_PUBLIC_KEY" in data["requires_env"] From 3f50558e268f96740592e18e71ed24327aeb7f0f Mon Sep 17 00:00:00 2001 From: bgodlin <37313677+bgodlin@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:22:01 +0000 Subject: [PATCH 2/2] fix(langfuse): close root-observation CMs to prevent interpreter-teardown TypeError The on_session_finalize hook (added in the previous commit) calls client.shutdown() but that only flushes the SDK's internal queues. It does not unwind the root observation context managers the plugin itself created: _start_root_trace enters start_as_current_observation(...).__enter__() but _finish_trace only called root_span.end(), never root_ctx.__exit__(). The generator stays suspended inside 'with otel_trace_api.use_span(parent_span):' until the GC collects it during interpreter teardown. By then opentelemetry.trace.Span has been torn down to None, and use_span's isinstance(span, Span) raises: TypeError: isinstance() arg 2 must be a type surfaced as 'Exception ignored in: ' on every CLI exit. Fix: call root_ctx.__exit__(None, None, None) right after root_span.end() in both _finish_trace and _evict_stale_locked. This unwinds the generator while all modules are intact. Regression test: test_finish_trace_exits_root_context_manager verifies __exit__ is called and fails on the pre-fix code. --- plugins/observability/langfuse/__init__.py | 16 +++++++ tests/plugins/test_langfuse_plugin.py | 54 ++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/plugins/observability/langfuse/__init__.py b/plugins/observability/langfuse/__init__.py index 2bdb7f4fa4c36..3d239b5bfa731 100644 --- a/plugins/observability/langfuse/__init__.py +++ b/plugins/observability/langfuse/__init__.py @@ -730,6 +730,11 @@ def _evict_stale_locked() -> None: _TRACE_STATE.pop(key, None) try: state.root_span.end() + if state.root_ctx is not None: + try: + state.root_ctx.__exit__(None, None, None) + except Exception: # pragma: no cover - fail-open + pass except Exception as exc: # pragma: no cover - fail-open _debug(f"evict stale trace failed: {exc}") @@ -757,6 +762,17 @@ def _finish_trace(task_key: str, *, output: Any = None) -> None: state.root_span.set_trace_io(output=final_output) state.root_span.update(output=final_output) state.root_span.end() + # Properly exit the root context manager so the generator unwinds + # now, while opentelemetry.trace.Span is still a real type. Without + # this the generator is left suspended; at interpreter teardown the + # GC calls .close(), which throws GeneratorExit through use_span + # __exit__ -> isinstance(span, Span) -- but Span has been torn down + # to None by then, producing the TypeError traceback on quit. + if state.root_ctx is not None: + try: + state.root_ctx.__exit__(None, None, None) + except Exception: # pragma: no cover - fail-open + pass except Exception as exc: # pragma: no cover - fail-open _debug(f"finish trace failed: {exc}") finally: diff --git a/tests/plugins/test_langfuse_plugin.py b/tests/plugins/test_langfuse_plugin.py index 469a9819e345c..a959c8e7d1776 100644 --- a/tests/plugins/test_langfuse_plugin.py +++ b/tests/plugins/test_langfuse_plugin.py @@ -362,6 +362,60 @@ def test_non_finalizing_turns_do_not_grow_state_unboundedly(self, monkeypatch): surviving = sorted(int(k.rsplit("turn", 1)[1]) for k in mod._TRACE_STATE) assert surviving == list(range(42, 50)) + def test_finish_trace_exits_root_context_manager(self, monkeypatch): + """_finish_trace must call root_ctx.__exit__(), not just root_span.end(). + + Regression for the "Exception ignored in: " traceback + on CLI exit. The plugin enters the root observation's context + manager (start_as_current_observation(...).__enter__()) but must + also exit it; otherwise the generator is left suspended and is + only unwound when the GC collects it during interpreter teardown. + By then opentelemetry.trace.Span has been set to None, and the + generator's close() -> use_span.__exit__ -> isinstance(span, Span) + raises TypeError: isinstance() arg 2 must be a type. Exiting the + context manager here unwinds the generator while modules are intact. + """ + mod = self._fresh_plugin() + started: list = [] + monkeypatch.setattr(mod, "_end_observation", lambda *a, **k: None) + mod._TRACE_STATE.clear() + + exited: list = [] + + class _S: + def update(self, **kw): pass + def end(self, **kw): pass + def set_trace_io(self, **kw): pass + def start_observation(self, **kw): return _S() + + class _TrackingRootCM: + def __enter__(self): + return _S() + def __exit__(self, *exc): + exited.append(exc) + return False + + class _TrackingClient: + def create_trace_id(self, seed=None): + return f"trace::{seed}" + def start_as_current_observation(self, **kw): + started.append(kw.get("trace_context", {}).get("trace_id")) + return _TrackingRootCM() + def flush(self): + pass + + monkeypatch.setattr(mod, "_get_langfuse", lambda: _TrackingClient()) + + self._run_turn(mod, session="sess-exit", turn_n=1, finalize=True) + + assert exited, ( + "_finish_trace did not call root_ctx.__exit__; the generator is " + "left suspended and will raise TypeError on GC at interpreter " + "teardown when opentelemetry.trace.Span is None" + ) + assert len(exited) == 1 + assert exited[0] == (None, None, None) + # --------------------------------------------------------------------------- # Placeholder-credential guard (#23823).