Skip to content

Commit 529ebd7

Browse files
committed
feat: add agent span support in langchain instrumentation
1 parent 7bacbfd commit 529ebd7

File tree

5 files changed

+519
-0
lines changed

5 files changed

+519
-0
lines changed

instrumentation-genai/opentelemetry-instrumentation-langchain/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
- Added agent span support for GenAI LangChain instrumentation with `invoke_agent` operation and chain tracking.
11+
([#TBD](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/TBD))
1012
- Added span support for genAI langchain llm invocation.
1113
([#3665](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3665))

instrumentation-genai/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from typing import Any
1818
from uuid import UUID
1919

20+
from langchain_core.agents import AgentAction, AgentFinish # type: ignore
2021
from langchain_core.callbacks import BaseCallbackHandler # type: ignore
2122
from langchain_core.messages import BaseMessage # type: ignore
2223
from langchain_core.outputs import LLMResult # type: ignore
@@ -222,3 +223,99 @@ def on_llm_error(
222223
**kwargs: Any,
223224
) -> None:
224225
self.span_manager.handle_error(error, run_id)
226+
227+
def on_chain_start(
228+
self,
229+
serialized: dict[str, Any],
230+
inputs: dict[str, Any],
231+
*,
232+
run_id: UUID,
233+
parent_run_id: UUID | None = None,
234+
tags: list[str] | None = None,
235+
metadata: dict[str, Any] | None = None,
236+
**kwargs: Any,
237+
) -> None:
238+
"""Run when chain starts running."""
239+
# Extract chain name from serialized or kwargs
240+
chain_name = "unknown"
241+
if serialized and "kwargs" in serialized and serialized["kwargs"].get("name"):
242+
chain_name = serialized["kwargs"]["name"]
243+
elif kwargs.get("name"):
244+
chain_name = kwargs["name"]
245+
elif serialized.get("name"):
246+
chain_name = serialized["name"]
247+
elif "id" in serialized:
248+
chain_name = serialized["id"][-1]
249+
250+
span = self.span_manager.create_chain_span(
251+
run_id=run_id,
252+
parent_run_id=parent_run_id,
253+
chain_name=chain_name,
254+
)
255+
256+
# If this is an agent chain, set agent-specific attributes
257+
if metadata and "agent_name" in metadata:
258+
span.set_attribute(GenAI.GEN_AI_AGENT_NAME, metadata["agent_name"])
259+
span.set_attribute(GenAI.GEN_AI_OPERATION_NAME, "invoke_agent")
260+
261+
def on_chain_end(
262+
self,
263+
outputs: dict[str, Any],
264+
*,
265+
run_id: UUID,
266+
parent_run_id: UUID | None = None,
267+
tags: list[str] | None = None,
268+
**kwargs: Any,
269+
) -> None:
270+
"""Run when chain ends running."""
271+
self.span_manager.end_span(run_id)
272+
273+
def on_chain_error(
274+
self,
275+
error: BaseException,
276+
*,
277+
run_id: UUID,
278+
parent_run_id: UUID | None = None,
279+
tags: list[str] | None = None,
280+
**kwargs: Any,
281+
) -> None:
282+
"""Run when chain errors."""
283+
self.span_manager.handle_error(error, run_id)
284+
285+
def on_agent_action(
286+
self,
287+
action: AgentAction, # type: ignore
288+
*,
289+
run_id: UUID,
290+
parent_run_id: UUID | None = None,
291+
tags: list[str] | None = None,
292+
**kwargs: Any,
293+
) -> None:
294+
"""Run on agent action."""
295+
# Agent actions are tracked as part of the chain span
296+
# We can add attributes to the existing span if needed
297+
span = self.span_manager.get_span(run_id)
298+
if span:
299+
tool = getattr(action, "tool", None)
300+
if tool:
301+
span.set_attribute("langchain.agent.action.tool", tool)
302+
tool_input = getattr(action, "tool_input", None)
303+
if tool_input:
304+
span.set_attribute("langchain.agent.action.tool_input", str(tool_input))
305+
306+
def on_agent_finish(
307+
self,
308+
finish: AgentFinish, # type: ignore
309+
*,
310+
run_id: UUID,
311+
parent_run_id: UUID | None = None,
312+
tags: list[str] | None = None,
313+
**kwargs: Any,
314+
) -> None:
315+
"""Run on agent finish."""
316+
# Agent finish is tracked as part of the chain span
317+
span = self.span_manager.get_span(run_id)
318+
if span:
319+
return_values = getattr(finish, "return_values", None)
320+
if return_values and "output" in return_values:
321+
span.set_attribute("langchain.agent.finish.output", str(return_values["output"]))

instrumentation-genai/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/span_manager.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,46 @@ def create_chat_span(
9191

9292
return span
9393

94+
def create_agent_span(
95+
self,
96+
run_id: UUID,
97+
parent_run_id: Optional[UUID],
98+
agent_name: Optional[str] = None,
99+
) -> Span:
100+
"""Create a span for agent invocation."""
101+
span_name = f"invoke_agent {agent_name}" if agent_name else "invoke_agent"
102+
span = self._create_span(
103+
run_id=run_id,
104+
parent_run_id=parent_run_id,
105+
span_name=span_name,
106+
kind=SpanKind.CLIENT,
107+
)
108+
span.set_attribute(
109+
GenAI.GEN_AI_OPERATION_NAME,
110+
"invoke_agent",
111+
)
112+
if agent_name:
113+
span.set_attribute(GenAI.GEN_AI_AGENT_NAME, agent_name)
114+
115+
return span
116+
117+
def create_chain_span(
118+
self,
119+
run_id: UUID,
120+
parent_run_id: Optional[UUID],
121+
chain_name: str,
122+
) -> Span:
123+
"""Create a span for chain execution."""
124+
span = self._create_span(
125+
run_id=run_id,
126+
parent_run_id=parent_run_id,
127+
span_name=f"chain {chain_name}",
128+
kind=SpanKind.INTERNAL,
129+
)
130+
# Chains are internal operations, not direct GenAI operations
131+
# We can track them but they don't have a gen_ai.operation.name
132+
return span
133+
94134
def end_span(self, run_id: UUID) -> None:
95135
state = self.spans[run_id]
96136
for child_id in state.children:
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Tests for agent-related spans in LangChain instrumentation."""
2+
3+
from unittest.mock import MagicMock, Mock
4+
from uuid import uuid4
5+
6+
import pytest
7+
from langchain_core.agents import AgentAction, AgentFinish
8+
9+
from opentelemetry.instrumentation.langchain.callback_handler import (
10+
OpenTelemetryLangChainCallbackHandler,
11+
)
12+
from opentelemetry.semconv._incubating.attributes import gen_ai_attributes as GenAI
13+
from opentelemetry.trace import SpanKind
14+
15+
16+
@pytest.fixture
17+
def callback_handler(tracer_provider):
18+
tracer = tracer_provider.get_tracer("test")
19+
return OpenTelemetryLangChainCallbackHandler(tracer=tracer)
20+
21+
22+
def test_agent_chain_span(callback_handler, span_exporter):
23+
"""Test that agent chains create proper invoke_agent spans."""
24+
run_id = uuid4()
25+
parent_run_id = uuid4()
26+
27+
# Start a chain that represents an agent
28+
callback_handler.on_chain_start(
29+
serialized={"name": "TestAgent", "id": ["langchain", "agents", "TestAgent"]},
30+
inputs={"input": "What is the capital of France?"},
31+
run_id=run_id,
32+
parent_run_id=parent_run_id,
33+
metadata={"agent_name": "TestAgent"},
34+
)
35+
36+
# End the chain
37+
callback_handler.on_chain_end(
38+
outputs={"output": "The capital of France is Paris."},
39+
run_id=run_id,
40+
parent_run_id=parent_run_id,
41+
)
42+
43+
# Verify the span
44+
spans = span_exporter.get_finished_spans()
45+
assert len(spans) == 1
46+
47+
span = spans[0]
48+
assert span.name == "chain TestAgent"
49+
assert span.kind == SpanKind.INTERNAL
50+
assert span.attributes.get(GenAI.GEN_AI_AGENT_NAME) == "TestAgent"
51+
assert span.attributes.get(GenAI.GEN_AI_OPERATION_NAME) == "invoke_agent"
52+
53+
54+
def test_agent_action_tracking(callback_handler, span_exporter):
55+
"""Test that agent actions are properly tracked."""
56+
run_id = uuid4()
57+
parent_run_id = uuid4()
58+
59+
# Start a chain
60+
callback_handler.on_chain_start(
61+
serialized={"name": "Agent"},
62+
inputs={"input": "What is 2 + 2?"},
63+
run_id=run_id,
64+
parent_run_id=parent_run_id,
65+
)
66+
67+
# Agent takes an action
68+
action = MagicMock(spec=AgentAction)
69+
action.tool = "calculator"
70+
action.tool_input = "2 + 2"
71+
72+
callback_handler.on_agent_action(
73+
action=action,
74+
run_id=run_id,
75+
parent_run_id=parent_run_id,
76+
)
77+
78+
# Agent finishes
79+
finish = MagicMock(spec=AgentFinish)
80+
finish.return_values = {"output": "The answer is 4"}
81+
82+
callback_handler.on_agent_finish(
83+
finish=finish,
84+
run_id=run_id,
85+
parent_run_id=parent_run_id,
86+
)
87+
88+
# End the chain
89+
callback_handler.on_chain_end(
90+
outputs={"output": "The answer is 4"},
91+
run_id=run_id,
92+
parent_run_id=parent_run_id,
93+
)
94+
95+
# Verify the span
96+
spans = span_exporter.get_finished_spans()
97+
assert len(spans) == 1
98+
99+
span = spans[0]
100+
assert span.attributes.get("langchain.agent.action.tool") == "calculator"
101+
assert span.attributes.get("langchain.agent.action.tool_input") == "2 + 2"
102+
assert span.attributes.get("langchain.agent.finish.output") == "The answer is 4"
103+
104+
105+
def test_regular_chain_without_agent(callback_handler, span_exporter):
106+
"""Test that regular chains don't get agent attributes."""
107+
run_id = uuid4()
108+
parent_run_id = uuid4()
109+
110+
# Start a regular chain (not an agent)
111+
callback_handler.on_chain_start(
112+
serialized={"name": "RegularChain"},
113+
inputs={"input": "Test input"},
114+
run_id=run_id,
115+
parent_run_id=parent_run_id,
116+
metadata={}, # No agent_name in metadata
117+
)
118+
119+
# End the chain
120+
callback_handler.on_chain_end(
121+
outputs={"output": "Test output"},
122+
run_id=run_id,
123+
parent_run_id=parent_run_id,
124+
)
125+
126+
# Verify the span
127+
spans = span_exporter.get_finished_spans()
128+
assert len(spans) == 1
129+
130+
span = spans[0]
131+
assert span.name == "chain RegularChain"
132+
assert span.kind == SpanKind.INTERNAL
133+
assert GenAI.GEN_AI_AGENT_NAME not in span.attributes
134+
assert GenAI.GEN_AI_OPERATION_NAME not in span.attributes # Regular chains don't have operation name
135+
136+
137+
def test_chain_error_handling(callback_handler, span_exporter):
138+
"""Test that chain errors are properly handled."""
139+
run_id = uuid4()
140+
parent_run_id = uuid4()
141+
142+
# Start a chain
143+
callback_handler.on_chain_start(
144+
serialized={"name": "ErrorChain"},
145+
inputs={"input": "Test input"},
146+
run_id=run_id,
147+
parent_run_id=parent_run_id,
148+
)
149+
150+
# Chain encounters an error
151+
error = ValueError("Test error")
152+
callback_handler.on_chain_error(
153+
error=error,
154+
run_id=run_id,
155+
parent_run_id=parent_run_id,
156+
)
157+
158+
# Verify the span
159+
spans = span_exporter.get_finished_spans()
160+
assert len(spans) == 1
161+
162+
span = spans[0]
163+
assert span.name == "chain ErrorChain"
164+
assert span.status.status_code.name == "ERROR"
165+
assert span.attributes.get("error.type") == "ValueError"

0 commit comments

Comments
 (0)