Skip to content

Commit 482cb38

Browse files
CopilotnikhilNava
andcommitted
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>
1 parent 765af45 commit 482cb38

4 files changed

Lines changed: 277 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from dataclasses import dataclass
5+
6+
7+
@dataclass
8+
class Response:
9+
"""Response details from agent execution."""
10+
11+
messages: list[str]
12+
"""The list of response messages."""
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from ..agent_details import AgentDetails
5+
from ..constants import GEN_AI_OUTPUT_MESSAGES_KEY
6+
from ..models.response import Response
7+
from ..opentelemetry_scope import OpenTelemetryScope
8+
from ..tenant_details import TenantDetails
9+
from ..utils import safe_json_dumps
10+
11+
OUTPUT_OPERATION_NAME = "output_messages"
12+
13+
14+
class OutputScope(OpenTelemetryScope):
15+
"""Provides OpenTelemetry tracing scope for output messages."""
16+
17+
@staticmethod
18+
def start(
19+
agent_details: AgentDetails,
20+
tenant_details: TenantDetails,
21+
response: Response,
22+
) -> "OutputScope":
23+
"""Creates and starts a new scope for output tracing.
24+
25+
Args:
26+
agent_details: The details of the agent
27+
tenant_details: The details of the tenant
28+
response: The response details from the agent
29+
30+
Returns:
31+
A new OutputScope instance
32+
"""
33+
return OutputScope(agent_details, tenant_details, response)
34+
35+
def __init__(
36+
self,
37+
agent_details: AgentDetails,
38+
tenant_details: TenantDetails,
39+
response: Response,
40+
):
41+
"""Initialize the output scope.
42+
43+
Args:
44+
agent_details: The details of the agent
45+
tenant_details: The details of the tenant
46+
response: The response details from the agent
47+
"""
48+
super().__init__(
49+
kind="Client",
50+
operation_name=OUTPUT_OPERATION_NAME,
51+
activity_name=(f"{OUTPUT_OPERATION_NAME} {agent_details.agent_id}"),
52+
agent_details=agent_details,
53+
tenant_details=tenant_details,
54+
)
55+
56+
# Set response messages
57+
self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(response.messages))
58+
59+
def record_output_messages(self, messages: list[str]) -> None:
60+
"""Records the output messages for telemetry tracking.
61+
62+
Args:
63+
messages: List of output messages
64+
"""
65+
self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(messages))
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
import os
5+
import sys
6+
import unittest
7+
from pathlib import Path
8+
9+
import pytest
10+
from microsoft_agents_a365.observability.core import (
11+
AgentDetails,
12+
TenantDetails,
13+
configure,
14+
get_tracer_provider,
15+
)
16+
from microsoft_agents_a365.observability.core.config import _telemetry_manager
17+
from microsoft_agents_a365.observability.core.constants import GEN_AI_OUTPUT_MESSAGES_KEY
18+
from microsoft_agents_a365.observability.core.models.response import Response
19+
from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope
20+
from microsoft_agents_a365.observability.core.spans_scopes.output_scope import OutputScope
21+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
22+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
23+
24+
25+
class TestOutputScope(unittest.TestCase):
26+
"""Unit tests for OutputScope and its methods."""
27+
28+
@classmethod
29+
def setUpClass(cls):
30+
"""Set up test environment once for all tests."""
31+
# Configure Microsoft Agent 365 for testing
32+
os.environ["ENABLE_A365_OBSERVABILITY"] = "true"
33+
34+
configure(
35+
service_name="test-output-scope-service",
36+
service_namespace="test-namespace",
37+
)
38+
# Create test data
39+
cls.tenant_details = TenantDetails(tenant_id="12345678-1234-5678-1234-567812345678")
40+
cls.agent_details = AgentDetails(
41+
agent_id="test-agent-123",
42+
agent_name="Test Agent",
43+
agent_description="A test agent for output scope testing",
44+
)
45+
46+
def setUp(self):
47+
super().setUp()
48+
49+
# Reset TelemetryManager state to ensure fresh configuration
50+
_telemetry_manager._tracer_provider = None
51+
_telemetry_manager._span_processors = {}
52+
OpenTelemetryScope._tracer = None
53+
54+
# Reconfigure to get a fresh TracerProvider
55+
configure(
56+
service_name="test-output-scope-service",
57+
service_namespace="test-namespace",
58+
)
59+
60+
# Set up tracer to capture spans
61+
self.span_exporter = InMemorySpanExporter()
62+
tracer_provider = get_tracer_provider()
63+
tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter))
64+
65+
def tearDown(self):
66+
super().tearDown()
67+
self.span_exporter.clear()
68+
69+
def test_output_scope_creation(self):
70+
"""Test that OutputScope can be created successfully."""
71+
response = Response(messages=["Hello, how can I help you?"])
72+
73+
scope = OutputScope.start(self.agent_details, self.tenant_details, response)
74+
75+
self.assertIsNotNone(scope)
76+
scope.dispose()
77+
78+
def test_record_output_messages_method_exists(self):
79+
"""Test that record_output_messages method exists on OutputScope."""
80+
response = Response(messages=["Initial message"])
81+
scope = OutputScope.start(self.agent_details, self.tenant_details, response)
82+
83+
if scope is not None:
84+
# Test that the method exists
85+
self.assertTrue(hasattr(scope, "record_output_messages"))
86+
self.assertTrue(callable(scope.record_output_messages))
87+
scope.dispose()
88+
89+
def test_output_messages_set_on_span(self):
90+
"""Test that output messages are set on span attributes."""
91+
response = Response(messages=["This is the agent response"])
92+
93+
scope = OutputScope.start(self.agent_details, self.tenant_details, response)
94+
95+
if scope is not None:
96+
scope.dispose()
97+
98+
finished_spans = self.span_exporter.get_finished_spans()
99+
self.assertTrue(finished_spans, "Expected at least one span to be created")
100+
101+
span = finished_spans[-1]
102+
span_attributes = getattr(span, "attributes", {}) or {}
103+
104+
self.assertIn(
105+
GEN_AI_OUTPUT_MESSAGES_KEY,
106+
span_attributes,
107+
"Expected output messages key to be set on span",
108+
)
109+
110+
# Verify the message content is in the serialized output
111+
output_value = span_attributes[GEN_AI_OUTPUT_MESSAGES_KEY]
112+
self.assertIn("This is the agent response", output_value)
113+
114+
def test_multiple_output_messages(self):
115+
"""Test that multiple output messages are properly recorded."""
116+
response = Response(messages=["First response", "Second response", "Third response"])
117+
118+
scope = OutputScope.start(self.agent_details, self.tenant_details, response)
119+
120+
if scope is not None:
121+
scope.dispose()
122+
123+
finished_spans = self.span_exporter.get_finished_spans()
124+
self.assertTrue(finished_spans, "Expected at least one span to be created")
125+
126+
span = finished_spans[-1]
127+
span_attributes = getattr(span, "attributes", {}) or {}
128+
129+
self.assertIn(
130+
GEN_AI_OUTPUT_MESSAGES_KEY,
131+
span_attributes,
132+
"Expected output messages key to be set on span",
133+
)
134+
135+
output_value = span_attributes[GEN_AI_OUTPUT_MESSAGES_KEY]
136+
self.assertIn("First response", output_value)
137+
self.assertIn("Second response", output_value)
138+
self.assertIn("Third response", output_value)
139+
140+
def test_record_output_messages_updates_span(self):
141+
"""Test that record_output_messages updates the span with new messages."""
142+
response = Response(messages=["Initial message"])
143+
144+
scope = OutputScope.start(self.agent_details, self.tenant_details, response)
145+
146+
if scope is not None:
147+
# Record updated messages
148+
scope.record_output_messages(["Updated message 1", "Updated message 2"])
149+
scope.dispose()
150+
151+
finished_spans = self.span_exporter.get_finished_spans()
152+
self.assertTrue(finished_spans, "Expected at least one span to be created")
153+
154+
span = finished_spans[-1]
155+
span_attributes = getattr(span, "attributes", {}) or {}
156+
157+
self.assertIn(
158+
GEN_AI_OUTPUT_MESSAGES_KEY,
159+
span_attributes,
160+
"Expected output messages key to be set on span",
161+
)
162+
163+
# The span should have the updated messages
164+
output_value = span_attributes[GEN_AI_OUTPUT_MESSAGES_KEY]
165+
self.assertIn("Updated message 1", output_value)
166+
self.assertIn("Updated message 2", output_value)
167+
168+
def test_output_scope_context_manager(self):
169+
"""Test that OutputScope works as a context manager."""
170+
response = Response(messages=["Context manager test"])
171+
172+
with OutputScope.start(self.agent_details, self.tenant_details, response) as scope:
173+
self.assertIsNotNone(scope)
174+
175+
finished_spans = self.span_exporter.get_finished_spans()
176+
self.assertTrue(finished_spans, "Expected at least one span to be created")
177+
178+
def test_output_scope_span_name(self):
179+
"""Test that OutputScope creates spans with correct operation name."""
180+
response = Response(messages=["Test message"])
181+
182+
scope = OutputScope.start(self.agent_details, self.tenant_details, response)
183+
184+
if scope is not None:
185+
scope.dispose()
186+
187+
finished_spans = self.span_exporter.get_finished_spans()
188+
self.assertTrue(finished_spans, "Expected at least one span to be created")
189+
190+
span = finished_spans[-1]
191+
# The activity name should contain "output_messages" and the agent id
192+
self.assertIn("output_messages", span.name)
193+
self.assertIn(self.agent_details.agent_id, span.name)
194+
195+
196+
if __name__ == "__main__":
197+
# Run pytest only on the current file
198+
sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:]))

0 commit comments

Comments
 (0)