-
Notifications
You must be signed in to change notification settings - Fork 783
feat: add agent span support in langchain instrumentation #3788
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 7 commits
529ebd7
b1fe20f
d030990
87b6b05
f1d1cee
207c54d
fdacebd
d3b2528
1b98f9f
ae6bf62
744ddb6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -91,6 +91,48 @@ def create_chat_span( | |
|
||
return span | ||
|
||
def create_agent_span( | ||
self, | ||
run_id: UUID, | ||
parent_run_id: Optional[UUID], | ||
agent_name: Optional[str] = None, | ||
) -> Span: | ||
"""Create a span for agent invocation.""" | ||
span_name = ( | ||
f"invoke_agent {agent_name}" if agent_name else "invoke_agent" | ||
) | ||
span = self._create_span( | ||
run_id=run_id, | ||
parent_run_id=parent_run_id, | ||
span_name=span_name, | ||
kind=SpanKind.CLIENT, | ||
) | ||
span.set_attribute( | ||
GenAI.GEN_AI_OPERATION_NAME, | ||
"invoke_agent", | ||
|
||
) | ||
if agent_name: | ||
|
||
span.set_attribute(GenAI.GEN_AI_AGENT_NAME, agent_name) | ||
|
||
return span | ||
|
||
def create_chain_span( | ||
self, | ||
run_id: UUID, | ||
parent_run_id: Optional[UUID], | ||
chain_name: str, | ||
) -> Span: | ||
"""Create a span for chain execution.""" | ||
span = self._create_span( | ||
run_id=run_id, | ||
parent_run_id=parent_run_id, | ||
span_name=f"chain {chain_name}", | ||
kind=SpanKind.INTERNAL, | ||
) | ||
# Chains are internal operations, not direct GenAI operations | ||
# We can track them but they don't have a gen_ai.operation.name | ||
return span | ||
|
||
def end_span(self, run_id: UUID) -> None: | ||
state = self.spans[run_id] | ||
for child_id in state.children: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
"""Tests for agent-related spans in LangChain instrumentation.""" | ||
|
||
from unittest.mock import MagicMock | ||
from uuid import uuid4 | ||
|
||
import pytest | ||
from langchain_core.agents import AgentAction, AgentFinish | ||
|
||
from opentelemetry.instrumentation.langchain.callback_handler import ( | ||
OpenTelemetryLangChainCallbackHandler, | ||
) | ||
from opentelemetry.semconv._incubating.attributes import ( | ||
gen_ai_attributes as GenAI, | ||
) | ||
from opentelemetry.trace import SpanKind | ||
|
||
|
||
@pytest.fixture | ||
def callback_handler(tracer_provider): | ||
tracer = tracer_provider.get_tracer("test") | ||
return OpenTelemetryLangChainCallbackHandler(tracer=tracer) | ||
|
||
|
||
def test_agent_chain_span(callback_handler, span_exporter): | ||
"""Test that agent chains create proper invoke_agent spans.""" | ||
run_id = uuid4() | ||
parent_run_id = uuid4() | ||
|
||
# Start a chain that represents an agent | ||
callback_handler.on_chain_start( | ||
serialized={ | ||
"name": "TestAgent", | ||
"id": ["langchain", "agents", "TestAgent"], | ||
}, | ||
inputs={"input": "What is the capital of France?"}, | ||
run_id=run_id, | ||
parent_run_id=parent_run_id, | ||
metadata={"agent_name": "TestAgent"}, | ||
) | ||
|
||
# End the chain | ||
callback_handler.on_chain_end( | ||
outputs={"output": "The capital of France is Paris."}, | ||
run_id=run_id, | ||
parent_run_id=parent_run_id, | ||
) | ||
|
||
# Verify the span | ||
spans = span_exporter.get_finished_spans() | ||
assert len(spans) == 1 | ||
|
||
span = spans[0] | ||
assert span.name == "chain TestAgent" | ||
assert span.kind == SpanKind.INTERNAL | ||
assert span.attributes.get(GenAI.GEN_AI_AGENT_NAME) == "TestAgent" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the above comment you mentioned that chain spans are internal operations and will not have an operation name but there you are adding assert statements for it, did I miss something? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for your review! Let me try to clarify. Regular chains don't have
I'll update the docstrings to make this distinction clearer. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Awesome. Thanks for the clarification. |
||
assert span.attributes.get(GenAI.GEN_AI_OPERATION_NAME) == "invoke_agent" | ||
|
||
|
||
def test_agent_action_tracking(callback_handler, span_exporter): | ||
"""Test that agent actions are properly tracked.""" | ||
run_id = uuid4() | ||
parent_run_id = uuid4() | ||
|
||
# Start a chain | ||
callback_handler.on_chain_start( | ||
serialized={"name": "Agent"}, | ||
inputs={"input": "What is 2 + 2?"}, | ||
run_id=run_id, | ||
parent_run_id=parent_run_id, | ||
) | ||
|
||
# Agent takes an action | ||
action = MagicMock(spec=AgentAction) | ||
action.tool = "calculator" | ||
action.tool_input = "2 + 2" | ||
|
||
callback_handler.on_agent_action( | ||
action=action, | ||
run_id=run_id, | ||
parent_run_id=parent_run_id, | ||
) | ||
|
||
# Agent finishes | ||
finish = MagicMock(spec=AgentFinish) | ||
finish.return_values = {"output": "The answer is 4"} | ||
|
||
callback_handler.on_agent_finish( | ||
finish=finish, | ||
run_id=run_id, | ||
parent_run_id=parent_run_id, | ||
) | ||
|
||
# End the chain | ||
callback_handler.on_chain_end( | ||
outputs={"output": "The answer is 4"}, | ||
run_id=run_id, | ||
parent_run_id=parent_run_id, | ||
) | ||
|
||
# Verify the span | ||
spans = span_exporter.get_finished_spans() | ||
assert len(spans) == 1 | ||
|
||
span = spans[0] | ||
assert span.attributes.get("langchain.agent.action.tool") == "calculator" | ||
assert span.attributes.get("langchain.agent.action.tool_input") == "2 + 2" | ||
assert ( | ||
span.attributes.get("langchain.agent.finish.output") | ||
== "The answer is 4" | ||
) | ||
|
||
|
||
def test_regular_chain_without_agent(callback_handler, span_exporter): | ||
"""Test that regular chains don't get agent attributes.""" | ||
run_id = uuid4() | ||
parent_run_id = uuid4() | ||
|
||
# Start a regular chain (not an agent) | ||
callback_handler.on_chain_start( | ||
serialized={"name": "RegularChain"}, | ||
inputs={"input": "Test input"}, | ||
run_id=run_id, | ||
parent_run_id=parent_run_id, | ||
metadata={}, # No agent_name in metadata | ||
) | ||
|
||
# End the chain | ||
callback_handler.on_chain_end( | ||
outputs={"output": "Test output"}, | ||
run_id=run_id, | ||
parent_run_id=parent_run_id, | ||
) | ||
|
||
# Verify the span | ||
spans = span_exporter.get_finished_spans() | ||
assert len(spans) == 1 | ||
|
||
span = spans[0] | ||
assert span.name == "chain RegularChain" | ||
assert span.kind == SpanKind.INTERNAL | ||
assert GenAI.GEN_AI_AGENT_NAME not in span.attributes | ||
assert ( | ||
GenAI.GEN_AI_OPERATION_NAME not in span.attributes | ||
) # Regular chains don't have operation name | ||
|
||
|
||
def test_chain_error_handling(callback_handler, span_exporter): | ||
"""Test that chain errors are properly handled.""" | ||
run_id = uuid4() | ||
parent_run_id = uuid4() | ||
|
||
# Start a chain | ||
callback_handler.on_chain_start( | ||
serialized={"name": "ErrorChain"}, | ||
inputs={"input": "Test input"}, | ||
run_id=run_id, | ||
parent_run_id=parent_run_id, | ||
) | ||
|
||
# Chain encounters an error | ||
error = ValueError("Test error") | ||
callback_handler.on_chain_error( | ||
error=error, | ||
run_id=run_id, | ||
parent_run_id=parent_run_id, | ||
) | ||
|
||
# Verify the span | ||
spans = span_exporter.get_finished_spans() | ||
assert len(spans) == 1 | ||
|
||
span = spans[0] | ||
assert span.name == "chain ErrorChain" | ||
assert span.status.status_code.name == "ERROR" | ||
assert span.attributes.get("error.type") == "ValueError" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's make sure it creates internal span
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sound good - confirmed chain spans use
SpanKind.INTERNAL
here: