66import unittest
77from unittest .mock import Mock , patch
88
9- from microsoft_agents_a365 .observability .core .constants import GEN_AI_AGENT_ID_KEY , TENANT_ID_KEY
9+ from microsoft_agents_a365 .observability .core .constants import (
10+ GEN_AI_AGENT_ID_KEY ,
11+ GEN_AI_OPERATION_NAME_KEY ,
12+ INVOKE_AGENT_OPERATION_NAME ,
13+ TENANT_ID_KEY ,
14+ )
1015from microsoft_agents_a365 .observability .core .exporters .agent365_exporter import (
1116 DEFAULT_ENDPOINT_URL ,
1217 _Agent365Exporter ,
@@ -54,6 +59,7 @@ def _create_mock_span(
5459 scope_version : str = "1.0.0" ,
5560 tenant_id : str = "test-tenant-123" ,
5661 agent_id : str = "test-agent-456" ,
62+ operation_name : str | None = INVOKE_AGENT_OPERATION_NAME ,
5763 ) -> ReadableSpan :
5864 """Create a mock ReadableSpan for testing."""
5965 mock_span = Mock (spec = ReadableSpan )
@@ -78,13 +84,15 @@ def _create_mock_span(
7884 mock_span .kind = Mock ()
7985 mock_span .kind .name = "INTERNAL"
8086
81- # Add identity attributes for partition_by_identity to work
87+ # Add identity attributes for filter_and_partition_by_identity to work
8288 span_attributes = attributes or {}
8389 if tenant_id and agent_id :
8490 span_attributes .update ({
8591 TENANT_ID_KEY : tenant_id ,
8692 GEN_AI_AGENT_ID_KEY : agent_id ,
8793 })
94+ if operation_name is not None and GEN_AI_OPERATION_NAME_KEY not in span_attributes :
95+ span_attributes [GEN_AI_OPERATION_NAME_KEY ] = operation_name
8896
8997 mock_span .attributes = span_attributes
9098 mock_span .events = []
@@ -350,10 +358,8 @@ def test_export_error_logging(self, mock_logger):
350358 # Verify export succeeded (no identity spans are treated as success)
351359 self .assertEqual (result , SpanExportResult .SUCCESS )
352360
353- # Verify info log for no identity
354- mock_logger .info .assert_called_with (
355- "No spans with tenant/agent identity found; nothing exported."
356- )
361+ # Verify info log for no eligible spans
362+ mock_logger .info .assert_called_with ("No eligible genAI spans to export; nothing exported." )
357363
358364 def test_exporter_is_internal (self ):
359365 """Test that _Agent365Exporter is marked as internal/private.
@@ -657,6 +663,97 @@ def test_export_no_fallback_when_default_succeeds(self):
657663 self .assertEqual (result , SpanExportResult .SUCCESS )
658664 mock_post .assert_called_once ()
659665
666+ def test_export_filters_out_non_genai_spans (self ):
667+ """Spans without a known gen_ai.operation.name are filtered out."""
668+ # Arrange: one genAI span and two non-genAI spans (no/unknown operation name)
669+ genai_span = self ._create_mock_span ("genai_span" , trace_id = 1 , span_id = 2 )
670+ no_op_span = self ._create_mock_span ("http_span" , trace_id = 3 , span_id = 4 , operation_name = None )
671+ unknown_op_span = self ._create_mock_span (
672+ "db_span" , trace_id = 5 , span_id = 6 , operation_name = "some_random_op"
673+ )
674+
675+ with patch .object (self .exporter , "_post_with_retries" , return_value = True ) as mock_post :
676+ # Act
677+ result = self .exporter .export ([genai_span , no_op_span , unknown_op_span ])
678+
679+ # Assert: only the genAI span is exported
680+ self .assertEqual (result , SpanExportResult .SUCCESS )
681+ mock_post .assert_called_once ()
682+ _ , body , _ = mock_post .call_args [0 ]
683+ request_data = json .loads (body )
684+ spans_out = request_data ["resourceSpans" ][0 ]["scopeSpans" ][0 ]["spans" ]
685+ self .assertEqual (len (spans_out ), 1 )
686+ self .assertEqual (spans_out [0 ]["name" ], "genai_span" )
687+
688+ def test_export_filters_out_only_non_genai_spans_returns_success (self ):
689+ """When all spans are filtered out, export returns SUCCESS without HTTP call."""
690+ # Arrange
691+ spans = [
692+ self ._create_mock_span ("http_span" , operation_name = None ),
693+ self ._create_mock_span ("db_span" , operation_name = "other" ),
694+ ]
695+
696+ with patch .object (self .exporter , "_post_with_retries" , return_value = True ) as mock_post :
697+ # Act
698+ result = self .exporter .export (spans )
699+
700+ # Assert
701+ self .assertEqual (result , SpanExportResult .SUCCESS )
702+ mock_post .assert_not_called ()
703+
704+ def test_export_includes_inference_operation_type_chat_spans (self ):
705+ """Spans with InferenceOperationType.CHAT value ('Chat') are kept without normalization."""
706+ # Arrange — server accepts 'Chat' via case-insensitive matching
707+ chat_span = self ._create_mock_span (
708+ "chat_span" , trace_id = 1 , span_id = 2 , operation_name = "Chat"
709+ )
710+
711+ with patch .object (self .exporter , "_post_with_retries" , return_value = True ) as mock_post :
712+ result = self .exporter .export ([chat_span ])
713+
714+ self .assertEqual (result , SpanExportResult .SUCCESS )
715+ mock_post .assert_called_once ()
716+ _ , body , _ = mock_post .call_args [0 ]
717+ request_data = json .loads (body )
718+ spans_out = request_data ["resourceSpans" ][0 ]["scopeSpans" ][0 ]["spans" ]
719+ self .assertEqual (len (spans_out ), 1 )
720+ # Value is preserved as-is; no normalization
721+ self .assertEqual (spans_out [0 ]["attributes" ][GEN_AI_OPERATION_NAME_KEY ], "Chat" )
722+
723+ def test_export_filters_out_unsupported_inference_operation_types (self ):
724+ """Spans with TextCompletion / GenerateContent are filtered out."""
725+ text_completion_span = self ._create_mock_span (
726+ "text_completion_span" , trace_id = 3 , span_id = 4 , operation_name = "TextCompletion"
727+ )
728+ generate_content_span = self ._create_mock_span (
729+ "generate_content_span" , trace_id = 5 , span_id = 6 , operation_name = "GenerateContent"
730+ )
731+
732+ with patch .object (self .exporter , "_post_with_retries" , return_value = True ) as mock_post :
733+ result = self .exporter .export ([text_completion_span , generate_content_span ])
734+
735+ # Both are filtered out — nothing to export
736+ self .assertEqual (result , SpanExportResult .SUCCESS )
737+ mock_post .assert_not_called ()
738+
739+ def test_export_does_not_normalize_canonical_operation_names (self ):
740+ """invoke_agent / execute_tool / output_messages / chat are not rewritten."""
741+ cases = ["invoke_agent" , "execute_tool" , "output_messages" , "chat" ]
742+ for op in cases :
743+ with self .subTest (operation_name = op ):
744+ span = self ._create_mock_span (
745+ f"{ op } _span" , trace_id = 1 , span_id = 2 , operation_name = op
746+ )
747+ with patch .object (
748+ self .exporter , "_post_with_retries" , return_value = True
749+ ) as mock_post :
750+ result = self .exporter .export ([span ])
751+ self .assertEqual (result , SpanExportResult .SUCCESS )
752+ _ , body , _ = mock_post .call_args [0 ]
753+ request_data = json .loads (body )
754+ span_out = request_data ["resourceSpans" ][0 ]["scopeSpans" ][0 ]["spans" ][0 ]
755+ self .assertEqual (span_out ["attributes" ][GEN_AI_OPERATION_NAME_KEY ], op )
756+
660757
661758if __name__ == "__main__" :
662759 unittest .main ()
0 commit comments