Skip to content

fix(langchain): trace nested workflows - #617

Open
1fanwang wants to merge 3 commits into
open-telemetry:mainfrom
1fanwang:1fannnw/langchain-nested-workflow-578
Open

fix(langchain): trace nested workflows#617
1fanwang wants to merge 3 commits into
open-telemetry:mainfrom
1fanwang:1fannnw/langchain-nested-workflow-578

Conversation

@1fanwang

@1fanwang 1fanwang commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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 one invoke_agent span; 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

  • Bug fix (non-breaking change which fixes an issue)

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
$ P=instrumentation/opentelemetry-instrumentation-genai-langchain
$ S=$P/src/opentelemetry/instrumentation/genai/langchain
$ T=$P/tests/test_workflow.py
$ git restore --source=origin/main -- "$S"/{agent_context,callback_handler,operation_mapping}.py
$ pytest -q "$T"::test_nested_graph_emits_workflow_span
test_nested_graph_emits_workflow_span[sync] FAILED
E       assert 1 == 2
test_nested_graph_emits_workflow_span[async] FAILED
E       assert 1 == 2
2 failed

$ git restore --source=HEAD -- "$S"/{agent_context,callback_handler,operation_mapping}.py
$ pytest -q "$T"::test_nested_graph_emits_workflow_span "$T"::test_nested_graph_with_bound_agent_metadata_is_agent "$T"::test_graph_child_with_local_operation_metadata "$T"::test_nested_graph_under_inherited_agent_metadata_is_workflow
17 passed, 1 xfailed

Checklist

  • Followed the style guidelines of this project
  • Changelog updated if the change requires an entry
  • Unit tests added

Signed-off-by: 1fanwang <1fannnw@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.

@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Sep 4, 2026

Copy link
Copy Markdown

Pull request dashboard status

Waiting 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):

  • Inline threads: 1
Status above doesn't look right?
  • Just replied or pushed? Anything around or after the refresh time above may not be picked up yet — give it a few minutes.
  • Should this be with reviewers? Comment /dashboard route:reviewers to route it to them.
  • Anything wrong — including the routing? Report it with what you expected; it helps us improve the dashboard.

Signed-off-by: 1fanwang <1fannnw@gmail.com>
workflow_name = (
kwargs.get("name")
or serialized.get("name")
or declared_workflow_name

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_agent

That 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, None

That 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.

Comment on lines +136 to +140
workflow_name = (
kwargs.get("name")
or serialized.get("name")
or declared_workflow_name
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

[langchain] Add nested workflow span on on_chain_start

3 participants