From fad30fd058bfeed9ab5eb946863c470b770dc7f0 Mon Sep 17 00:00:00 2001 From: poshinchen Date: Tue, 30 Sep 2025 17:12:19 -0400 Subject: [PATCH] feat(telemetry): updated traces to match OTEL v1.37 semantic conventions --- src/strands/telemetry/tracer.py | 332 ++++++++++++++++++------ src/strands/types/traces.py | 19 +- tests/strands/telemetry/test_tracer.py | 340 ++++++++++++++++++++++++- 3 files changed, 610 insertions(+), 81 deletions(-) diff --git a/src/strands/telemetry/tracer.py b/src/strands/telemetry/tracer.py index d1862b859..b39de27ea 100644 --- a/src/strands/telemetry/tracer.py +++ b/src/strands/telemetry/tracer.py @@ -6,6 +6,7 @@ import json import logging +import os from datetime import date, datetime, timezone from typing import Any, Dict, Mapping, Optional @@ -17,7 +18,7 @@ from ..types.content import ContentBlock, Message, Messages from ..types.streaming import StopReason, Usage from ..types.tools import ToolResult, ToolUse -from ..types.traces import AttributeValue +from ..types.traces import Attributes, AttributeValue logger = logging.getLogger(__name__) @@ -90,6 +91,19 @@ def __init__( self.tracer = self.tracer_provider.get_tracer(self.service_name) ThreadingInstrumentor().instrument() + # Read OTEL_SEMCONV_STABILITY_OPT_IN environment variable + self.use_latest_genai_conventions = self._parse_semconv_opt_in() + + def _parse_semconv_opt_in(self) -> bool: + """Parse the OTEL_SEMCONV_STABILITY_OPT_IN environment variable. + + Returns: + Set of opt-in values from the environment variable + """ + opt_in_env = os.getenv("OTEL_SEMCONV_STABILITY_OPT_IN", "") + + return "gen_ai_latest_experimental" in opt_in_env + def _start_span( self, span_name: str, @@ -194,7 +208,7 @@ def end_span_with_error(self, span: Span, error_message: str, exception: Optiona error = exception or Exception(error_message) self._end_span(span, error=error) - def _add_event(self, span: Optional[Span], event_name: str, event_attributes: Dict[str, AttributeValue]) -> None: + def _add_event(self, span: Optional[Span], event_name: str, event_attributes: Attributes) -> None: """Add an event with attributes to a span. Args: @@ -249,10 +263,7 @@ def start_model_invoke_span( Returns: The created span, or None if tracing is not enabled. """ - attributes: Dict[str, AttributeValue] = { - "gen_ai.system": "strands-agents", - "gen_ai.operation.name": "chat", - } + attributes: Dict[str, AttributeValue] = self._get_common_attributes(operation_name="chat") if model_id: attributes["gen_ai.request.model"] = model_id @@ -261,12 +272,8 @@ def start_model_invoke_span( attributes.update({k: v for k, v in kwargs.items() if isinstance(v, (str, int, float, bool))}) span = self._start_span("chat", parent_span, attributes=attributes, span_kind=trace_api.SpanKind.CLIENT) - for message in messages: - self._add_event( - span, - self._get_event_name_for_message(message), - {"content": serialize(message["content"])}, - ) + self._add_event_messages(span, messages) + return span def end_model_invoke_span( @@ -291,11 +298,28 @@ def end_model_invoke_span( "gen_ai.usage.cache_write_input_tokens": usage.get("cacheWriteInputTokens", 0), } - self._add_event( - span, - "gen_ai.choice", - event_attributes={"finish_reason": str(stop_reason), "message": serialize(message["content"])}, - ) + if self.use_latest_genai_conventions: + self._add_event( + span, + "gen_ai.client.inference.operation.details", + { + "gen_ai.output.messages": serialize( + [ + { + "role": message["role"], + "parts": [{"type": "text", "content": serialize(message["content"])}], + "finish_reason": str(stop_reason), + } + ] + ), + }, + ) + else: + self._add_event( + span, + "gen_ai.choice", + event_attributes={"finish_reason": str(stop_reason), "message": serialize(message["content"])}, + ) self._end_span(span, attributes, error) @@ -310,12 +334,13 @@ def start_tool_call_span(self, tool: ToolUse, parent_span: Optional[Span] = None Returns: The created span, or None if tracing is not enabled. """ - attributes: Dict[str, AttributeValue] = { - "gen_ai.operation.name": "execute_tool", - "gen_ai.system": "strands-agents", - "gen_ai.tool.name": tool["name"], - "gen_ai.tool.call.id": tool["toolUseId"], - } + attributes: Dict[str, AttributeValue] = self._get_common_attributes(operation_name="execute_tool") + attributes.update( + { + "gen_ai.tool.name": tool["name"], + "gen_ai.tool.call.id": tool["toolUseId"], + } + ) # Add additional kwargs as attributes attributes.update(kwargs) @@ -323,15 +348,38 @@ def start_tool_call_span(self, tool: ToolUse, parent_span: Optional[Span] = None span_name = f"execute_tool {tool['name']}" span = self._start_span(span_name, parent_span, attributes=attributes, span_kind=trace_api.SpanKind.INTERNAL) - self._add_event( - span, - "gen_ai.tool.message", - event_attributes={ - "role": "tool", - "content": serialize(tool["input"]), - "id": tool["toolUseId"], - }, - ) + if self.use_latest_genai_conventions: + self._add_event( + span, + "gen_ai.client.inference.operation.details", + { + "gen_ai.input.messages": serialize( + [ + { + "role": "tool", + "parts": [ + { + "type": "tool_call", + "name": tool["name"], + "id": tool["toolUseId"], + "arguments": [{"content": serialize(tool["input"])}], + } + ], + } + ] + ) + }, + ) + else: + self._add_event( + span, + "gen_ai.tool.message", + event_attributes={ + "role": "tool", + "content": serialize(tool["input"]), + "id": tool["toolUseId"], + }, + ) return span @@ -352,18 +400,40 @@ def end_tool_call_span( attributes.update( { - "tool.status": status_str, + "gen_ai.tool.status": status_str, } ) - self._add_event( - span, - "gen_ai.choice", - event_attributes={ - "message": serialize(tool_result.get("content")), - "id": tool_result.get("toolUseId", ""), - }, - ) + if self.use_latest_genai_conventions: + self._add_event( + span, + "gen_ai.client.inference.operation.details", + { + "gen_ai.output.messages": serialize( + [ + { + "role": "tool", + "parts": [ + { + "type": "tool_call_response", + "id": tool_result.get("toolUseId", ""), + "result": serialize(tool_result.get("content")), + } + ], + } + ] + ) + }, + ) + else: + self._add_event( + span, + "gen_ai.choice", + event_attributes={ + "message": serialize(tool_result.get("content")), + "id": tool_result.get("toolUseId", ""), + }, + ) self._end_span(span, attributes, error) @@ -400,12 +470,7 @@ def start_event_loop_cycle_span( span_name = "execute_event_loop_cycle" span = self._start_span(span_name, parent_span, attributes) - for message in messages or []: - self._add_event( - span, - self._get_event_name_for_message(message), - {"content": serialize(message["content"])}, - ) + self._add_event_messages(span, messages) return span @@ -429,7 +494,24 @@ def end_event_loop_cycle_span( if tool_result_message: event_attributes["tool.result"] = serialize(tool_result_message["content"]) - self._add_event(span, "gen_ai.choice", event_attributes=event_attributes) + + if self.use_latest_genai_conventions: + self._add_event( + span, + "gen_ai.client.inference.operation.details", + { + "gen_ai.output.messages": serialize( + [ + { + "role": tool_result_message["role"], + "parts": [{"type": "text", "content": serialize(tool_result_message["content"])}], + } + ] + ) + }, + ) + else: + self._add_event(span, "gen_ai.choice", event_attributes=event_attributes) self._end_span(span, attributes, error) def start_agent_span( @@ -454,11 +536,12 @@ def start_agent_span( Returns: The created span, or None if tracing is not enabled. """ - attributes: Dict[str, AttributeValue] = { - "gen_ai.system": "strands-agents", - "gen_ai.agent.name": agent_name, - "gen_ai.operation.name": "invoke_agent", - } + attributes: Dict[str, AttributeValue] = self._get_common_attributes(operation_name="invoke_agent") + attributes.update( + { + "gen_ai.agent.name": agent_name, + } + ) if model_id: attributes["gen_ai.request.model"] = model_id @@ -477,12 +560,7 @@ def start_agent_span( span = self._start_span( f"invoke_agent {agent_name}", attributes=attributes, span_kind=trace_api.SpanKind.CLIENT ) - for message in messages: - self._add_event( - span, - self._get_event_name_for_message(message), - {"content": serialize(message["content"])}, - ) + self._add_event_messages(span, messages) return span @@ -502,11 +580,28 @@ def end_agent_span( attributes: Dict[str, AttributeValue] = {} if response: - self._add_event( - span, - "gen_ai.choice", - event_attributes={"message": str(response), "finish_reason": str(response.stop_reason)}, - ) + if self.use_latest_genai_conventions: + self._add_event( + span, + "gen_ai.client.inference.operation.details", + { + "gen_ai.output.messages": serialize( + [ + { + "role": "assistant", + "parts": [{"type": "text", "content": str(response)}], + "finish_reason": str(response.stop_reason), + } + ] + ) + }, + ) + else: + self._add_event( + span, + "gen_ai.choice", + event_attributes={"message": str(response), "finish_reason": str(response.stop_reason)}, + ) if hasattr(response, "metrics") and hasattr(response.metrics, "accumulated_usage"): accumulated_usage = response.metrics.accumulated_usage @@ -530,19 +625,33 @@ def start_multiagent_span( instance: str, ) -> Span: """Start a new span for swarm invocation.""" - attributes: Dict[str, AttributeValue] = { - "gen_ai.system": "strands-agents", - "gen_ai.agent.name": instance, - "gen_ai.operation.name": f"invoke_{instance}", - } + operation = f"invoke_{instance}" + attributes: Dict[str, AttributeValue] = self._get_common_attributes(operation) + attributes.update( + { + "gen_ai.agent.name": instance, + } + ) - span = self._start_span(f"invoke_{instance}", attributes=attributes, span_kind=trace_api.SpanKind.CLIENT) + span = self._start_span(operation, attributes=attributes, span_kind=trace_api.SpanKind.CLIENT) content = serialize(task) if isinstance(task, list) else task - self._add_event( - span, - "gen_ai.user.message", - event_attributes={"content": content}, - ) + + if self.use_latest_genai_conventions: + self._add_event( + span, + "gen_ai.client.inference.operation.details", + { + "gen_ai.input.messages": serialize( + [{"role": "user", "parts": [{"type": "text", "content": content}]}] + ) + }, + ) + else: + self._add_event( + span, + "gen_ai.user.message", + event_attributes={"content": content}, + ) return span @@ -553,11 +662,78 @@ def end_swarm_span( ) -> None: """End a swarm span with results.""" if result: + if self.use_latest_genai_conventions: + self._add_event( + span, + "gen_ai.client.inference.operation.details", + { + "gen_ai.output.messages": serialize( + [ + { + "role": "assistant", + "parts": [{"type": "text", "content": result}], + } + ] + ) + }, + ) + else: + self._add_event( + span, + "gen_ai.choice", + event_attributes={"message": result}, + ) + + def _get_common_attributes( + self, + operation_name: str, + ) -> Dict[str, AttributeValue]: + """Returns a dictionary of common attributes based on the convention version used. + + Args: + operation_name: The name of the operation. + + Returns: + A dictionary of attributes following the appropriate GenAI conventions. + """ + common_attributes = {"gen_ai.operation.name": operation_name} + if self.use_latest_genai_conventions: + common_attributes.update( + { + "gen_ai.provider.name": "strands-agents", + } + ) + else: + common_attributes.update( + { + "gen_ai.system": "strands-agents", + } + ) + return dict(common_attributes) + + def _add_event_messages(self, span: Span, messages: Messages) -> None: + """Adds messages as event to the provided span based on the current GenAI conventions. + + Args: + span: The span to which events will be added. + messages: List of messages being sent to the agent. + """ + if self.use_latest_genai_conventions: + input_messages: list = [] + for message in messages: + input_messages.append( + {"role": message["role"], "parts": [{"type": "text", "content": serialize(message["content"])}]} + ) self._add_event( - span, - "gen_ai.choice", - event_attributes={"message": result}, + span, "gen_ai.client.inference.operation.details", {"gen_ai.input.messages": serialize(input_messages)} ) + else: + for message in messages: + self._add_event( + span, + self._get_event_name_for_message(message), + {"content": serialize(message["content"])}, + ) # Singleton instance for global access diff --git a/src/strands/types/traces.py b/src/strands/types/traces.py index b850196ae..af6188adb 100644 --- a/src/strands/types/traces.py +++ b/src/strands/types/traces.py @@ -1,5 +1,20 @@ """Tracing type definitions for the SDK.""" -from typing import List, Union +from typing import List, Mapping, Optional, Sequence, Union -AttributeValue = Union[str, bool, float, int, List[str], List[bool], List[float], List[int]] +AttributeValue = Union[ + str, + bool, + float, + int, + List[str], + List[bool], + List[float], + List[int], + Sequence[str], + Sequence[bool], + Sequence[int], + Sequence[float], +] + +Attributes = Optional[Mapping[str, AttributeValue]] diff --git a/tests/strands/telemetry/test_tracer.py b/tests/strands/telemetry/test_tracer.py index 8c4f9ae20..eed060294 100644 --- a/tests/strands/telemetry/test_tracer.py +++ b/tests/strands/telemetry/test_tracer.py @@ -163,6 +163,43 @@ def test_start_model_invoke_span(mock_tracer): assert span is not None +def test_start_model_invoke_span_latest_conventions(mock_tracer): + """Test starting a model invoke span with the latest semantic conventions.""" + with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): + tracer = Tracer() + tracer.use_latest_genai_conventions = True + tracer.tracer = mock_tracer + + mock_span = mock.MagicMock() + mock_tracer.start_span.return_value = mock_span + + messages = [{"role": "user", "content": [{"text": "Hello"}]}] + model_id = "test-model" + + span = tracer.start_model_invoke_span(messages=messages, agent_name="TestAgent", model_id=model_id) + + mock_tracer.start_span.assert_called_once() + assert mock_tracer.start_span.call_args[1]["name"] == "chat" + assert mock_tracer.start_span.call_args[1]["kind"] == SpanKind.CLIENT + mock_span.set_attribute.assert_any_call("gen_ai.provider.name", "strands-agents") + mock_span.set_attribute.assert_any_call("gen_ai.operation.name", "chat") + mock_span.set_attribute.assert_any_call("gen_ai.request.model", model_id) + mock_span.add_event.assert_called_with( + "gen_ai.client.inference.operation.details", + attributes={ + "gen_ai.input.messages": serialize( + [ + { + "role": messages[0]["role"], + "parts": [{"type": "text", "content": serialize(messages[0]["content"])}], + } + ] + ) + }, + ) + assert span is not None + + def test_end_model_invoke_span(mock_span): """Test ending a model invoke span.""" tracer = Tracer() @@ -187,6 +224,43 @@ def test_end_model_invoke_span(mock_span): mock_span.end.assert_called_once() +def test_end_model_invoke_span_latest_conventions(mock_span): + """Test ending a model invoke span with the latest semantic conventions.""" + with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): + tracer = Tracer() + tracer.use_latest_genai_conventions = True + message = {"role": "assistant", "content": [{"text": "Response"}]} + usage = Usage(inputTokens=10, outputTokens=20, totalTokens=30) + stop_reason: StopReason = "end_turn" + + tracer.end_model_invoke_span(mock_span, message, usage, stop_reason) + + mock_span.set_attribute.assert_any_call("gen_ai.usage.prompt_tokens", 10) + mock_span.set_attribute.assert_any_call("gen_ai.usage.input_tokens", 10) + mock_span.set_attribute.assert_any_call("gen_ai.usage.completion_tokens", 20) + mock_span.set_attribute.assert_any_call("gen_ai.usage.output_tokens", 20) + mock_span.set_attribute.assert_any_call("gen_ai.usage.total_tokens", 30) + mock_span.set_attribute.assert_any_call("gen_ai.usage.cache_read_input_tokens", 0) + mock_span.set_attribute.assert_any_call("gen_ai.usage.cache_write_input_tokens", 0) + mock_span.add_event.assert_called_with( + "gen_ai.client.inference.operation.details", + attributes={ + "gen_ai.output.messages": serialize( + [ + { + "role": "assistant", + "parts": [{"type": "text", "content": serialize(message["content"])}], + "finish_reason": "end_turn", + } + ] + ), + }, + ) + + mock_span.set_status.assert_called_once_with(StatusCode.OK) + mock_span.end.assert_called_once() + + def test_start_tool_call_span(mock_tracer): """Test starting a tool call span.""" with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): @@ -212,6 +286,49 @@ def test_start_tool_call_span(mock_tracer): assert span is not None +def test_start_tool_call_span_latest_conventions(mock_tracer): + """Test starting a tool call span with the latest semantic conventions.""" + with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): + tracer = Tracer() + tracer.use_latest_genai_conventions = True + tracer.tracer = mock_tracer + + mock_span = mock.MagicMock() + mock_tracer.start_span.return_value = mock_span + + tool = {"name": "test-tool", "toolUseId": "123", "input": {"param": "value"}} + + span = tracer.start_tool_call_span(tool) + + mock_tracer.start_span.assert_called_once() + assert mock_tracer.start_span.call_args[1]["name"] == "execute_tool test-tool" + mock_span.set_attribute.assert_any_call("gen_ai.tool.name", "test-tool") + mock_span.set_attribute.assert_any_call("gen_ai.provider.name", "strands-agents") + mock_span.set_attribute.assert_any_call("gen_ai.operation.name", "execute_tool") + mock_span.set_attribute.assert_any_call("gen_ai.tool.call.id", "123") + mock_span.add_event.assert_called_with( + "gen_ai.client.inference.operation.details", + attributes={ + "gen_ai.input.messages": serialize( + [ + { + "role": "tool", + "parts": [ + { + "type": "tool_call", + "name": tool["name"], + "id": tool["toolUseId"], + "arguments": [{"content": serialize(tool["input"])}], + } + ], + } + ] + ) + }, + ) + assert span is not None + + def test_start_swarm_call_span_with_string_task(mock_tracer): """Test starting a swarm call span with task as string.""" with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): @@ -258,6 +375,36 @@ def test_start_swarm_span_with_contentblock_task(mock_tracer): assert span is not None +def test_start_swarm_span_with_contentblock_task_latest_conventions(mock_tracer): + """Test starting a swarm call span with task as list of contentBlock with latest semantic conventions.""" + with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): + tracer = Tracer() + tracer.use_latest_genai_conventions = True + tracer.tracer = mock_tracer + + mock_span = mock.MagicMock() + mock_tracer.start_span.return_value = mock_span + + task = [ContentBlock(text="Original Task: foo bar")] + + span = tracer.start_multiagent_span(task, "swarm") + + mock_tracer.start_span.assert_called_once() + assert mock_tracer.start_span.call_args[1]["name"] == "invoke_swarm" + mock_span.set_attribute.assert_any_call("gen_ai.provider.name", "strands-agents") + mock_span.set_attribute.assert_any_call("gen_ai.agent.name", "swarm") + mock_span.set_attribute.assert_any_call("gen_ai.operation.name", "invoke_swarm") + mock_span.add_event.assert_any_call( + "gen_ai.client.inference.operation.details", + attributes={ + "gen_ai.input.messages": serialize( + [{"role": "user", "parts": [{"type": "text", "content": '[{"text": "Original Task: foo bar"}]'}]}] + ) + }, + ) + assert span is not None + + def test_end_swarm_span(mock_span): """Test ending a tool call span.""" tracer = Tracer() @@ -271,6 +418,29 @@ def test_end_swarm_span(mock_span): ) +def test_end_swarm_span_latest_conventions(mock_span): + """Test ending a tool call span with latest semantic conventions.""" + tracer = Tracer() + tracer.use_latest_genai_conventions = True + swarm_final_reuslt = "foo bar bar" + + tracer.end_swarm_span(mock_span, swarm_final_reuslt) + + mock_span.add_event.assert_called_with( + "gen_ai.client.inference.operation.details", + attributes={ + "gen_ai.output.messages": serialize( + [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "foo bar bar"}], + } + ] + ) + }, + ) + + def test_start_graph_call_span(mock_tracer): """Test starting a graph call span.""" with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): @@ -303,7 +473,7 @@ def test_end_tool_call_span(mock_span): tracer.end_tool_call_span(mock_span, tool_result) - mock_span.set_attribute.assert_any_call("tool.status", "success") + mock_span.set_attribute.assert_any_call("gen_ai.tool.status", "success") mock_span.add_event.assert_called_with( "gen_ai.choice", attributes={"message": json.dumps(tool_result.get("content")), "id": ""}, @@ -312,6 +482,38 @@ def test_end_tool_call_span(mock_span): mock_span.end.assert_called_once() +def test_end_tool_call_span_latest_conventions(mock_span): + """Test ending a tool call span with the latest semantic conventions.""" + tracer = Tracer() + tracer.use_latest_genai_conventions = True + tool_result = {"status": "success", "content": [{"text": "Tool result"}]} + + tracer.end_tool_call_span(mock_span, tool_result) + + mock_span.set_attribute.assert_any_call("gen_ai.tool.status", "success") + mock_span.add_event.assert_called_with( + "gen_ai.client.inference.operation.details", + attributes={ + "gen_ai.output.messages": serialize( + [ + { + "role": "tool", + "parts": [ + { + "type": "tool_call_response", + "id": tool_result.get("toolUseId", ""), + "result": serialize(tool_result.get("content")), + } + ], + } + ] + ) + }, + ) + mock_span.set_status.assert_called_once_with(StatusCode.OK) + mock_span.end.assert_called_once() + + def test_start_event_loop_cycle_span(mock_tracer): """Test starting an event loop cycle span.""" with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): @@ -335,6 +537,35 @@ def test_start_event_loop_cycle_span(mock_tracer): assert span is not None +def test_start_event_loop_cycle_span_latest_conventions(mock_tracer): + """Test starting an event loop cycle span with the latest semantic conventions.""" + with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): + tracer = Tracer() + tracer.use_latest_genai_conventions = True + tracer.tracer = mock_tracer + + mock_span = mock.MagicMock() + mock_tracer.start_span.return_value = mock_span + + event_loop_kwargs = {"event_loop_cycle_id": "cycle-123"} + messages = [{"role": "user", "content": [{"text": "Hello"}]}] + + span = tracer.start_event_loop_cycle_span(event_loop_kwargs, messages=messages) + + mock_tracer.start_span.assert_called_once() + assert mock_tracer.start_span.call_args[1]["name"] == "execute_event_loop_cycle" + mock_span.set_attribute.assert_any_call("event_loop.cycle_id", "cycle-123") + mock_span.add_event.assert_any_call( + "gen_ai.client.inference.operation.details", + attributes={ + "gen_ai.input.messages": serialize( + [{"role": "user", "parts": [{"type": "text", "content": serialize(messages[0]["content"])}]}] + ) + }, + ) + assert span is not None + + def test_end_event_loop_cycle_span(mock_span): """Test ending an event loop cycle span.""" tracer = Tracer() @@ -354,6 +585,32 @@ def test_end_event_loop_cycle_span(mock_span): mock_span.end.assert_called_once() +def test_end_event_loop_cycle_span_latest_conventions(mock_span): + """Test ending an event loop cycle span with the latest semantic conventions.""" + tracer = Tracer() + tracer.use_latest_genai_conventions = True + message = {"role": "assistant", "content": [{"text": "Response"}]} + tool_result_message = {"role": "assistant", "content": [{"toolResult": {"response": "Success"}}]} + + tracer.end_event_loop_cycle_span(mock_span, message, tool_result_message) + + mock_span.add_event.assert_called_with( + "gen_ai.client.inference.operation.details", + attributes={ + "gen_ai.output.messages": serialize( + [ + { + "role": "assistant", + "parts": [{"type": "text", "content": serialize(tool_result_message["content"])}], + } + ] + ) + }, + ) + mock_span.set_status.assert_called_once_with(StatusCode.OK) + mock_span.end.assert_called_once() + + def test_start_agent_span(mock_tracer): """Test starting an agent span.""" with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): @@ -386,6 +643,46 @@ def test_start_agent_span(mock_tracer): assert span is not None +def test_start_agent_span_latest_conventions(mock_tracer): + """Test starting an agent span with the latest semantic conventions.""" + with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): + tracer = Tracer() + tracer.use_latest_genai_conventions = True + tracer.tracer = mock_tracer + + mock_span = mock.MagicMock() + mock_tracer.start_span.return_value = mock_span + + content = [{"text": "test prompt"}] + model_id = "test-model" + tools = [{"name": "weather_tool"}] + custom_attrs = {"custom_attr": "value"} + + span = tracer.start_agent_span( + custom_trace_attributes=custom_attrs, + agent_name="WeatherAgent", + messages=[{"content": content, "role": "user"}], + model_id=model_id, + tools=tools, + ) + + mock_tracer.start_span.assert_called_once() + assert mock_tracer.start_span.call_args[1]["name"] == "invoke_agent WeatherAgent" + mock_span.set_attribute.assert_any_call("gen_ai.provider.name", "strands-agents") + mock_span.set_attribute.assert_any_call("gen_ai.agent.name", "WeatherAgent") + mock_span.set_attribute.assert_any_call("gen_ai.request.model", model_id) + mock_span.set_attribute.assert_any_call("custom_attr", "value") + mock_span.add_event.assert_any_call( + "gen_ai.client.inference.operation.details", + attributes={ + "gen_ai.input.messages": serialize( + [{"role": "user", "parts": [{"type": "text", "content": '[{"text": "test prompt"}]'}]}] + ) + }, + ) + assert span is not None + + def test_end_agent_span(mock_span): """Test ending an agent span.""" tracer = Tracer() @@ -416,6 +713,47 @@ def test_end_agent_span(mock_span): mock_span.end.assert_called_once() +def test_end_agent_span_latest_conventions(mock_span): + """Test ending an agent span with the latest semantic conventions.""" + tracer = Tracer() + tracer.use_latest_genai_conventions = True + + # Mock AgentResult with metrics + mock_metrics = mock.MagicMock() + mock_metrics.accumulated_usage = {"inputTokens": 50, "outputTokens": 100, "totalTokens": 150} + + mock_response = mock.MagicMock() + mock_response.metrics = mock_metrics + mock_response.stop_reason = "end_turn" + mock_response.__str__ = mock.MagicMock(return_value="Agent response") + + tracer.end_agent_span(mock_span, mock_response) + + mock_span.set_attribute.assert_any_call("gen_ai.usage.prompt_tokens", 50) + mock_span.set_attribute.assert_any_call("gen_ai.usage.input_tokens", 50) + mock_span.set_attribute.assert_any_call("gen_ai.usage.completion_tokens", 100) + mock_span.set_attribute.assert_any_call("gen_ai.usage.output_tokens", 100) + mock_span.set_attribute.assert_any_call("gen_ai.usage.total_tokens", 150) + mock_span.set_attribute.assert_any_call("gen_ai.usage.cache_read_input_tokens", 0) + mock_span.set_attribute.assert_any_call("gen_ai.usage.cache_write_input_tokens", 0) + mock_span.add_event.assert_called_with( + "gen_ai.client.inference.operation.details", + attributes={ + "gen_ai.output.messages": serialize( + [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Agent response"}], + "finish_reason": "end_turn", + } + ] + ) + }, + ) + mock_span.set_status.assert_called_once_with(StatusCode.OK) + mock_span.end.assert_called_once() + + def test_end_model_invoke_span_with_cache_metrics(mock_span): """Test ending a model invoke span with cache metrics.""" tracer = Tracer()