fix(langchain): trace nested workflows - #617
Conversation
Signed-off-by: 1fanwang <1fannnw@gmail.com>
There was a problem hiding this comment.
🟢 Approval recommended
The changes are well-scoped to LangGraph nested workflow span classification and are backed by targeted regression tests (sync and async) covering the reported failure mode.
Pull request overview
This PR fixes LangChain/LangGraph workflow tracing so that named nested subgraphs emit their own invoke_workflow spans, instead of disappearing into the outer workflow span. It extends the “graph announcement” mechanism (introduced for nested agents) to all compiled graphs, while keeping agent graphs classified as invoke_agent and continuing to suppress ordinary nested RunnableSequence chains.
Changes:
- Extend graph announcements to cover non-agent compiled graphs, enabling nested subgraph workflow spans.
- Adjust chain-run classification precedence to honor explicit overrides and prefer announced nested workflows over inherited agent metadata.
- Add regression tests validating that a named nested subgraph produces an additional workflow span (sync + async).
File summaries
| File | Description |
|---|---|
| instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_workflow.py | New regression tests for nested LangGraph subgraph workflow spans and suppression behavior for non-graph nested chains. |
| instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_operation_mapping.py | Adds unit tests covering workflow override behavior when nested and precedence rules with announcements vs metadata. |
| instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/operation_mapping.py | Updates workflow/agent classification logic to allow nested workflow spans when a compiled-graph announcement is present. |
| instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/callback_handler.py | Uses the generalized graph announcement (claim_graph) and threads announced_workflow into classification; supports workflow naming from announcements. |
| instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/agent_context.py | Generalizes pending context from “agent” to “graph” announcements and announces non-agent graphs during Pregel stream entry. |
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Pull request dashboard statusWaiting on the author · refreshed 2026-09-05 16:17 UTC Respond to 1 review item (e.g. link a commit, explain why not, ask a follow-up):
Status above doesn't look right?
|
Signed-off-by: 1fanwang <1fannnw@gmail.com>
| workflow_name = ( | ||
| kwargs.get("name") | ||
| or serialized.get("name") | ||
| or declared_workflow_name |
There was a problem hiding this comment.
If serialized.get("name") returns a default name (like "LangGraph"), it can shadow declared_workflow_name. Consider checking declared_workflow_name before fallback serialized names: kwargs.get("name") or declared_workflow_name or serialized.get("name").
| return OperationName.INVOKE_WORKFLOW | ||
|
|
||
| # 3. A nested graph announcement is stronger than inherited agent metadata. | ||
| if announced_workflow and parent_run_id is not None: |
There was a problem hiding this comment.
Since announced_workflow confirms the graph is not an agent via claim_graph(), should it take precedence over _has_agent_signals consistently at both root and nested levels? If so, this and step 4 can be unified before _has_agent_signals.
| assert set(workflow_spans_by_name) == {"LangGraph", "named_subgraph"} | ||
| inner_span = workflow_spans_by_name["named_subgraph"] | ||
| outer_span = workflow_spans_by_name["LangGraph"] | ||
| if not async_mode: |
There was a problem hiding this comment.
Is parentage expected to differ in async mode? If possible, we should assert span parentage in async mode as well or test the actual hierarchy.
| def _push(name: str | None) -> _PendingAgent: | ||
| """Announce ``name`` as the innermost running agent.""" | ||
| entry = _PendingAgent(name) | ||
| def _workflow_name(graph: Any) -> str | None: |
There was a problem hiding this comment.
A subgraph that declares agent metadata on its own config should be one invoke_agent span. It now gets two - the graph root is announced as a workflow, and the agent signal is picked up again by the inner node run:
sub = _graph(_respond, name="my_agent").with_config({"metadata": {"agent_name": "my_agent"}})
_graph(sub).invoke({"messages": [HumanMessage(content="hello")]})
# main: invoke_workflow LangGraph > invoke_agent my_agent
# here: invoke_workflow LangGraph > invoke_workflow my_agent > invoke_agent my_agentThat metadata is on the graph's own bound config, which is the one place inheritance can't lie, so announce it as an agent here rather than reclassifying in classify_chain_run:
react_name = _react_agent_name(graph)
if react_name is not None:
return True, react_name or None
if isinstance(config, Mapping):
metadata = cast("Mapping[str, Any]", config).get("metadata")
if isinstance(metadata, Mapping):
m = cast("Mapping[str, Any]", metadata)
if m.get("otel_agent_span") or m.get("agent_type") or m.get("agent_name"):
name = m.get("agent_name")
return True, str(name) if name else None
return False, NoneThat collapses it back to a single invoke_agent my_agent, needs no change in classify_chain_run, and the existing tests still pass. Please add the case above as a test - agent metadata on the subgraph itself, not on the outer invoke.
| workflow_name = ( | ||
| kwargs.get("name") | ||
| or serialized.get("name") | ||
| or declared_workflow_name | ||
| ) |
There was a problem hiding this comment.
Both fallbacks here are dead. langchain_core always passes name=config.get("run_name") or self.get_name(), and for a Pregel get_name() is the graph name - the same value _workflow_name reads - so kwargs["name"] is always set and always equal to declared_workflow_name. serialized.get("name") is never reached either, which is the only reason it doesn't blow up: with current langgraph serialized is None on every chain callback.
| workflow_name = ( | |
| kwargs.get("name") | |
| or serialized.get("name") | |
| or declared_workflow_name | |
| ) | |
| workflow_name = kwargs.get("name") |
If that's right, _workflow_name and _PendingGraph.name for the workflow path can go too - the announcement only needs to carry is_agent. If there is a case where they differ, it needs a test.
| assert set(workflow_spans_by_name) == {"LangGraph", "named_subgraph"} | ||
| inner_span = workflow_spans_by_name["named_subgraph"] | ||
| outer_span = workflow_spans_by_name["LangGraph"] | ||
| if not async_mode: |
There was a problem hiding this comment.
In async the nested span is a second root span, not a child - that's #513, not a bug in this change. Please make it visible rather than skipping the assert:
if async_mode:
pytest.xfail("nested spans are not parented in async - #513")
assert inner_span.parent.span_id == outer_span.context.span_id| handler consults when the root run starts. | ||
| """ | ||
|
|
||
| from __future__ import annotations |
There was a problem hiding this comment.
nit: the module docstring and the body of claim_graph's docstring explained why the announcement exists at all - callback metadata can't identify a nested graph root, only the innermost announcement is claimable, and only once. None of that is inferable from the code; please keep it, updated for graphs.
Assisted-by: GitHub Copilot CLI (GPT-5.6 Sol) Signed-off-by: 1fanwang <1fannnw@gmail.com>
Description
Nested LangGraph subgraphs currently disappear from workflow telemetry. The outer graph is reported, but a named inner graph has no independent duration or status.
This announces each compiled graph at its entry point. Nested graphs emit
invoke_workflow; graph-bound agent metadata emits oneinvoke_agentspan; inherited metadata does not create duplicate spans; local child overrides still work.Fixes #578
Known gap: async nested spans are still emitted as roots. #513 tracks that defect, and the parent assertion is marked xfail.
Type of change
How has this been tested?
The tests invoke real sync and async LangGraph graphs, including nested workflows, bound agent markers, inherited metadata, and local child overrides.
Raw logs
Checklist