From 765af457cfbf5c632a13c403b5d38d1abb822c71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:15:44 +0000 Subject: [PATCH 01/10] Initial plan From 482cb38ef92897dd2d211129ca134b8bb8f3e9b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:19:14 +0000 Subject: [PATCH 02/10] Add OutputScope for output message tracing - Add Response model class for agent execution response details - Add spans_scopes directory with OutputScope implementation - Add unit tests for OutputScope functionality Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../observability/core/models/response.py | 12 ++ .../core/spans_scopes/__init__.py | 2 + .../core/spans_scopes/output_scope.py | 65 ++++++ tests/observability/core/test_output_scope.py | 198 ++++++++++++++++++ 4 files changed, 277 insertions(+) create mode 100644 libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py create mode 100644 libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/__init__.py create mode 100644 libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py create mode 100644 tests/observability/core/test_output_scope.py diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py new file mode 100644 index 00000000..184635fa --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from dataclasses import dataclass + + +@dataclass +class Response: + """Response details from agent execution.""" + + messages: list[str] + """The list of response messages.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/__init__.py new file mode 100644 index 00000000..59e481eb --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py new file mode 100644 index 00000000..10ce1153 --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from ..agent_details import AgentDetails +from ..constants import GEN_AI_OUTPUT_MESSAGES_KEY +from ..models.response import Response +from ..opentelemetry_scope import OpenTelemetryScope +from ..tenant_details import TenantDetails +from ..utils import safe_json_dumps + +OUTPUT_OPERATION_NAME = "output_messages" + + +class OutputScope(OpenTelemetryScope): + """Provides OpenTelemetry tracing scope for output messages.""" + + @staticmethod + def start( + agent_details: AgentDetails, + tenant_details: TenantDetails, + response: Response, + ) -> "OutputScope": + """Creates and starts a new scope for output tracing. + + Args: + agent_details: The details of the agent + tenant_details: The details of the tenant + response: The response details from the agent + + Returns: + A new OutputScope instance + """ + return OutputScope(agent_details, tenant_details, response) + + def __init__( + self, + agent_details: AgentDetails, + tenant_details: TenantDetails, + response: Response, + ): + """Initialize the output scope. + + Args: + agent_details: The details of the agent + tenant_details: The details of the tenant + response: The response details from the agent + """ + super().__init__( + kind="Client", + operation_name=OUTPUT_OPERATION_NAME, + activity_name=(f"{OUTPUT_OPERATION_NAME} {agent_details.agent_id}"), + agent_details=agent_details, + tenant_details=tenant_details, + ) + + # Set response messages + self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(response.messages)) + + def record_output_messages(self, messages: list[str]) -> None: + """Records the output messages for telemetry tracking. + + Args: + messages: List of output messages + """ + self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(messages)) diff --git a/tests/observability/core/test_output_scope.py b/tests/observability/core/test_output_scope.py new file mode 100644 index 00000000..e5d65a2a --- /dev/null +++ b/tests/observability/core/test_output_scope.py @@ -0,0 +1,198 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import os +import sys +import unittest +from pathlib import Path + +import pytest +from microsoft_agents_a365.observability.core import ( + AgentDetails, + TenantDetails, + configure, + get_tracer_provider, +) +from microsoft_agents_a365.observability.core.config import _telemetry_manager +from microsoft_agents_a365.observability.core.constants import GEN_AI_OUTPUT_MESSAGES_KEY +from microsoft_agents_a365.observability.core.models.response import Response +from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope +from microsoft_agents_a365.observability.core.spans_scopes.output_scope import OutputScope +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + +class TestOutputScope(unittest.TestCase): + """Unit tests for OutputScope and its methods.""" + + @classmethod + def setUpClass(cls): + """Set up test environment once for all tests.""" + # Configure Microsoft Agent 365 for testing + os.environ["ENABLE_A365_OBSERVABILITY"] = "true" + + configure( + service_name="test-output-scope-service", + service_namespace="test-namespace", + ) + # Create test data + cls.tenant_details = TenantDetails(tenant_id="12345678-1234-5678-1234-567812345678") + cls.agent_details = AgentDetails( + agent_id="test-agent-123", + agent_name="Test Agent", + agent_description="A test agent for output scope testing", + ) + + def setUp(self): + super().setUp() + + # Reset TelemetryManager state to ensure fresh configuration + _telemetry_manager._tracer_provider = None + _telemetry_manager._span_processors = {} + OpenTelemetryScope._tracer = None + + # Reconfigure to get a fresh TracerProvider + configure( + service_name="test-output-scope-service", + service_namespace="test-namespace", + ) + + # Set up tracer to capture spans + self.span_exporter = InMemorySpanExporter() + tracer_provider = get_tracer_provider() + tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) + + def tearDown(self): + super().tearDown() + self.span_exporter.clear() + + def test_output_scope_creation(self): + """Test that OutputScope can be created successfully.""" + response = Response(messages=["Hello, how can I help you?"]) + + scope = OutputScope.start(self.agent_details, self.tenant_details, response) + + self.assertIsNotNone(scope) + scope.dispose() + + def test_record_output_messages_method_exists(self): + """Test that record_output_messages method exists on OutputScope.""" + response = Response(messages=["Initial message"]) + scope = OutputScope.start(self.agent_details, self.tenant_details, response) + + if scope is not None: + # Test that the method exists + self.assertTrue(hasattr(scope, "record_output_messages")) + self.assertTrue(callable(scope.record_output_messages)) + scope.dispose() + + def test_output_messages_set_on_span(self): + """Test that output messages are set on span attributes.""" + response = Response(messages=["This is the agent response"]) + + scope = OutputScope.start(self.agent_details, self.tenant_details, response) + + if scope is not None: + scope.dispose() + + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span to be created") + + span = finished_spans[-1] + span_attributes = getattr(span, "attributes", {}) or {} + + self.assertIn( + GEN_AI_OUTPUT_MESSAGES_KEY, + span_attributes, + "Expected output messages key to be set on span", + ) + + # Verify the message content is in the serialized output + output_value = span_attributes[GEN_AI_OUTPUT_MESSAGES_KEY] + self.assertIn("This is the agent response", output_value) + + def test_multiple_output_messages(self): + """Test that multiple output messages are properly recorded.""" + response = Response(messages=["First response", "Second response", "Third response"]) + + scope = OutputScope.start(self.agent_details, self.tenant_details, response) + + if scope is not None: + scope.dispose() + + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span to be created") + + span = finished_spans[-1] + span_attributes = getattr(span, "attributes", {}) or {} + + self.assertIn( + GEN_AI_OUTPUT_MESSAGES_KEY, + span_attributes, + "Expected output messages key to be set on span", + ) + + output_value = span_attributes[GEN_AI_OUTPUT_MESSAGES_KEY] + self.assertIn("First response", output_value) + self.assertIn("Second response", output_value) + self.assertIn("Third response", output_value) + + def test_record_output_messages_updates_span(self): + """Test that record_output_messages updates the span with new messages.""" + response = Response(messages=["Initial message"]) + + scope = OutputScope.start(self.agent_details, self.tenant_details, response) + + if scope is not None: + # Record updated messages + scope.record_output_messages(["Updated message 1", "Updated message 2"]) + scope.dispose() + + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span to be created") + + span = finished_spans[-1] + span_attributes = getattr(span, "attributes", {}) or {} + + self.assertIn( + GEN_AI_OUTPUT_MESSAGES_KEY, + span_attributes, + "Expected output messages key to be set on span", + ) + + # The span should have the updated messages + output_value = span_attributes[GEN_AI_OUTPUT_MESSAGES_KEY] + self.assertIn("Updated message 1", output_value) + self.assertIn("Updated message 2", output_value) + + def test_output_scope_context_manager(self): + """Test that OutputScope works as a context manager.""" + response = Response(messages=["Context manager test"]) + + with OutputScope.start(self.agent_details, self.tenant_details, response) as scope: + self.assertIsNotNone(scope) + + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span to be created") + + def test_output_scope_span_name(self): + """Test that OutputScope creates spans with correct operation name.""" + response = Response(messages=["Test message"]) + + scope = OutputScope.start(self.agent_details, self.tenant_details, response) + + if scope is not None: + scope.dispose() + + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span to be created") + + span = finished_spans[-1] + # The activity name should contain "output_messages" and the agent id + self.assertIn("output_messages", span.name) + self.assertIn(self.agent_details.agent_id, span.name) + + +if __name__ == "__main__": + # Run pytest only on the current file + sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:])) From 53bd58d46d122666376704f5de2d7e758d88b3eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:20:47 +0000 Subject: [PATCH 03/10] Improve Response.messages field documentation Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../observability/core/models/response.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py index 184635fa..e77c9d7d 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py @@ -9,4 +9,9 @@ class Response: """Response details from agent execution.""" messages: list[str] - """The list of response messages.""" + """The list of response messages from the agent. + + Each message represents a text response generated by the agent during execution. + Messages are serialized to JSON format for OpenTelemetry span attributes. + An empty list is valid and represents no response messages. + """ From 9ddca17cbb021037800a9fd4821c2a3f585aa477 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:34:16 +0000 Subject: [PATCH 04/10] Add parent_id parameter to OutputScope Similar to the .NET ExecuteToolScope, the OutputScope now accepts an optional parent_id parameter to link spans to upstream operations. Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../core/spans_scopes/output_scope.py | 13 ++++- tests/observability/core/test_output_scope.py | 53 ++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py index 10ce1153..44e19bad 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from ..agent_details import AgentDetails -from ..constants import GEN_AI_OUTPUT_MESSAGES_KEY +from ..constants import CUSTOM_PARENT_SPAN_ID_KEY, GEN_AI_OUTPUT_MESSAGES_KEY from ..models.response import Response from ..opentelemetry_scope import OpenTelemetryScope from ..tenant_details import TenantDetails @@ -19,6 +19,7 @@ def start( agent_details: AgentDetails, tenant_details: TenantDetails, response: Response, + parent_id: str | None = None, ) -> "OutputScope": """Creates and starts a new scope for output tracing. @@ -26,17 +27,20 @@ def start( agent_details: The details of the agent tenant_details: The details of the tenant response: The response details from the agent + parent_id: Optional parent Activity ID used to link this span to an upstream + operation Returns: A new OutputScope instance """ - return OutputScope(agent_details, tenant_details, response) + return OutputScope(agent_details, tenant_details, response, parent_id) def __init__( self, agent_details: AgentDetails, tenant_details: TenantDetails, response: Response, + parent_id: str | None = None, ): """Initialize the output scope. @@ -44,6 +48,8 @@ def __init__( agent_details: The details of the agent tenant_details: The details of the tenant response: The response details from the agent + parent_id: Optional parent Activity ID used to link this span to an upstream + operation """ super().__init__( kind="Client", @@ -53,6 +59,9 @@ def __init__( tenant_details=tenant_details, ) + # Set parent ID if provided + self.set_tag_maybe(CUSTOM_PARENT_SPAN_ID_KEY, parent_id) + # Set response messages self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(response.messages)) diff --git a/tests/observability/core/test_output_scope.py b/tests/observability/core/test_output_scope.py index e5d65a2a..7058b8ea 100644 --- a/tests/observability/core/test_output_scope.py +++ b/tests/observability/core/test_output_scope.py @@ -14,7 +14,10 @@ get_tracer_provider, ) from microsoft_agents_a365.observability.core.config import _telemetry_manager -from microsoft_agents_a365.observability.core.constants import GEN_AI_OUTPUT_MESSAGES_KEY +from microsoft_agents_a365.observability.core.constants import ( + CUSTOM_PARENT_SPAN_ID_KEY, + GEN_AI_OUTPUT_MESSAGES_KEY, +) from microsoft_agents_a365.observability.core.models.response import Response from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope from microsoft_agents_a365.observability.core.spans_scopes.output_scope import OutputScope @@ -192,6 +195,54 @@ def test_output_scope_span_name(self): self.assertIn("output_messages", span.name) self.assertIn(self.agent_details.agent_id, span.name) + def test_output_scope_with_parent_id(self): + """Test that OutputScope records parent_id when provided.""" + response = Response(messages=["Test message with parent"]) + parent_id = "00-1234567890abcdef1234567890abcdef-abcdefabcdef1234-01" + + scope = OutputScope.start( + self.agent_details, self.tenant_details, response, parent_id=parent_id + ) + + if scope is not None: + scope.dispose() + + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span to be created") + + span = finished_spans[-1] + span_attributes = getattr(span, "attributes", {}) or {} + + # Verify the parent ID is set as a span attribute + self.assertIn( + CUSTOM_PARENT_SPAN_ID_KEY, + span_attributes, + "Expected custom parent span ID to be set on span", + ) + self.assertEqual(span_attributes[CUSTOM_PARENT_SPAN_ID_KEY], parent_id) + + def test_output_scope_without_parent_id(self): + """Test that OutputScope doesn't set parent_id attribute when not provided.""" + response = Response(messages=["Test message without parent"]) + + scope = OutputScope.start(self.agent_details, self.tenant_details, response) + + if scope is not None: + scope.dispose() + + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span to be created") + + span = finished_spans[-1] + span_attributes = getattr(span, "attributes", {}) or {} + + # Verify the parent ID attribute is NOT set when not provided + self.assertNotIn( + CUSTOM_PARENT_SPAN_ID_KEY, + span_attributes, + "Expected custom parent span ID NOT to be set when not provided", + ) + if __name__ == "__main__": # Run pytest only on the current file From 227482895bc5231324e10c08637c0d1a5f6ef15b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:41:24 +0000 Subject: [PATCH 05/10] Add parent_id to base OpenTelemetryScope class Similar to the .NET implementation, the base OpenTelemetryScope class now accepts an optional parent_id parameter that sets the CUSTOM_PARENT_SPAN_ID_KEY attribute on the span. This allows all scope subclasses to support parent span linking consistently. Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../observability/core/opentelemetry_scope.py | 7 +++++++ .../observability/core/spans_scopes/output_scope.py | 6 ++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py index eb92c81f..39b45692 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py @@ -13,6 +13,7 @@ from opentelemetry.trace import Span, SpanKind, Status, StatusCode, Tracer, set_span_in_context from .constants import ( + CUSTOM_PARENT_SPAN_ID_KEY, ENABLE_A365_OBSERVABILITY, ENABLE_OBSERVABILITY, ERROR_TYPE_KEY, @@ -71,6 +72,7 @@ def __init__( activity_name: str, agent_details: "AgentDetails | None" = None, tenant_details: "TenantDetails | None" = None, + parent_id: str | None = None, ): """Initialize the OpenTelemetry scope. @@ -80,6 +82,8 @@ def __init__( activity_name: The name of the activity for display purposes agent_details: Optional agent details tenant_details: Optional tenant details + parent_id: Optional parent Activity ID used to link this span to an upstream + operation """ self._span: Span | None = None self._start_time = time.time() @@ -145,6 +149,9 @@ def __init__( if tenant_details: self.set_tag_maybe(TENANT_ID_KEY, str(tenant_details.tenant_id)) + # Set parent ID if provided + self.set_tag_maybe(CUSTOM_PARENT_SPAN_ID_KEY, parent_id) + def record_error(self, exception: Exception) -> None: """Record an error in the span. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py index 44e19bad..d315c92f 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from ..agent_details import AgentDetails -from ..constants import CUSTOM_PARENT_SPAN_ID_KEY, GEN_AI_OUTPUT_MESSAGES_KEY +from ..constants import GEN_AI_OUTPUT_MESSAGES_KEY from ..models.response import Response from ..opentelemetry_scope import OpenTelemetryScope from ..tenant_details import TenantDetails @@ -57,11 +57,9 @@ def __init__( activity_name=(f"{OUTPUT_OPERATION_NAME} {agent_details.agent_id}"), agent_details=agent_details, tenant_details=tenant_details, + parent_id=parent_id, ) - # Set parent ID if provided - self.set_tag_maybe(CUSTOM_PARENT_SPAN_ID_KEY, parent_id) - # Set response messages self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(response.messages)) From f1e7c5dbd3980b2441bddfea94328e3e8a128ad6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:52:33 +0000 Subject: [PATCH 06/10] Make record_output_messages append messages instead of replacing Changed the behavior of record_output_messages to append new messages to the accumulated list rather than replacing all messages. This allows collecting output messages over multiple calls during the scope's lifetime. Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../core/spans_scopes/output_scope.py | 12 ++++-- tests/observability/core/test_output_scope.py | 39 ++++++++++++++++--- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py index d315c92f..6f01c42c 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py @@ -60,13 +60,19 @@ def __init__( parent_id=parent_id, ) + # Initialize accumulated messages list + self._output_messages: list[str] = list(response.messages) + # Set response messages - self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(response.messages)) + self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(self._output_messages)) def record_output_messages(self, messages: list[str]) -> None: """Records the output messages for telemetry tracking. + Appends the provided messages to the accumulated output messages list. + Args: - messages: List of output messages + messages: List of output messages to append """ - self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(messages)) + self._output_messages.extend(messages) + self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(self._output_messages)) diff --git a/tests/observability/core/test_output_scope.py b/tests/observability/core/test_output_scope.py index 7058b8ea..953ab82d 100644 --- a/tests/observability/core/test_output_scope.py +++ b/tests/observability/core/test_output_scope.py @@ -141,14 +141,14 @@ def test_multiple_output_messages(self): self.assertIn("Third response", output_value) def test_record_output_messages_updates_span(self): - """Test that record_output_messages updates the span with new messages.""" + """Test that record_output_messages appends messages to the span.""" response = Response(messages=["Initial message"]) scope = OutputScope.start(self.agent_details, self.tenant_details, response) if scope is not None: - # Record updated messages - scope.record_output_messages(["Updated message 1", "Updated message 2"]) + # Record additional messages (should append, not replace) + scope.record_output_messages(["Appended message 1", "Appended message 2"]) scope.dispose() finished_spans = self.span_exporter.get_finished_spans() @@ -163,10 +163,37 @@ def test_record_output_messages_updates_span(self): "Expected output messages key to be set on span", ) - # The span should have the updated messages + # The span should have all messages (initial + appended) output_value = span_attributes[GEN_AI_OUTPUT_MESSAGES_KEY] - self.assertIn("Updated message 1", output_value) - self.assertIn("Updated message 2", output_value) + self.assertIn("Initial message", output_value) + self.assertIn("Appended message 1", output_value) + self.assertIn("Appended message 2", output_value) + + def test_record_output_messages_multiple_appends(self): + """Test that multiple calls to record_output_messages accumulate messages.""" + response = Response(messages=["First message"]) + + scope = OutputScope.start(self.agent_details, self.tenant_details, response) + + if scope is not None: + # First append + scope.record_output_messages(["Second message"]) + # Second append + scope.record_output_messages(["Third message", "Fourth message"]) + scope.dispose() + + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span to be created") + + span = finished_spans[-1] + span_attributes = getattr(span, "attributes", {}) or {} + + output_value = span_attributes[GEN_AI_OUTPUT_MESSAGES_KEY] + # All four messages should be present + self.assertIn("First message", output_value) + self.assertIn("Second message", output_value) + self.assertIn("Third message", output_value) + self.assertIn("Fourth message", output_value) def test_output_scope_context_manager(self): """Test that OutputScope works as a context manager.""" From f902aada6d32ac8cf8487f78ada8861112552a1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:38:31 +0000 Subject: [PATCH 07/10] Fix parent_id to set on span context instead of span attribute Changed the parent_id implementation to properly link spans by parsing the W3C Trace Context format parent ID and using it to create a proper span context. This aligns with the .NET implementation which calls `activity?.SetParentId(parentId!)`. Added validation for W3C Trace Context version and trace_id/span_id lengths. Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../observability/core/opentelemetry_scope.py | 91 +++++++++++++++++-- tests/observability/core/test_output_scope.py | 50 +++++----- 2 files changed, 110 insertions(+), 31 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py index 39b45692..4a1a3605 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py @@ -10,10 +10,19 @@ from typing import TYPE_CHECKING, Any from opentelemetry import baggage, context, trace -from opentelemetry.trace import Span, SpanKind, Status, StatusCode, Tracer, set_span_in_context +from opentelemetry.trace import ( + NonRecordingSpan, + Span, + SpanContext, + SpanKind, + Status, + StatusCode, + TraceFlags, + Tracer, + set_span_in_context, +) from .constants import ( - CUSTOM_PARENT_SPAN_ID_KEY, ENABLE_A365_OBSERVABILITY, ENABLE_OBSERVABILITY, ERROR_TYPE_KEY, @@ -42,6 +51,70 @@ logger = logging.getLogger(__name__) +def _parse_parent_id_to_context(parent_id: str | None) -> context.Context | None: + """Parse a W3C trace context parent ID and return a context with the parent span. + + The parent_id format is expected to be W3C Trace Context format: + "00-{trace_id}-{span_id}-{trace_flags}" + Example: "00-1234567890abcdef1234567890abcdef-abcdefabcdef1234-01" + + Args: + parent_id: The W3C Trace Context format parent ID string + + Returns: + A context containing the parent span, or None if parent_id is invalid + """ + if not parent_id: + return None + + try: + # W3C Trace Context format: "00-{trace_id}-{span_id}-{trace_flags}" + parts = parent_id.split("-") + if len(parts) != 4: + logger.warning(f"Invalid parent_id format (expected 4 parts): {parent_id}") + return None + + version, trace_id_hex, span_id_hex, trace_flags_hex = parts + + # Validate W3C Trace Context version + if version != "00": + logger.warning(f"Unsupported W3C Trace Context version: {version}") + return None + + # Validate trace_id length (must be 32 hex chars) + if len(trace_id_hex) != 32: + logger.warning(f"Invalid trace_id length (expected 32 chars): {len(trace_id_hex)}") + return None + + # Validate span_id length (must be 16 hex chars) + if len(span_id_hex) != 16: + logger.warning(f"Invalid span_id length (expected 16 chars): {len(span_id_hex)}") + return None + + # Parse the hex values + trace_id = int(trace_id_hex, 16) + span_id = int(span_id_hex, 16) + trace_flags = TraceFlags(int(trace_flags_hex, 16)) + + # Create a SpanContext from the parsed values + parent_span_context = SpanContext( + trace_id=trace_id, + span_id=span_id, + is_remote=True, + trace_flags=trace_flags, + ) + + # Create a NonRecordingSpan with the parent context + parent_span = NonRecordingSpan(parent_span_context) + + # Create a context with the parent span + return set_span_in_context(parent_span) + + except (ValueError, IndexError) as e: + logger.warning(f"Failed to parse parent_id '{parent_id}': {e}") + return None + + class OpenTelemetryScope: """Base class for OpenTelemetry tracing scopes in the SDK.""" @@ -106,12 +179,13 @@ def __init__( elif kind.lower() == "consumer": activity_kind = SpanKind.CONSUMER - # Get current context for parent relationship - current_context = context.get_current() + # Get context for parent relationship + # If parent_id is provided, parse it and use it as the parent context + # Otherwise, use the current context + parent_context = _parse_parent_id_to_context(parent_id) + span_context = parent_context if parent_context else context.get_current() - self._span = tracer.start_span( - activity_name, kind=activity_kind, context=current_context - ) + self._span = tracer.start_span(activity_name, kind=activity_kind, context=span_context) # Log span creation if self._span: @@ -149,9 +223,6 @@ def __init__( if tenant_details: self.set_tag_maybe(TENANT_ID_KEY, str(tenant_details.tenant_id)) - # Set parent ID if provided - self.set_tag_maybe(CUSTOM_PARENT_SPAN_ID_KEY, parent_id) - def record_error(self, exception: Exception) -> None: """Record an error in the span. diff --git a/tests/observability/core/test_output_scope.py b/tests/observability/core/test_output_scope.py index 953ab82d..9feef757 100644 --- a/tests/observability/core/test_output_scope.py +++ b/tests/observability/core/test_output_scope.py @@ -14,10 +14,7 @@ get_tracer_provider, ) from microsoft_agents_a365.observability.core.config import _telemetry_manager -from microsoft_agents_a365.observability.core.constants import ( - CUSTOM_PARENT_SPAN_ID_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, -) +from microsoft_agents_a365.observability.core.constants import GEN_AI_OUTPUT_MESSAGES_KEY from microsoft_agents_a365.observability.core.models.response import Response from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope from microsoft_agents_a365.observability.core.spans_scopes.output_scope import OutputScope @@ -223,9 +220,13 @@ def test_output_scope_span_name(self): self.assertIn(self.agent_details.agent_id, span.name) def test_output_scope_with_parent_id(self): - """Test that OutputScope records parent_id when provided.""" + """Test that OutputScope uses parent_id to link span to parent.""" response = Response(messages=["Test message with parent"]) - parent_id = "00-1234567890abcdef1234567890abcdef-abcdefabcdef1234-01" + # W3C Trace Context format: "00-{trace_id}-{span_id}-{trace_flags}" + # trace_id: 32 hex chars, span_id: 16 hex chars + parent_trace_id = "1234567890abcdef1234567890abcdef" + parent_span_id = "abcdefabcdef1234" + parent_id = f"00-{parent_trace_id}-{parent_span_id}-01" scope = OutputScope.start( self.agent_details, self.tenant_details, response, parent_id=parent_id @@ -238,18 +239,28 @@ def test_output_scope_with_parent_id(self): self.assertTrue(finished_spans, "Expected at least one span to be created") span = finished_spans[-1] - span_attributes = getattr(span, "attributes", {}) or {} - # Verify the parent ID is set as a span attribute - self.assertIn( - CUSTOM_PARENT_SPAN_ID_KEY, - span_attributes, - "Expected custom parent span ID to be set on span", + # Verify the span has the correct parent trace context + # The span's trace_id should match the parent's trace_id + span_trace_id = f"{span.context.trace_id:032x}" + self.assertEqual( + span_trace_id, + parent_trace_id, + "Expected span's trace_id to match parent's trace_id", + ) + + # The span's parent_span_id should match the parent's span_id + span_parent_id = None + if span.parent and hasattr(span.parent, "span_id"): + span_parent_id = f"{span.parent.span_id:016x}" + self.assertEqual( + span_parent_id, + parent_span_id, + "Expected span's parent_span_id to match parent's span_id", ) - self.assertEqual(span_attributes[CUSTOM_PARENT_SPAN_ID_KEY], parent_id) def test_output_scope_without_parent_id(self): - """Test that OutputScope doesn't set parent_id attribute when not provided.""" + """Test that OutputScope creates a span without forced parent when not provided.""" response = Response(messages=["Test message without parent"]) scope = OutputScope.start(self.agent_details, self.tenant_details, response) @@ -261,14 +272,11 @@ def test_output_scope_without_parent_id(self): self.assertTrue(finished_spans, "Expected at least one span to be created") span = finished_spans[-1] - span_attributes = getattr(span, "attributes", {}) or {} - # Verify the parent ID attribute is NOT set when not provided - self.assertNotIn( - CUSTOM_PARENT_SPAN_ID_KEY, - span_attributes, - "Expected custom parent span ID NOT to be set when not provided", - ) + # When no parent_id is provided, the span should either have no parent + # or inherit from the current context (which in tests is typically empty) + # We just verify the span was created successfully + self.assertIsNotNone(span.context.span_id) if __name__ == "__main__": From b965b0fcd29068999e9ee045cf0b994bd272922b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:26:39 +0000 Subject: [PATCH 08/10] Move parse_parent_id_to_context to utils.py Refactored W3C Trace Context parsing: - Added W3C Trace Context validation constants - Added validate_w3c_trace_context_version() helper - Added validate_trace_id() helper with hex validation - Added validate_span_id() helper with hex validation - Moved parse_parent_id_to_context() from opentelemetry_scope.py Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../observability/core/opentelemetry_scope.py | 70 +--------- .../observability/core/utils.py | 125 +++++++++++++++++- 2 files changed, 126 insertions(+), 69 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py index 4a1a3605..90b3e84b 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py @@ -11,13 +11,10 @@ from opentelemetry import baggage, context, trace from opentelemetry.trace import ( - NonRecordingSpan, Span, - SpanContext, SpanKind, Status, StatusCode, - TraceFlags, Tracer, set_span_in_context, ) @@ -42,6 +39,7 @@ SOURCE_NAME, TENANT_ID_KEY, ) +from .utils import parse_parent_id_to_context if TYPE_CHECKING: from .agent_details import AgentDetails @@ -51,70 +49,6 @@ logger = logging.getLogger(__name__) -def _parse_parent_id_to_context(parent_id: str | None) -> context.Context | None: - """Parse a W3C trace context parent ID and return a context with the parent span. - - The parent_id format is expected to be W3C Trace Context format: - "00-{trace_id}-{span_id}-{trace_flags}" - Example: "00-1234567890abcdef1234567890abcdef-abcdefabcdef1234-01" - - Args: - parent_id: The W3C Trace Context format parent ID string - - Returns: - A context containing the parent span, or None if parent_id is invalid - """ - if not parent_id: - return None - - try: - # W3C Trace Context format: "00-{trace_id}-{span_id}-{trace_flags}" - parts = parent_id.split("-") - if len(parts) != 4: - logger.warning(f"Invalid parent_id format (expected 4 parts): {parent_id}") - return None - - version, trace_id_hex, span_id_hex, trace_flags_hex = parts - - # Validate W3C Trace Context version - if version != "00": - logger.warning(f"Unsupported W3C Trace Context version: {version}") - return None - - # Validate trace_id length (must be 32 hex chars) - if len(trace_id_hex) != 32: - logger.warning(f"Invalid trace_id length (expected 32 chars): {len(trace_id_hex)}") - return None - - # Validate span_id length (must be 16 hex chars) - if len(span_id_hex) != 16: - logger.warning(f"Invalid span_id length (expected 16 chars): {len(span_id_hex)}") - return None - - # Parse the hex values - trace_id = int(trace_id_hex, 16) - span_id = int(span_id_hex, 16) - trace_flags = TraceFlags(int(trace_flags_hex, 16)) - - # Create a SpanContext from the parsed values - parent_span_context = SpanContext( - trace_id=trace_id, - span_id=span_id, - is_remote=True, - trace_flags=trace_flags, - ) - - # Create a NonRecordingSpan with the parent context - parent_span = NonRecordingSpan(parent_span_context) - - # Create a context with the parent span - return set_span_in_context(parent_span) - - except (ValueError, IndexError) as e: - logger.warning(f"Failed to parse parent_id '{parent_id}': {e}") - return None - - class OpenTelemetryScope: """Base class for OpenTelemetry tracing scopes in the SDK.""" @@ -182,7 +116,7 @@ def __init__( # Get context for parent relationship # If parent_id is provided, parse it and use it as the parent context # Otherwise, use the current context - parent_context = _parse_parent_id_to_context(parent_id) + parent_context = parse_parent_id_to_context(parent_id) span_context = parent_context if parent_context else context.get_current() self._span = tracer.start_span(activity_name, kind=activity_kind, context=span_context) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py index 73622815..571b95c5 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py @@ -13,11 +13,12 @@ from threading import RLock from typing import Any, Generic, TypeVar, cast +from opentelemetry import context from opentelemetry.semconv.attributes.exception_attributes import ( EXCEPTION_MESSAGE, EXCEPTION_STACKTRACE, ) -from opentelemetry.trace import Span +from opentelemetry.trace import NonRecordingSpan, Span, SpanContext, TraceFlags, set_span_in_context from opentelemetry.util.types import AttributeValue from wrapt import ObjectProxy @@ -27,6 +28,128 @@ logger.addHandler(logging.NullHandler()) +# W3C Trace Context constants +W3C_TRACE_CONTEXT_VERSION = "00" +W3C_TRACE_ID_LENGTH = 32 # 32 hex chars = 128 bits +W3C_SPAN_ID_LENGTH = 16 # 16 hex chars = 64 bits + + +def validate_w3c_trace_context_version(version: str) -> bool: + """Validate W3C Trace Context version. + + Args: + version: The version string to validate + + Returns: + True if valid, False otherwise + """ + return version == W3C_TRACE_CONTEXT_VERSION + + +def _is_valid_hex(hex_string: str) -> bool: + """Check if a string contains only valid hexadecimal characters. + + Args: + hex_string: The string to validate + + Returns: + True if all characters are valid hexadecimal (0-9, a-f, A-F), False otherwise + """ + return all(c in "0123456789abcdefABCDEF" for c in hex_string) + + +def validate_trace_id(trace_id_hex: str) -> bool: + """Validate W3C Trace Context trace_id format. + + Args: + trace_id_hex: The trace_id hex string to validate (should be 32 hex chars) + + Returns: + True if valid (32 hex chars), False otherwise + """ + return len(trace_id_hex) == W3C_TRACE_ID_LENGTH and _is_valid_hex(trace_id_hex) + + +def validate_span_id(span_id_hex: str) -> bool: + """Validate W3C Trace Context span_id format. + + Args: + span_id_hex: The span_id hex string to validate (should be 16 hex chars) + + Returns: + True if valid (16 hex chars), False otherwise + """ + return len(span_id_hex) == W3C_SPAN_ID_LENGTH and _is_valid_hex(span_id_hex) + + +def parse_parent_id_to_context(parent_id: str | None) -> context.Context | None: + """Parse a W3C trace context parent ID and return a context with the parent span. + + The parent_id format is expected to be W3C Trace Context format: + "00-{trace_id}-{span_id}-{trace_flags}" + Example: "00-1234567890abcdef1234567890abcdef-abcdefabcdef1234-01" + + Args: + parent_id: The W3C Trace Context format parent ID string + + Returns: + A context containing the parent span, or None if parent_id is invalid + """ + if not parent_id: + return None + + try: + # W3C Trace Context format: "00-{trace_id}-{span_id}-{trace_flags}" + parts = parent_id.split("-") + if len(parts) != 4: + logger.warning(f"Invalid parent_id format (expected 4 parts): {parent_id}") + return None + + version, trace_id_hex, span_id_hex, trace_flags_hex = parts + + # Validate W3C Trace Context version + if not validate_w3c_trace_context_version(version): + logger.warning(f"Unsupported W3C Trace Context version: {version}") + return None + + # Validate trace_id (must be 32 hex chars) + if not validate_trace_id(trace_id_hex): + logger.warning( + f"Invalid trace_id (expected {W3C_TRACE_ID_LENGTH} hex chars): '{trace_id_hex}'" + ) + return None + + # Validate span_id (must be 16 hex chars) + if not validate_span_id(span_id_hex): + logger.warning( + f"Invalid span_id (expected {W3C_SPAN_ID_LENGTH} hex chars): '{span_id_hex}'" + ) + return None + + # Parse the hex values + trace_id = int(trace_id_hex, 16) + span_id = int(span_id_hex, 16) + trace_flags = TraceFlags(int(trace_flags_hex, 16)) + + # Create a SpanContext from the parsed values + parent_span_context = SpanContext( + trace_id=trace_id, + span_id=span_id, + is_remote=True, + trace_flags=trace_flags, + ) + + # Create a NonRecordingSpan with the parent context + parent_span = NonRecordingSpan(parent_span_context) + + # Create a context with the parent span + return set_span_in_context(parent_span) + + except (ValueError, IndexError) as e: + logger.warning(f"Failed to parse parent_id '{parent_id}': {e}") + return None + + def safe_json_dumps(obj: Any, **kwargs: Any) -> str: return json.dumps(obj, default=str, ensure_ascii=False, **kwargs) From fb08009fa92f2f09ffa7ae7556e2254ea0016b17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:23:48 +0000 Subject: [PATCH 09/10] Clean up test_output_scope.py - remove redundant tests and aggregate similar ones Reduced from 10 tests to 4 focused tests: - test_output_scope_creates_span_with_messages (merged span name and messages tests) - test_record_output_messages_appends (merged multiple append tests) - test_output_scope_with_parent_id (parent linking) - test_output_scope_dispose (manual dispose) All tests use real spans via InMemorySpanExporter (no mocks). Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- tests/observability/core/test_output_scope.py | 238 ++++-------------- 1 file changed, 51 insertions(+), 187 deletions(-) diff --git a/tests/observability/core/test_output_scope.py b/tests/observability/core/test_output_scope.py index 9feef757..c1886657 100644 --- a/tests/observability/core/test_output_scope.py +++ b/tests/observability/core/test_output_scope.py @@ -23,19 +23,18 @@ class TestOutputScope(unittest.TestCase): - """Unit tests for OutputScope and its methods.""" + """Unit tests for OutputScope.""" @classmethod def setUpClass(cls): """Set up test environment once for all tests.""" - # Configure Microsoft Agent 365 for testing os.environ["ENABLE_A365_OBSERVABILITY"] = "true" configure( service_name="test-output-scope-service", service_namespace="test-namespace", ) - # Create test data + cls.tenant_details = TenantDetails(tenant_id="12345678-1234-5678-1234-567812345678") cls.agent_details = AgentDetails( agent_id="test-agent-123", @@ -46,18 +45,16 @@ def setUpClass(cls): def setUp(self): super().setUp() - # Reset TelemetryManager state to ensure fresh configuration + # Reset TelemetryManager state _telemetry_manager._tracer_provider = None _telemetry_manager._span_processors = {} OpenTelemetryScope._tracer = None - # Reconfigure to get a fresh TracerProvider configure( service_name="test-output-scope-service", service_namespace="test-namespace", ) - # Set up tracer to capture spans self.span_exporter = InMemorySpanExporter() tracer_provider = get_tracer_provider() tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) @@ -66,219 +63,86 @@ def tearDown(self): super().tearDown() self.span_exporter.clear() - def test_output_scope_creation(self): - """Test that OutputScope can be created successfully.""" - response = Response(messages=["Hello, how can I help you?"]) - - scope = OutputScope.start(self.agent_details, self.tenant_details, response) - - self.assertIsNotNone(scope) - scope.dispose() - - def test_record_output_messages_method_exists(self): - """Test that record_output_messages method exists on OutputScope.""" - response = Response(messages=["Initial message"]) - scope = OutputScope.start(self.agent_details, self.tenant_details, response) - - if scope is not None: - # Test that the method exists - self.assertTrue(hasattr(scope, "record_output_messages")) - self.assertTrue(callable(scope.record_output_messages)) - scope.dispose() - - def test_output_messages_set_on_span(self): - """Test that output messages are set on span attributes.""" - response = Response(messages=["This is the agent response"]) - - scope = OutputScope.start(self.agent_details, self.tenant_details, response) - - if scope is not None: - scope.dispose() - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - - span = finished_spans[-1] - span_attributes = getattr(span, "attributes", {}) or {} - - self.assertIn( - GEN_AI_OUTPUT_MESSAGES_KEY, - span_attributes, - "Expected output messages key to be set on span", - ) - - # Verify the message content is in the serialized output - output_value = span_attributes[GEN_AI_OUTPUT_MESSAGES_KEY] - self.assertIn("This is the agent response", output_value) - - def test_multiple_output_messages(self): - """Test that multiple output messages are properly recorded.""" - response = Response(messages=["First response", "Second response", "Third response"]) - - scope = OutputScope.start(self.agent_details, self.tenant_details, response) - - if scope is not None: - scope.dispose() - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - - span = finished_spans[-1] - span_attributes = getattr(span, "attributes", {}) or {} - - self.assertIn( - GEN_AI_OUTPUT_MESSAGES_KEY, - span_attributes, - "Expected output messages key to be set on span", - ) - - output_value = span_attributes[GEN_AI_OUTPUT_MESSAGES_KEY] - self.assertIn("First response", output_value) - self.assertIn("Second response", output_value) - self.assertIn("Third response", output_value) - - def test_record_output_messages_updates_span(self): - """Test that record_output_messages appends messages to the span.""" - response = Response(messages=["Initial message"]) - - scope = OutputScope.start(self.agent_details, self.tenant_details, response) - - if scope is not None: - # Record additional messages (should append, not replace) - scope.record_output_messages(["Appended message 1", "Appended message 2"]) - scope.dispose() - + def _get_last_span(self): + """Helper to get the last finished span and its attributes.""" finished_spans = self.span_exporter.get_finished_spans() self.assertTrue(finished_spans, "Expected at least one span to be created") - span = finished_spans[-1] - span_attributes = getattr(span, "attributes", {}) or {} - - self.assertIn( - GEN_AI_OUTPUT_MESSAGES_KEY, - span_attributes, - "Expected output messages key to be set on span", - ) - - # The span should have all messages (initial + appended) - output_value = span_attributes[GEN_AI_OUTPUT_MESSAGES_KEY] - self.assertIn("Initial message", output_value) - self.assertIn("Appended message 1", output_value) - self.assertIn("Appended message 2", output_value) - - def test_record_output_messages_multiple_appends(self): - """Test that multiple calls to record_output_messages accumulate messages.""" - response = Response(messages=["First message"]) + attributes = getattr(span, "attributes", {}) or {} + return span, attributes - scope = OutputScope.start(self.agent_details, self.tenant_details, response) + def test_output_scope_creates_span_with_messages(self): + """Test OutputScope creates span with output messages attribute.""" + response = Response(messages=["First message", "Second message"]) - if scope is not None: - # First append - scope.record_output_messages(["Second message"]) - # Second append - scope.record_output_messages(["Third message", "Fourth message"]) - scope.dispose() + with OutputScope.start(self.agent_details, self.tenant_details, response): + pass - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") + span, attributes = self._get_last_span() - span = finished_spans[-1] - span_attributes = getattr(span, "attributes", {}) or {} + # Verify span name contains operation name and agent id + self.assertIn("output_messages", span.name) + self.assertIn(self.agent_details.agent_id, span.name) - output_value = span_attributes[GEN_AI_OUTPUT_MESSAGES_KEY] - # All four messages should be present + # Verify output messages are set + self.assertIn(GEN_AI_OUTPUT_MESSAGES_KEY, attributes) + output_value = attributes[GEN_AI_OUTPUT_MESSAGES_KEY] self.assertIn("First message", output_value) self.assertIn("Second message", output_value) - self.assertIn("Third message", output_value) - self.assertIn("Fourth message", output_value) - def test_output_scope_context_manager(self): - """Test that OutputScope works as a context manager.""" - response = Response(messages=["Context manager test"]) + def test_record_output_messages_appends(self): + """Test record_output_messages appends to accumulated messages.""" + response = Response(messages=["Initial"]) with OutputScope.start(self.agent_details, self.tenant_details, response) as scope: - self.assertIsNotNone(scope) - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - - def test_output_scope_span_name(self): - """Test that OutputScope creates spans with correct operation name.""" - response = Response(messages=["Test message"]) + scope.record_output_messages(["Appended 1"]) + scope.record_output_messages(["Appended 2", "Appended 3"]) - scope = OutputScope.start(self.agent_details, self.tenant_details, response) - - if scope is not None: - scope.dispose() - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") + _, attributes = self._get_last_span() - span = finished_spans[-1] - # The activity name should contain "output_messages" and the agent id - self.assertIn("output_messages", span.name) - self.assertIn(self.agent_details.agent_id, span.name) + output_value = attributes[GEN_AI_OUTPUT_MESSAGES_KEY] + # All messages should be present (initial + all appended) + self.assertIn("Initial", output_value) + self.assertIn("Appended 1", output_value) + self.assertIn("Appended 2", output_value) + self.assertIn("Appended 3", output_value) def test_output_scope_with_parent_id(self): - """Test that OutputScope uses parent_id to link span to parent.""" - response = Response(messages=["Test message with parent"]) - # W3C Trace Context format: "00-{trace_id}-{span_id}-{trace_flags}" - # trace_id: 32 hex chars, span_id: 16 hex chars + """Test OutputScope uses parent_id to link span to parent context.""" + response = Response(messages=["Test"]) parent_trace_id = "1234567890abcdef1234567890abcdef" parent_span_id = "abcdefabcdef1234" parent_id = f"00-{parent_trace_id}-{parent_span_id}-01" - scope = OutputScope.start( + with OutputScope.start( self.agent_details, self.tenant_details, response, parent_id=parent_id - ) + ): + pass - if scope is not None: - scope.dispose() - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - - span = finished_spans[-1] + span, _ = self._get_last_span() - # Verify the span has the correct parent trace context - # The span's trace_id should match the parent's trace_id + # Verify span inherits parent's trace_id span_trace_id = f"{span.context.trace_id:032x}" - self.assertEqual( - span_trace_id, - parent_trace_id, - "Expected span's trace_id to match parent's trace_id", - ) + self.assertEqual(span_trace_id, parent_trace_id) - # The span's parent_span_id should match the parent's span_id - span_parent_id = None - if span.parent and hasattr(span.parent, "span_id"): - span_parent_id = f"{span.parent.span_id:016x}" - self.assertEqual( - span_parent_id, - parent_span_id, - "Expected span's parent_span_id to match parent's span_id", - ) + # Verify span's parent_span_id matches + self.assertIsNotNone(span.parent, "Expected span to have a parent") + self.assertTrue(hasattr(span.parent, "span_id"), "Expected parent to have span_id") + span_parent_id = f"{span.parent.span_id:016x}" + self.assertEqual(span_parent_id, parent_span_id) - def test_output_scope_without_parent_id(self): - """Test that OutputScope creates a span without forced parent when not provided.""" - response = Response(messages=["Test message without parent"]) + def test_output_scope_dispose(self): + """Test OutputScope dispose method ends the span.""" + response = Response(messages=["Test"]) scope = OutputScope.start(self.agent_details, self.tenant_details, response) + self.assertIsNotNone(scope) + scope.dispose() - if scope is not None: - scope.dispose() - + # Verify span was created and ended finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - - span = finished_spans[-1] - - # When no parent_id is provided, the span should either have no parent - # or inherit from the current context (which in tests is typically empty) - # We just verify the span was created successfully - self.assertIsNotNone(span.context.span_id) + self.assertEqual(len(finished_spans), 1) if __name__ == "__main__": - # Run pytest only on the current file sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:])) From 3901fa435f91800b5d1c59fb58e14f8f0c9332c1 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 9 Feb 2026 20:06:20 +0530 Subject: [PATCH 10/10] address PR comments --- .../observability/core/models/response.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py index e77c9d7d..0e4ae9fd 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py @@ -8,10 +8,5 @@ class Response: """Response details from agent execution.""" + """The list of response messages from the agent.""" messages: list[str] - """The list of response messages from the agent. - - Each message represents a text response generated by the agent during execution. - Messages are serialized to JSON format for OpenTelemetry span attributes. - An empty list is valid and represents no response messages. - """