mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
* 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>
320 lines
11 KiB
Python
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
|