deer-flow/backend/tests/test_worker_langfuse_metadata.py
d33kayyy 84bbdf6e00
fix(langfuse): resolve trace user from runtime context (#3794)
* fix(langfuse): resolve trace user from runtime context

The worker built langfuse_user_id from get_effective_user_id(), which reads
the request-scoped _current_user ContextVar. For runs invoked over an
internal token on behalf of an end user, that ContextVar is never the end
user, so traces recorded langfuse_user_id="default".

Switch to resolve_runtime_user_id(runtime), matching the sandbox
middleware/tools sites: it reads runtime.context["user_id"] (the owner
carried in the run request's context, which survives background-task
boundaries) and falls back to get_effective_user_id() for no-auth / browser
paths. Caller-supplied metadata still wins via inject_langfuse_metadata's
setdefault.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-05 00:07:27 +08:00

320 lines
11 KiB
Python

"""Integration test: worker.run_agent injects Langfuse trace metadata.
Verifies that the agent factory's resulting graph receives a
``RunnableConfig`` whose ``metadata`` carries the Langfuse reserved keys
(``langfuse_session_id`` / ``langfuse_user_id`` / ``langfuse_trace_name``).
"""
from __future__ import annotations
import asyncio
import pytest
from deerflow.runtime.runs.manager import RunRecord
from deerflow.runtime.runs.schemas import DisconnectMode, RunStatus
from deerflow.runtime.runs.worker import RunContext, run_agent
from deerflow.trace_context import DEERFLOW_TRACE_METADATA_KEY, request_trace_context
class _FakeAgent:
"""Minimal LangGraph-like graph that captures the runnable config."""
def __init__(self) -> None:
self.captured_config: dict | None = None
self.metadata: dict = {}
# Worker may assign these attributes; need them to exist.
self.checkpointer = None
self.store = None
self.interrupt_before_nodes: list[str] = []
self.interrupt_after_nodes: list[str] = []
async def astream(self, graph_input, *, config, stream_mode, **kwargs):
self.captured_config = config
# Empty async generator — no chunks produced.
return
yield # pragma: no cover (makes this an async generator)
class _FakeRunManager:
async def wait_for_prior_finalizing(self, *_args, **_kwargs) -> None:
return None
async def has_later_run(self, *_args, **_kwargs) -> bool:
return False
async def has_later_started_run(self, *_args, **_kwargs) -> bool:
return False
async def set_status(self, *_args, **_kwargs) -> None:
return None
async def update_model_name(self, *_args, **_kwargs) -> None:
return None
async def update_run_completion(self, *_args, **_kwargs) -> None:
return None
class _FakeBridge:
def __init__(self) -> None:
self.events: list[tuple[str, object]] = []
async def publish(self, _run_id, event, payload) -> None:
self.events.append((event, payload))
async def publish_end(self, _run_id) -> None:
self.events.append(("end", None))
async def cleanup(self, _run_id, *, delay: int = 0) -> None:
return None
@pytest.fixture(autouse=True)
def _clear_tracing_env(monkeypatch):
from deerflow.config.tracing_config import reset_tracing_config
for name in ("LANGFUSE_TRACING", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_BASE_URL"):
monkeypatch.delenv(name, raising=False)
reset_tracing_config()
yield
reset_tracing_config()
@pytest.mark.asyncio
async def test_run_agent_injects_langfuse_metadata(monkeypatch):
monkeypatch.setenv("LANGFUSE_TRACING", "true")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-test")
from deerflow.config.tracing_config import reset_tracing_config
reset_tracing_config()
fake_agent = _FakeAgent()
def agent_factory(config):
return fake_agent
record = RunRecord(
run_id="run-1",
thread_id="thread-xyz",
assistant_id="lead-agent",
status=RunStatus.pending,
on_disconnect=DisconnectMode.cancel,
model_name="gpt-4o",
)
record.abort_event = asyncio.Event()
ctx = RunContext(checkpointer=None)
with request_trace_context("gateway-trace-1"):
await run_agent(
_FakeBridge(),
_FakeRunManager(),
record,
ctx=ctx,
agent_factory=agent_factory,
graph_input={"messages": []},
config={"configurable": {"thread_id": "thread-xyz"}},
)
assert fake_agent.captured_config is not None, "astream was not invoked"
metadata = fake_agent.captured_config.get("metadata") or {}
assert metadata.get("langfuse_session_id") == "thread-xyz"
# conftest.py autouse fixture injects ``test-user-autouse`` into the
# contextvar — the worker should read it via ``get_effective_user_id``.
user_id = metadata.get("langfuse_user_id")
assert user_id == "test-user-autouse", f"expected test-user-autouse, got {user_id}"
assert metadata.get("langfuse_trace_name") == "lead-agent"
assert metadata.get(DEERFLOW_TRACE_METADATA_KEY) == "gateway-trace-1"
assert fake_agent.captured_config.get("context", {}).get(DEERFLOW_TRACE_METADATA_KEY) == "gateway-trace-1"
tags = metadata.get("langfuse_tags") or []
assert "model:gpt-4o" in tags
@pytest.mark.asyncio
async def test_run_agent_uses_context_user_id_over_contextvar(monkeypatch):
"""A run carrying ``context.user_id`` traces to that user, not the contextvar.
Internal-token callers invoke a run on behalf of an end user, so the
``_current_user`` ContextVar is never that end user. The caller instead
carries the real owner in the run request's ``config['context']['user_id']``,
which ``resolve_runtime_user_id(runtime)`` must prefer over the contextvar —
even though conftest's autouse fixture injects ``test-user-autouse`` into it.
"""
monkeypatch.setenv("LANGFUSE_TRACING", "true")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-test")
from deerflow.config.tracing_config import reset_tracing_config
reset_tracing_config()
fake_agent = _FakeAgent()
def agent_factory(config):
return fake_agent
record = RunRecord(
run_id="run-ctx-user",
thread_id="thread-ctx",
assistant_id="lead-agent",
status=RunStatus.pending,
on_disconnect=DisconnectMode.cancel,
)
record.abort_event = asyncio.Event()
ctx = RunContext(checkpointer=None)
await run_agent(
_FakeBridge(),
_FakeRunManager(),
record,
ctx=ctx,
agent_factory=agent_factory,
graph_input={"messages": []},
config={
"configurable": {"thread_id": "thread-ctx"},
"context": {"user_id": "real-end-user"},
},
)
metadata = fake_agent.captured_config.get("metadata") or {}
# context.user_id wins over the contextvar's ``test-user-autouse``.
assert metadata.get("langfuse_user_id") == "real-end-user"
@pytest.mark.asyncio
async def test_run_agent_falls_back_to_default_user_when_unset(monkeypatch):
"""When no user is in the contextvar (and no context.user_id), langfuse_user_id
falls back to 'default'.
Uses ``monkeypatch.setattr`` to redirect ``get_effective_user_id`` to return
``"default"`` rather than directly mutating the contextvar — direct contextvar
operations across pytest test boundaries have produced spooky cross-file
pollution when combined with the langfuse OTel global tracer provider.
The worker resolves the trace user via ``resolve_runtime_user_id(runtime)``;
with no ``context.user_id`` it falls back to ``get_effective_user_id()`` — so
we patch that fallback at its definition module (``user_context``), which is
the name ``resolve_runtime_user_id`` actually calls.
"""
monkeypatch.setenv("LANGFUSE_TRACING", "true")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-test")
from deerflow.config.tracing_config import reset_tracing_config
from deerflow.runtime import user_context as user_context_module
from deerflow.runtime.user_context import DEFAULT_USER_ID
reset_tracing_config()
monkeypatch.setattr(user_context_module, "get_effective_user_id", lambda: DEFAULT_USER_ID)
fake_agent = _FakeAgent()
def agent_factory(config):
return fake_agent
record = RunRecord(
run_id="run-fallback",
thread_id="thread-fb",
assistant_id="lead-agent",
status=RunStatus.pending,
on_disconnect=DisconnectMode.cancel,
)
record.abort_event = asyncio.Event()
ctx = RunContext(checkpointer=None)
await run_agent(
_FakeBridge(),
_FakeRunManager(),
record,
ctx=ctx,
agent_factory=agent_factory,
graph_input={"messages": []},
config={"configurable": {"thread_id": "thread-fb"}},
)
metadata = fake_agent.captured_config.get("metadata") or {}
assert metadata.get("langfuse_user_id") == "default"
@pytest.mark.asyncio
async def test_run_agent_preserves_caller_metadata_overrides(monkeypatch):
"""Caller-provided langfuse_* keys must NOT be overridden by the default injection."""
monkeypatch.setenv("LANGFUSE_TRACING", "true")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-test")
from deerflow.config.tracing_config import reset_tracing_config
reset_tracing_config()
fake_agent = _FakeAgent()
def agent_factory(config):
return fake_agent
record = RunRecord(
run_id="run-2",
thread_id="thread-default",
assistant_id="lead-agent",
status=RunStatus.pending,
on_disconnect=DisconnectMode.cancel,
)
record.abort_event = asyncio.Event()
ctx = RunContext(checkpointer=None)
await run_agent(
_FakeBridge(),
_FakeRunManager(),
record,
ctx=ctx,
agent_factory=agent_factory,
graph_input={"messages": []},
config={
"configurable": {"thread_id": "thread-default"},
"metadata": {
DEERFLOW_TRACE_METADATA_KEY: "explicit-deerflow-trace",
"langfuse_session_id": "custom-session-id",
"langfuse_user_id": "explicit-user",
},
},
)
metadata = fake_agent.captured_config.get("metadata") or {}
# Caller-supplied keys win.
assert metadata["langfuse_session_id"] == "custom-session-id"
assert metadata["langfuse_user_id"] == "explicit-user"
assert metadata[DEERFLOW_TRACE_METADATA_KEY] == "explicit-deerflow-trace"
assert fake_agent.captured_config.get("context", {}).get(DEERFLOW_TRACE_METADATA_KEY) == "explicit-deerflow-trace"
# Worker still fills in keys that the caller didn't set.
assert metadata["langfuse_trace_name"] == "lead-agent"
@pytest.mark.asyncio
async def test_run_agent_skips_metadata_when_langfuse_disabled(monkeypatch):
fake_agent = _FakeAgent()
def agent_factory(config):
return fake_agent
record = RunRecord(
run_id="run-3",
thread_id="thread-noop",
assistant_id="lead-agent",
status=RunStatus.pending,
on_disconnect=DisconnectMode.cancel,
)
record.abort_event = asyncio.Event()
ctx = RunContext(checkpointer=None)
await run_agent(
_FakeBridge(),
_FakeRunManager(),
record,
ctx=ctx,
agent_factory=agent_factory,
graph_input={"messages": []},
config={"configurable": {"thread_id": "thread-noop"}},
)
metadata = fake_agent.captured_config.get("metadata") or {}
assert "langfuse_session_id" not in metadata
assert "langfuse_user_id" not in metadata
assert "langfuse_trace_name" not in metadata