Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions plugins/observability/langfuse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1125,6 +1141,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: <generator>" 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
Expand All @@ -1135,3 +1178,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)
1 change: 1 addition & 0 deletions plugins/observability/langfuse/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ hooks:
- post_llm_call
- pre_tool_call
- post_tool_call
- on_session_finalize
57 changes: 56 additions & 1 deletion tests/plugins/test_langfuse_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -361,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: <generator>" 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).
Expand Down