Skip to content

Commit 9a59513

Browse files
committed
Add support for OTel GenAI Semantic Convention patterns in LLO handler
1 parent 4ea7469 commit 9a59513

File tree

3 files changed

+362
-1
lines changed

3 files changed

+362
-1
lines changed

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/llo_handler.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
ROLE_SYSTEM = "system"
1717
ROLE_USER = "user"
1818
ROLE_ASSISTANT = "assistant"
19+
ROLE_TOOL = "tool"
1920

2021
_logger = logging.getLogger(__name__)
2122

@@ -137,6 +138,35 @@ class PatternConfig(TypedDict, total=False):
137138
"role": ROLE_USER,
138139
"source": "prompt",
139140
},
141+
# OTel GenAI Semantic Convention used by the latest Strands SDK
142+
# References:
143+
# - OTel GenAI SemConv: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-events/
144+
# - Strands SDK PR: https://github.com/strands-agents/sdk-python/pull/319
145+
"gen_ai.user.message": {
146+
"type": PatternType.DIRECT,
147+
"role": ROLE_USER,
148+
"source": "prompt",
149+
},
150+
"gen_ai.assistant.message": {
151+
"type": PatternType.DIRECT,
152+
"role": ROLE_ASSISTANT,
153+
"source": "output",
154+
},
155+
"gen_ai.system.message": {
156+
"type": PatternType.DIRECT,
157+
"role": ROLE_SYSTEM,
158+
"source": "prompt",
159+
},
160+
"gen_ai.tool.message": {
161+
"type": PatternType.DIRECT,
162+
"role": ROLE_USER,
163+
"source": "prompt",
164+
},
165+
"gen_ai.choice": {
166+
"type": PatternType.DIRECT,
167+
"role": ROLE_ASSISTANT,
168+
"source": "output",
169+
},
140170
}
141171

142172

@@ -279,6 +309,12 @@ def _collect_llo_attributes_from_span(self, span: ReadableSpan) -> Dict[str, Any
279309
# Collect from span events
280310
if span.events:
281311
for event in span.events:
312+
# Check if event name itself is an LLO pattern (e.g., "gen_ai.user.message")
313+
if self._is_llo_attribute(event.name):
314+
# Put all event attributes as the content as LLO in log event
315+
all_llo_attributes[event.name] = dict(event.attributes) if event.attributes else {}
316+
317+
# Also check traditional pattern - LLO attributes within event attributes
282318
if event.attributes:
283319
for key, value in event.attributes.items():
284320
if self._is_llo_attribute(key):
@@ -372,6 +408,10 @@ def _filter_span_events(self, span: ReadableSpan) -> None:
372408
updated_events = []
373409

374410
for event in span.events:
411+
# Skip entire event if event name is an LLO pattern
412+
if self._is_llo_attribute(event.name):
413+
continue
414+
375415
if not event.attributes:
376416
updated_events.append(event)
377417
continue

aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/llo_handler/test_llo_handler_events.py

Lines changed: 300 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from test_llo_handler_base import LLOHandlerTestBase
88

99

10-
class TestLLOHandlerEvents(LLOHandlerTestBase):
10+
class TestLLOHandlerEvents(LLOHandlerTestBase): # pylint: disable=too-many-public-methods
1111
"""Test event emission and formatting functionality."""
1212

1313
def test_emit_llo_attributes(self):
@@ -649,3 +649,302 @@ def test_emit_llo_attributes_with_session_id_and_other_attributes(self):
649649
self.assertNotIn("other.attribute", emitted_event.attributes)
650650
self.assertNotIn("gen_ai.prompt", emitted_event.attributes)
651651
self.assertNotIn("gen_ai.completion", emitted_event.attributes)
652+
653+
def test_emit_llo_attributes_otel_genai_patterns(self):
654+
"""
655+
Test the new GenAI patterns from Strands SDK that follow OTel GenAI Semantic Convention.
656+
"""
657+
attributes = {
658+
"gen_ai.user.message": "What is machine learning?",
659+
"gen_ai.assistant.message": (
660+
"Machine learning is a subset of AI that enables computers to learn patterns from data."
661+
),
662+
"gen_ai.system.message": "You are a helpful AI assistant specializing in technology topics.",
663+
"gen_ai.tool.message": "Searching knowledge base for machine learning definitions...",
664+
"gen_ai.choice": "Best answer: Machine learning uses algorithms to find patterns in data.",
665+
}
666+
667+
span = self._create_mock_span(attributes)
668+
span.end_time = 1234567899
669+
span.instrumentation_scope = MagicMock()
670+
span.instrumentation_scope.name = "strands.genai.scope"
671+
672+
self.llo_handler._emit_llo_attributes(span, attributes)
673+
674+
self.event_logger_mock.emit.assert_called_once()
675+
emitted_event = self.event_logger_mock.emit.call_args[0][0]
676+
677+
self.assertEqual(emitted_event.name, "strands.genai.scope")
678+
self.assertEqual(emitted_event.timestamp, span.end_time)
679+
680+
event_body = emitted_event.body
681+
self.assertIn("input", event_body)
682+
self.assertIn("output", event_body)
683+
684+
# Check input messages (system, user, and tool messages)
685+
input_messages = event_body["input"]["messages"]
686+
self.assertEqual(len(input_messages), 3)
687+
688+
input_contents = [msg["content"] for msg in input_messages]
689+
self.assertIn("What is machine learning?", input_contents)
690+
self.assertIn("You are a helpful AI assistant specializing in technology topics.", input_contents)
691+
self.assertIn("Searching knowledge base for machine learning definitions...", input_contents)
692+
693+
# Verify roles for input messages
694+
user_msg = next((msg for msg in input_messages if msg["content"] == "What is machine learning?"), None)
695+
self.assertIsNotNone(user_msg)
696+
self.assertEqual(user_msg["role"], "user")
697+
698+
system_msg = next((msg for msg in input_messages if "helpful AI assistant" in msg["content"]), None)
699+
self.assertIsNotNone(system_msg)
700+
self.assertEqual(system_msg["role"], "system")
701+
702+
tool_msg = next((msg for msg in input_messages if "Searching knowledge base" in msg["content"]), None)
703+
self.assertIsNotNone(tool_msg)
704+
self.assertEqual(tool_msg["role"], "user")
705+
706+
# Check output messages (assistant message and choice)
707+
output_messages = event_body["output"]["messages"]
708+
self.assertEqual(len(output_messages), 2)
709+
710+
output_contents = [msg["content"] for msg in output_messages]
711+
self.assertIn(
712+
"Machine learning is a subset of AI that enables computers to learn patterns from data.", output_contents
713+
)
714+
self.assertIn("Best answer: Machine learning uses algorithms to find patterns in data.", output_contents)
715+
716+
# Verify all output messages have assistant role
717+
for msg in output_messages:
718+
self.assertEqual(msg["role"], "assistant")
719+
720+
def test_emit_llo_attributes_genai_user_message_only(self):
721+
"""
722+
Test event generation with only gen_ai.user.message attribute.
723+
"""
724+
attributes = {
725+
"gen_ai.user.message": "Hello, how are you today?",
726+
}
727+
728+
span = self._create_mock_span(attributes)
729+
span.end_time = 1234567899
730+
span.instrumentation_scope = MagicMock()
731+
span.instrumentation_scope.name = "test.scope"
732+
733+
self.llo_handler._emit_llo_attributes(span, attributes)
734+
735+
self.event_logger_mock.emit.assert_called_once()
736+
emitted_event = self.event_logger_mock.emit.call_args[0][0]
737+
738+
event_body = emitted_event.body
739+
self.assertIn("input", event_body)
740+
self.assertNotIn("output", event_body)
741+
742+
input_messages = event_body["input"]["messages"]
743+
self.assertEqual(len(input_messages), 1)
744+
self.assertEqual(input_messages[0]["content"], "Hello, how are you today?")
745+
self.assertEqual(input_messages[0]["role"], "user")
746+
747+
def test_emit_llo_attributes_genai_assistant_message_only(self):
748+
"""
749+
Test event generation with only gen_ai.assistant.message attribute.
750+
"""
751+
attributes = {
752+
"gen_ai.assistant.message": "I'm doing well, thank you for asking!",
753+
}
754+
755+
span = self._create_mock_span(attributes)
756+
span.end_time = 1234567899
757+
span.instrumentation_scope = MagicMock()
758+
span.instrumentation_scope.name = "test.scope"
759+
760+
self.llo_handler._emit_llo_attributes(span, attributes)
761+
762+
self.event_logger_mock.emit.assert_called_once()
763+
emitted_event = self.event_logger_mock.emit.call_args[0][0]
764+
765+
event_body = emitted_event.body
766+
self.assertNotIn("input", event_body)
767+
self.assertIn("output", event_body)
768+
769+
output_messages = event_body["output"]["messages"]
770+
self.assertEqual(len(output_messages), 1)
771+
self.assertEqual(output_messages[0]["content"], "I'm doing well, thank you for asking!")
772+
self.assertEqual(output_messages[0]["role"], "assistant")
773+
774+
def test_emit_llo_attributes_genai_system_message_only(self):
775+
"""
776+
Test event generation with only gen_ai.system.message attribute.
777+
"""
778+
attributes = {
779+
"gen_ai.system.message": "You are a creative writing assistant. Help users write stories.",
780+
}
781+
782+
span = self._create_mock_span(attributes)
783+
span.end_time = 1234567899
784+
span.instrumentation_scope = MagicMock()
785+
span.instrumentation_scope.name = "test.scope"
786+
787+
self.llo_handler._emit_llo_attributes(span, attributes)
788+
789+
self.event_logger_mock.emit.assert_called_once()
790+
emitted_event = self.event_logger_mock.emit.call_args[0][0]
791+
792+
event_body = emitted_event.body
793+
self.assertIn("input", event_body)
794+
self.assertNotIn("output", event_body)
795+
796+
input_messages = event_body["input"]["messages"]
797+
self.assertEqual(len(input_messages), 1)
798+
self.assertEqual(
799+
input_messages[0]["content"], "You are a creative writing assistant. Help users write stories."
800+
)
801+
self.assertEqual(input_messages[0]["role"], "system")
802+
803+
def test_emit_llo_attributes_genai_tool_message_only(self):
804+
"""
805+
Test event generation with only gen_ai.tool.message attribute.
806+
"""
807+
attributes = {
808+
"gen_ai.tool.message": "Executing search function with query: 'latest news'",
809+
}
810+
811+
span = self._create_mock_span(attributes)
812+
span.end_time = 1234567899
813+
span.instrumentation_scope = MagicMock()
814+
span.instrumentation_scope.name = "test.scope"
815+
816+
self.llo_handler._emit_llo_attributes(span, attributes)
817+
818+
self.event_logger_mock.emit.assert_called_once()
819+
emitted_event = self.event_logger_mock.emit.call_args[0][0]
820+
821+
event_body = emitted_event.body
822+
self.assertIn("input", event_body)
823+
self.assertNotIn("output", event_body)
824+
825+
input_messages = event_body["input"]["messages"]
826+
self.assertEqual(len(input_messages), 1)
827+
self.assertEqual(input_messages[0]["content"], "Executing search function with query: 'latest news'")
828+
self.assertEqual(input_messages[0]["role"], "user")
829+
830+
def test_emit_llo_attributes_genai_choice_only(self):
831+
"""
832+
Test event generation with only gen_ai.choice attribute.
833+
"""
834+
attributes = {
835+
"gen_ai.choice": "Selected option: Continue with the current approach.",
836+
}
837+
838+
span = self._create_mock_span(attributes)
839+
span.end_time = 1234567899
840+
span.instrumentation_scope = MagicMock()
841+
span.instrumentation_scope.name = "test.scope"
842+
843+
self.llo_handler._emit_llo_attributes(span, attributes)
844+
845+
self.event_logger_mock.emit.assert_called_once()
846+
emitted_event = self.event_logger_mock.emit.call_args[0][0]
847+
848+
event_body = emitted_event.body
849+
self.assertNotIn("input", event_body)
850+
self.assertIn("output", event_body)
851+
852+
output_messages = event_body["output"]["messages"]
853+
self.assertEqual(len(output_messages), 1)
854+
self.assertEqual(output_messages[0]["content"], "Selected option: Continue with the current approach.")
855+
self.assertEqual(output_messages[0]["role"], "assistant")
856+
857+
def test_emit_llo_attributes_from_span_events(self):
858+
"""
859+
Test that LLO attributes are collected from span events when event names match LLO patterns.
860+
"""
861+
# Create span with normal attributes
862+
span_attributes = {"normal.attribute": "value"}
863+
864+
# Create span events where event names are LLO patterns
865+
user_event = MagicMock()
866+
user_event.name = "gen_ai.user.message"
867+
user_event.attributes = {"content": "What is the weather today?", "other": "metadata"}
868+
user_event.timestamp = 1234567890
869+
870+
assistant_event = MagicMock()
871+
assistant_event.name = "gen_ai.assistant.message"
872+
assistant_event.attributes = {"content": "It's sunny and 75°F", "confidence": 0.95}
873+
assistant_event.timestamp = 1234567891
874+
875+
system_event = MagicMock()
876+
system_event.name = "gen_ai.system.message"
877+
system_event.attributes = {"content": "You are a weather assistant"}
878+
system_event.timestamp = 1234567892
879+
880+
span = self._create_mock_span(span_attributes)
881+
span.events = [user_event, assistant_event, system_event]
882+
span.end_time = 1234567899
883+
span.instrumentation_scope = MagicMock()
884+
span.instrumentation_scope.name = "event.patterns.scope"
885+
886+
# Collect LLO attributes and emit event
887+
all_llo_attrs = self.llo_handler._collect_llo_attributes_from_span(span)
888+
self.llo_handler._emit_llo_attributes(span, all_llo_attrs)
889+
890+
# Should emit event because LLO attributes were collected from span events
891+
self.event_logger_mock.emit.assert_called_once()
892+
emitted_event = self.event_logger_mock.emit.call_args[0][0]
893+
894+
event_body = emitted_event.body
895+
self.assertIn("input", event_body)
896+
self.assertIn("output", event_body)
897+
898+
# Check input messages (user and system)
899+
input_messages = event_body["input"]["messages"]
900+
self.assertEqual(len(input_messages), 2)
901+
902+
# User message should contain all event attributes as content
903+
user_msg = next((msg for msg in input_messages if msg["role"] == "user"), None)
904+
self.assertIsNotNone(user_msg)
905+
self.assertEqual(user_msg["content"], {"content": "What is the weather today?", "other": "metadata"})
906+
907+
# System message should contain all event attributes as content
908+
system_msg = next((msg for msg in input_messages if msg["role"] == "system"), None)
909+
self.assertIsNotNone(system_msg)
910+
self.assertEqual(system_msg["content"], {"content": "You are a weather assistant"})
911+
912+
# Check output messages (assistant)
913+
output_messages = event_body["output"]["messages"]
914+
self.assertEqual(len(output_messages), 1)
915+
self.assertEqual(output_messages[0]["role"], "assistant")
916+
self.assertEqual(output_messages[0]["content"], {"content": "It's sunny and 75°F", "confidence": 0.95})
917+
918+
def test_filter_span_events_removes_llo_pattern_events(self):
919+
"""
920+
Test that span events are completely removed when their names match LLO patterns.
921+
"""
922+
# Create span events
923+
normal_event = MagicMock()
924+
normal_event.name = "normal.event"
925+
normal_event.attributes = {"data": "keep this"}
926+
927+
llo_event1 = MagicMock()
928+
llo_event1.name = "gen_ai.user.message"
929+
llo_event1.attributes = {"content": "remove this event"}
930+
931+
llo_event2 = MagicMock()
932+
llo_event2.name = "gen_ai.assistant.message"
933+
llo_event2.attributes = {"content": "remove this too"}
934+
935+
another_normal_event = MagicMock()
936+
another_normal_event.name = "another.normal.event"
937+
another_normal_event.attributes = {"info": "keep this too"}
938+
939+
span = self._create_mock_span({})
940+
span.events = [normal_event, llo_event1, llo_event2, another_normal_event]
941+
942+
self.llo_handler._filter_span_events(span)
943+
944+
# Should only have normal events left
945+
self.assertEqual(len(span._events), 2)
946+
remaining_names = [event.name for event in span._events]
947+
self.assertIn("normal.event", remaining_names)
948+
self.assertIn("another.normal.event", remaining_names)
949+
self.assertNotIn("gen_ai.user.message", remaining_names)
950+
self.assertNotIn("gen_ai.assistant.message", remaining_names)

0 commit comments

Comments
 (0)