From 50ff85c74d11f6b7a9f4d7c92e218bbe123a03be Mon Sep 17 00:00:00 2001 From: Jianbiao Lu Date: Mon, 27 Apr 2026 14:39:46 -0700 Subject: [PATCH 1/7] Filter A365 exports to only include genAI spans --- .../core/exporters/agent365_exporter.py | 10 ++ .../observability/core/exporters/utils.py | 58 +++++++++++- .../core/test_agent365_exporter.py | 93 ++++++++++++++++++- 3 files changed, 158 insertions(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py index e3c6e73b..27a08f1d 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py @@ -19,6 +19,7 @@ from .utils import ( DEFAULT_MAX_PAYLOAD_BYTES, + INFERENCE_OPERATION_TYPE_NAMES, build_export_url, chunk_by_size, estimate_span_bytes, @@ -31,6 +32,7 @@ status_name, truncate_span, ) +from ..constants import CHAT_OPERATION_NAME, GEN_AI_OPERATION_NAME_KEY # ---- Exporter --------------------------------------------------------------- @@ -339,6 +341,14 @@ def _map_span(self, sp: ReadableSpan) -> dict[str, Any]: # attributes attrs = dict(sp.attributes or {}) + # Normalize gen_ai.operation.name from any InferenceOperationType enum + # value (Chat, TextCompletion, GenerateContent) to the canonical "chat" + # value the ingest service accepts. This is applied only on the export + # payload; the underlying span attribute is left untouched. + op_name = attrs.get(GEN_AI_OPERATION_NAME_KEY) + if isinstance(op_name, str) and op_name in INFERENCE_OPERATION_TYPE_NAMES: + attrs[GEN_AI_OPERATION_NAME_KEY] = CHAT_OPERATION_NAME + # events events = [] for ev in sp.events: diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py index 36906f0d..5fd47f9e 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py @@ -14,16 +14,47 @@ from opentelemetry.trace import SpanKind, StatusCode from ..constants import ( + CHAT_OPERATION_NAME, ENABLE_A365_OBSERVABILITY_EXPORTER, + EXECUTE_TOOL_OPERATION_NAME, GEN_AI_AGENT_ID_KEY, + GEN_AI_OPERATION_NAME_KEY, + INVOKE_AGENT_OPERATION_NAME, + OUTPUT_MESSAGES_OPERATION_NAME, TENANT_ID_KEY, ) +from ..inference_operation_type import InferenceOperationType logger = logging.getLogger(__name__) # Maximum allowed span size in bytes (250KB) MAX_SPAN_SIZE_BYTES = 250 * 1024 +# Operation names that identify a span as a genAI span eligible for export to +# the Agent 365 observability ingest service. Spans without a known +# gen_ai.operation.name are filtered out of the export batch. +GEN_AI_OPERATION_NAMES: frozenset[str] = frozenset( + { + INVOKE_AGENT_OPERATION_NAME, + EXECUTE_TOOL_OPERATION_NAME, + OUTPUT_MESSAGES_OPERATION_NAME, + CHAT_OPERATION_NAME, + InferenceOperationType.CHAT.value, + InferenceOperationType.TEXT_COMPLETION.value, + InferenceOperationType.GENERATE_CONTENT.value, + } +) + +# Inference operation type values that the ingest service expects to be +# normalized to the canonical "chat" gen_ai.operation.name. +INFERENCE_OPERATION_TYPE_NAMES: frozenset[str] = frozenset( + { + InferenceOperationType.CHAT.value, + InferenceOperationType.TEXT_COMPLETION.value, + InferenceOperationType.GENERATE_CONTENT.value, + } +) + def hex_trace_id(value: int) -> str: # 128-bit -> 32 hex chars @@ -131,18 +162,41 @@ def partition_by_identity( spans: Sequence[ReadableSpan], ) -> dict[tuple[str, str], list[ReadableSpan]]: """ - Extract (tenantId, agentId). Prefer attributes; if you also stamp baggage - into attributes via a processor, they'll be here already. + Partition spans by (tenantId, agentId). + + Only genAI spans (those with a known ``gen_ai.operation.name``) are + included; non-genAI spans (e.g. HTTP, DB) are filtered out. Spans + without both tenant and agent identity are also skipped. """ groups: dict[tuple[str, str], list[ReadableSpan]] = {} + non_gen_ai_count = 0 + missing_identity_count = 0 for sp in spans: attrs = sp.attributes or {} + operation_name = as_str(attrs.get(GEN_AI_OPERATION_NAME_KEY)) + if not operation_name or operation_name not in GEN_AI_OPERATION_NAMES: + non_gen_ai_count += 1 + continue tenant = as_str(attrs.get(TENANT_ID_KEY)) agent = as_str(attrs.get(GEN_AI_AGENT_ID_KEY)) if not tenant or not agent: + missing_identity_count += 1 continue key = (tenant, agent) groups.setdefault(key, []).append(sp) + + if non_gen_ai_count > 0: + logger.info(f"[Agent365Exporter] {non_gen_ai_count} non-genAI spans filtered out") + if missing_identity_count > 0: + logger.warning( + f"[Agent365Exporter] {missing_identity_count} spans skipped due to " + "missing tenant or agent ID" + ) + skipped = non_gen_ai_count + missing_identity_count + logger.info( + f"[Agent365Exporter] Partitioned into {len(groups)} identity groups " + f"({skipped} spans skipped)" + ) return groups diff --git a/tests/observability/core/test_agent365_exporter.py b/tests/observability/core/test_agent365_exporter.py index 0220b52c..5871c4b2 100644 --- a/tests/observability/core/test_agent365_exporter.py +++ b/tests/observability/core/test_agent365_exporter.py @@ -6,7 +6,12 @@ import unittest from unittest.mock import Mock, patch -from microsoft_agents_a365.observability.core.constants import GEN_AI_AGENT_ID_KEY, TENANT_ID_KEY +from microsoft_agents_a365.observability.core.constants import ( + GEN_AI_AGENT_ID_KEY, + GEN_AI_OPERATION_NAME_KEY, + INVOKE_AGENT_OPERATION_NAME, + TENANT_ID_KEY, +) from microsoft_agents_a365.observability.core.exporters.agent365_exporter import ( DEFAULT_ENDPOINT_URL, _Agent365Exporter, @@ -54,6 +59,7 @@ def _create_mock_span( scope_version: str = "1.0.0", tenant_id: str = "test-tenant-123", agent_id: str = "test-agent-456", + operation_name: str | None = INVOKE_AGENT_OPERATION_NAME, ) -> ReadableSpan: """Create a mock ReadableSpan for testing.""" mock_span = Mock(spec=ReadableSpan) @@ -85,6 +91,8 @@ def _create_mock_span( TENANT_ID_KEY: tenant_id, GEN_AI_AGENT_ID_KEY: agent_id, }) + if operation_name is not None and GEN_AI_OPERATION_NAME_KEY not in span_attributes: + span_attributes[GEN_AI_OPERATION_NAME_KEY] = operation_name mock_span.attributes = span_attributes mock_span.events = [] @@ -657,6 +665,89 @@ def test_export_no_fallback_when_default_succeeds(self): self.assertEqual(result, SpanExportResult.SUCCESS) mock_post.assert_called_once() + def test_export_filters_out_non_genai_spans(self): + """Spans without a known gen_ai.operation.name are filtered out.""" + # Arrange: one genAI span and two non-genAI spans (no/unknown operation name) + genai_span = self._create_mock_span("genai_span", trace_id=1, span_id=2) + no_op_span = self._create_mock_span("http_span", trace_id=3, span_id=4, operation_name=None) + unknown_op_span = self._create_mock_span( + "db_span", trace_id=5, span_id=6, operation_name="some_random_op" + ) + + with patch.object(self.exporter, "_post_with_retries", return_value=True) as mock_post: + # Act + result = self.exporter.export([genai_span, no_op_span, unknown_op_span]) + + # Assert: only the genAI span is exported + self.assertEqual(result, SpanExportResult.SUCCESS) + mock_post.assert_called_once() + _, body, _ = mock_post.call_args[0] + request_data = json.loads(body) + spans_out = request_data["resourceSpans"][0]["scopeSpans"][0]["spans"] + self.assertEqual(len(spans_out), 1) + self.assertEqual(spans_out[0]["name"], "genai_span") + + def test_export_filters_out_only_non_genai_spans_returns_success(self): + """When all spans are filtered out, export returns SUCCESS without HTTP call.""" + # Arrange + spans = [ + self._create_mock_span("http_span", operation_name=None), + self._create_mock_span("db_span", operation_name="other"), + ] + + with patch.object(self.exporter, "_post_with_retries", return_value=True) as mock_post: + # Act + result = self.exporter.export(spans) + + # Assert + self.assertEqual(result, SpanExportResult.SUCCESS) + mock_post.assert_not_called() + + def test_export_includes_inference_operation_type_spans(self): + """Spans with InferenceOperationType enum values are kept and normalized.""" + # Arrange + chat_span = self._create_mock_span( + "chat_span", trace_id=1, span_id=2, operation_name="Chat" + ) + text_completion_span = self._create_mock_span( + "text_completion_span", trace_id=3, span_id=4, operation_name="TextCompletion" + ) + generate_content_span = self._create_mock_span( + "generate_content_span", trace_id=5, span_id=6, operation_name="GenerateContent" + ) + + with patch.object(self.exporter, "_post_with_retries", return_value=True) as mock_post: + # Act + result = self.exporter.export([chat_span, text_completion_span, generate_content_span]) + + # Assert: all three are exported and normalized to "chat" + self.assertEqual(result, SpanExportResult.SUCCESS) + mock_post.assert_called_once() + _, body, _ = mock_post.call_args[0] + request_data = json.loads(body) + spans_out = request_data["resourceSpans"][0]["scopeSpans"][0]["spans"] + self.assertEqual(len(spans_out), 3) + for span in spans_out: + self.assertEqual(span["attributes"]["gen_ai.operation.name"], "chat") + + def test_export_does_not_normalize_canonical_operation_names(self): + """invoke_agent / execute_tool / output_messages / chat are not rewritten.""" + cases = ["invoke_agent", "execute_tool", "output_messages", "chat"] + for op in cases: + with self.subTest(operation_name=op): + span = self._create_mock_span( + f"{op}_span", trace_id=1, span_id=2, operation_name=op + ) + with patch.object( + self.exporter, "_post_with_retries", return_value=True + ) as mock_post: + result = self.exporter.export([span]) + self.assertEqual(result, SpanExportResult.SUCCESS) + _, body, _ = mock_post.call_args[0] + request_data = json.loads(body) + span_out = request_data["resourceSpans"][0]["scopeSpans"][0]["spans"][0] + self.assertEqual(span_out["attributes"]["gen_ai.operation.name"], op) + if __name__ == "__main__": unittest.main() From e968703bd074bdf5e0a380be3788c8bbf3bd9b16 Mon Sep 17 00:00:00 2001 From: Jianbiao Lu Date: Tue, 28 Apr 2026 14:04:38 -0700 Subject: [PATCH 2/7] Remove InferenceOperationType.TEXT_COMPLETION, InferenceOperationType.GENERATE_CONTENT from the list --- .../core/exporters/agent365_exporter.py | 9 ----- .../observability/core/exporters/utils.py | 12 ------- .../core/test_agent365_exporter.py | 34 ++++++++++++------- 3 files changed, 21 insertions(+), 34 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py index 27a08f1d..19b0a2bc 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py @@ -32,7 +32,6 @@ status_name, truncate_span, ) -from ..constants import CHAT_OPERATION_NAME, GEN_AI_OPERATION_NAME_KEY # ---- Exporter --------------------------------------------------------------- @@ -341,14 +340,6 @@ def _map_span(self, sp: ReadableSpan) -> dict[str, Any]: # attributes attrs = dict(sp.attributes or {}) - # Normalize gen_ai.operation.name from any InferenceOperationType enum - # value (Chat, TextCompletion, GenerateContent) to the canonical "chat" - # value the ingest service accepts. This is applied only on the export - # payload; the underlying span attribute is left untouched. - op_name = attrs.get(GEN_AI_OPERATION_NAME_KEY) - if isinstance(op_name, str) and op_name in INFERENCE_OPERATION_TYPE_NAMES: - attrs[GEN_AI_OPERATION_NAME_KEY] = CHAT_OPERATION_NAME - # events events = [] for ev in sp.events: diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py index 5fd47f9e..a71600e2 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py @@ -40,18 +40,6 @@ OUTPUT_MESSAGES_OPERATION_NAME, CHAT_OPERATION_NAME, InferenceOperationType.CHAT.value, - InferenceOperationType.TEXT_COMPLETION.value, - InferenceOperationType.GENERATE_CONTENT.value, - } -) - -# Inference operation type values that the ingest service expects to be -# normalized to the canonical "chat" gen_ai.operation.name. -INFERENCE_OPERATION_TYPE_NAMES: frozenset[str] = frozenset( - { - InferenceOperationType.CHAT.value, - InferenceOperationType.TEXT_COMPLETION.value, - InferenceOperationType.GENERATE_CONTENT.value, } ) diff --git a/tests/observability/core/test_agent365_exporter.py b/tests/observability/core/test_agent365_exporter.py index 5871c4b2..df7eb3b1 100644 --- a/tests/observability/core/test_agent365_exporter.py +++ b/tests/observability/core/test_agent365_exporter.py @@ -703,12 +703,27 @@ def test_export_filters_out_only_non_genai_spans_returns_success(self): self.assertEqual(result, SpanExportResult.SUCCESS) mock_post.assert_not_called() - def test_export_includes_inference_operation_type_spans(self): - """Spans with InferenceOperationType enum values are kept and normalized.""" - # Arrange + def test_export_includes_inference_operation_type_chat_spans(self): + """Spans with InferenceOperationType.CHAT value ('Chat') are kept without normalization.""" + # Arrange — server accepts 'Chat' via case-insensitive matching chat_span = self._create_mock_span( "chat_span", trace_id=1, span_id=2, operation_name="Chat" ) + + with patch.object(self.exporter, "_post_with_retries", return_value=True) as mock_post: + result = self.exporter.export([chat_span]) + + self.assertEqual(result, SpanExportResult.SUCCESS) + mock_post.assert_called_once() + _, body, _ = mock_post.call_args[0] + request_data = json.loads(body) + spans_out = request_data["resourceSpans"][0]["scopeSpans"][0]["spans"] + self.assertEqual(len(spans_out), 1) + # Value is preserved as-is; no normalization + self.assertEqual(spans_out[0]["attributes"]["gen_ai.operation.name"], "Chat") + + def test_export_filters_out_unsupported_inference_operation_types(self): + """Spans with TextCompletion / GenerateContent are filtered out.""" text_completion_span = self._create_mock_span( "text_completion_span", trace_id=3, span_id=4, operation_name="TextCompletion" ) @@ -717,18 +732,11 @@ def test_export_includes_inference_operation_type_spans(self): ) with patch.object(self.exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = self.exporter.export([chat_span, text_completion_span, generate_content_span]) + result = self.exporter.export([text_completion_span, generate_content_span]) - # Assert: all three are exported and normalized to "chat" + # Both are filtered out — nothing to export self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - _, body, _ = mock_post.call_args[0] - request_data = json.loads(body) - spans_out = request_data["resourceSpans"][0]["scopeSpans"][0]["spans"] - self.assertEqual(len(spans_out), 3) - for span in spans_out: - self.assertEqual(span["attributes"]["gen_ai.operation.name"], "chat") + mock_post.assert_not_called() def test_export_does_not_normalize_canonical_operation_names(self): """invoke_agent / execute_tool / output_messages / chat are not rewritten.""" From e7b834d2e4fc64e4e5a4032c1fda65d91bc2a18c Mon Sep 17 00:00:00 2001 From: Jianbiao Lu Date: Tue, 28 Apr 2026 14:33:02 -0700 Subject: [PATCH 3/7] Addressing comments --- .../core/exporters/agent365_exporter.py | 4 ++-- .../observability/core/exporters/utils.py | 16 +++++++--------- .../observability/core/test_agent365_exporter.py | 2 +- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py index 19b0a2bc..cd1df27a 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py @@ -28,7 +28,7 @@ hex_trace_id, kind_name, parse_retry_after, - partition_by_identity, + filter_and_partition_by_identity, status_name, truncate_span, ) @@ -81,7 +81,7 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: return SpanExportResult.FAILURE try: - groups = partition_by_identity(spans) + groups = filter_and_partition_by_identity(spans) if not groups: # No spans with identity; treat as success logger.info("No spans with tenant/agent identity found; nothing exported.") diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py index a71600e2..f963a8fd 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py @@ -146,11 +146,11 @@ def truncate_span(span_dict: dict[str, Any]) -> dict[str, Any]: return span_dict -def partition_by_identity( +def filter_and_partition_by_identity( spans: Sequence[ReadableSpan], ) -> dict[tuple[str, str], list[ReadableSpan]]: """ - Partition spans by (tenantId, agentId). + Filter export-eligible spans and partition them by (tenantId, agentId). Only genAI spans (those with a known ``gen_ai.operation.name``) are included; non-genAI spans (e.g. HTTP, DB) are filtered out. Spans @@ -174,17 +174,15 @@ def partition_by_identity( groups.setdefault(key, []).append(sp) if non_gen_ai_count > 0: - logger.info(f"[Agent365Exporter] {non_gen_ai_count} non-genAI spans filtered out") + logger.debug( + f"[Agent365Exporter] {non_gen_ai_count} spans without an eligible " + "gen_ai.operation.name filtered out" + ) if missing_identity_count > 0: - logger.warning( + logger.debug( f"[Agent365Exporter] {missing_identity_count} spans skipped due to " "missing tenant or agent ID" ) - skipped = non_gen_ai_count + missing_identity_count - logger.info( - f"[Agent365Exporter] Partitioned into {len(groups)} identity groups " - f"({skipped} spans skipped)" - ) return groups diff --git a/tests/observability/core/test_agent365_exporter.py b/tests/observability/core/test_agent365_exporter.py index df7eb3b1..cb6be9b7 100644 --- a/tests/observability/core/test_agent365_exporter.py +++ b/tests/observability/core/test_agent365_exporter.py @@ -84,7 +84,7 @@ def _create_mock_span( mock_span.kind = Mock() mock_span.kind.name = "INTERNAL" - # Add identity attributes for partition_by_identity to work + # Add identity attributes for filter_and_partition_by_identity to work span_attributes = attributes or {} if tenant_id and agent_id: span_attributes.update({ From 3d9f8eebee4abd4f614ea1f3af428d0ec6023f43 Mon Sep 17 00:00:00 2001 From: Jianbiao Lu Date: Wed, 29 Apr 2026 11:10:31 -0700 Subject: [PATCH 4/7] minor change --- .../observability/core/exporters/agent365_exporter.py | 5 ++--- .../observability/core/exporters/utils.py | 10 ++++++---- .../core/exporters/test_payload_chunking.py | 3 +++ tests/observability/core/test_agent365_exporter.py | 4 ++-- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py index cd1df27a..698c6c9f 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py @@ -19,7 +19,6 @@ from .utils import ( DEFAULT_MAX_PAYLOAD_BYTES, - INFERENCE_OPERATION_TYPE_NAMES, build_export_url, chunk_by_size, estimate_span_bytes, @@ -83,8 +82,8 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: try: groups = filter_and_partition_by_identity(spans) if not groups: - # No spans with identity; treat as success - logger.info("No spans with tenant/agent identity found; nothing exported.") + # No eligible genAI spans to export after filtering/partitioning; treat as success + logger.info("No eligible genAI spans to export; nothing exported.") return SpanExportResult.SUCCESS # Log number of groups and total span count diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py index f963a8fd..afd3d116 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py @@ -175,13 +175,15 @@ def filter_and_partition_by_identity( if non_gen_ai_count > 0: logger.debug( - f"[Agent365Exporter] {non_gen_ai_count} spans without an eligible " - "gen_ai.operation.name filtered out" + "[Agent365Exporter] %d spans without an eligible " + "gen_ai.operation.name filtered out", + non_gen_ai_count, ) if missing_identity_count > 0: logger.debug( - f"[Agent365Exporter] {missing_identity_count} spans skipped due to " - "missing tenant or agent ID" + "[Agent365Exporter] %d spans skipped due to " + "missing tenant or agent ID", + missing_identity_count, ) return groups diff --git a/tests/observability/core/exporters/test_payload_chunking.py b/tests/observability/core/exporters/test_payload_chunking.py index 4ffe7702..6ed9ba9f 100644 --- a/tests/observability/core/exporters/test_payload_chunking.py +++ b/tests/observability/core/exporters/test_payload_chunking.py @@ -10,7 +10,9 @@ from unittest.mock import Mock, patch from microsoft_agents_a365.observability.core.constants import ( + CHAT_OPERATION_NAME, GEN_AI_AGENT_ID_KEY, + GEN_AI_OPERATION_NAME_KEY, TENANT_ID_KEY, ) from microsoft_agents_a365.observability.core.exporters.agent365_exporter import ( @@ -165,6 +167,7 @@ def _make_span(self, span_id: int, attribute_size: int) -> ReadableSpan: mock_span.attributes = { TENANT_ID_KEY: "tenant-1", GEN_AI_AGENT_ID_KEY: "agent-1", + GEN_AI_OPERATION_NAME_KEY: CHAT_OPERATION_NAME, "payload": "x" * attribute_size, } mock_span.events = [] diff --git a/tests/observability/core/test_agent365_exporter.py b/tests/observability/core/test_agent365_exporter.py index cb6be9b7..dab02ac8 100644 --- a/tests/observability/core/test_agent365_exporter.py +++ b/tests/observability/core/test_agent365_exporter.py @@ -720,7 +720,7 @@ def test_export_includes_inference_operation_type_chat_spans(self): spans_out = request_data["resourceSpans"][0]["scopeSpans"][0]["spans"] self.assertEqual(len(spans_out), 1) # Value is preserved as-is; no normalization - self.assertEqual(spans_out[0]["attributes"]["gen_ai.operation.name"], "Chat") + self.assertEqual(spans_out[0]["attributes"][GEN_AI_OPERATION_NAME_KEY], "Chat") def test_export_filters_out_unsupported_inference_operation_types(self): """Spans with TextCompletion / GenerateContent are filtered out.""" @@ -754,7 +754,7 @@ def test_export_does_not_normalize_canonical_operation_names(self): _, body, _ = mock_post.call_args[0] request_data = json.loads(body) span_out = request_data["resourceSpans"][0]["scopeSpans"][0]["spans"][0] - self.assertEqual(span_out["attributes"]["gen_ai.operation.name"], op) + self.assertEqual(span_out["attributes"][GEN_AI_OPERATION_NAME_KEY], op) if __name__ == "__main__": From 8012bc1a691b3afebfbb8b49f477db84d72a44cb Mon Sep 17 00:00:00 2001 From: Jianbiao Lu Date: Wed, 29 Apr 2026 11:55:13 -0700 Subject: [PATCH 5/7] Fix test failure with format --- .../observability/core/exporters/utils.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py index afd3d116..8c62644e 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py @@ -175,14 +175,12 @@ def filter_and_partition_by_identity( if non_gen_ai_count > 0: logger.debug( - "[Agent365Exporter] %d spans without an eligible " - "gen_ai.operation.name filtered out", + "[Agent365Exporter] %d spans without an eligible gen_ai.operation.name filtered out", non_gen_ai_count, ) if missing_identity_count > 0: logger.debug( - "[Agent365Exporter] %d spans skipped due to " - "missing tenant or agent ID", + "[Agent365Exporter] %d spans skipped due to missing tenant or agent ID", missing_identity_count, ) return groups From 5d6610e721a1e4e6b51855c4ab102196dbddbe71 Mon Sep 17 00:00:00 2001 From: Jianbiao Lu Date: Wed, 29 Apr 2026 12:39:30 -0700 Subject: [PATCH 6/7] Fix test failure --- .../observability/core/exporters/utils.py | 13 +++++++------ tests/observability/core/test_agent365_exporter.py | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py index 8c62644e..2d84b074 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py @@ -30,9 +30,9 @@ # Maximum allowed span size in bytes (250KB) MAX_SPAN_SIZE_BYTES = 250 * 1024 -# Operation names that identify a span as a genAI span eligible for export to -# the Agent 365 observability ingest service. Spans without a known -# gen_ai.operation.name are filtered out of the export batch. +# Operation names that identify a span as eligible for export to the Agent 365 +# observability ingest service. Only spans whose gen_ai.operation.name matches +# one of these values are included; all other spans are filtered out. GEN_AI_OPERATION_NAMES: frozenset[str] = frozenset( { INVOKE_AGENT_OPERATION_NAME, @@ -152,9 +152,10 @@ def filter_and_partition_by_identity( """ Filter export-eligible spans and partition them by (tenantId, agentId). - Only genAI spans (those with a known ``gen_ai.operation.name``) are - included; non-genAI spans (e.g. HTTP, DB) are filtered out. Spans - without both tenant and agent identity are also skipped. + Only spans whose ``gen_ai.operation.name`` is in + ``GEN_AI_OPERATION_NAMES`` are included; non-genAI spans (e.g. HTTP, DB) + and spans with other operation names are filtered out. Spans without + both tenant and agent identity are also skipped. """ groups: dict[tuple[str, str], list[ReadableSpan]] = {} non_gen_ai_count = 0 diff --git a/tests/observability/core/test_agent365_exporter.py b/tests/observability/core/test_agent365_exporter.py index dab02ac8..2b92f076 100644 --- a/tests/observability/core/test_agent365_exporter.py +++ b/tests/observability/core/test_agent365_exporter.py @@ -358,9 +358,9 @@ def test_export_error_logging(self, mock_logger): # Verify export succeeded (no identity spans are treated as success) self.assertEqual(result, SpanExportResult.SUCCESS) - # Verify info log for no identity + # Verify info log for no eligible spans mock_logger.info.assert_called_with( - "No spans with tenant/agent identity found; nothing exported." + "No eligible genAI spans to export; nothing exported." ) def test_exporter_is_internal(self): From 297e54a10d291298a6c23baada600f05450b7dfb Mon Sep 17 00:00:00 2001 From: Jianbiao Lu Date: Wed, 29 Apr 2026 13:04:21 -0700 Subject: [PATCH 7/7] Formatting --- tests/observability/core/test_agent365_exporter.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/observability/core/test_agent365_exporter.py b/tests/observability/core/test_agent365_exporter.py index 2b92f076..32ef426f 100644 --- a/tests/observability/core/test_agent365_exporter.py +++ b/tests/observability/core/test_agent365_exporter.py @@ -359,9 +359,7 @@ def test_export_error_logging(self, mock_logger): self.assertEqual(result, SpanExportResult.SUCCESS) # Verify info log for no eligible spans - mock_logger.info.assert_called_with( - "No eligible genAI spans to export; nothing exported." - ) + mock_logger.info.assert_called_with("No eligible genAI spans to export; nothing exported.") def test_exporter_is_internal(self): """Test that _Agent365Exporter is marked as internal/private.