|
6 | 6 | import unittest |
7 | 7 | from unittest.mock import Mock, patch |
8 | 8 |
|
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 | +) |
10 | 15 | from microsoft_agents_a365.observability.core.exporters.agent365_exporter import ( |
11 | 16 | DEFAULT_ENDPOINT_URL, |
12 | 17 | _Agent365Exporter, |
@@ -54,6 +59,7 @@ def _create_mock_span( |
54 | 59 | scope_version: str = "1.0.0", |
55 | 60 | tenant_id: str = "test-tenant-123", |
56 | 61 | agent_id: str = "test-agent-456", |
| 62 | + operation_name: str | None = INVOKE_AGENT_OPERATION_NAME, |
57 | 63 | ) -> ReadableSpan: |
58 | 64 | """Create a mock ReadableSpan for testing.""" |
59 | 65 | mock_span = Mock(spec=ReadableSpan) |
@@ -85,6 +91,8 @@ def _create_mock_span( |
85 | 91 | TENANT_ID_KEY: tenant_id, |
86 | 92 | GEN_AI_AGENT_ID_KEY: agent_id, |
87 | 93 | }) |
| 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 |
88 | 96 |
|
89 | 97 | mock_span.attributes = span_attributes |
90 | 98 | mock_span.events = [] |
@@ -657,6 +665,89 @@ def test_export_no_fallback_when_default_succeeds(self): |
657 | 665 | self.assertEqual(result, SpanExportResult.SUCCESS) |
658 | 666 | mock_post.assert_called_once() |
659 | 667 |
|
| 668 | + def test_export_filters_out_non_genai_spans(self): |
| 669 | + """Spans without a known gen_ai.operation.name are filtered out.""" |
| 670 | + # Arrange: one genAI span and two non-genAI spans (no/unknown operation name) |
| 671 | + genai_span = self._create_mock_span("genai_span", trace_id=1, span_id=2) |
| 672 | + no_op_span = self._create_mock_span("http_span", trace_id=3, span_id=4, operation_name=None) |
| 673 | + unknown_op_span = self._create_mock_span( |
| 674 | + "db_span", trace_id=5, span_id=6, operation_name="some_random_op" |
| 675 | + ) |
| 676 | + |
| 677 | + with patch.object(self.exporter, "_post_with_retries", return_value=True) as mock_post: |
| 678 | + # Act |
| 679 | + result = self.exporter.export([genai_span, no_op_span, unknown_op_span]) |
| 680 | + |
| 681 | + # Assert: only the genAI span is exported |
| 682 | + self.assertEqual(result, SpanExportResult.SUCCESS) |
| 683 | + mock_post.assert_called_once() |
| 684 | + _, body, _ = mock_post.call_args[0] |
| 685 | + request_data = json.loads(body) |
| 686 | + spans_out = request_data["resourceSpans"][0]["scopeSpans"][0]["spans"] |
| 687 | + self.assertEqual(len(spans_out), 1) |
| 688 | + self.assertEqual(spans_out[0]["name"], "genai_span") |
| 689 | + |
| 690 | + def test_export_filters_out_only_non_genai_spans_returns_success(self): |
| 691 | + """When all spans are filtered out, export returns SUCCESS without HTTP call.""" |
| 692 | + # Arrange |
| 693 | + spans = [ |
| 694 | + self._create_mock_span("http_span", operation_name=None), |
| 695 | + self._create_mock_span("db_span", operation_name="other"), |
| 696 | + ] |
| 697 | + |
| 698 | + with patch.object(self.exporter, "_post_with_retries", return_value=True) as mock_post: |
| 699 | + # Act |
| 700 | + result = self.exporter.export(spans) |
| 701 | + |
| 702 | + # Assert |
| 703 | + self.assertEqual(result, SpanExportResult.SUCCESS) |
| 704 | + mock_post.assert_not_called() |
| 705 | + |
| 706 | + def test_export_includes_inference_operation_type_spans(self): |
| 707 | + """Spans with InferenceOperationType enum values are kept and normalized.""" |
| 708 | + # Arrange |
| 709 | + chat_span = self._create_mock_span( |
| 710 | + "chat_span", trace_id=1, span_id=2, operation_name="Chat" |
| 711 | + ) |
| 712 | + text_completion_span = self._create_mock_span( |
| 713 | + "text_completion_span", trace_id=3, span_id=4, operation_name="TextCompletion" |
| 714 | + ) |
| 715 | + generate_content_span = self._create_mock_span( |
| 716 | + "generate_content_span", trace_id=5, span_id=6, operation_name="GenerateContent" |
| 717 | + ) |
| 718 | + |
| 719 | + with patch.object(self.exporter, "_post_with_retries", return_value=True) as mock_post: |
| 720 | + # Act |
| 721 | + result = self.exporter.export([chat_span, text_completion_span, generate_content_span]) |
| 722 | + |
| 723 | + # Assert: all three are exported and normalized to "chat" |
| 724 | + self.assertEqual(result, SpanExportResult.SUCCESS) |
| 725 | + mock_post.assert_called_once() |
| 726 | + _, body, _ = mock_post.call_args[0] |
| 727 | + request_data = json.loads(body) |
| 728 | + spans_out = request_data["resourceSpans"][0]["scopeSpans"][0]["spans"] |
| 729 | + self.assertEqual(len(spans_out), 3) |
| 730 | + for span in spans_out: |
| 731 | + self.assertEqual(span["attributes"]["gen_ai.operation.name"], "chat") |
| 732 | + |
| 733 | + def test_export_does_not_normalize_canonical_operation_names(self): |
| 734 | + """invoke_agent / execute_tool / output_messages / chat are not rewritten.""" |
| 735 | + cases = ["invoke_agent", "execute_tool", "output_messages", "chat"] |
| 736 | + for op in cases: |
| 737 | + with self.subTest(operation_name=op): |
| 738 | + span = self._create_mock_span( |
| 739 | + f"{op}_span", trace_id=1, span_id=2, operation_name=op |
| 740 | + ) |
| 741 | + with patch.object( |
| 742 | + self.exporter, "_post_with_retries", return_value=True |
| 743 | + ) as mock_post: |
| 744 | + result = self.exporter.export([span]) |
| 745 | + self.assertEqual(result, SpanExportResult.SUCCESS) |
| 746 | + _, body, _ = mock_post.call_args[0] |
| 747 | + request_data = json.loads(body) |
| 748 | + span_out = request_data["resourceSpans"][0]["scopeSpans"][0]["spans"][0] |
| 749 | + self.assertEqual(span_out["attributes"]["gen_ai.operation.name"], op) |
| 750 | + |
660 | 751 |
|
661 | 752 | if __name__ == "__main__": |
662 | 753 | unittest.main() |
0 commit comments