Skip to content

Commit 6c8c6e7

Browse files
author
Vamil Gandhi
committed
fix: correctly label tool result messages in OpenTelemetry events
Tool result messages were incorrectly labeled as 'gen_ai.user.message' in OpenTelemetry traces. This fix adds proper detection of tool results in message content and labels them as 'gen_ai.tool.message' according to OpenTelemetry semantic conventions. - Add _get_event_name_for_message() helper method to determine correct event name - Update start_model_invoke_span(), start_event_loop_cycle_span(), and start_agent_span() to use the new helper - Add comprehensive tests covering all message types and edge cases - Maintain backwards compatibility for non-tool messages Fixes #714
1 parent ab125f5 commit 6c8c6e7

File tree

2 files changed

+194
-3
lines changed

2 files changed

+194
-3
lines changed

src/strands/telemetry/tracer.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,26 @@ def _add_event(self, span: Optional[Span], event_name: str, event_attributes: Di
207207

208208
span.add_event(event_name, attributes=event_attributes)
209209

210+
def _get_event_name_for_message(self, message: Message) -> str:
211+
"""Determine the appropriate OpenTelemetry event name for a message.
212+
213+
According to OpenTelemetry semantic conventions, messages containing tool results
214+
should be labeled as 'gen_ai.tool.message' regardless of their role field.
215+
This ensures proper categorization of tool responses in traces.
216+
217+
Args:
218+
message: The message to determine the event name for
219+
220+
Returns:
221+
The OpenTelemetry event name (e.g., 'gen_ai.user.message', 'gen_ai.tool.message')
222+
"""
223+
# Check if the message contains a tool result
224+
for content_block in message.get("content", []):
225+
if "toolResult" in content_block:
226+
return "gen_ai.tool.message"
227+
228+
return f"gen_ai.{message['role']}.message"
229+
210230
def start_model_invoke_span(
211231
self,
212232
messages: Messages,
@@ -240,7 +260,7 @@ def start_model_invoke_span(
240260
for message in messages:
241261
self._add_event(
242262
span,
243-
f"gen_ai.{message['role']}.message",
263+
self._get_event_name_for_message(message),
244264
{"content": serialize(message["content"])},
245265
)
246266
return span
@@ -377,7 +397,7 @@ def start_event_loop_cycle_span(
377397
for message in messages or []:
378398
self._add_event(
379399
span,
380-
f"gen_ai.{message['role']}.message",
400+
self._get_event_name_for_message(message),
381401
{"content": serialize(message["content"])},
382402
)
383403

@@ -454,7 +474,7 @@ def start_agent_span(
454474
for message in messages:
455475
self._add_event(
456476
span,
457-
f"gen_ai.{message['role']}.message",
477+
self._get_event_name_for_message(message),
458478
{"content": serialize(message["content"])},
459479
)
460480

tests/strands/telemetry/test_tracer.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,3 +684,174 @@ def test_serialize_vs_json_dumps():
684684
custom_result = serialize({"text": japanese_text})
685685
assert japanese_text in custom_result
686686
assert "\\u" not in custom_result
687+
688+
689+
def test_get_event_name_for_message_user():
690+
"""Test getting event name for regular user message."""
691+
tracer = Tracer()
692+
message = {"role": "user", "content": [{"text": "Hello"}]}
693+
694+
event_name = tracer._get_event_name_for_message(message)
695+
696+
assert event_name == "gen_ai.user.message"
697+
698+
699+
def test_get_event_name_for_message_assistant():
700+
"""Test getting event name for regular assistant message."""
701+
tracer = Tracer()
702+
message = {"role": "assistant", "content": [{"text": "Hello"}]}
703+
704+
event_name = tracer._get_event_name_for_message(message)
705+
706+
assert event_name == "gen_ai.assistant.message"
707+
708+
709+
def test_get_event_name_for_message_with_tool_result():
710+
"""Test getting event name for message containing tool result."""
711+
tracer = Tracer()
712+
message = {
713+
"role": "user",
714+
"content": [{"toolResult": {"toolUseId": "123", "status": "success", "content": [{"text": "Tool response"}]}}],
715+
}
716+
717+
event_name = tracer._get_event_name_for_message(message)
718+
719+
assert event_name == "gen_ai.tool.message"
720+
721+
722+
def test_get_event_name_for_message_with_mixed_content():
723+
"""Test getting event name for message with both text and tool result."""
724+
tracer = Tracer()
725+
message = {
726+
"role": "user",
727+
"content": [
728+
{"text": "Here are the results:"},
729+
{"toolResult": {"toolUseId": "123", "status": "success", "content": [{"text": "Tool response"}]}},
730+
],
731+
}
732+
733+
event_name = tracer._get_event_name_for_message(message)
734+
735+
# Should be tool message since it contains a tool result
736+
assert event_name == "gen_ai.tool.message"
737+
738+
739+
def test_get_event_name_for_message_empty_content():
740+
"""Test getting event name for message with empty content."""
741+
tracer = Tracer()
742+
message = {"role": "user", "content": []}
743+
744+
event_name = tracer._get_event_name_for_message(message)
745+
746+
assert event_name == "gen_ai.user.message"
747+
748+
749+
def test_get_event_name_for_message_no_content():
750+
"""Test getting event name for message with no content key."""
751+
tracer = Tracer()
752+
message = {"role": "assistant"}
753+
754+
event_name = tracer._get_event_name_for_message(message)
755+
756+
assert event_name == "gen_ai.assistant.message"
757+
758+
759+
def test_get_event_name_for_message_multiple_tool_results():
760+
"""Test getting event name for message with multiple tool results."""
761+
tracer = Tracer()
762+
message = {
763+
"role": "user",
764+
"content": [
765+
{"toolResult": {"toolUseId": "123", "status": "success", "content": [{"text": "First tool"}]}},
766+
{"toolResult": {"toolUseId": "456", "status": "success", "content": [{"text": "Second tool"}]}},
767+
],
768+
}
769+
770+
event_name = tracer._get_event_name_for_message(message)
771+
772+
assert event_name == "gen_ai.tool.message"
773+
774+
775+
def test_start_model_invoke_span_with_tool_result_message(mock_tracer):
776+
"""Test that start_model_invoke_span correctly labels tool result messages."""
777+
with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer):
778+
tracer = Tracer()
779+
tracer.tracer = mock_tracer
780+
781+
mock_span = mock.MagicMock()
782+
mock_tracer.start_span.return_value = mock_span
783+
784+
# Message that contains a tool result
785+
messages = [
786+
{
787+
"role": "user",
788+
"content": [
789+
{"toolResult": {"toolUseId": "123", "status": "success", "content": [{"text": "Weather is sunny"}]}}
790+
],
791+
}
792+
]
793+
794+
span = tracer.start_model_invoke_span(messages=messages, model_id="test-model")
795+
796+
# Should use gen_ai.tool.message event name instead of gen_ai.user.message
797+
mock_span.add_event.assert_called_with(
798+
"gen_ai.tool.message", attributes={"content": json.dumps(messages[0]["content"])}
799+
)
800+
assert span is not None
801+
802+
803+
def test_start_agent_span_with_tool_result_message(mock_tracer):
804+
"""Test that start_agent_span correctly labels tool result messages."""
805+
with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer):
806+
tracer = Tracer()
807+
tracer.tracer = mock_tracer
808+
809+
mock_span = mock.MagicMock()
810+
mock_tracer.start_span.return_value = mock_span
811+
812+
# Message that contains a tool result
813+
messages = [
814+
{
815+
"role": "user",
816+
"content": [
817+
{"toolResult": {"toolUseId": "123", "status": "success", "content": [{"text": "Weather is sunny"}]}}
818+
],
819+
}
820+
]
821+
822+
span = tracer.start_agent_span(messages=messages, agent_name="WeatherAgent", model_id="test-model")
823+
824+
# Should use gen_ai.tool.message event name instead of gen_ai.user.message
825+
mock_span.add_event.assert_called_with(
826+
"gen_ai.tool.message", attributes={"content": json.dumps(messages[0]["content"])}
827+
)
828+
assert span is not None
829+
830+
831+
def test_start_event_loop_cycle_span_with_tool_result_message(mock_tracer):
832+
"""Test that start_event_loop_cycle_span correctly labels tool result messages."""
833+
with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer):
834+
tracer = Tracer()
835+
tracer.tracer = mock_tracer
836+
837+
mock_span = mock.MagicMock()
838+
mock_tracer.start_span.return_value = mock_span
839+
840+
# Message that contains a tool result
841+
messages = [
842+
{
843+
"role": "user",
844+
"content": [
845+
{"toolResult": {"toolUseId": "123", "status": "success", "content": [{"text": "Weather is sunny"}]}}
846+
],
847+
}
848+
]
849+
850+
event_loop_kwargs = {"event_loop_cycle_id": "cycle-123"}
851+
span = tracer.start_event_loop_cycle_span(event_loop_kwargs, messages=messages)
852+
853+
# Should use gen_ai.tool.message event name instead of gen_ai.user.message
854+
mock_span.add_event.assert_called_with(
855+
"gen_ai.tool.message", attributes={"content": json.dumps(messages[0]["content"])}
856+
)
857+
assert span is not None

0 commit comments

Comments
 (0)