Skip to content

Commit 94bb019

Browse files
committed
Filter A365 exports to only include genAI spans
1 parent f3d1d5f commit 94bb019

3 files changed

Lines changed: 158 additions & 3 deletions

File tree

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from opentelemetry.trace import StatusCode
1919

2020
from .utils import (
21+
INFERENCE_OPERATION_TYPE_NAMES,
2122
build_export_url,
2223
get_validated_domain_override,
2324
hex_span_id,
@@ -28,6 +29,7 @@
2829
status_name,
2930
truncate_span,
3031
)
32+
from ..constants import CHAT_OPERATION_NAME, GEN_AI_OPERATION_NAME_KEY
3133

3234
# ---- Exporter ---------------------------------------------------------------
3335

@@ -277,6 +279,14 @@ def _map_span(self, sp: ReadableSpan) -> dict[str, Any]:
277279
# attributes
278280
attrs = dict(sp.attributes or {})
279281

282+
# Normalize gen_ai.operation.name from any InferenceOperationType enum
283+
# value (Chat, TextCompletion, GenerateContent) to the canonical "chat"
284+
# value the ingest service accepts. This is applied only on the export
285+
# payload; the underlying span attribute is left untouched.
286+
op_name = attrs.get(GEN_AI_OPERATION_NAME_KEY)
287+
if isinstance(op_name, str) and op_name in INFERENCE_OPERATION_TYPE_NAMES:
288+
attrs[GEN_AI_OPERATION_NAME_KEY] = CHAT_OPERATION_NAME
289+
280290
# events
281291
events = []
282292
for ev in sp.events:

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,47 @@
1414
from opentelemetry.trace import SpanKind, StatusCode
1515

1616
from ..constants import (
17+
CHAT_OPERATION_NAME,
1718
ENABLE_A365_OBSERVABILITY_EXPORTER,
19+
EXECUTE_TOOL_OPERATION_NAME,
1820
GEN_AI_AGENT_ID_KEY,
21+
GEN_AI_OPERATION_NAME_KEY,
22+
INVOKE_AGENT_OPERATION_NAME,
23+
OUTPUT_MESSAGES_OPERATION_NAME,
1924
TENANT_ID_KEY,
2025
)
26+
from ..inference_operation_type import InferenceOperationType
2127

2228
logger = logging.getLogger(__name__)
2329

2430
# Maximum allowed span size in bytes (250KB)
2531
MAX_SPAN_SIZE_BYTES = 250 * 1024
2632

33+
# Operation names that identify a span as a genAI span eligible for export to
34+
# the Agent 365 observability ingest service. Spans without a known
35+
# gen_ai.operation.name are filtered out of the export batch.
36+
GEN_AI_OPERATION_NAMES: frozenset[str] = frozenset(
37+
{
38+
INVOKE_AGENT_OPERATION_NAME,
39+
EXECUTE_TOOL_OPERATION_NAME,
40+
OUTPUT_MESSAGES_OPERATION_NAME,
41+
CHAT_OPERATION_NAME,
42+
InferenceOperationType.CHAT.value,
43+
InferenceOperationType.TEXT_COMPLETION.value,
44+
InferenceOperationType.GENERATE_CONTENT.value,
45+
}
46+
)
47+
48+
# Inference operation type values that the ingest service expects to be
49+
# normalized to the canonical "chat" gen_ai.operation.name.
50+
INFERENCE_OPERATION_TYPE_NAMES: frozenset[str] = frozenset(
51+
{
52+
InferenceOperationType.CHAT.value,
53+
InferenceOperationType.TEXT_COMPLETION.value,
54+
InferenceOperationType.GENERATE_CONTENT.value,
55+
}
56+
)
57+
2758

2859
def hex_trace_id(value: int) -> str:
2960
# 128-bit -> 32 hex chars
@@ -131,18 +162,41 @@ def partition_by_identity(
131162
spans: Sequence[ReadableSpan],
132163
) -> dict[tuple[str, str], list[ReadableSpan]]:
133164
"""
134-
Extract (tenantId, agentId). Prefer attributes; if you also stamp baggage
135-
into attributes via a processor, they'll be here already.
165+
Partition spans by (tenantId, agentId).
166+
167+
Only genAI spans (those with a known ``gen_ai.operation.name``) are
168+
included; non-genAI spans (e.g. HTTP, DB) are filtered out. Spans
169+
without both tenant and agent identity are also skipped.
136170
"""
137171
groups: dict[tuple[str, str], list[ReadableSpan]] = {}
172+
non_gen_ai_count = 0
173+
missing_identity_count = 0
138174
for sp in spans:
139175
attrs = sp.attributes or {}
176+
operation_name = as_str(attrs.get(GEN_AI_OPERATION_NAME_KEY))
177+
if not operation_name or operation_name not in GEN_AI_OPERATION_NAMES:
178+
non_gen_ai_count += 1
179+
continue
140180
tenant = as_str(attrs.get(TENANT_ID_KEY))
141181
agent = as_str(attrs.get(GEN_AI_AGENT_ID_KEY))
142182
if not tenant or not agent:
183+
missing_identity_count += 1
143184
continue
144185
key = (tenant, agent)
145186
groups.setdefault(key, []).append(sp)
187+
188+
if non_gen_ai_count > 0:
189+
logger.info(f"[Agent365Exporter] {non_gen_ai_count} non-genAI spans filtered out")
190+
if missing_identity_count > 0:
191+
logger.warning(
192+
f"[Agent365Exporter] {missing_identity_count} spans skipped due to "
193+
"missing tenant or agent ID"
194+
)
195+
skipped = non_gen_ai_count + missing_identity_count
196+
logger.info(
197+
f"[Agent365Exporter] Partitioned into {len(groups)} identity groups "
198+
f"({skipped} spans skipped)"
199+
)
146200
return groups
147201

148202

tests/observability/core/test_agent365_exporter.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
import unittest
77
from 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+
)
1015
from 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)
@@ -85,6 +91,8 @@ def _create_mock_span(
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 = []
@@ -657,6 +665,89 @@ def test_export_no_fallback_when_default_succeeds(self):
657665
self.assertEqual(result, SpanExportResult.SUCCESS)
658666
mock_post.assert_called_once()
659667

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+
660751

661752
if __name__ == "__main__":
662753
unittest.main()

0 commit comments

Comments
 (0)