diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index 8137f1887..9de5ffd21 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -938,6 +938,7 @@ def _start_agent_trace_span(self, messages: Messages) -> trace_api.Span: tools=self.tool_names, system_prompt=self.system_prompt, custom_trace_attributes=self.trace_attributes, + tools_config=self.tool_registry.get_all_tools_config(), ) def _end_agent_trace_span( diff --git a/src/strands/telemetry/tracer.py b/src/strands/telemetry/tracer.py index 9cefc6911..a68aad8b7 100644 --- a/src/strands/telemetry/tracer.py +++ b/src/strands/telemetry/tracer.py @@ -79,11 +79,16 @@ class Tracer: When the OTEL_EXPORTER_OTLP_ENDPOINT environment variable is set, traces are sent to the OTLP endpoint. + + Attributes: + use_latest_genai_conventions: If True, uses the latest experimental GenAI semantic conventions. + include_tool_definitions: If True, includes detailed tool definitions in the agent trace span. + + Both attributes are controlled by including "gen_ai_latest_experimental" or "gen_ai_tool_definitions", + respectively, in the OTEL_SEMCONV_STABILITY_OPT_IN environment variable. """ - def __init__( - self, - ) -> None: + def __init__(self) -> None: """Initialize the tracer.""" self.service_name = __name__ self.tracer_provider: Optional[trace_api.TracerProvider] = None @@ -92,17 +97,18 @@ def __init__( ThreadingInstrumentor().instrument() # Read OTEL_SEMCONV_STABILITY_OPT_IN environment variable - self.use_latest_genai_conventions = self._parse_semconv_opt_in() + opt_in_values = self._parse_semconv_opt_in() + self.use_latest_genai_conventions = "gen_ai_latest_experimental" in opt_in_values + self.include_tool_definitions = "gen_ai_tool_definitions" in opt_in_values - def _parse_semconv_opt_in(self) -> bool: + def _parse_semconv_opt_in(self) -> set[str]: """Parse the OTEL_SEMCONV_STABILITY_OPT_IN environment variable. Returns: - Set of opt-in values from the environment variable + A 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 + return {value.strip() for value in opt_in_env.split(",")} def _start_span( self, @@ -551,6 +557,7 @@ def start_agent_span( model_id: Optional[str] = None, tools: Optional[list] = None, custom_trace_attributes: Optional[Mapping[str, AttributeValue]] = None, + tools_config: Optional[dict] = None, **kwargs: Any, ) -> Span: """Start a new span for an agent invocation. @@ -561,6 +568,7 @@ def start_agent_span( model_id: Optional model identifier. tools: Optional list of tools being used. custom_trace_attributes: Optional mapping of custom trace attributes to include in the span. + tools_config: Optional dictionary of tool configurations. **kwargs: Additional attributes to add to the span. Returns: @@ -577,8 +585,15 @@ def start_agent_span( attributes["gen_ai.request.model"] = model_id if tools: - tools_json = serialize(tools) - attributes["gen_ai.agent.tools"] = tools_json + attributes["gen_ai.agent.tools"] = serialize(tools) + + if self.include_tool_definitions and tools_config: + try: + tool_definitions = self._construct_tool_definitions(tools_config) + attributes["gen_ai.tool.definitions"] = serialize(tool_definitions) + except Exception: + # A failure in telemetry should not crash the agent + logger.warning("failed to attach tool metadata to agent span", exc_info=True) # Add custom trace attributes if provided if custom_trace_attributes: @@ -649,6 +664,18 @@ def end_agent_span( self._end_span(span, attributes, error) + def _construct_tool_definitions(self, tools_config: dict) -> list[dict[str, Any]]: + """Constructs a list of tool definitions from the provided tools_config.""" + return [ + { + "name": name, + "description": spec.get("description"), + "inputSchema": spec.get("inputSchema"), + "outputSchema": spec.get("outputSchema"), + } + for name, spec in tools_config.items() + ] + def start_multiagent_span( self, task: str | list[ContentBlock], diff --git a/tests/strands/agent/test_agent.py b/tests/strands/agent/test_agent.py index 3a0bc2dfb..b96a04b21 100644 --- a/tests/strands/agent/test_agent.py +++ b/tests/strands/agent/test_agent.py @@ -1360,6 +1360,7 @@ def test_agent_call_creates_and_ends_span_on_success(mock_get_tracer, mock_model tools=agent.tool_names, system_prompt=agent.system_prompt, custom_trace_attributes=agent.trace_attributes, + tools_config=unittest.mock.ANY, ) # Verify span was ended with the result @@ -1394,6 +1395,7 @@ async def test_event_loop(*args, **kwargs): tools=agent.tool_names, system_prompt=agent.system_prompt, custom_trace_attributes=agent.trace_attributes, + tools_config=unittest.mock.ANY, ) expected_response = AgentResult( @@ -1432,6 +1434,7 @@ def test_agent_call_creates_and_ends_span_on_exception(mock_get_tracer, mock_mod tools=agent.tool_names, system_prompt=agent.system_prompt, custom_trace_attributes=agent.trace_attributes, + tools_config=unittest.mock.ANY, ) # Verify span was ended with the exception @@ -1468,6 +1471,7 @@ async def test_agent_stream_async_creates_and_ends_span_on_exception(mock_get_tr tools=agent.tool_names, system_prompt=agent.system_prompt, custom_trace_attributes=agent.trace_attributes, + tools_config=unittest.mock.ANY, ) # Verify span was ended with the exception @@ -2240,8 +2244,8 @@ def test_agent_backwards_compatibility_single_text_block(): # Should extract text for backwards compatibility assert agent.system_prompt == text - - + + @pytest.mark.parametrize( "content, expected", [ diff --git a/tests/strands/telemetry/test_tracer.py b/tests/strands/telemetry/test_tracer.py index 05dbe387f..25d477588 100644 --- a/tests/strands/telemetry/test_tracer.py +++ b/tests/strands/telemetry/test_tracer.py @@ -1324,3 +1324,60 @@ def test_start_event_loop_cycle_span_with_tool_result_message(mock_tracer): "gen_ai.tool.message", attributes={"content": json.dumps(messages[0]["content"])} ) assert span is not None + + +def test_start_agent_span_does_not_include_tool_definitions_by_default(): + """Verify that start_agent_span does not include tool definitions by default.""" + tracer = Tracer() + tracer.include_tool_definitions = False + tracer._start_span = mock.MagicMock() + + tools_config = { + "my_tool": { + "name": "my_tool", + "description": "A test tool", + "inputSchema": {"json": {}}, + "outputSchema": {"json": {}}, + } + } + + tracer.start_agent_span(messages=[], agent_name="TestAgent", tools_config=tools_config) + + tracer._start_span.assert_called_once() + _, call_kwargs = tracer._start_span.call_args + attributes = call_kwargs.get("attributes", {}) + assert "gen_ai.tool.definitions" not in attributes + + +def test_start_agent_span_includes_tool_definitions_when_enabled(): + """Verify that start_agent_span includes tool definitions when enabled.""" + tracer = Tracer() + tracer.include_tool_definitions = True + tracer._start_span = mock.MagicMock() + + tools_config = { + "my_tool": { + "name": "my_tool", + "description": "A test tool", + "inputSchema": {"json": {"type": "object", "properties": {}}}, + "outputSchema": {"json": {"type": "object", "properties": {}}}, + } + } + + tracer.start_agent_span(messages=[], agent_name="TestAgent", tools_config=tools_config) + + tracer._start_span.assert_called_once() + _, call_kwargs = tracer._start_span.call_args + attributes = call_kwargs.get("attributes", {}) + + assert "gen_ai.tool.definitions" in attributes + expected_tool_details = [ + { + "name": "my_tool", + "description": "A test tool", + "inputSchema": {"json": {"type": "object", "properties": {}}}, + "outputSchema": {"json": {"type": "object", "properties": {}}}, + } + ] + expected_json = serialize(expected_tool_details) + assert attributes["gen_ai.tool.definitions"] == expected_json