Skip to content
Closed
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
1 change: 1 addition & 0 deletions .changelog/4629.fixed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-instrumentation-openai-agents-v2`: handle MCPListToolsSpanData so MCP list tools spans are no longer reported as "unknown"
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
GenerationSpanData,
GuardrailSpanData,
HandoffSpanData,
MCPListToolsSpanData,
ResponseSpanData,
SpeechSpanData,
TranscriptionSpanData,
Expand All @@ -51,6 +52,7 @@
GenerationSpanData = getattr(tracing_module, "GenerationSpanData", Any) # type: ignore[assignment]
GuardrailSpanData = getattr(tracing_module, "GuardrailSpanData", Any) # type: ignore[assignment]
HandoffSpanData = getattr(tracing_module, "HandoffSpanData", Any) # type: ignore[assignment]
MCPListToolsSpanData = getattr(tracing_module, "MCPListToolsSpanData", Any) # type: ignore[assignment]
ResponseSpanData = getattr(tracing_module, "ResponseSpanData", Any) # type: ignore[assignment]
SpeechSpanData = getattr(tracing_module, "SpeechSpanData", Any) # type: ignore[assignment]
TranscriptionSpanData = getattr(
Expand Down Expand Up @@ -123,6 +125,7 @@ class GenAIOperationName:
SPEECH = "speech_generation"
GUARDRAIL = "guardrail_check"
HANDOFF = "agent_handoff"
MCP_LIST_TOOLS = "mcp_list_tools"
RESPONSE = "response" # internal aggregator in current processor

CLASS_FALLBACK = {
Expand Down Expand Up @@ -244,6 +247,8 @@ def _attr(name: str, fallback: str) -> str:
GEN_AI_HANDOFF_TO_AGENT = "gen_ai.handoff.to_agent"
GEN_AI_EMBEDDINGS_DIMENSION_COUNT = "gen_ai.embeddings.dimension.count"
GEN_AI_TOKEN_TYPE = _attr("GEN_AI_TOKEN_TYPE", "gen_ai.token.type")
GEN_AI_MCP_SERVER_NAME = "gen_ai.mcp.server.name"
GEN_AI_MCP_TOOL_NAMES = "gen_ai.mcp.tool.names"

# ---- Normalization utilities (embedded from utils.py) ----

Expand Down Expand Up @@ -423,6 +428,9 @@ def get_span_name(
if operation_name == GenAIOperationName.HANDOFF:
return f"{base_name} {agent_name}" if agent_name else base_name

if operation_name == GenAIOperationName.MCP_LIST_TOOLS:
return f"{base_name} {tool_name}" if tool_name else base_name

return base_name


Expand Down Expand Up @@ -1286,6 +1294,8 @@ def _get_span_kind(self, span_data: Any) -> SpanKind:
return SpanKind.CLIENT # API calls to model providers
if _is_instance_of(span_data, AgentSpanData):
return SpanKind.CLIENT
if _is_instance_of(span_data, MCPListToolsSpanData):
return SpanKind.CLIENT # MCP server call
if _is_instance_of(span_data, (GuardrailSpanData, HandoffSpanData)):
return SpanKind.INTERNAL # Agent operations are internal
return SpanKind.INTERNAL
Expand Down Expand Up @@ -1362,11 +1372,12 @@ def on_span_start(self, span: Span[Any]) -> None:
if not agent_name:
agent_name = self._agent_name_default

tool_name = (
getattr(span.span_data, "name", None)
if _is_instance_of(span.span_data, FunctionSpanData)
else None
)
if _is_instance_of(span.span_data, FunctionSpanData):
tool_name = getattr(span.span_data, "name", None)
elif _is_instance_of(span.span_data, MCPListToolsSpanData):
tool_name = getattr(span.span_data, "server", None)
else:
tool_name = None

# Generate spec-compliant span name
span_name = get_span_name(operation_name, model, agent_name, tool_name)
Expand Down Expand Up @@ -1548,6 +1559,8 @@ def _get_operation_name(self, span_data: Any) -> str:
return GenAIOperationName.GUARDRAIL
if _is_instance_of(span_data, HandoffSpanData):
return GenAIOperationName.HANDOFF
if _is_instance_of(span_data, MCPListToolsSpanData):
return GenAIOperationName.MCP_LIST_TOOLS
return "unknown"

def _extract_genai_attributes(
Expand Down Expand Up @@ -1608,6 +1621,10 @@ def _extract_genai_attributes(
yield from self._get_attributes_from_guardrail_span_data(span_data)
elif _is_instance_of(span_data, HandoffSpanData):
yield from self._get_attributes_from_handoff_span_data(span_data)
elif _is_instance_of(span_data, MCPListToolsSpanData):
yield from self._get_attributes_from_mcp_list_tools_span_data(
span_data
)

def _get_attributes_from_generation_span_data(
self, span_data: GenerationSpanData, payload: ContentPayload
Expand Down Expand Up @@ -2173,6 +2190,18 @@ def _get_attributes_from_handoff_span_data(
normalize_output_type(self._infer_output_type(span_data)),
)

def _get_attributes_from_mcp_list_tools_span_data(
self, span_data: MCPListToolsSpanData
) -> Iterator[tuple[str, AttributeValue]]:
"""Extract attributes from MCP list tools span."""
yield GEN_AI_OPERATION_NAME, GenAIOperationName.MCP_LIST_TOOLS

if span_data.server:
yield GEN_AI_MCP_SERVER_NAME, span_data.server

if span_data.result:
yield GEN_AI_MCP_TOOL_NAMES, span_data.result

def _cleanup_spans_for_trace(self, trace_id: str) -> None:
"""Clean up spans for a trace to prevent memory leaks."""
spans_to_remove = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
SPAN_TYPE_AGENT = "agent"
SPAN_TYPE_FUNCTION = "function"
SPAN_TYPE_GENERATION = "generation"
SPAN_TYPE_MCP_TOOLS = "mcp_tools"
SPAN_TYPE_RESPONSE = "response"

__all__ = [
Expand All @@ -27,10 +28,12 @@
"agent_span",
"generation_span",
"function_span",
"mcp_list_tools_span",
"response_span",
"AgentSpanData",
"GenerationSpanData",
"FunctionSpanData",
"MCPListToolsSpanData",
"ResponseSpanData",
]

Expand Down Expand Up @@ -71,6 +74,16 @@ def type(self) -> str:
return SPAN_TYPE_GENERATION


@dataclass
class MCPListToolsSpanData:
server: str | None = None
result: list[str] | None = None

@property
def type(self) -> str:
return SPAN_TYPE_MCP_TOOLS


@dataclass
class ResponseSpanData:
response: Any = None
Expand Down Expand Up @@ -229,6 +242,17 @@ def function_span(**kwargs: Any):
span.finish()


@contextmanager
def mcp_list_tools_span(**kwargs: Any):
data = MCPListToolsSpanData(**kwargs)
span = _PROVIDER.create_span(data, parent=_CURRENT_TRACE)
span.start()
try:
yield span
finally:
span.finish()


@contextmanager
def response_span(**kwargs: Any):
data = ResponseSpanData(**kwargs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
agent_span,
function_span,
generation_span,
mcp_list_tools_span,
response_span,
set_trace_processors,
trace,
Expand Down Expand Up @@ -559,3 +560,60 @@ def __init__(self) -> None:
finally:
instrumentor.uninstrument()
exporter.clear()


def test_mcp_list_tools_span_records_attributes():
instrumentor, exporter = _instrument_with_provider()

try:
with trace("workflow"):
with mcp_list_tools_span(
server="Time",
result=["get_current_time", "convert_timezone"],
):
pass

spans = exporter.get_finished_spans()
mcp_span = next(
span
for span in spans
if span.attributes.get(GenAI.GEN_AI_OPERATION_NAME)
== "mcp_list_tools"
)

assert mcp_span.kind is SpanKind.CLIENT
assert mcp_span.name == "mcp_list_tools Time"
assert mcp_span.attributes[GEN_AI_PROVIDER_NAME] == "openai"
assert mcp_span.attributes["gen_ai.mcp.server.name"] == "Time"
assert mcp_span.attributes["gen_ai.mcp.tool.names"] == (
"get_current_time",
"convert_timezone",
)
finally:
instrumentor.uninstrument()
exporter.clear()


def test_mcp_list_tools_span_without_result():
instrumentor, exporter = _instrument_with_provider()

try:
with trace("workflow"):
with mcp_list_tools_span(server="Empty"):
pass

spans = exporter.get_finished_spans()
mcp_span = next(
span
for span in spans
if span.attributes.get(GenAI.GEN_AI_OPERATION_NAME)
== "mcp_list_tools"
)

assert mcp_span.kind is SpanKind.CLIENT
assert mcp_span.name == "mcp_list_tools Empty"
assert mcp_span.attributes["gen_ai.mcp.server.name"] == "Empty"
assert "gen_ai.mcp.tool.names" not in mcp_span.attributes
finally:
instrumentor.uninstrument()
exporter.clear()
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
AgentSpanData,
FunctionSpanData,
GenerationSpanData,
MCPListToolsSpanData,
ResponseSpanData,
)

Expand Down Expand Up @@ -212,6 +213,24 @@ class UnknownSpanData:
== "create_agent"
)

# MCPListToolsSpanData maps to mcp_list_tools
mcp_data = MCPListToolsSpanData(server="Time")
assert (
processor._get_operation_name(mcp_data)
== sp.GenAIOperationName.MCP_LIST_TOOLS
)
assert processor._get_span_kind(mcp_data) is SpanKind.CLIENT
assert (
sp.get_span_name(
sp.GenAIOperationName.MCP_LIST_TOOLS, tool_name="Time"
)
== "mcp_list_tools Time"
)
assert (
sp.get_span_name(sp.GenAIOperationName.MCP_LIST_TOOLS)
== "mcp_list_tools"
)


def test_attribute_builders(processor_setup):
processor, _ = processor_setup
Expand Down Expand Up @@ -361,6 +380,44 @@ def __init__(self) -> None:
assert function_attrs[sp.GEN_AI_TOOL_CALL_RESULT] == {"temperature": 70}
assert function_attrs[sp.GEN_AI_OUTPUT_TYPE] == sp.GenAIOutputType.JSON

# MCP list tools span
mcp_span = MCPListToolsSpanData(
server="Time",
result=["get_current_time", "convert_timezone"],
)
mcp_attrs = _collect(
processor._get_attributes_from_mcp_list_tools_span_data(mcp_span)
)
assert mcp_attrs[sp.GEN_AI_OPERATION_NAME] == "mcp_list_tools"
assert mcp_attrs[sp.GEN_AI_MCP_SERVER_NAME] == "Time"
assert mcp_attrs[sp.GEN_AI_MCP_TOOL_NAMES] == [
"get_current_time",
"convert_timezone",
]

# MCP list tools span without result
mcp_span_no_result = MCPListToolsSpanData(server="Empty")
mcp_attrs_no_result = _collect(
processor._get_attributes_from_mcp_list_tools_span_data(
mcp_span_no_result
)
)
assert mcp_attrs_no_result[sp.GEN_AI_OPERATION_NAME] == "mcp_list_tools"
assert mcp_attrs_no_result[sp.GEN_AI_MCP_SERVER_NAME] == "Empty"
assert sp.GEN_AI_MCP_TOOL_NAMES not in mcp_attrs_no_result

# MCP list tools span without server
mcp_span_no_server = MCPListToolsSpanData(
result=["tool_a"],
)
mcp_attrs_no_server = _collect(
processor._get_attributes_from_mcp_list_tools_span_data(
mcp_span_no_server
)
)
assert sp.GEN_AI_MCP_SERVER_NAME not in mcp_attrs_no_server
assert mcp_attrs_no_server[sp.GEN_AI_MCP_TOOL_NAMES] == ["tool_a"]


def test_extract_genai_attributes_unknown_type(processor_setup):
processor, _ = processor_setup
Expand Down Expand Up @@ -538,3 +595,39 @@ def test_chat_span_renamed_with_model(processor_setup):

span_names = {span.name for span in exporter.get_finished_spans()}
assert "chat gpt-4o" in span_names


def test_mcp_list_tools_span_lifecycle(processor_setup):
processor, exporter = processor_setup

trace = FakeTrace(name="workflow", trace_id="trace-mcp")
processor.on_trace_start(trace)

mcp_data = MCPListToolsSpanData(
server="Time",
result=["get_current_time", "convert_timezone"],
)
mcp_span = FakeSpan(
trace_id=trace.trace_id,
span_id="mcp-span",
span_data=mcp_data,
started_at="2025-01-01T00:00:00Z",
ended_at="2025-01-01T00:00:01Z",
)
processor.on_span_start(mcp_span)
processor.on_span_end(mcp_span)
processor.on_trace_end(trace)

finished = exporter.get_finished_spans()
mcp_otel_span = next(
span for span in finished if span.name == "mcp_list_tools Time"
)
assert mcp_otel_span.kind is SpanKind.CLIENT
assert (
mcp_otel_span.attributes[sp.GEN_AI_OPERATION_NAME] == "mcp_list_tools"
)
assert mcp_otel_span.attributes[sp.GEN_AI_MCP_SERVER_NAME] == "Time"
assert mcp_otel_span.attributes[sp.GEN_AI_MCP_TOOL_NAMES] == (
"get_current_time",
"convert_timezone",
)
Loading