Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,12 @@ def on_chain_start(
workflow_name_override = (
metadata.get("workflow_name") if metadata else None
)
root_operation_name = self._find_enclosing_root_operation_name(
parent_run_id
)
workflow = self._telemetry_handler.workflow(
name=workflow_name_override or workflow_name
name=workflow_name_override or workflow_name,
root_operation_name=root_operation_name,
)
workflow.input_messages = make_input_message(inputs)
self._invocation_manager.add_invocation_state(
Expand All @@ -95,8 +99,14 @@ def on_chain_start(
else None
)
if suggested_agent_name_lower != agent_invocation_name_lower:
root_operation_name = (
self._find_enclosing_root_operation_name(
parent_run_id
)
)
agent = self._telemetry_handler.invoke_local_agent(
agent_name=suggested_agent_name,
root_operation_name=root_operation_name,
)
agent.input_messages = make_input_message(inputs)

Expand Down Expand Up @@ -500,3 +510,36 @@ def _find_nearest_agent(
return entity
current = self._invocation_manager.get_parent_run_id(current)
return None

def _find_enclosing_root_operation_name(
self, run_id: Optional[UUID]
) -> Optional[str]:
current = run_id
visited: set[UUID] = set()
while current is not None and current not in visited:
visited.add(current)
entity = self._invocation_manager.get_invocation(current)
if isinstance(entity, (AgentInvocation, WorkflowInvocation)):
if entity.root_operation_name:
return entity.root_operation_name

# If this is the top-most enclosing invocation, derive the
# operation name from invocation fields.
parent = self._invocation_manager.get_parent_run_id(current)
if parent is None:
return self._resolve_operation_name(entity)

parent = self._invocation_manager.get_parent_run_id(current)
current = parent
return None

def _resolve_operation_name(
self, entity: AgentInvocation | WorkflowInvocation
) -> str:
if isinstance(entity, AgentInvocation):
if entity.agent_name:
return f"invoke_agent {entity.agent_name}"
return "invoke_agent"
if entity.name:
return f"invoke_workflow {entity.name}"
return "invoke_workflow"
Original file line number Diff line number Diff line change
Expand Up @@ -107,36 +107,60 @@ def _has_agent_signals(metadata: Optional[dict[str, Any]]) -> bool:
)


def _is_langgraph_graph(
serialized: dict[str, Any],
kwargs: dict[str, Any],
) -> bool:
"""Return True if the chain is a LangGraph graph (``Pregel``) invocation.

LangGraph reports the graph itself under the ``LangGraph`` identifier as the
run name (``kwargs['name']`` at runtime, or ``serialized['name']`` /
``serialized['graph']['id']`` in the serialized repr). Individual graph
nodes are reported under the node name instead, so this reliably
distinguishes a (sub)graph invocation from a node invocation.
"""
name = kwargs.get("name")
if not name and serialized:
name = serialized.get("name")
if name and LANGGRAPH_IDENTIFIER in str(name):
return True

if serialized and isinstance(serialized.get("graph"), dict):
graph_id = serialized["graph"].get("id", "")
if LANGGRAPH_IDENTIFIER in str(graph_id):
return True

return False


def _looks_like_workflow(
serialized: dict[str, Any],
metadata: Optional[dict[str, Any]],
kwargs: dict[str, Any],
parent_run_id: Optional[UUID],
) -> bool:
"""Return True if the chain looks like a top-level workflow/graph."""
if parent_run_id is not None:
return False
"""Return True if the chain looks like a workflow/graph.

Both top-level graphs and nested subgraphs are treated as workflows, so a
multi-graph pipeline produces one ``invoke_workflow`` span per graph. A
nested subgraph is distinguished from a top-level workflow later, when the
span is created, by inspecting the run tree.
"""
# An explicit workflow override is authoritative.
if metadata and metadata.get(_META_WORKFLOW_SPAN):
return True

# Heuristic: check for LangGraph identifier in the serialized repr.
if serialized:
name = serialized.get("name", "")
graph_id = (
serialized.get("graph", {}).get("id", "")
if isinstance(serialized.get("graph"), dict)
else ""
)
return LANGGRAPH_IDENTIFIER in name or LANGGRAPH_IDENTIFIER in graph_id

# No serialized data to inspect, but this is a top-level chain
# (parent_run_id is None). When we have zero information about a root-level
# chain we prefer to emit a span rather than silently drop it — more data
# is better than missing the outermost invocation entirely. Treat it as a
# workflow so the outermost operation always gets a span even when the
# chain didn't populate its serialized representation.
return True
# A LangGraph graph invocation, whether top-level or a nested subgraph.
if _is_langgraph_graph(serialized, kwargs):
return True

# A root-level chain with no serialized data to inspect. We have zero
# information about it, but prefer emitting a span for the outermost
# invocation rather than silently dropping it.
if parent_run_id is None and not serialized:
return True

return False


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -213,7 +237,7 @@ def classify_chain_run(
return OperationName.INVOKE_AGENT

# 3. Workflow / orchestration detection.
if _looks_like_workflow(serialized, metadata, parent_run_id):
if _looks_like_workflow(serialized, metadata, kwargs, parent_run_id):
return OperationName.INVOKE_WORKFLOW

# 4. Default: suppress unclassified chains.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
interactions:
- request:
body: |-
{
"messages": [
{
"content": "You are a research assistant. Provide 2-3 factual sentences.",
"role": "system"
},
{
"content": "What is the capital of France?",
"role": "user"
}
],
"model": "gpt-3.5-turbo",
"max_completion_tokens": 200,
"seed": 42,
"stream": false,
"temperature": 0.1
}
headers:
Accept:
- application/json
Content-Type:
- application/json
Host:
- api.openai.com
authorization:
- Bearer test_openai_api_key
method: POST
uri: https://api.openai.com/v1/chat/completions
response:
body:
string: |-
{
"id": "chatcmpl-nested001research",
"object": "chat.completion",
"created": 1771535300,
"model": "gpt-3.5-turbo-0125",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "The capital of France is Paris. Paris sits on the Seine and has been the country's capital since the 10th century.",
"refusal": null,
"annotations": []
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 35,
"completion_tokens": 27,
"total_tokens": 62,
"prompt_tokens_details": {
"cached_tokens": 0,
"audio_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0
}
},
"service_tier": "default",
"system_fingerprint": null
}
headers:
Connection:
- keep-alive
Content-Type:
- application/json
Date:
- Wed, 20 May 2026 00:07:12 GMT
Server:
- cloudflare
Set-Cookie: test_set_cookie
content-length:
- '640'
openai-organization: test_openai_org_id
openai-version:
- '2020-10-01'
x-request-id:
- req_nested001research
status:
code: 200
message: OK
- request:
body: |-
{
"messages": [
{
"content": "You are an expert summariser. Condense the text below into one clear sentence.",
"role": "system"
},
{
"content": "The capital of France is Paris. Paris sits on the Seine and has been the country's capital since the 10th century.",
"role": "user"
}
],
"model": "gpt-3.5-turbo",
"max_completion_tokens": 200,
"seed": 42,
"stream": false,
"temperature": 0.1
}
headers:
Accept:
- application/json
Content-Type:
- application/json
Host:
- api.openai.com
authorization:
- Bearer test_openai_api_key
method: POST
uri: https://api.openai.com/v1/chat/completions
response:
body:
string: |-
{
"id": "chatcmpl-nested001summary",
"object": "chat.completion",
"created": 1771535301,
"model": "gpt-3.5-turbo-0125",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Paris, on the Seine, has been France's capital since the 10th century.",
"refusal": null,
"annotations": []
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 52,
"completion_tokens": 18,
"total_tokens": 70,
"prompt_tokens_details": {
"cached_tokens": 0,
"audio_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0
}
},
"service_tier": "default",
"system_fingerprint": null
}
headers:
Connection:
- keep-alive
Content-Type:
- application/json
Date:
- Wed, 20 May 2026 00:07:13 GMT
Server:
- cloudflare
Set-Cookie: test_set_cookie
content-length:
- '600'
openai-organization: test_openai_org_id
openai-version:
- '2020-10-01'
x-request-id:
- req_nested001summary
status:
code: 200
message: OK
version: 1
Loading
Loading