Skip to content

Commit 17ffcb1

Browse files
authored
Merge branch 'main' into copilot/fix-docfx-cross-reference-links
2 parents afd2a90 + 23c9edb commit 17ffcb1

5 files changed

Lines changed: 155 additions & 14 deletions

File tree

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
hex_trace_id,
2828
kind_name,
2929
parse_retry_after,
30-
partition_by_identity,
30+
filter_and_partition_by_identity,
3131
status_name,
3232
truncate_span,
3333
)
@@ -80,10 +80,10 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
8080
return SpanExportResult.FAILURE
8181

8282
try:
83-
groups = partition_by_identity(spans)
83+
groups = filter_and_partition_by_identity(spans)
8484
if not groups:
85-
# No spans with identity; treat as success
86-
logger.info("No spans with tenant/agent identity found; nothing exported.")
85+
# No eligible genAI spans to export after filtering/partitioning; treat as success
86+
logger.info("No eligible genAI spans to export; nothing exported.")
8787
return SpanExportResult.SUCCESS
8888

8989
# Log number of groups and total span count

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

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,35 @@
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 eligible for export to the Agent 365
34+
# observability ingest service. Only spans whose gen_ai.operation.name matches
35+
# one of these values are included; all other spans are filtered out.
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+
}
44+
)
45+
2746

2847
def hex_trace_id(value: int) -> str:
2948
# 128-bit -> 32 hex chars
@@ -127,22 +146,44 @@ def truncate_span(span_dict: dict[str, Any]) -> dict[str, Any]:
127146
return span_dict
128147

129148

130-
def partition_by_identity(
149+
def filter_and_partition_by_identity(
131150
spans: Sequence[ReadableSpan],
132151
) -> dict[tuple[str, str], list[ReadableSpan]]:
133152
"""
134-
Extract (tenantId, agentId). Prefer attributes; if you also stamp baggage
135-
into attributes via a processor, they'll be here already.
153+
Filter export-eligible spans and partition them by (tenantId, agentId).
154+
155+
Only spans whose ``gen_ai.operation.name`` is in
156+
``GEN_AI_OPERATION_NAMES`` are included; non-genAI spans (e.g. HTTP, DB)
157+
and spans with other operation names are filtered out. Spans without
158+
both tenant and agent identity are also skipped.
136159
"""
137160
groups: dict[tuple[str, str], list[ReadableSpan]] = {}
161+
non_gen_ai_count = 0
162+
missing_identity_count = 0
138163
for sp in spans:
139164
attrs = sp.attributes or {}
165+
operation_name = as_str(attrs.get(GEN_AI_OPERATION_NAME_KEY))
166+
if not operation_name or operation_name not in GEN_AI_OPERATION_NAMES:
167+
non_gen_ai_count += 1
168+
continue
140169
tenant = as_str(attrs.get(TENANT_ID_KEY))
141170
agent = as_str(attrs.get(GEN_AI_AGENT_ID_KEY))
142171
if not tenant or not agent:
172+
missing_identity_count += 1
143173
continue
144174
key = (tenant, agent)
145175
groups.setdefault(key, []).append(sp)
176+
177+
if non_gen_ai_count > 0:
178+
logger.debug(
179+
"[Agent365Exporter] %d spans without an eligible gen_ai.operation.name filtered out",
180+
non_gen_ai_count,
181+
)
182+
if missing_identity_count > 0:
183+
logger.debug(
184+
"[Agent365Exporter] %d spans skipped due to missing tenant or agent ID",
185+
missing_identity_count,
186+
)
146187
return groups
147188

148189

tests/observability/core/exporters/test_payload_chunking.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
from unittest.mock import Mock, patch
1111

1212
from microsoft_agents_a365.observability.core.constants import (
13+
CHAT_OPERATION_NAME,
1314
GEN_AI_AGENT_ID_KEY,
15+
GEN_AI_OPERATION_NAME_KEY,
1416
TENANT_ID_KEY,
1517
)
1618
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:
165167
mock_span.attributes = {
166168
TENANT_ID_KEY: "tenant-1",
167169
GEN_AI_AGENT_ID_KEY: "agent-1",
170+
GEN_AI_OPERATION_NAME_KEY: CHAT_OPERATION_NAME,
168171
"payload": "x" * attribute_size,
169172
}
170173
mock_span.events = []

tests/observability/core/test_agent365_exporter.py

Lines changed: 103 additions & 6 deletions
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)
@@ -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

661758
if __name__ == "__main__":
662759
unittest.main()

versioning/TARGET-VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.3.0
1+
1.0.0

0 commit comments

Comments
 (0)