Skip to content

Commit 7dc05ed

Browse files
committed
feat(telemetry): Add tool definitions to traces via semconv opt-in
1 parent 89bab98 commit 7dc05ed

File tree

4 files changed

+111
-12
lines changed

4 files changed

+111
-12
lines changed

src/strands/agent/agent.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,7 @@ def _start_agent_trace_span(self, messages: Messages) -> trace_api.Span:
938938
tools=self.tool_names,
939939
system_prompt=self.system_prompt,
940940
custom_trace_attributes=self.trace_attributes,
941+
tools_config=self.tool_registry.get_all_tools_config(),
941942
)
942943

943944
def _end_agent_trace_span(

src/strands/telemetry/tracer.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,7 @@ class Tracer:
8181
are sent to the OTLP endpoint.
8282
"""
8383

84-
def __init__(
85-
self,
86-
) -> None:
84+
def __init__(self) -> None:
8785
"""Initialize the tracer."""
8886
self.service_name = __name__
8987
self.tracer_provider: Optional[trace_api.TracerProvider] = None
@@ -92,17 +90,18 @@ def __init__(
9290
ThreadingInstrumentor().instrument()
9391

9492
# Read OTEL_SEMCONV_STABILITY_OPT_IN environment variable
95-
self.use_latest_genai_conventions = self._parse_semconv_opt_in()
93+
opt_in_values = self._parse_semconv_opt_in()
94+
self.use_latest_genai_conventions = "gen_ai_latest_experimental" in opt_in_values
95+
self.include_tool_definitions = "gen_ai_tool_definitions" in opt_in_values
9696

97-
def _parse_semconv_opt_in(self) -> bool:
97+
def _parse_semconv_opt_in(self) -> set[str]:
9898
"""Parse the OTEL_SEMCONV_STABILITY_OPT_IN environment variable.
9999
100100
Returns:
101-
Set of opt-in values from the environment variable
101+
A set of opt-in values from the environment variable.
102102
"""
103103
opt_in_env = os.getenv("OTEL_SEMCONV_STABILITY_OPT_IN", "")
104-
105-
return "gen_ai_latest_experimental" in opt_in_env
104+
return {value.strip() for value in opt_in_env.split(",")}
106105

107106
def _start_span(
108107
self,
@@ -551,6 +550,7 @@ def start_agent_span(
551550
model_id: Optional[str] = None,
552551
tools: Optional[list] = None,
553552
custom_trace_attributes: Optional[Mapping[str, AttributeValue]] = None,
553+
tools_config: Optional[dict] = None,
554554
**kwargs: Any,
555555
) -> Span:
556556
"""Start a new span for an agent invocation.
@@ -561,6 +561,7 @@ def start_agent_span(
561561
model_id: Optional model identifier.
562562
tools: Optional list of tools being used.
563563
custom_trace_attributes: Optional mapping of custom trace attributes to include in the span.
564+
tools_config: Optional dictionary of tool configurations.
564565
**kwargs: Additional attributes to add to the span.
565566
566567
Returns:
@@ -577,8 +578,15 @@ def start_agent_span(
577578
attributes["gen_ai.request.model"] = model_id
578579

579580
if tools:
580-
tools_json = serialize(tools)
581-
attributes["gen_ai.agent.tools"] = tools_json
581+
attributes["gen_ai.agent.tools"] = serialize(tools)
582+
583+
if self.include_tool_definitions and tools_config:
584+
try:
585+
tool_definitions = self._construct_tool_definitions(tools_config)
586+
attributes["gen_ai.tool.definitions"] = serialize(tool_definitions)
587+
except Exception:
588+
# A failure in telemetry should not crash the agent
589+
logger.warning("failed to attach tool metadata to agent span", exc_info=True)
582590

583591
# Add custom trace attributes if provided
584592
if custom_trace_attributes:
@@ -649,6 +657,18 @@ def end_agent_span(
649657

650658
self._end_span(span, attributes, error)
651659

660+
def _construct_tool_definitions(self, tools_config: dict) -> list[dict[str, Any]]:
661+
"""Constructs a list of tool definitions from the provided tools_config."""
662+
return [
663+
{
664+
"name": name,
665+
"description": spec.get("description"),
666+
"inputSchema": spec.get("inputSchema"),
667+
"outputSchema": spec.get("outputSchema"),
668+
}
669+
for name, spec in tools_config.items()
670+
]
671+
652672
def start_multiagent_span(
653673
self,
654674
task: str | list[ContentBlock],

tests/strands/agent/test_agent.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,6 +1360,7 @@ def test_agent_call_creates_and_ends_span_on_success(mock_get_tracer, mock_model
13601360
tools=agent.tool_names,
13611361
system_prompt=agent.system_prompt,
13621362
custom_trace_attributes=agent.trace_attributes,
1363+
tools_config=unittest.mock.ANY,
13631364
)
13641365

13651366
# Verify span was ended with the result
@@ -1394,6 +1395,7 @@ async def test_event_loop(*args, **kwargs):
13941395
tools=agent.tool_names,
13951396
system_prompt=agent.system_prompt,
13961397
custom_trace_attributes=agent.trace_attributes,
1398+
tools_config=unittest.mock.ANY,
13971399
)
13981400

13991401
expected_response = AgentResult(
@@ -1432,6 +1434,7 @@ def test_agent_call_creates_and_ends_span_on_exception(mock_get_tracer, mock_mod
14321434
tools=agent.tool_names,
14331435
system_prompt=agent.system_prompt,
14341436
custom_trace_attributes=agent.trace_attributes,
1437+
tools_config=unittest.mock.ANY,
14351438
)
14361439

14371440
# 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
14681471
tools=agent.tool_names,
14691472
system_prompt=agent.system_prompt,
14701473
custom_trace_attributes=agent.trace_attributes,
1474+
tools_config=unittest.mock.ANY,
14711475
)
14721476

14731477
# Verify span was ended with the exception
@@ -2240,8 +2244,8 @@ def test_agent_backwards_compatibility_single_text_block():
22402244

22412245
# Should extract text for backwards compatibility
22422246
assert agent.system_prompt == text
2243-
2244-
2247+
2248+
22452249
@pytest.mark.parametrize(
22462250
"content, expected",
22472251
[

tests/strands/telemetry/test_tracer.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import os
3+
import unittest.mock
34
from datetime import date, datetime, timezone
45
from unittest import mock
56

@@ -9,9 +10,12 @@
910
StatusCode, # type: ignore
1011
)
1112

13+
import strands
14+
from strands import Agent
1215
from strands.telemetry.tracer import JSONEncoder, Tracer, get_tracer, serialize
1316
from strands.types.content import ContentBlock
1417
from strands.types.streaming import Metrics, StopReason, Usage
18+
from tests.fixtures.mocked_model_provider import MockedModelProvider
1519

1620

1721
@pytest.fixture(autouse=True)
@@ -49,6 +53,15 @@ def clean_env():
4953
yield
5054

5155

56+
@pytest.fixture
57+
def tool_decorated():
58+
@strands.tools.tool(name="tool_decorated")
59+
def function(random_string: str) -> str:
60+
return random_string
61+
62+
return function
63+
64+
5265
def test_init_default():
5366
"""Test initializing the Tracer with default parameters."""
5467
tracer = Tracer()
@@ -1324,3 +1337,64 @@ def test_start_event_loop_cycle_span_with_tool_result_message(mock_tracer):
13241337
"gen_ai.tool.message", attributes={"content": json.dumps(messages[0]["content"])}
13251338
)
13261339
assert span is not None
1340+
1341+
1342+
def test_agent_does_not_include_tools_in_trace_by_default(tool_decorated):
1343+
"""Verify that by default, the agent does not add tool specs to the trace."""
1344+
with unittest.mock.patch("strands.agent.agent.get_tracer") as mock_get_tracer:
1345+
mock_tracer_instance = unittest.mock.MagicMock()
1346+
mock_span = unittest.mock.MagicMock()
1347+
mock_tracer_instance.start_agent_span.return_value = mock_span
1348+
mock_tracer_instance.include_tool_definitions = False
1349+
mock_get_tracer.return_value = mock_tracer_instance
1350+
1351+
mock_model = MockedModelProvider([{"role": "assistant", "content": [{"text": "hello!"}]}])
1352+
1353+
agent = Agent(tools=[tool_decorated], model=mock_model)
1354+
agent("test prompt")
1355+
1356+
# Check that set_attribute was not called for our specific key
1357+
called_attributes = [call.args[0] for call in mock_span.set_attribute.call_args_list]
1358+
assert "gen_ai.tool.definitions" not in called_attributes
1359+
1360+
1361+
def test_agent_includes_tools_in_trace_when_enabled(tool_decorated):
1362+
"""Verify that the agent adds tool specs to the trace when the flag is enabled."""
1363+
with unittest.mock.patch("strands.agent.agent.get_tracer") as mock_get_tracer:
1364+
# Create a REAL tracer instance so its logic runs
1365+
mock_tracer_instance = Tracer()
1366+
# but turn off the flag
1367+
mock_tracer_instance.include_tool_definitions = True
1368+
mock_span = unittest.mock.MagicMock()
1369+
1370+
# Mock the lower-level _start_span method, NOT start_agent_span
1371+
mock_tracer_instance._start_span = unittest.mock.MagicMock(return_value=mock_span)
1372+
mock_get_tracer.return_value = mock_tracer_instance
1373+
1374+
mock_model = MockedModelProvider([{"role": "assistant", "content": [{"text": "hello!"}]}])
1375+
1376+
agent = Agent(tools=[tool_decorated], model=mock_model)
1377+
agent("test prompt")
1378+
1379+
# Now, we assert that _start_span was called, and we inspect the
1380+
# attributes that were passed to it.
1381+
mock_tracer_instance._start_span.assert_called_once()
1382+
call_args, call_kwargs = mock_tracer_instance._start_span.call_args
1383+
passed_attributes = call_kwargs.get("attributes", {})
1384+
1385+
# Construct the data we expect to find in the attributes
1386+
tool_spec = tool_decorated.tool_spec
1387+
expected_tool_details = [
1388+
{
1389+
"name": tool_spec.get("name"),
1390+
"description": tool_spec.get("description"),
1391+
"inputSchema": tool_spec.get("inputSchema"),
1392+
"outputSchema": tool_spec.get("outputSchema"),
1393+
}
1394+
]
1395+
1396+
expected_json = serialize(expected_tool_details)
1397+
1398+
# Verify that our tool definitions are present in the attributes passed to the span
1399+
assert "gen_ai.tool.definitions" in passed_attributes
1400+
assert passed_attributes["gen_ai.tool.definitions"] == expected_json

0 commit comments

Comments
 (0)