diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py index 16a6a631..476be985 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py @@ -4,3 +4,18 @@ """ Microsoft Agent 365 Observability Hosting Library. """ + +from .middleware.baggage_middleware import BaggageMiddleware +from .middleware.observability_hosting_manager import ( + ObservabilityHostingManager, + ObservabilityHostingOptions, +) +from .middleware.output_logging_middleware import A365_PARENT_SPAN_KEY, OutputLoggingMiddleware + +__all__ = [ + "BaggageMiddleware", + "OutputLoggingMiddleware", + "A365_PARENT_SPAN_KEY", + "ObservabilityHostingManager", + "ObservabilityHostingOptions", +] diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/__init__.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/__init__.py new file mode 100644 index 00000000..f556fc0f --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from .baggage_middleware import BaggageMiddleware +from .observability_hosting_manager import ObservabilityHostingManager, ObservabilityHostingOptions +from .output_logging_middleware import A365_PARENT_SPAN_KEY, OutputLoggingMiddleware + +__all__ = [ + "BaggageMiddleware", + "OutputLoggingMiddleware", + "A365_PARENT_SPAN_KEY", + "ObservabilityHostingManager", + "ObservabilityHostingOptions", +] diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/baggage_middleware.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/baggage_middleware.py new file mode 100644 index 00000000..f1ee17cb --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/baggage_middleware.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Middleware that propagates OpenTelemetry baggage context derived from TurnContext.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from microsoft_agents.activity import ActivityEventNames, ActivityTypes +from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder + +from ..scope_helpers.populate_baggage import populate + + +class BaggageMiddleware: + """Middleware that propagates OpenTelemetry baggage context derived from TurnContext. + + Async replies (ContinueConversation) are passed through without baggage setup. + """ + + async def on_turn( + self, + context: TurnContext, + logic: Callable[[TurnContext], Awaitable], + ) -> None: + activity = context.activity + is_async_reply = ( + activity is not None + and activity.type == ActivityTypes.event + and activity.name == ActivityEventNames.continue_conversation + ) + + if is_async_reply: + await logic() + return + + builder = BaggageBuilder() + populate(builder, context) + baggage_scope = builder.build() + + with baggage_scope: + await logic() diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/observability_hosting_manager.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/observability_hosting_manager.py new file mode 100644 index 00000000..54f56487 --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/observability_hosting_manager.py @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Singleton manager for configuring hosting-layer observability middleware.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from microsoft_agents.hosting.core.middleware_set import MiddlewareSet + +from .baggage_middleware import BaggageMiddleware +from .output_logging_middleware import OutputLoggingMiddleware + +logger = logging.getLogger(__name__) + + +@dataclass +class ObservabilityHostingOptions: + """Configuration options for the hosting observability layer.""" + + enable_baggage: bool = False + """Enable baggage propagation middleware. Defaults to ``False``.""" + + enable_output_logging: bool = False + """Enable output logging middleware for tracing outgoing messages. Defaults to ``False``.""" + + +class ObservabilityHostingManager: + """Singleton manager for configuring hosting-layer observability middleware. + + Example: + .. code-block:: python + + ObservabilityHostingManager.configure(adapter.middleware_set, ObservabilityHostingOptions( + enable_output_logging=True, + )) + """ + + _instance: ObservabilityHostingManager | None = None + + def __init__(self) -> None: + """Private constructor — use :meth:`configure` instead.""" + + @classmethod + def configure( + cls, + middleware_set: MiddlewareSet, + options: ObservabilityHostingOptions, + ) -> ObservabilityHostingManager: + """Configure the singleton instance and register middleware. + + Subsequent calls after the first are no-ops and return the existing instance. + + Args: + middleware_set: The middleware set to register middleware on + (e.g., ``adapter.middleware_set``). + options: Configuration options controlling which middleware to enable. + + Returns: + The singleton :class:`ObservabilityHostingManager` instance. + + Raises: + TypeError: If *middleware_set* or *options* is ``None``. + """ + if middleware_set is None: + raise TypeError("middleware_set must not be None") + if options is None: + raise TypeError("options must not be None") + + if cls._instance is not None: + logger.warning( + "[ObservabilityHostingManager] Already configured. " + "Subsequent configure() calls are ignored." + ) + return cls._instance + + instance = cls() + + if options.enable_baggage: + middleware_set.use(BaggageMiddleware()) + logger.info("[ObservabilityHostingManager] BaggageMiddleware registered.") + + if options.enable_output_logging: + middleware_set.use(OutputLoggingMiddleware()) + logger.info("[ObservabilityHostingManager] OutputLoggingMiddleware registered.") + + logger.info( + "[ObservabilityHostingManager] Configured. Baggage: %s, OutputLogging: %s.", + options.enable_baggage, + options.enable_output_logging, + ) + + cls._instance = instance + return instance + + @classmethod + def reset(cls) -> None: + """Reset the singleton instance. Intended for testing only.""" + cls._instance = None diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py new file mode 100644 index 00000000..5bba6045 --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py @@ -0,0 +1,219 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Middleware that creates OutputScope spans for outgoing messages.""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable + +from microsoft_agents.activity import Activity +from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents_a365.observability.core.agent_details import AgentDetails +from microsoft_agents_a365.observability.core.constants import ( + GEN_AI_CALLER_ID_KEY, + GEN_AI_CALLER_NAME_KEY, + GEN_AI_CALLER_TENANT_ID_KEY, + GEN_AI_CALLER_UPN_KEY, + GEN_AI_CONVERSATION_ID_KEY, + GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, + GEN_AI_EXECUTION_SOURCE_NAME_KEY, + GEN_AI_EXECUTION_TYPE_KEY, +) +from microsoft_agents_a365.observability.core.models.caller_details import CallerDetails +from microsoft_agents_a365.observability.core.models.response import Response +from microsoft_agents_a365.observability.core.spans_scopes.output_scope import OutputScope +from microsoft_agents_a365.observability.core.tenant_details import TenantDetails + +from ..scope_helpers.utils import ( + get_execution_type_pair, +) + +logger = logging.getLogger(__name__) + +A365_PARENT_SPAN_KEY = "A365ParentSpanId" +"""TurnState key for the parent span reference.""" + + +def _derive_agent_details(context: TurnContext) -> AgentDetails | None: + """Derive target agent details from the activity recipient. + + Returns ``None`` when the activity is not an agentic request or the + recipient is missing, so callers can short-circuit without emitting + spans with empty identifiers. + """ + activity = context.activity + if not activity.is_agentic_request(): + return None + recipient = getattr(activity, "recipient", None) + if not recipient: + return None + return AgentDetails( + agent_id=activity.get_agentic_instance_id() or "", + agent_name=getattr(recipient, "name", None), + agent_auid=getattr(recipient, "aad_object_id", None), + agent_upn=activity.get_agentic_user(), + agent_description=getattr(recipient, "role", None), + tenant_id=getattr(recipient, "tenant_id", None), + ) + + +def _derive_tenant_details(context: TurnContext) -> TenantDetails | None: + """Derive tenant details from the activity recipient.""" + tenant_id = getattr(getattr(context.activity, "recipient", None), "tenant_id", None) + return TenantDetails(tenant_id=tenant_id) if tenant_id else None + + +def _derive_caller_details(context: TurnContext) -> CallerDetails | None: + """Derive caller identity details from the activity from property.""" + frm = getattr(context.activity, "from_property", None) + if not frm: + return None + return CallerDetails( + caller_id=getattr(frm, "aad_object_id", None), + caller_upn=getattr(frm, "agentic_user_id", None), + caller_name=getattr(frm, "name", None), + tenant_id=getattr(frm, "tenant_id", None), + ) + + +def _derive_conversation_id(context: TurnContext) -> str | None: + """Derive conversation id from the TurnContext.""" + conv = getattr(context.activity, "conversation", None) + return conv.id if conv else None + + +def _derive_source_metadata( + context: TurnContext, +) -> dict[str, str | None]: + """Derive source metadata (channel name and description) from TurnContext.""" + channel_id = getattr(context.activity, "channel_id", None) + channel_name: str | None = None + sub_channel: str | None = None + if channel_id is not None: + if isinstance(channel_id, str): + channel_name = channel_id + elif hasattr(channel_id, "channel"): + channel_name = channel_id.channel + sub_channel = channel_id.sub_channel + return {"name": channel_name, "description": sub_channel} + + +def _derive_execution_type(context: TurnContext) -> str | None: + """Derive execution type from the activity.""" + pairs = list(get_execution_type_pair(context.activity)) + if pairs: + return pairs[0][1] + return None + + +class OutputLoggingMiddleware: + """Middleware that creates :class:`OutputScope` spans for outgoing messages. + + Links to a parent span when :data:`A365_PARENT_SPAN_KEY` is set in + ``turn_state``. + + **Privacy note:** Outgoing message content is captured verbatim as span + attributes and exported to the configured telemetry backend. + """ + + async def on_turn( + self, + context: TurnContext, + logic: Callable[[TurnContext], Awaitable], + ) -> None: + agent_details = _derive_agent_details(context) + tenant_details = _derive_tenant_details(context) + + if not agent_details or not tenant_details: + await logic() + return + + caller_details = _derive_caller_details(context) + conversation_id = _derive_conversation_id(context) + source_metadata = _derive_source_metadata(context) + execution_type = _derive_execution_type(context) + + context.on_send_activities( + self._create_send_handler( + context, + agent_details, + tenant_details, + caller_details, + conversation_id, + source_metadata, + execution_type, + ) + ) + + await logic() + + def _create_send_handler( + self, + turn_context: TurnContext, + agent_details: AgentDetails, + tenant_details: TenantDetails, + caller_details: CallerDetails | None, + conversation_id: str | None, + source_metadata: dict[str, str | None], + execution_type: str | None, + ) -> Callable: + """Create a send handler that wraps outgoing messages in OutputScope spans. + + Reads parent span ref lazily so the agent handler can set it during ``logic()``. + """ + + async def handler( + ctx: TurnContext, + activities: list[Activity], + send_next: Callable, + ) -> None: + messages = [ + a.text for a in activities if getattr(a, "type", None) == "message" and a.text + ] + + if not messages: + await send_next() + return + + parent_id: str | None = turn_context.turn_state.get(A365_PARENT_SPAN_KEY) + if not parent_id: + logger.warning( + "[OutputLoggingMiddleware] No parent span ref in turn_state under " + "'%s'. OutputScope will not be linked to a parent.", + A365_PARENT_SPAN_KEY, + ) + + output_scope = OutputScope.start( + agent_details=agent_details, + tenant_details=tenant_details, + response=Response(messages=messages), + parent_id=parent_id, + ) + + # Set additional attributes on the scope + output_scope.set_tag_maybe(GEN_AI_CONVERSATION_ID_KEY, conversation_id) + output_scope.set_tag_maybe(GEN_AI_EXECUTION_TYPE_KEY, execution_type) + output_scope.set_tag_maybe( + GEN_AI_EXECUTION_SOURCE_NAME_KEY, source_metadata.get("name") + ) + output_scope.set_tag_maybe( + GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, source_metadata.get("description") + ) + + if caller_details: + output_scope.set_tag_maybe(GEN_AI_CALLER_ID_KEY, caller_details.caller_id) + output_scope.set_tag_maybe(GEN_AI_CALLER_UPN_KEY, caller_details.caller_upn) + output_scope.set_tag_maybe(GEN_AI_CALLER_NAME_KEY, caller_details.caller_name) + output_scope.set_tag_maybe(GEN_AI_CALLER_TENANT_ID_KEY, caller_details.tenant_id) + + try: + await send_next() + except Exception as error: + output_scope.record_error(error) + raise + finally: + output_scope.dispose() + + return handler diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py index d112fad3..b2b5eb9a 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py @@ -62,10 +62,10 @@ def get_target_agent_pairs(activity: Activity) -> Iterator[tuple[str, Any]]: rec = activity.recipient if not rec: return - yield GEN_AI_AGENT_ID_KEY, rec.agentic_app_id + yield GEN_AI_AGENT_ID_KEY, activity.get_agentic_instance_id() yield GEN_AI_AGENT_NAME_KEY, rec.name yield GEN_AI_AGENT_AUID_KEY, rec.aad_object_id - yield GEN_AI_AGENT_UPN_KEY, rec.agentic_user_id + yield GEN_AI_AGENT_UPN_KEY, activity.get_agentic_user() yield ( GEN_AI_AGENT_DESCRIPTION_KEY, rec.role, diff --git a/tests/observability/hosting/middleware/__init__.py b/tests/observability/hosting/middleware/__init__.py new file mode 100644 index 00000000..59e481eb --- /dev/null +++ b/tests/observability/hosting/middleware/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/observability/hosting/middleware/test_baggage_middleware.py b/tests/observability/hosting/middleware/test_baggage_middleware.py new file mode 100644 index 00000000..ddc00e00 --- /dev/null +++ b/tests/observability/hosting/middleware/test_baggage_middleware.py @@ -0,0 +1,97 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from unittest.mock import MagicMock + +import pytest +from microsoft_agents.activity import ( + Activity, + ActivityEventNames, + ActivityTypes, + ChannelAccount, + ConversationAccount, +) +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents_a365.observability.core.constants import ( + GEN_AI_CALLER_ID_KEY, + TENANT_ID_KEY, +) +from microsoft_agents_a365.observability.hosting.middleware.baggage_middleware import ( + BaggageMiddleware, +) +from opentelemetry import baggage + + +def _make_turn_context( + activity_type: str = "message", + activity_name: str | None = None, + text: str = "Hello", +) -> TurnContext: + """Create a TurnContext with a test activity.""" + kwargs: dict = { + "type": activity_type, + "text": text, + "from_property": ChannelAccount( + aad_object_id="caller-id", + name="Caller", + agentic_user_id="caller-upn", + tenant_id="tenant-id", + ), + "recipient": ChannelAccount( + tenant_id="tenant-123", + role="user", + name="Agent", + ), + "conversation": ConversationAccount(id="conv-id"), + "service_url": "https://example.com", + "channel_id": "test-channel", + } + if activity_name is not None: + kwargs["name"] = activity_name + activity = Activity(**kwargs) + adapter = MagicMock() + return TurnContext(adapter, activity) + + +@pytest.mark.asyncio +async def test_baggage_middleware_propagates_baggage(): + """BaggageMiddleware should set baggage context for the downstream logic.""" + middleware = BaggageMiddleware() + ctx = _make_turn_context() + + captured_caller_id = None + captured_tenant_id = None + + async def logic(): + nonlocal captured_caller_id, captured_tenant_id + captured_caller_id = baggage.get_baggage(GEN_AI_CALLER_ID_KEY) + captured_tenant_id = baggage.get_baggage(TENANT_ID_KEY) + + await middleware.on_turn(ctx, logic) + + assert captured_caller_id == "caller-id" + assert captured_tenant_id == "tenant-123" + + +@pytest.mark.asyncio +async def test_baggage_middleware_skips_async_reply(): + """BaggageMiddleware should skip baggage setup for ContinueConversation events.""" + middleware = BaggageMiddleware() + ctx = _make_turn_context( + activity_type=ActivityTypes.event, + activity_name=ActivityEventNames.continue_conversation, + ) + + logic_called = False + captured_caller_id = None + + async def logic(): + nonlocal logic_called, captured_caller_id + logic_called = True + captured_caller_id = baggage.get_baggage(GEN_AI_CALLER_ID_KEY) + + await middleware.on_turn(ctx, logic) + + assert logic_called is True + # Baggage should NOT be set because the middleware skipped it + assert captured_caller_id is None diff --git a/tests/observability/hosting/middleware/test_observability_hosting_manager.py b/tests/observability/hosting/middleware/test_observability_hosting_manager.py new file mode 100644 index 00000000..3b00bf90 --- /dev/null +++ b/tests/observability/hosting/middleware/test_observability_hosting_manager.py @@ -0,0 +1,73 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from unittest.mock import MagicMock + +import pytest +from microsoft_agents_a365.observability.hosting.middleware.baggage_middleware import ( + BaggageMiddleware, +) +from microsoft_agents_a365.observability.hosting.middleware.observability_hosting_manager import ( + ObservabilityHostingManager, + ObservabilityHostingOptions, +) +from microsoft_agents_a365.observability.hosting.middleware.output_logging_middleware import ( + OutputLoggingMiddleware, +) + + +@pytest.fixture(autouse=True) +def _reset_singleton(): + """Reset the singleton before and after each test.""" + ObservabilityHostingManager.reset() + yield + ObservabilityHostingManager.reset() + + +def test_configure_is_singleton(): + """configure() should return an ObservabilityHostingManager and be a singleton.""" + middleware_set = MagicMock() + options = ObservabilityHostingOptions() + first = ObservabilityHostingManager.configure(middleware_set, options) + assert isinstance(first, ObservabilityHostingManager) + second = ObservabilityHostingManager.configure(middleware_set, options) + assert first is second + + +@pytest.mark.parametrize( + "enable_baggage,enable_output_logging,expected_types", + [ + (True, False, [BaggageMiddleware]), + (True, True, [BaggageMiddleware, OutputLoggingMiddleware]), + (False, True, [OutputLoggingMiddleware]), + (False, False, []), + ], + ids=["default_baggage_only", "both_enabled", "output_only", "none"], +) +def test_configure_registers_expected_middlewares( + enable_baggage, enable_output_logging, expected_types +): + """configure() should register the correct middlewares based on options.""" + middleware_set = MagicMock() + options = ObservabilityHostingOptions( + enable_baggage=enable_baggage, enable_output_logging=enable_output_logging + ) + ObservabilityHostingManager.configure(middleware_set, options) + + assert middleware_set.use.call_count == len(expected_types) + for call, expected_type in zip(middleware_set.use.call_args_list, expected_types, strict=True): + assert isinstance(call[0][0], expected_type) + + +@pytest.mark.parametrize( + "middleware_set,options,match", + [ + (None, ObservabilityHostingOptions(), "middleware_set must not be None"), + (MagicMock(), None, "options must not be None"), + ], + ids=["none_middleware_set", "none_options"], +) +def test_configure_raises_on_none(middleware_set, options, match): + """configure() should raise TypeError when required args are None.""" + with pytest.raises(TypeError, match=match): + ObservabilityHostingManager.configure(middleware_set, options) diff --git a/tests/observability/hosting/middleware/test_output_logging_middleware.py b/tests/observability/hosting/middleware/test_output_logging_middleware.py new file mode 100644 index 00000000..c7d89389 --- /dev/null +++ b/tests/observability/hosting/middleware/test_output_logging_middleware.py @@ -0,0 +1,219 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from microsoft_agents.activity import ( + Activity, + ChannelAccount, + ConversationAccount, +) +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents_a365.observability.hosting.middleware.output_logging_middleware import ( + A365_PARENT_SPAN_KEY, + OutputLoggingMiddleware, +) + + +def _make_turn_context( + activity_type: str = "message", + activity_name: str | None = None, + text: str = "Hello", + recipient_tenant_id: str = "tenant-123", + recipient_agentic_app_id: str = "agent-app-id", +) -> TurnContext: + """Create a TurnContext with a test activity.""" + kwargs: dict = { + "type": activity_type, + "text": text, + "from_property": ChannelAccount( + aad_object_id="caller-id", + name="Caller", + agentic_user_id="caller-upn", + tenant_id="caller-tenant-id", + ), + "recipient": ChannelAccount( + tenant_id=recipient_tenant_id, + role="agenticAppInstance", + name="Agent One", + agentic_app_id=recipient_agentic_app_id, + aad_object_id="agent-auid", + agentic_user_id="agent-upn", + ), + "conversation": ConversationAccount(id="conv-id"), + "service_url": "https://example.com", + "channel_id": "test-channel", + } + if activity_name is not None: + kwargs["name"] = activity_name + activity = Activity(**kwargs) + adapter = MagicMock() + return TurnContext(adapter, activity) + + +@pytest.mark.asyncio +async def test_output_logging_registers_send_handler(): + """OutputLoggingMiddleware should register an on_send_activities handler.""" + middleware = OutputLoggingMiddleware() + ctx = _make_turn_context() + + initial_handler_count = len(ctx._on_send_activities) + + async def logic(): + pass + + await middleware.on_turn(ctx, logic) + + assert len(ctx._on_send_activities) == initial_handler_count + 1 + + +@pytest.mark.asyncio +async def test_output_logging_passes_through_without_recipient(): + """Should pass through without registering handlers if no recipient.""" + middleware = OutputLoggingMiddleware() + activity = Activity( + type="message", + text="Hello", + from_property=ChannelAccount(name="Caller"), + conversation=ConversationAccount(id="conv-id"), + service_url="https://example.com", + ) + # Remove recipient so agent details cannot be derived + activity.recipient = None + adapter = MagicMock() + ctx = TurnContext(adapter, activity) + + logic_called = False + + async def logic(): + nonlocal logic_called + logic_called = True + + await middleware.on_turn(ctx, logic) + + assert logic_called is True + assert len(ctx._on_send_activities) == 0 + + +@pytest.mark.asyncio +async def test_output_logging_passes_through_without_tenant(): + """Should pass through without registering handlers if no tenant id.""" + middleware = OutputLoggingMiddleware() + ctx = _make_turn_context(recipient_tenant_id=None) + + logic_called = False + + async def logic(): + nonlocal logic_called + logic_called = True + + await middleware.on_turn(ctx, logic) + + assert logic_called is True + assert len(ctx._on_send_activities) == 0 + + +@pytest.mark.asyncio +async def test_send_handler_skips_non_message_activities(): + """Send handler should skip non-message activities and call send_next.""" + middleware = OutputLoggingMiddleware() + ctx = _make_turn_context() + + await middleware.on_turn(ctx, AsyncMock()) + + # Get the registered handler + handler = ctx._on_send_activities[-1] + + # Create non-message activities + activities = [Activity(type="typing")] + send_next = AsyncMock() + + await handler(ctx, activities, send_next) + send_next.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_send_handler_creates_output_scope_for_messages(): + """Send handler should create an OutputScope for message activities and dispose on success.""" + middleware = OutputLoggingMiddleware() + ctx = _make_turn_context() + + await middleware.on_turn(ctx, AsyncMock()) + + handler = ctx._on_send_activities[-1] + + activities = [Activity(type="message", text="Reply message")] + send_next = AsyncMock() + + with patch( + "microsoft_agents_a365.observability.hosting.middleware" + ".output_logging_middleware.OutputScope" + ) as mock_output_scope_cls: + mock_scope = MagicMock() + mock_output_scope_cls.start.return_value = mock_scope + + await handler(ctx, activities, send_next) + + mock_output_scope_cls.start.assert_called_once() + send_next.assert_awaited_once() + mock_scope.dispose.assert_called_once() + mock_scope.record_error.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_handler_uses_parent_span_from_turn_state(): + """Send handler should pass parent_id from turn_state to OutputScope.""" + middleware = OutputLoggingMiddleware() + ctx = _make_turn_context() + + parent_id = "00-1af7651916cd43dd8448eb211c80319c-c7ad6b7169203331-01" + ctx.turn_state[A365_PARENT_SPAN_KEY] = parent_id + + await middleware.on_turn(ctx, AsyncMock()) + + handler = ctx._on_send_activities[-1] + + activities = [Activity(type="message", text="Reply")] + send_next = AsyncMock() + + with patch( + "microsoft_agents_a365.observability.hosting.middleware" + ".output_logging_middleware.OutputScope" + ) as mock_output_scope_cls: + mock_scope = MagicMock() + mock_output_scope_cls.start.return_value = mock_scope + + await handler(ctx, activities, send_next) + + call_kwargs = mock_output_scope_cls.start.call_args + assert call_kwargs.kwargs["parent_id"] == parent_id + + +@pytest.mark.asyncio +async def test_send_handler_rethrows_errors(): + """Send handler should re-throw errors from send_next after recording them.""" + middleware = OutputLoggingMiddleware() + ctx = _make_turn_context() + + await middleware.on_turn(ctx, AsyncMock()) + + handler = ctx._on_send_activities[-1] + + activities = [Activity(type="message", text="Reply")] + send_error = RuntimeError("send pipeline failed") + send_next = AsyncMock(side_effect=send_error) + + with patch( + "microsoft_agents_a365.observability.hosting.middleware" + ".output_logging_middleware.OutputScope" + ) as mock_output_scope_cls: + mock_scope = MagicMock() + mock_output_scope_cls.start.return_value = mock_scope + + with pytest.raises(RuntimeError, match="send pipeline failed"): + await handler(ctx, activities, send_next) + + mock_scope.record_error.assert_called_once_with(send_error) + mock_scope.dispose.assert_called_once() diff --git a/tests/observability/hosting/scope_helpers/test_scope_helper_utils.py b/tests/observability/hosting/scope_helpers/test_scope_helper_utils.py index eee20a79..c1816ba9 100644 --- a/tests/observability/hosting/scope_helpers/test_scope_helper_utils.py +++ b/tests/observability/hosting/scope_helpers/test_scope_helper_utils.py @@ -66,7 +66,7 @@ def test_get_target_agent_pairs(): name="Test Agent", aad_object_id="agent-auid", agentic_user_id="agent-upn", - role="Assistant", + role="agenticAppInstance", ) activity = Activity(type="message", recipient=recipient) @@ -76,7 +76,7 @@ def test_get_target_agent_pairs(): assert (GEN_AI_AGENT_NAME_KEY, "Test Agent") in result assert (GEN_AI_AGENT_AUID_KEY, "agent-auid") in result assert (GEN_AI_AGENT_UPN_KEY, "agent-upn") in result - assert (GEN_AI_AGENT_DESCRIPTION_KEY, "Assistant") in result + assert (GEN_AI_AGENT_DESCRIPTION_KEY, "agenticAppInstance") in result def test_get_tenant_id_pair():