fix(langfuse): atexit finalizer flushes settled clients without reading credentials

_finalize_all_traces called _get_langfuse(), which lazily BUILDS a client when the
launch-profile slot is empty. Under a multiplex gateway every turn runs scoped, so
only the per-home slots are populated; at exit (no scope) the credential read
raised UnscopedSecretError and the flush loop was skipped for every profile,
losing all pending traces. The finalizer now iterates the already-settled
clients (launch slot + per-home map) and never initializes. Test drives two
profiles under real home-override + secret scopes, then finalizes unscoped.
This commit is contained in:
teknium1
2026-09-12 23:47:10 -07:00
committed by Teknium
parent d32350fcc5
commit 4e74beb064
2 changed files with 54 additions and 3 deletions

View File

@@ -633,9 +633,11 @@ def _finalize_all_traces() -> None:
_end_children(state, include_subagents=True)
_end_root(state, f"atexit finalize for {key}")
if states:
# atexit runs unscoped; flush every profile's client, not just the launch profile's.
for client in (_get_langfuse(), *_LANGFUSE_CLIENT_BY_HOME.values()):
if client is not _INIT_FAILED:
# atexit runs with NO profile scope, so it must never build a client (a credential read
# here raises UnscopedSecretError under multiplex and would skip every flush). Flush only
# the clients that settled during the run — the launch profile's slot plus one per home.
for client in (_LANGFUSE_CLIENT, *_LANGFUSE_CLIENT_BY_HOME.values()):
if client is not None and client is not _INIT_FAILED:
_flush(client)

View File

@@ -1741,6 +1741,55 @@ class TestAtexitFinalization(TestTurnTraceIsolation):
assert mod._get_langfuse() is not None
assert mod._finalize_all_traces in registered
def test_finalize_flushes_every_profile_without_an_ambient_scope(self, monkeypatch, tmp_path):
"""Multiplex gateway: every turn ran inside a profile scope (home override + secret scope),
so only the per-home slots hold clients and the launch slot stays empty. atexit has no
scope, and a credential read there raises UnscopedSecretError. The finalizer must not read
credentials at all — it ends the open roots and flushes each settled client, so neither
profile loses its pending traces."""
from agent import secret_scope
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
mod = self._fresh_plugin()
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
monkeypatch.setattr(mod, "_end_observation", lambda obs, **k: None)
mod._LANGFUSE_CLIENT = None
mod._TRACE_STATE.clear()
mod._LANGFUSE_CLIENT_BY_HOME.clear()
flushed: list = []
fake_client = self._fake_client
def _sdk(**kw):
client = fake_client([])
client.flush = lambda pk=kw["public_key"]: flushed.append(pk)
return client
monkeypatch.setattr(mod, "Langfuse", _sdk)
for profile in ("alpha", "beta"):
home = tmp_path / profile
home.mkdir()
home_token = set_hermes_home_override(home)
scope_token = secret_scope.set_secret_scope({
"HERMES_LANGFUSE_PUBLIC_KEY": f"pk-lf-{profile}-0123456789",
"HERMES_LANGFUSE_SECRET_KEY": f"sk-lf-{profile}-0123456789",
})
try:
self._run_turn(mod, session=f"{profile}-turn", turn_n=0, finalize=False)
finally:
secret_scope.reset_secret_scope(scope_token)
reset_hermes_home_override(home_token)
assert len(mod._TRACE_STATE) == 2 and len(mod._LANGFUSE_CLIENT_BY_HOME) == 2
assert mod._LANGFUSE_CLIENT is None and secret_scope.current_secret_scope() is None
mod._finalize_all_traces() # no scope: must not raise, must not build a client
assert sorted(flushed) == ["pk-lf-alpha-0123456789", "pk-lf-beta-0123456789"]
assert mod._TRACE_STATE == {} and mod._LANGFUSE_CLIENT is None
class TestSystemPromptInGenerationInput:
"""The generation input must carry the system prompt even for providers
that move it out of ``messages``: Anthropic Messages (``system`` kwarg)