feat(subagents): attribute subagent spans to parent thread's Langfuse… - #3611
Conversation
fancyboi999
left a comment
There was a problem hiding this comment.
Read the full diff and traced the wiring against the existing tracing layer — this looks correct and lands cleanly. What I verified:
- Signature match (the main risk for this kind of change):
inject_langfuse_metadata(config, *, thread_id, user_id, assistant_id, model_name, environment)(tracing/metadata.py:73-82) matches the call inexecutor.pyexactly — no kwarg drift. - No-op when Langfuse is off is real:
inject_langfuse_metadatareturns early whenbuild_langfuse_trace_metadata()yields{}(metadata.py:99-100), so LangSmith-only / no-provider deployments are unaffected — consistent withtest_aexecute_skips_langfuse_metadata_when_disabled. And thesetdefaultmerge keeps any caller-suppliedlangfuse_*override, same as the worker/client paths. - Pattern parity: graph-root
build_tracing_callbacks()+attach_tracing=Falseon the model mirrors the lead-agent wiring documented inbackend/CLAUDE.md, so there's no double-counted trace. The append ([*existing_callbacks, *tracing_callbacks]) preserves theSubagentTokenCollector, whichtest_aexecute_appends_tracing_callbacks_to_run_configpins (asserts >=2 callbacks). - user_id capture:
resolve_runtime_user_id(runtime/user_context.py:112) andDEFAULT_USER_ID = "default"(:97) exist as the tests assume; the default-fallback assertion is accurate, andresolve_runtime_user_id(None)is safe (signature takesobject | None), so calling it unconditionally intask_toolwon't blow up when there's no runtime. - Mapping:
thread_id -> langfuse_session_idmatches the field table you updated inCLAUDE.md. - Tests are genuine regression anchors (session_id == parent thread_id, user_id == captured,
_->-trace-name normalization, env tag, empty-name fallback, disabled no-op) rather than vacuous mocks.
Scope is tight — all four files serve the attribution goal and the doc is updated to match. Looks correct to me; good to merge once CI is green. One optional non-blocking nit inline.
|
Please fix the lint check error @heart-scalpel |
… session The subagent execution path did not call inject_langfuse_metadata(...) and built its model with attach_tracing=True, so subagent LLM/tool spans landed in Langfuse as isolated top-level traces carrying fresh session ids and the default user. They were findable in the unfiltered trace list but did not group under the parent thread's session card, and Langfuse cost attribution for subagent traffic did not line up with the parent conversation — even though DeerFlow's internal token accounting (SubagentTokenCollector) was already correct. Extend the lead-agent tracing wiring to the subagent path so a single subagent run produces one trace that shares the parent thread's session_id and user_id, with a subagent:<name> trace name: - subagents/executor.py: append build_tracing_callbacks() output to run_config["callbacks"] (preserving SubagentTokenCollector) and call inject_langfuse_metadata(...) with thread_id, user_id, and the normalized subagent:<name> trace name. Build the model with attach_tracing=False so model-level tracing does not double-count with the graph-root callbacks — the same pairing the lead agent uses. - tools/builtins/task_tool.py: resolve user_id via resolve_runtime_user_id(runtime) at the parent tool layer (before the background thread starts) and thread it through SubagentExecutor.__init__, because the _current_user contextvar is not guaranteed to survive the _execution_pool boundary. Trace topology is unchanged: subagent traces remain separate top-level traces in the same session, not nested as child spans under the lead trace (Plan B follow-up). Tests: tests/test_subagent_executor.py::TestSubagentTracingWiring covers the callback append, the session/user/trace-name injection, the disabled-langfuse no-op, the DEFAULT_USER_ID fallback, the empty-name trace-name fallback, and the env-tag emission. Existing test_create_agent_threads_explicit_app_config_to_model_and_middlewares now also asserts attach_tracing=False. Docs: CLAUDE.md Tracing System section documents subagents/executor.py as a third injection point alongside worker.py and client.py.
2694417 to
cb02d2a
Compare
|
@fancyboi999 Thank you very much for your thorough review and valuable suggestions. |
|
Thanks! And your call on the subagent-name normalization is the right one — since the names come from For the failing Lint Check: it isn't a |
|
Bash(uvx ruff format --check tests/test_subagent_executor.py && uvx ruff check @fancyboi999 |
|
Confirmed on my end. I pulled the current PR head ( Two heads-ups so the red ❌ makes sense:
|
It was my fault for not communicating clearly. I’ll lay everything out in detail next time. Thank you for your thoughtful advice and help — it’s truly appreciated. |
… session (bytedance#3611) The subagent execution path did not call inject_langfuse_metadata(...) and built its model with attach_tracing=True, so subagent LLM/tool spans landed in Langfuse as isolated top-level traces carrying fresh session ids and the default user. They were findable in the unfiltered trace list but did not group under the parent thread's session card, and Langfuse cost attribution for subagent traffic did not line up with the parent conversation — even though DeerFlow's internal token accounting (SubagentTokenCollector) was already correct. Extend the lead-agent tracing wiring to the subagent path so a single subagent run produces one trace that shares the parent thread's session_id and user_id, with a subagent:<name> trace name: - subagents/executor.py: append build_tracing_callbacks() output to run_config["callbacks"] (preserving SubagentTokenCollector) and call inject_langfuse_metadata(...) with thread_id, user_id, and the normalized subagent:<name> trace name. Build the model with attach_tracing=False so model-level tracing does not double-count with the graph-root callbacks — the same pairing the lead agent uses. - tools/builtins/task_tool.py: resolve user_id via resolve_runtime_user_id(runtime) at the parent tool layer (before the background thread starts) and thread it through SubagentExecutor.__init__, because the _current_user contextvar is not guaranteed to survive the _execution_pool boundary. Trace topology is unchanged: subagent traces remain separate top-level traces in the same session, not nested as child spans under the lead trace (Plan B follow-up). Tests: tests/test_subagent_executor.py::TestSubagentTracingWiring covers the callback append, the session/user/trace-name injection, the disabled-langfuse no-op, the DEFAULT_USER_ID fallback, the empty-name trace-name fallback, and the env-tag emission. Existing test_create_agent_threads_explicit_app_config_to_model_and_middlewares now also asserts attach_tracing=False. Docs: CLAUDE.md Tracing System section documents subagents/executor.py as a third injection point alongside worker.py and client.py.
… session (bytedance#3611) The subagent execution path did not call inject_langfuse_metadata(...) and built its model with attach_tracing=True, so subagent LLM/tool spans landed in Langfuse as isolated top-level traces carrying fresh session ids and the default user. They were findable in the unfiltered trace list but did not group under the parent thread's session card, and Langfuse cost attribution for subagent traffic did not line up with the parent conversation — even though DeerFlow's internal token accounting (SubagentTokenCollector) was already correct. Extend the lead-agent tracing wiring to the subagent path so a single subagent run produces one trace that shares the parent thread's session_id and user_id, with a subagent:<name> trace name: - subagents/executor.py: append build_tracing_callbacks() output to run_config["callbacks"] (preserving SubagentTokenCollector) and call inject_langfuse_metadata(...) with thread_id, user_id, and the normalized subagent:<name> trace name. Build the model with attach_tracing=False so model-level tracing does not double-count with the graph-root callbacks — the same pairing the lead agent uses. - tools/builtins/task_tool.py: resolve user_id via resolve_runtime_user_id(runtime) at the parent tool layer (before the background thread starts) and thread it through SubagentExecutor.__init__, because the _current_user contextvar is not guaranteed to survive the _execution_pool boundary. Trace topology is unchanged: subagent traces remain separate top-level traces in the same session, not nested as child spans under the lead trace (Plan B follow-up). Tests: tests/test_subagent_executor.py::TestSubagentTracingWiring covers the callback append, the session/user/trace-name injection, the disabled-langfuse no-op, the DEFAULT_USER_ID fallback, the empty-name trace-name fallback, and the env-tag emission. Existing test_create_agent_threads_explicit_app_config_to_model_and_middlewares now also asserts attach_tracing=False. Docs: CLAUDE.md Tracing System section documents subagents/executor.py as a third injection point alongside worker.py and client.py.
- telegram-streaming: 29/29 checkbox ticked (839fa99 bytedance#3534) - minimax-providers: 34/34 checkbox ticked (cd5beda bytedance#3437) - event-store-history: 9/15 ticked + 6 TODO 2026-Q3 (229c809) - langfuse-tracing: status: completed (PR bytedance#1717 + bytedance#2944 bytedance#3611) - No code changes, no checkbox added/removed
… session (bytedance#3611) The subagent execution path did not call inject_langfuse_metadata(...) and built its model with attach_tracing=True, so subagent LLM/tool spans landed in Langfuse as isolated top-level traces carrying fresh session ids and the default user. They were findable in the unfiltered trace list but did not group under the parent thread's session card, and Langfuse cost attribution for subagent traffic did not line up with the parent conversation — even though DeerFlow's internal token accounting (SubagentTokenCollector) was already correct. Extend the lead-agent tracing wiring to the subagent path so a single subagent run produces one trace that shares the parent thread's session_id and user_id, with a subagent:<name> trace name: - subagents/executor.py: append build_tracing_callbacks() output to run_config["callbacks"] (preserving SubagentTokenCollector) and call inject_langfuse_metadata(...) with thread_id, user_id, and the normalized subagent:<name> trace name. Build the model with attach_tracing=False so model-level tracing does not double-count with the graph-root callbacks — the same pairing the lead agent uses. - tools/builtins/task_tool.py: resolve user_id via resolve_runtime_user_id(runtime) at the parent tool layer (before the background thread starts) and thread it through SubagentExecutor.__init__, because the _current_user contextvar is not guaranteed to survive the _execution_pool boundary. Trace topology is unchanged: subagent traces remain separate top-level traces in the same session, not nested as child spans under the lead trace (Plan B follow-up). Tests: tests/test_subagent_executor.py::TestSubagentTracingWiring covers the callback append, the session/user/trace-name injection, the disabled-langfuse no-op, the DEFAULT_USER_ID fallback, the empty-name trace-name fallback, and the env-tag emission. Existing test_create_agent_threads_explicit_app_config_to_model_and_middlewares now also asserts attach_tracing=False. Docs: CLAUDE.md Tracing System section documents subagents/executor.py as a third injection point alongside worker.py and client.py.
… session (bytedance#3611) The subagent execution path did not call inject_langfuse_metadata(...) and built its model with attach_tracing=True, so subagent LLM/tool spans landed in Langfuse as isolated top-level traces carrying fresh session ids and the default user. They were findable in the unfiltered trace list but did not group under the parent thread's session card, and Langfuse cost attribution for subagent traffic did not line up with the parent conversation — even though DeerFlow's internal token accounting (SubagentTokenCollector) was already correct. Extend the lead-agent tracing wiring to the subagent path so a single subagent run produces one trace that shares the parent thread's session_id and user_id, with a subagent:<name> trace name: - subagents/executor.py: append build_tracing_callbacks() output to run_config["callbacks"] (preserving SubagentTokenCollector) and call inject_langfuse_metadata(...) with thread_id, user_id, and the normalized subagent:<name> trace name. Build the model with attach_tracing=False so model-level tracing does not double-count with the graph-root callbacks — the same pairing the lead agent uses. - tools/builtins/task_tool.py: resolve user_id via resolve_runtime_user_id(runtime) at the parent tool layer (before the background thread starts) and thread it through SubagentExecutor.__init__, because the _current_user contextvar is not guaranteed to survive the _execution_pool boundary. Trace topology is unchanged: subagent traces remain separate top-level traces in the same session, not nested as child spans under the lead trace (Plan B follow-up). Tests: tests/test_subagent_executor.py::TestSubagentTracingWiring covers the callback append, the session/user/trace-name injection, the disabled-langfuse no-op, the DEFAULT_USER_ID fallback, the empty-name trace-name fallback, and the env-tag emission. Existing test_create_agent_threads_explicit_app_config_to_model_and_middlewares now also asserts attach_tracing=False. Docs: CLAUDE.md Tracing System section documents subagents/executor.py as a third injection point alongside worker.py and client.py.
Fixes #3604
Why
Subagent LLM/tool spans were landing in Langfuse — they were not invisible. What was missing was grouping and attribution: the subagent execution path did not call
inject_langfuse_metadata(...)and built its model withattach_tracing=True, so every subagent run produced an isolated top-level trace with a freshsession_idand the default user. They were findable in the unfiltered trace list (thesubagent:<name>tag narrowed things down) but could not be pivoted to from the parent thread's Sessions view, and Langfuse cost attribution for subagent traffic did not line up with the parent conversation — even though DeerFlow's internalSubagentTokenCollectoraccounting was already correct.This extends the lead-agent tracing wiring to the subagent path.
What changed
From the operator's perspective: opening a thread in the Langfuse Sessions page now shows all traces for that conversation — the lead agent trace and every subagent trace — under the same session card, carrying the same
user_id, with each subagent trace clearly named (subagent:general-purpose,subagent:bash, …) so it stays distinguishable from the lead trace.Trace topology is unchanged: subagent traces remain separate top-level traces in the same session. Nesting them as child spans under the lead trace is a harder follow-up (Plan B) and intentionally out of scope.
Three concrete code changes implement this:
subagents/executor.py— In_aexecute,build_tracing_callbacks()is appended torun_config["callbacks"](alongside the existingSubagentTokenCollector, not replacing it) andinject_langfuse_metadata(...)is called with the parentthread_id, the constructor-supplieduser_id, and a normalizedsubagent:<name>trace name._create_agentnow builds the model withattach_tracing=False— the same pairing the lead agent uses, so model-level tracing does not double-count with the graph-root callbacks.tools/builtins/task_tool.py—user_idis resolved viaresolve_runtime_user_id(runtime)at the parent tool layer (before the background thread starts) and threaded throughSubagentExecutor.__init__. This matters because_current_useris aContextVarand the subagent runs in_execution_pool, where the contextvar is not guaranteed to be propagated.backend/CLAUDE.md— Tracing System section now listssubagents/executor.py::_aexecuteas a third trace-attribute injection point alongsideruntime/runs/worker.py::run_agentandclient.py::DeerFlowClient.stream, and the field-mapping table documents the subagent-specific sources (user_id viaresolve_runtime_user_id, trace namesubagent:<name>with_→-normalization).Name normalization is inline (lowercase,
_→-) becauseruntime/runs/naming.py::resolve_root_run_nameonly handles the lead-agent path; there is no shared helper to call.Surface area
frontend/backend/applanggraph.json, or prompt changedocker/or sandboxed executionskills/backend/pyproject.tomlorfrontend/package.json(say what it buys us)Runtime behavior change is limited to tracing attribution. No user-facing API or default setting changes shape — the Langfuse session card just gains the subagent traces that already existed as isolated top-level traces. Operators who have not enabled Langfuse are unaffected (
inject_langfuse_metadatano-ops when Langfuse is not in the enabled providers, andbuild_tracing_callbacksreturns an empty list).Test verification
New behavior is pinned by a dedicated test class added before the implementation was considered done (TDD):
backend/tests/test_subagent_executor.py::TestSubagentTracingWiringtest_aexecute_appends_tracing_callbacks_to_run_config—build_tracing_callbacks()output is appended (not replacing) torun_config["callbacks"], andSubagentTokenCollectorsurvives the merge.test_aexecute_injects_langfuse_session_user_and_trace_name— when Langfuse is enabled,run_config["metadata"]carrieslangfuse_session_id == parent_thread_id,langfuse_user_idfrom the constructor,langfuse_trace_name == "subagent:general-purpose"(underscore→hyphen normalization), and amodel:<name>tag.test_aexecute_skips_langfuse_metadata_when_disabled— LangSmith-only deployments stay inert.test_user_id_defaults_when_not_supplied,test_trace_name_falls_back_when_config_name_empty,test_environment_tag_emitted_from_deer_flow_env— pin the metadata field sources so the wiring cannot regress silently.test_create_agent_threads_explicit_app_config_to_model_and_middlewareswas extended to assertattach_tracing=False, locking in the pairing with graph-root callbacks.Validation
End-to-end Langfuse UI verification is pending — the unit tests pin the wiring (
run_config["callbacks"]contents andrun_config["metadata"]keys) but do not exercise the Langfuse handler against a live Langfuse instance. Reviewers with a Langfuse project configured should confirm in Sessions view that a thread with subagent delegations shows the subagent traces under the parent session card.Manual E2E Validation
Verified on a live Langfuse-enabled instance:
session_id/user_idcorrectly inheritedScreenshots attached below.

AI assistance
Tool(s) used: Claude Code
How you used it:
Initial Plan A implementation (staged diff:
build_tracing_callbacksappend,inject_langfuse_metadatacall,attach_tracing=False,user_idplumbing throughtask_tool.py) was reviewed by Claude Code against the lead-agent pattern, and the review flagged the stalebuild_run_configcomment reference, the inconsistentrun_config["callbacks"]merge shape, the misleadinguser_iddocstring, the missing TDD coverage, and the staleCLAUDE.mdTracing section.Polish (comments, docstring, callback merge shape) and the entire
TestSubagentTracingWiringtest class were written by Claude Code. The test class was iterated against the real executor until all six cases passed; one case (model:tag emission) caught a real setup issue (self.model_namewasNonebecause_create_agentwas mocked) and was fixed by passingparent_modelthrough_make_executorso__init__resolves the model name eagerly.I (the author) reviewed every diff line, ran the test/lint matrix above, and take responsibility for the change.
I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.