From 2235a71a9cff50f9f4bb5ecf4f4471d8d0993929 Mon Sep 17 00:00:00 2001 From: Jonathan Wrede Date: Tue, 26 May 2026 10:19:37 +0000 Subject: [PATCH] opentelemetry-instrumentation-openai-agents-v2: handle MCPListToolsSpanData Add MCPListToolsSpanData handling to the GenAISemanticProcessor so MCP list tools spans are reported with proper operation name, span kind, and attributes instead of showing as "unknown" with no data. Assisted-by: Claude Opus 4.6 --- .changelog/4629.fixed | 1 + .../openai_agents/span_processor.py | 39 +++++++- .../tests/stubs/agents/tracing/__init__.py | 24 +++++ .../tests/test_tracer.py | 58 ++++++++++++ .../tests/test_z_span_processor_unit.py | 93 +++++++++++++++++++ 5 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 .changelog/4629.fixed diff --git a/.changelog/4629.fixed b/.changelog/4629.fixed new file mode 100644 index 0000000000..4512fb0a09 --- /dev/null +++ b/.changelog/4629.fixed @@ -0,0 +1 @@ +`opentelemetry-instrumentation-openai-agents-v2`: handle MCPListToolsSpanData so MCP list tools spans are no longer reported as "unknown" diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/span_processor.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/span_processor.py index fb71fdae2c..1ade2d0408 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/span_processor.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/span_processor.py @@ -37,6 +37,7 @@ GenerationSpanData, GuardrailSpanData, HandoffSpanData, + MCPListToolsSpanData, ResponseSpanData, SpeechSpanData, TranscriptionSpanData, @@ -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( @@ -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 = { @@ -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) ---- @@ -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 @@ -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 @@ -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) @@ -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( @@ -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 @@ -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 = [ diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/__init__.py index f3c7b94247..6cfb52afe2 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/__init__.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/__init__.py @@ -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__ = [ @@ -27,10 +28,12 @@ "agent_span", "generation_span", "function_span", + "mcp_list_tools_span", "response_span", "AgentSpanData", "GenerationSpanData", "FunctionSpanData", + "MCPListToolsSpanData", "ResponseSpanData", ] @@ -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 @@ -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) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_tracer.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_tracer.py index ab8cb8daab..8c6a8767e3 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_tracer.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_tracer.py @@ -24,6 +24,7 @@ agent_span, function_span, generation_span, + mcp_list_tools_span, response_span, set_trace_processors, trace, @@ -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() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_z_span_processor_unit.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_z_span_processor_unit.py index 02c33312db..c05f3f0b2a 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_z_span_processor_unit.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_z_span_processor_unit.py @@ -18,6 +18,7 @@ AgentSpanData, FunctionSpanData, GenerationSpanData, + MCPListToolsSpanData, ResponseSpanData, ) @@ -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 @@ -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 @@ -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", + )