mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-21 15:36:48 +00:00
df95154282
* fix(tracing): propagate session_id and user_id into Langfuse traces
Adds Langfuse v4 reserved trace attributes (langfuse_session_id,
langfuse_user_id, langfuse_trace_name, langfuse_tags) to
RunnableConfig.metadata inside the run worker, so the langchain
CallbackHandler can lift them onto the root trace.
- New deerflow.tracing.metadata.build_langfuse_trace_metadata() returns
the reserved keys when Langfuse is in the enabled providers, else {}.
- worker.run_agent merges them with setdefault so caller-supplied keys
win, allowing per-request overrides from upstream metadata.
- session_id mirrors the LangGraph thread_id; user_id reads
get_effective_user_id() (falls back to "default" in no-auth mode).
- trace_name defaults to "lead-agent"; tags carry env and model name
when DEER_FLOW_ENV (or ENVIRONMENT) and a model name are present.
Closes #2930
* fix(tracing): attach Langfuse callback at graph root so metadata propagates
The first commit injected ``langfuse_session_id`` / ``langfuse_user_id`` /
``langfuse_trace_name`` / ``langfuse_tags`` into ``RunnableConfig.metadata``,
but on ``main`` the Langfuse callback is attached at *model* level
(``models/factory.py``). LangChain still threads ``parent_run_id`` through
the contextvar, so the handler sees the model as a nested observation and
``__on_llm_action`` strips the ``langfuse_*`` keys
(``keep_langfuse_trace_attributes=False``). The trace's top-level
``sessionId`` / ``userId`` therefore stayed empty in deer-flow's LangGraph
runtime — confirmed live against a real Langfuse instance.
This commit moves the callback to the **graph invocation root** so the
handler fires ``on_chain_start(parent_run_id=None)`` and runs the
``propagate_attributes`` path that actually lifts ``session_id`` /
``user_id`` onto the trace:
- ``models/factory.py``: add ``attach_tracing`` keyword (default ``True``)
so standalone callers (``MemoryUpdater``, etc.) keep their direct
model-level tracing.
- ``agents/lead_agent/agent.py``: call ``build_tracing_callbacks()`` once
inside ``_make_lead_agent`` and append the result to
``config["callbacks"]``; the four in-graph ``create_chat_model`` sites
(bootstrap, default agent, sync + async summarization) pass
``attach_tracing=False`` to avoid duplicate spans.
- ``agents/middlewares/title_middleware.py``: same ``attach_tracing=False``
for the title-generation model, since it inherits the graph's
RunnableConfig via ``_get_runnable_config``.
Test updates:
- ``tests/test_lead_agent_model_resolution.py`` and
``tests/test_title_middleware_core_logic.py``: extend the fake
``create_chat_model`` signatures / mock assertions to accept the new
``attach_tracing`` kwarg.
- ``tests/test_worker_langfuse_metadata.py``: switch the no-user fallback
test from direct ContextVar mutation to ``monkeypatch.setattr`` on
``get_effective_user_id`` to avoid pollution across the langfuse OTel
global tracer provider.
- ``tests/conftest.py``: add an autouse fixture that resets
``deerflow.config.title_config._title_config`` to its pristine default
after every test. Any test that loads the real ``config.yaml`` (via
``get_app_config()``) calls ``load_title_config_from_dict`` and mutates
the module-level singleton, which previously poisoned the
title-middleware suite when run after, e.g., the new
``test_worker_langfuse_metadata.py`` cases. The fixture is independent
of this PR's main change but unblocks the cross-file test run.
Live verification (same Langfuse instance as before):
- Drove ``worker.run_agent`` against the real ``make_lead_agent`` +
``gpt-4o-mini`` for three distinct ``user_context`` identities
(``fancy-engineer``, ``alice-pm``, ``bob-designer``).
- Each run produced one ``lead-agent`` trace whose top-level
``sessionId`` / ``userId`` / ``tags`` carry the expected values, e.g.
``session=e2e-2930-8f347c-alice-pm user=alice-pm name='lead-agent'
tags=['model:gpt-4o-mini']``.
Refs #2930.
* fix(tracing): extend root-callback + metadata injection to the embedded client
Addresses Copilot review on PR #2944.
Commit 2 disabled model-level tracing for ``TitleMiddleware`` and
``_create_summarization_middleware`` because ``_make_lead_agent`` now
attaches the tracing callbacks at the graph invocation root. But the
embedded ``DeerFlowClient`` does not call ``_make_lead_agent`` — it
calls ``_build_middlewares`` directly and never appends the tracing
handlers to its ``RunnableConfig``. So under the embedded path,
title-generation and summarization LLM calls were left untraced —
a regression introduced by this PR.
This commit mirrors the gateway worker's injection in
``DeerFlowClient.stream``:
- Append ``build_tracing_callbacks()`` to ``config["callbacks"]`` so
the Langfuse handler sees ``on_chain_start(parent_run_id=None)`` at
the graph root and runs the ``propagate_attributes`` path.
- Merge ``build_langfuse_trace_metadata(...)`` into
``config["metadata"]`` with ``setdefault`` so caller-supplied keys
still win.
- ``_ensure_agent`` now creates its main model with
``attach_tracing=False`` to avoid duplicate spans now that the
callback lives at the graph root.
Docs:
- ``backend/CLAUDE.md`` Tracing section rewritten to describe the
graph-root attachment model (replacing the inaccurate
"at model-creation time" wording).
- ``README.md`` Langfuse section now lists both injection points
(worker + client) instead of only the worker path.
Tests:
- ``tests/test_client_langfuse_metadata.py`` (new, 3 cases):
callbacks + metadata are injected when Langfuse is enabled,
caller-supplied metadata overrides win via ``setdefault``, and the
injection is inert when Langfuse is disabled.
Live verification on the real Langfuse instance:
=== user=fancy-client ===
id=cbd22847.. session=client-2930-6b9491-fancy-client user=fancy-client name='lead-agent'
=== user=alice-client ===
id=b4f6f576.. session=client-2930-6b9491-alice-client user=alice-client name='lead-agent'
Refs #2930.
* refactor(tracing): address maintainer review on PR #2944
Addresses @WillemJiang's 5 comments.
1. Duplicated metadata-injection code between worker.py and client.py
New ``deerflow.tracing.inject_langfuse_metadata(config, ...)`` helper
takes the 10-line build + merge + setdefault logic that was duplicated
in ``runtime/runs/worker.py`` and ``client.py``. Both callers now share
a single source of truth, so the two paths cannot drift.
2. Direct private-attribute mutation in conftest.py and tests
Added public ``reset_tracing_config()`` / ``reset_title_config()``
functions. ``tests/conftest.py`` and every test that previously did
``tracing_module._tracing_config = None`` or
``title_module._title_config = TitleConfig()`` now goes through the
public API. A future internal rename will surface as an ImportError
instead of a silent no-op.
3. client.py reading os.environ directly
``DeerFlowClient.__init__`` grows an optional ``environment`` parameter
so programmatic callers can pass the deployment label explicitly.
``stream()`` consults ``self._environment`` first and only falls back
to ``DEER_FLOW_ENV`` / ``ENVIRONMENT`` env vars when nothing was
passed in. Backwards compatible — env-var behaviour preserved for
callers that opt to keep using it.
4. build_tracing_callbacks() cached on hot path
Not implemented. Inspected the langfuse v4 ``langchain.CallbackHandler``
constructor: it only resolves the module-level singleton client via
``get_client()`` and initialises a few dicts (no I/O, no env parsing
at construction time). The build is essentially free. Caching would
trade a non-measurable speedup for two real risks: handler instances
carry per-run state internally (``_run_states``, ``_root_run_states``,
``last_trace_id``), and tracing config can be reloaded by env-var
changes between runs. Will revisit if profiling ever shows it as
a hot spot.
5. attach_tracing=False easy to forget at new in-graph call sites
- Module docstring at the top of ``lead_agent/agent.py`` documents
the invariant ("every in-graph ``create_chat_model`` MUST pass
``attach_tracing=False``") and enumerates the current sites.
- New regression test
``test_make_lead_agent_attaches_tracing_callbacks_at_graph_root`` in
``tests/test_lead_agent_model_resolution.py`` locks both halves of
the invariant: ``config["callbacks"]`` carries the tracing handler
after ``_make_lead_agent``, AND every ``create_chat_model`` call
captured by the test passes ``attach_tracing=False``. A future
in-graph site that forgets the flag will fail this test.
Lint clean. Full touched-suite bundle: 246 passed.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
231 lines
7.7 KiB
Python
231 lines
7.7 KiB
Python
"""Test configuration for the backend test suite.
|
|
|
|
Sets up sys.path and pre-mocks modules that would cause circular import
|
|
issues when unit-testing lightweight config/registry code in isolation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from support.detectors.blocking_io import BlockingIOProbe, detect_blocking_io
|
|
|
|
# Make 'app' and 'deerflow' importable from any working directory
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts"))
|
|
|
|
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
|
_blocking_io_probe = BlockingIOProbe(_BACKEND_ROOT)
|
|
_BLOCKING_IO_DETECTOR_ATTR = "_blocking_io_detector"
|
|
|
|
# Break the circular import chain that exists in production code:
|
|
# deerflow.subagents.__init__
|
|
# -> .executor (SubagentExecutor, SubagentResult)
|
|
# -> deerflow.agents.thread_state
|
|
# -> deerflow.agents.__init__
|
|
# -> lead_agent.agent
|
|
# -> subagent_limit_middleware
|
|
# -> deerflow.subagents.executor <-- circular!
|
|
#
|
|
# By injecting a mock for deerflow.subagents.executor *before* any test module
|
|
# triggers the import, __init__.py's "from .executor import ..." succeeds
|
|
# immediately without running the real executor module.
|
|
_executor_mock = MagicMock()
|
|
_executor_mock.SubagentExecutor = MagicMock
|
|
_executor_mock.SubagentResult = MagicMock
|
|
_executor_mock.SubagentStatus = MagicMock
|
|
_executor_mock.MAX_CONCURRENT_SUBAGENTS = 3
|
|
_executor_mock.get_background_task_result = MagicMock()
|
|
|
|
sys.modules["deerflow.subagents.executor"] = _executor_mock
|
|
|
|
|
|
@pytest.fixture()
|
|
def provisioner_module():
|
|
"""Load docker/provisioner/app.py as an importable test module.
|
|
|
|
Shared by test_provisioner_kubeconfig and test_provisioner_pvc_volumes so
|
|
that any change to the provisioner entry-point path or module name only
|
|
needs to be updated in one place.
|
|
"""
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
module_path = repo_root / "docker" / "provisioner" / "app.py"
|
|
spec = importlib.util.spec_from_file_location("provisioner_app_test", module_path)
|
|
assert spec is not None
|
|
assert spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
@pytest.fixture()
|
|
def blocking_io_detector():
|
|
"""Fail a focused test if blocking calls run on the event loop thread."""
|
|
with detect_blocking_io(fail_on_exit=True) as detector:
|
|
yield detector
|
|
|
|
|
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
group = parser.getgroup("blocking-io")
|
|
group.addoption(
|
|
"--detect-blocking-io",
|
|
action="store_true",
|
|
default=False,
|
|
help="Collect blocking calls made while an asyncio event loop is running and report a summary.",
|
|
)
|
|
group.addoption(
|
|
"--detect-blocking-io-fail",
|
|
action="store_true",
|
|
default=False,
|
|
help="Set a failing exit status when --detect-blocking-io records violations.",
|
|
)
|
|
|
|
|
|
def pytest_configure(config: pytest.Config) -> None:
|
|
config.addinivalue_line("markers", "no_blocking_io_probe: skip the optional blocking IO probe")
|
|
|
|
|
|
def pytest_sessionstart(session: pytest.Session) -> None:
|
|
if _blocking_io_probe_enabled(session.config):
|
|
_blocking_io_probe.clear()
|
|
|
|
|
|
@pytest.hookimpl(hookwrapper=True)
|
|
def pytest_runtest_call(item: pytest.Item):
|
|
if not _blocking_io_probe_enabled(item.config) or _blocking_io_probe_skipped(item):
|
|
yield
|
|
return
|
|
|
|
detector = detect_blocking_io(fail_on_exit=False, stack_limit=18)
|
|
detector.__enter__()
|
|
setattr(item, _BLOCKING_IO_DETECTOR_ATTR, detector)
|
|
yield
|
|
|
|
|
|
@pytest.hookimpl(hookwrapper=True)
|
|
def pytest_runtest_teardown(item: pytest.Item):
|
|
yield
|
|
|
|
detector = getattr(item, _BLOCKING_IO_DETECTOR_ATTR, None)
|
|
if detector is None:
|
|
return
|
|
|
|
try:
|
|
detector.__exit__(None, None, None)
|
|
_blocking_io_probe.record(item.nodeid, detector.violations)
|
|
finally:
|
|
delattr(item, _BLOCKING_IO_DETECTOR_ATTR)
|
|
|
|
|
|
def pytest_sessionfinish(session: pytest.Session) -> None:
|
|
if _blocking_io_fail_enabled(session.config) and _blocking_io_probe.violation_count and session.exitstatus == pytest.ExitCode.OK:
|
|
session.exitstatus = pytest.ExitCode.TESTS_FAILED
|
|
|
|
|
|
def pytest_terminal_summary(terminalreporter: pytest.TerminalReporter) -> None:
|
|
if not _blocking_io_probe_enabled(terminalreporter.config):
|
|
return
|
|
|
|
header, *details = _blocking_io_probe.format_summary().splitlines()
|
|
terminalreporter.write_sep("=", header)
|
|
for line in details:
|
|
terminalreporter.write_line(line)
|
|
|
|
|
|
def _blocking_io_probe_enabled(config: pytest.Config) -> bool:
|
|
return bool(config.getoption("--detect-blocking-io") or config.getoption("--detect-blocking-io-fail"))
|
|
|
|
|
|
def _blocking_io_fail_enabled(config: pytest.Config) -> bool:
|
|
return bool(config.getoption("--detect-blocking-io-fail"))
|
|
|
|
|
|
def _blocking_io_probe_skipped(item: pytest.Item) -> bool:
|
|
return item.path.name == "test_blocking_io_detector.py" or item.get_closest_marker("no_blocking_io_probe") is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auto-set user context for every test unless marked no_auto_user
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Repository methods read ``user_id`` from a contextvar by default
|
|
# (see ``deerflow.runtime.user_context``). Without this fixture, every
|
|
# pre-existing persistence test would raise RuntimeError because the
|
|
# contextvar is unset. The fixture sets a default test user on every
|
|
# test; tests that explicitly want to verify behaviour *without* a user
|
|
# context should mark themselves ``@pytest.mark.no_auto_user``.
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_skill_storage_singleton():
|
|
"""Reset the SkillStorage singleton between tests to prevent cross-test contamination."""
|
|
try:
|
|
from deerflow.skills.storage import reset_skill_storage
|
|
except ImportError:
|
|
yield
|
|
return
|
|
reset_skill_storage()
|
|
try:
|
|
yield
|
|
finally:
|
|
reset_skill_storage()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _restore_title_config_singleton():
|
|
"""Reset ``_title_config`` to its pristine default after every test.
|
|
|
|
``AppConfig.from_file()`` writes the on-disk ``title`` block into the
|
|
module-level singleton (``config/app_config.py`` calls
|
|
``load_title_config_from_dict``). Any test that loads the real
|
|
``config.yaml`` therefore leaves the singleton in a state that
|
|
``test_title_middleware_core_logic.py`` does not expect; that suite
|
|
relies on the pristine ``TitleConfig()`` default (``enabled=True``).
|
|
We restore the default after every test so test files stay
|
|
independent regardless of order.
|
|
"""
|
|
try:
|
|
from deerflow.config.title_config import reset_title_config
|
|
except ImportError:
|
|
yield
|
|
return
|
|
|
|
try:
|
|
yield
|
|
finally:
|
|
reset_title_config()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _auto_user_context(request):
|
|
"""Inject a default ``test-user-autouse`` into the contextvar.
|
|
|
|
Opt-out via ``@pytest.mark.no_auto_user``. Uses lazy import so that
|
|
tests which don't touch the persistence layer never pay the cost
|
|
of importing runtime.user_context.
|
|
"""
|
|
if request.node.get_closest_marker("no_auto_user"):
|
|
yield
|
|
return
|
|
|
|
try:
|
|
from deerflow.runtime.user_context import (
|
|
reset_current_user,
|
|
set_current_user,
|
|
)
|
|
except ImportError:
|
|
yield
|
|
return
|
|
|
|
user = SimpleNamespace(id="test-user-autouse", email="test@local")
|
|
token = set_current_user(user)
|
|
try:
|
|
yield
|
|
finally:
|
|
reset_current_user(token)
|