diff --git a/.changelog/4607.fixed b/.changelog/4607.fixed new file mode 100644 index 0000000000..3c394b61f8 --- /dev/null +++ b/.changelog/4607.fixed @@ -0,0 +1 @@ +Improve OpenAI Agents SDK instrumentation support for current tracing payloads. diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/pyproject.toml index 64d320e4c7..ea2fce9461 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ [project.optional-dependencies] instruments = [ - "openai-agents >= 0.3.3", + "openai-agents >= 0.17.0", ] [project.entry-points.opentelemetry_instrumentor] diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/package.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/package.py index c5292e659c..08c8fa0cf5 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/package.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/package.py @@ -1,5 +1,5 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 -_instruments = ("openai-agents >= 0.3.3",) +_instruments = ("openai-agents >= 0.17.0",) _supports_metrics = False 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..d5fd5e941c 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 @@ -33,13 +33,18 @@ from agents.tracing import Span, Trace, TracingProcessor from agents.tracing.span_data import ( AgentSpanData, + CustomSpanData, FunctionSpanData, GenerationSpanData, GuardrailSpanData, HandoffSpanData, + MCPListToolsSpanData, ResponseSpanData, + SpeechGroupSpanData, SpeechSpanData, + TaskSpanData, TranscriptionSpanData, + TurnSpanData, ) except ModuleNotFoundError: # pragma: no cover - test stubs tracing_module = importlib.import_module("agents.tracing") @@ -47,15 +52,20 @@ Trace = getattr(tracing_module, "Trace") TracingProcessor = getattr(tracing_module, "TracingProcessor") AgentSpanData = getattr(tracing_module, "AgentSpanData", Any) # type: ignore[assignment] + CustomSpanData = getattr(tracing_module, "CustomSpanData") FunctionSpanData = getattr(tracing_module, "FunctionSpanData", Any) # type: ignore[assignment] 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") ResponseSpanData = getattr(tracing_module, "ResponseSpanData", Any) # type: ignore[assignment] + SpeechGroupSpanData = getattr(tracing_module, "SpeechGroupSpanData") SpeechSpanData = getattr(tracing_module, "SpeechSpanData", Any) # type: ignore[assignment] + TaskSpanData = getattr(tracing_module, "TaskSpanData") TranscriptionSpanData = getattr( tracing_module, "TranscriptionSpanData", Any ) # type: ignore[assignment] + TurnSpanData = getattr(tracing_module, "TurnSpanData") from opentelemetry.context import attach, detach from opentelemetry.metrics import Histogram, get_meter @@ -647,7 +657,7 @@ def _emit_content_events( return def _collect_system_instructions( - self, messages: Sequence[Any] | None + self, messages: Sequence[Any] | str | None ) -> list[dict[str, str]]: """Return system/ai role instructions as typed text objects. @@ -655,7 +665,7 @@ def _collect_system_instructions( Handles message content that may be a string, list of parts, or a dict with text/content fields. """ - if not messages: + if not messages or isinstance(messages, str): return [] out: list[dict[str, str]] = [] for m in messages: @@ -710,7 +720,7 @@ def _redacted_text_parts(self) -> list[dict[str, str]]: return [{"type": "text", "content": "readacted"}] def _normalize_messages_to_role_parts( - self, messages: Sequence[Any] | None + self, messages: Sequence[Any] | str | None ) -> list[dict[str, Any]]: """Normalize input messages to enforced role+parts schema. @@ -720,6 +730,20 @@ def _normalize_messages_to_role_parts( """ if not messages: return [] + if isinstance(messages, str): + return [ + { + "role": "user", + "parts": [ + { + "type": "text", + "content": "readacted" + if not self.include_sensitive_data + else messages, + } + ], + } + ] normalized: list[dict[str, Any]] = [] for m in messages: if not isinstance(m, dict): @@ -1160,9 +1184,11 @@ def _infer_output_type(self, span_data: Any) -> str: if _is_instance_of(span_data, FunctionSpanData): # Tool results are typically JSON return GenAIOutputType.JSON + if _is_instance_of(span_data, MCPListToolsSpanData): + return GenAIOutputType.JSON if _is_instance_of(span_data, TranscriptionSpanData): return GenAIOutputType.TEXT - if _is_instance_of(span_data, SpeechSpanData): + if _is_instance_of(span_data, (SpeechGroupSpanData, SpeechSpanData)): return GenAIOutputType.SPEECH if _is_instance_of(span_data, GuardrailSpanData): return GenAIOutputType.TEXT @@ -1286,10 +1312,65 @@ 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, (GuardrailSpanData, HandoffSpanData)): + if _is_instance_of( + span_data, + ( + GuardrailSpanData, + HandoffSpanData, + MCPListToolsSpanData, + SpeechGroupSpanData, + TaskSpanData, + TurnSpanData, + ), + ): return SpanKind.INTERNAL # Agent operations are internal return SpanKind.INTERNAL + def _get_agent_name_for_span(self, span_data: Any) -> Optional[str]: + """Return the best available agent label for span naming.""" + if self.agent_name: + return self.agent_name + if _is_instance_of(span_data, AgentSpanData): + return getattr(span_data, "name", None) + if _is_instance_of(span_data, TaskSpanData): + return getattr(span_data, "name", None) + if _is_instance_of(span_data, TurnSpanData): + return getattr(span_data, "agent_name", None) + return self._agent_name_default + + def _get_tool_name_for_span(self, span_data: Any) -> Optional[str]: + """Return the best available tool label for span naming.""" + if _is_instance_of(span_data, FunctionSpanData): + return getattr(span_data, "name", None) + if _is_instance_of(span_data, MCPListToolsSpanData): + return "list_tools" + return None + + def _get_span_display_name( + self, + span_data: Any, + operation_name: Optional[str], + model: Optional[str], + ) -> str: + """Return the OTel span name for known and custom Agents spans.""" + if operation_name: + return get_span_name( + operation_name, + model, + self._get_agent_name_for_span(span_data), + self._get_tool_name_for_span(span_data), + ) + + name = getattr(span_data, "name", None) + if isinstance(name, str) and name: + return name + + span_type = getattr(span_data, "type", None) + if isinstance(span_type, str) and span_type: + return span_type + + return "span" + def on_trace_start(self, trace: Trace) -> None: """Create root span when trace starts.""" if self._tracer: @@ -1355,27 +1436,17 @@ def on_span_start(self, span: Span[Any]) -> None: response_obj = getattr(span.span_data, "response", None) model = getattr(response_obj, "model", None) - # Use configured agent name or get from span data - agent_name = self.agent_name - if not agent_name and _is_instance_of(span.span_data, AgentSpanData): - agent_name = getattr(span.span_data, "name", 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 - ) - # Generate spec-compliant span name - span_name = get_span_name(operation_name, model, agent_name, tool_name) + span_name = self._get_span_display_name( + span.span_data, operation_name, model + ) attributes = { GEN_AI_PROVIDER_NAME: self.system_name, GEN_AI_SYSTEM_KEY: self.system_name, - GEN_AI_OPERATION_NAME: operation_name, } + if operation_name: + attributes[GEN_AI_OPERATION_NAME] = operation_name # Legacy emission removed # Add configured agent and server attributes @@ -1520,7 +1591,7 @@ def force_flush(self) -> None: """Force flush (no-op for this processor).""" pass - def _get_operation_name(self, span_data: Any) -> str: + def _get_operation_name(self, span_data: Any) -> Optional[str]: """Determine operation name from span data type.""" if _is_instance_of(span_data, GenerationSpanData): # Check if it's embeddings @@ -1536,19 +1607,23 @@ def _get_operation_name(self, span_data: Any) -> str: # The OpenAI Agents SDK AgentSpanData has no "operation" field; # agent spans always represent invoke_agent. return GenAIOperationName.INVOKE_AGENT + if _is_instance_of(span_data, (TaskSpanData, TurnSpanData)): + return GenAIOperationName.INVOKE_AGENT if _is_instance_of(span_data, FunctionSpanData): return GenAIOperationName.EXECUTE_TOOL + if _is_instance_of(span_data, MCPListToolsSpanData): + return GenAIOperationName.EXECUTE_TOOL if _is_instance_of(span_data, ResponseSpanData): return GenAIOperationName.CHAT # Response typically from chat if _is_instance_of(span_data, TranscriptionSpanData): return GenAIOperationName.TRANSCRIPTION - if _is_instance_of(span_data, SpeechSpanData): + if _is_instance_of(span_data, (SpeechGroupSpanData, SpeechSpanData)): return GenAIOperationName.SPEECH if _is_instance_of(span_data, GuardrailSpanData): return GenAIOperationName.GUARDRAIL if _is_instance_of(span_data, HandoffSpanData): return GenAIOperationName.HANDOFF - return "unknown" + return None def _extract_genai_attributes( self, @@ -1590,10 +1665,18 @@ def _extract_genai_attributes( yield from self._get_attributes_from_agent_span_data( span_data, agent_content ) + elif _is_instance_of(span_data, TaskSpanData): + yield from self._get_attributes_from_task_span_data(span_data) + elif _is_instance_of(span_data, TurnSpanData): + yield from self._get_attributes_from_turn_span_data(span_data) + elif _is_instance_of(span_data, CustomSpanData): + yield from self._get_attributes_from_custom_span_data(span_data) elif _is_instance_of(span_data, FunctionSpanData): yield from self._get_attributes_from_function_span_data( span_data, payload ) + elif _is_instance_of(span_data, MCPListToolsSpanData): + yield from self._get_attributes_from_mcp_tools_span_data(span_data) elif _is_instance_of(span_data, ResponseSpanData): yield from self._get_attributes_from_response_span_data( span_data, payload @@ -1608,6 +1691,38 @@ 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, SpeechGroupSpanData): + yield from self._get_attributes_from_speech_group_span_data( + span_data + ) + + def _get_usage_attributes( + self, usage: Any + ) -> Iterator[tuple[str, AttributeValue]]: + """Extract token usage attributes from dict or object payloads.""" + if not usage: + return + + self._sanitize_usage_payload(usage) + if isinstance(usage, dict): + input_tokens = usage.get("prompt_tokens") or usage.get( + "input_tokens" + ) + output_tokens = usage.get("completion_tokens") or usage.get( + "output_tokens" + ) + else: + input_tokens = getattr(usage, "input_tokens", None) + if input_tokens is None: + input_tokens = getattr(usage, "prompt_tokens", None) + output_tokens = getattr(usage, "output_tokens", None) + if output_tokens is None: + output_tokens = getattr(usage, "completion_tokens", None) + + if input_tokens is not None: + yield GEN_AI_USAGE_INPUT_TOKENS, input_tokens + if output_tokens is not None: + yield GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens def _get_attributes_from_generation_span_data( self, span_data: GenerationSpanData, payload: ContentPayload @@ -1924,6 +2039,44 @@ def _get_attributes_from_agent_span_data( normalize_output_type(self._infer_output_type(span_data)), ) + def _get_attributes_from_task_span_data( + self, span_data: TaskSpanData + ) -> Iterator[tuple[str, AttributeValue]]: + """Extract attributes from an Agents SDK task span.""" + yield GEN_AI_OPERATION_NAME, GenAIOperationName.INVOKE_AGENT + yield from self._get_usage_attributes( + getattr(span_data, "usage", None) + ) + yield ( + GEN_AI_OUTPUT_TYPE, + normalize_output_type(self._infer_output_type(span_data)), + ) + + def _get_attributes_from_turn_span_data( + self, span_data: TurnSpanData + ) -> Iterator[tuple[str, AttributeValue]]: + """Extract attributes from an Agents SDK turn span.""" + yield GEN_AI_OPERATION_NAME, GenAIOperationName.INVOKE_AGENT + agent_name = getattr(span_data, "agent_name", None) + if agent_name: + yield GEN_AI_AGENT_NAME, agent_name + yield from self._get_usage_attributes( + getattr(span_data, "usage", None) + ) + yield ( + GEN_AI_OUTPUT_TYPE, + normalize_output_type(self._infer_output_type(span_data)), + ) + + def _get_attributes_from_custom_span_data( + self, span_data: CustomSpanData + ) -> Iterator[tuple[str, AttributeValue]]: + """Keep custom spans as regular OTel spans without GenAI operations.""" + yield ( + GEN_AI_OUTPUT_TYPE, + normalize_output_type(self._infer_output_type(span_data)), + ) + def _get_attributes_from_function_span_data( self, span_data: FunctionSpanData, payload: ContentPayload ) -> Iterator[tuple[str, AttributeValue]]: @@ -1973,6 +2126,31 @@ def _get_attributes_from_function_span_data( normalize_output_type(self._infer_output_type(span_data)), ) + def _get_attributes_from_mcp_tools_span_data( + self, span_data: MCPListToolsSpanData + ) -> Iterator[tuple[str, AttributeValue]]: + """Extract attributes from MCP list-tools spans.""" + yield GEN_AI_OPERATION_NAME, GenAIOperationName.EXECUTE_TOOL + yield GEN_AI_TOOL_NAME, "list_tools" + yield GEN_AI_TOOL_TYPE, GenAIToolType.EXTENSION + + server = getattr(span_data, "server", None) + if server: + yield GEN_AI_DATA_SOURCE_ID, server + + result = getattr(span_data, "result", None) + if ( + result is not None + and self.include_sensitive_data + and self._content_mode.capture_in_span + ): + yield GEN_AI_TOOL_CALL_RESULT, safe_json_dumps(result) + + yield ( + GEN_AI_OUTPUT_TYPE, + normalize_output_type(self._infer_output_type(span_data)), + ) + def _get_attributes_from_response_span_data( self, span_data: ResponseSpanData, payload: ContentPayload ) -> Iterator[tuple[str, AttributeValue]]: @@ -2173,6 +2351,16 @@ def _get_attributes_from_handoff_span_data( normalize_output_type(self._infer_output_type(span_data)), ) + def _get_attributes_from_speech_group_span_data( + self, span_data: SpeechGroupSpanData + ) -> Iterator[tuple[str, AttributeValue]]: + """Extract attributes from a speech group span.""" + yield GEN_AI_OPERATION_NAME, GenAIOperationName.SPEECH + yield ( + GEN_AI_OUTPUT_TYPE, + normalize_output_type(self._infer_output_type(span_data)), + ) + 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/requirements.latest.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/requirements.latest.txt index 2bd34ba349..50094a4035 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/requirements.latest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/requirements.latest.txt @@ -36,7 +36,7 @@ # This variant of the requirements aims to test the system using # the newest supported version of external dependencies. -openai-agents==0.3.3 +openai-agents==0.17.2 pydantic>=2.10,<3 httpx==0.27.2 Deprecated==1.2.14 diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/requirements.oldest.txt index 833db0299f..a82d4a97aa 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/requirements.oldest.txt @@ -15,7 +15,7 @@ # This variant of the requirements aims to test the system using # the oldest supported version of external dependencies. -openai-agents==0.3.3 +openai-agents==0.17.0 pydantic>=2.10,<3 httpx==0.27.2 Deprecated==1.2.14 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..c8382b7ef5 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 @@ -15,9 +15,14 @@ from .traces import Trace SPAN_TYPE_AGENT = "agent" +SPAN_TYPE_CUSTOM = "custom" SPAN_TYPE_FUNCTION = "function" SPAN_TYPE_GENERATION = "generation" +SPAN_TYPE_MCP_TOOLS = "mcp_tools" SPAN_TYPE_RESPONSE = "response" +SPAN_TYPE_SPEECH_GROUP = "speech_group" +SPAN_TYPE_TASK = "task" +SPAN_TYPE_TURN = "turn" __all__ = [ "TraceProvider", @@ -28,10 +33,20 @@ "generation_span", "function_span", "response_span", + "task_span", + "turn_span", + "custom_span", + "mcp_tools_span", + "speech_group_span", "AgentSpanData", + "CustomSpanData", "GenerationSpanData", "FunctionSpanData", + "MCPListToolsSpanData", "ResponseSpanData", + "SpeechGroupSpanData", + "TaskSpanData", + "TurnSpanData", ] @@ -58,6 +73,16 @@ def type(self) -> str: return SPAN_TYPE_FUNCTION +@dataclass +class CustomSpanData: + name: str + data: dict[str, Any] | None = None + + @property + def type(self) -> str: + return SPAN_TYPE_CUSTOM + + @dataclass class GenerationSpanData: input: Sequence[Mapping[str, Any]] | None = None @@ -74,12 +99,53 @@ def type(self) -> str: @dataclass class ResponseSpanData: response: Any = None + input: Any = None @property def type(self) -> str: return SPAN_TYPE_RESPONSE +@dataclass +class TaskSpanData: + name: str + usage: Mapping[str, Any] | None = None + + @property + def type(self) -> str: + return SPAN_TYPE_TASK + + +@dataclass +class TurnSpanData: + turn: int + agent_name: str + usage: Mapping[str, Any] | None = None + + @property + def type(self) -> str: + return SPAN_TYPE_TURN + + +@dataclass +class MCPListToolsSpanData: + server: str | None = None + result: list[str] | None = None + + @property + def type(self) -> str: + return SPAN_TYPE_MCP_TOOLS + + +@dataclass +class SpeechGroupSpanData: + input: str | None = None + + @property + def type(self) -> str: + return SPAN_TYPE_SPEECH_GROUP + + class _ProcessorFanout(TracingProcessor): def __init__(self) -> None: self._processors: list[TracingProcessor] = [] @@ -238,3 +304,58 @@ def response_span(**kwargs: Any): yield span finally: span.finish() + + +@contextmanager +def task_span(**kwargs: Any): + data = TaskSpanData(**kwargs) + span = _PROVIDER.create_span(data, parent=_CURRENT_TRACE) + span.start() + try: + yield span + finally: + span.finish() + + +@contextmanager +def turn_span(**kwargs: Any): + data = TurnSpanData(**kwargs) + span = _PROVIDER.create_span(data, parent=_CURRENT_TRACE) + span.start() + try: + yield span + finally: + span.finish() + + +@contextmanager +def custom_span(**kwargs: Any): + data = CustomSpanData(**kwargs) + span = _PROVIDER.create_span(data, parent=_CURRENT_TRACE) + span.start() + try: + yield span + finally: + span.finish() + + +@contextmanager +def mcp_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 speech_group_span(**kwargs: Any): + data = SpeechGroupSpanData(**kwargs) + span = _PROVIDER.create_span(data, parent=_CURRENT_TRACE) + span.start() + try: + yield span + finally: + span.finish() 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..480ee10e01 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 @@ -22,11 +22,16 @@ import agents.tracing as agents_tracing # noqa: E402 from agents.tracing import ( # noqa: E402 agent_span, + custom_span, function_span, generation_span, + mcp_tools_span, response_span, set_trace_processors, + speech_group_span, + task_span, trace, + turn_span, ) from openai.types.responses import FunctionTool # noqa: E402 @@ -69,6 +74,10 @@ GEN_AI_TOOL_DEFINITIONS = getattr( GenAI, "GEN_AI_TOOL_DEFINITIONS", "gen_ai.tool.definitions" ) +GEN_AI_DATA_SOURCE_ID = getattr( + GenAI, "GEN_AI_DATA_SOURCE_ID", "gen_ai.data_source.id" +) +GEN_AI_TOOL_CALL_RESULT = "gen_ai.tool.call.result" def _instrument_with_provider(**instrument_kwargs): @@ -559,3 +568,129 @@ def __init__(self) -> None: finally: instrumentor.uninstrument() exporter.clear() + + +def test_response_span_string_input_records_single_user_message(): + instrumentor, exporter = _instrument_with_provider() + + class _Response: + def __init__(self) -> None: + self.id = "resp-456" + self.model = "gpt-4o-mini" + self.output = [] + self.usage = None + self.tools = [] + + try: + with trace("workflow"): + with response_span( + input="single user prompt", + response=_Response(), + ): + pass + + spans = exporter.get_finished_spans() + response = next( + span + for span in spans + if span.attributes.get(GenAI.GEN_AI_RESPONSE_ID) == "resp-456" + ) + + prompt = json.loads(response.attributes[GEN_AI_INPUT_MESSAGES]) + assert prompt == [ + { + "role": "user", + "parts": [{"type": "text", "content": "single user prompt"}], + } + ] + finally: + instrumentor.uninstrument() + exporter.clear() + + +def test_current_agents_sdk_span_types_avoid_unknown_operations(): + instrumentor, exporter = _instrument_with_provider() + + try: + with trace("workflow"): + with task_span( + name="Agent workflow", + usage={"input_tokens": 20, "output_tokens": 6}, + ): + pass + with turn_span( + turn=1, + agent_name="Support Agent", + usage={"input_tokens": 8, "output_tokens": 4}, + ): + pass + with mcp_tools_span( + server="filesystem", + result=["read_file", "write_file"], + ): + pass + with speech_group_span(input="say hello"): + pass + with custom_span(name="application work", data={"step": "local"}): + pass + + spans = exporter.get_finished_spans() + assert all( + span.attributes.get(GenAI.GEN_AI_OPERATION_NAME) != "unknown" + for span in spans + ) + + task = next( + span + for span in spans + if span.name == "invoke_agent Agent workflow" + ) + assert ( + task.attributes[GenAI.GEN_AI_OPERATION_NAME] + == GenAI.GenAiOperationNameValues.INVOKE_AGENT.value + ) + assert task.attributes[GenAI.GEN_AI_USAGE_INPUT_TOKENS] == 20 + assert task.attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] == 6 + + turn = next( + span for span in spans if span.name == "invoke_agent Support Agent" + ) + assert ( + turn.attributes[GenAI.GEN_AI_OPERATION_NAME] + == GenAI.GenAiOperationNameValues.INVOKE_AGENT.value + ) + assert turn.attributes[GenAI.GEN_AI_AGENT_NAME] == "Support Agent" + assert turn.attributes[GenAI.GEN_AI_USAGE_INPUT_TOKENS] == 8 + assert turn.attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] == 4 + + mcp = next( + span for span in spans if span.name == "execute_tool list_tools" + ) + assert ( + mcp.attributes[GenAI.GEN_AI_OPERATION_NAME] + == GenAI.GenAiOperationNameValues.EXECUTE_TOOL.value + ) + assert mcp.attributes[GenAI.GEN_AI_TOOL_NAME] == "list_tools" + assert mcp.attributes[GenAI.GEN_AI_TOOL_TYPE] == "extension" + assert mcp.attributes[GEN_AI_DATA_SOURCE_ID] == "filesystem" + assert json.loads(mcp.attributes[GEN_AI_TOOL_CALL_RESULT]) == [ + "read_file", + "write_file", + ] + + speech = next( + span for span in spans if span.name == "speech_generation" + ) + assert ( + speech.attributes[GenAI.GEN_AI_OPERATION_NAME] + == "speech_generation" + ) + assert speech.attributes[GenAI.GEN_AI_OUTPUT_TYPE] == "speech" + + custom = next( + span for span in spans if span.name == "application work" + ) + assert GenAI.GEN_AI_OPERATION_NAME not in custom.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..d18e14c826 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 @@ -188,7 +188,7 @@ class UnknownSpanData: pass unknown = UnknownSpanData() - assert processor._get_operation_name(unknown) == "unknown" + assert processor._get_operation_name(unknown) is None assert processor._get_span_kind(GenerationSpanData()) is SpanKind.CLIENT assert processor._get_span_kind(FunctionSpanData()) is SpanKind.INTERNAL