From 4a36531d8d4f7ac4b47ae732647dd8cf85b6a5c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:40:19 +0000 Subject: [PATCH 1/9] Initial plan From c858b8b16e0da4bd273d55b5a0bf7c01bcb80892 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:52:26 +0000 Subject: [PATCH 2/9] Add BaggageMiddleware, OutputLoggingMiddleware, and ObservabilityHostingManager Implement Python equivalents of the Node.js PR #210 middleware: - BaggageMiddleware: propagates OpenTelemetry baggage from TurnContext - OutputLoggingMiddleware: creates OutputScope spans for outgoing messages - ObservabilityHostingManager: singleton to configure hosting middleware - 19 unit tests covering all three middleware components Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../observability/hosting/__init__.py | 15 ++ .../hosting/middleware/__init__.py | 14 + .../hosting/middleware/baggage_middleware.py | 47 ++++ .../observability_hosting_manager.py | 106 ++++++++ .../middleware/output_logging_middleware.py | 216 ++++++++++++++++ .../hosting/middleware/__init__.py | 2 + .../middleware/test_baggage_middleware.py | 109 ++++++++ .../test_observability_hosting_manager.py | 97 +++++++ .../test_output_logging_middleware.py | 244 ++++++++++++++++++ 9 files changed, 850 insertions(+) create mode 100644 libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/__init__.py create mode 100644 libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/baggage_middleware.py create mode 100644 libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/observability_hosting_manager.py create mode 100644 libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py create mode 100644 tests/observability/hosting/middleware/__init__.py create mode 100644 tests/observability/hosting/middleware/test_baggage_middleware.py create mode 100644 tests/observability/hosting/middleware/test_observability_hosting_manager.py create mode 100644 tests/observability/hosting/middleware/test_output_logging_middleware.py 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..822c14aa --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/baggage_middleware.py @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Middleware that propagates OpenTelemetry baggage context derived from TurnContext.""" + +from __future__ import annotations + +import logging +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 + +logger = logging.getLogger(__name__) + + +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..64372eed --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/observability_hosting_manager.py @@ -0,0 +1,106 @@ +# 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 typing import Protocol + +from microsoft_agents.hosting.core import Middleware + +from .baggage_middleware import BaggageMiddleware +from .output_logging_middleware import OutputLoggingMiddleware + +logger = logging.getLogger(__name__) + + +class _AdapterLike(Protocol): + """Protocol for adapter objects that support middleware registration.""" + + def use(self, middleware: Middleware) -> object: ... + + +@dataclass +class ObservabilityHostingOptions: + """Configuration options for the hosting observability layer.""" + + enable_baggage: bool = True + """Enable baggage propagation middleware. Defaults to ``True``.""" + + 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, ObservabilityHostingOptions( + enable_output_logging=True, + )) + """ + + _instance: ObservabilityHostingManager | None = None + + def __init__(self) -> None: + """Private constructor — use :meth:`configure` instead.""" + + @classmethod + def configure( + cls, + adapter: _AdapterLike | None = None, + options: ObservabilityHostingOptions | None = None, + ) -> ObservabilityHostingManager: + """Configure the singleton instance and register middleware on the adapter. + + Subsequent calls after the first are no-ops and return the existing instance. + + Args: + adapter: An adapter that supports ``.use()`` for middleware registration. + options: Configuration options. Defaults are used when ``None``. + + Returns: + The singleton :class:`ObservabilityHostingManager` instance. + """ + if cls._instance is not None: + logger.warning( + "[ObservabilityHostingManager] Already configured. " + "Subsequent configure() calls are ignored." + ) + return cls._instance + + instance = cls() + + if adapter is not None: + opts = options or ObservabilityHostingOptions() + + if opts.enable_baggage: + adapter.use(BaggageMiddleware()) + logger.info("[ObservabilityHostingManager] BaggageMiddleware registered.") + + if opts.enable_output_logging: + adapter.use(OutputLoggingMiddleware()) + logger.info("[ObservabilityHostingManager] OutputLoggingMiddleware registered.") + + logger.info( + "[ObservabilityHostingManager] Configured. Baggage: %s, OutputLogging: %s.", + opts.enable_baggage, + opts.enable_output_logging, + ) + else: + logger.warning( + "[ObservabilityHostingManager] No adapter provided. No middleware registered." + ) + + 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..124f385c --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py @@ -0,0 +1,216 @@ +# 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. + +Set this in ``turn_state`` to link OutputScope spans as children of an +InvokeAgentScope. The value should be a W3C traceparent string in the format +``"00-{trace_id}-{span_id}-{trace_flags}"``. +""" + + +def _derive_agent_details(context: TurnContext) -> AgentDetails | None: + """Derive target agent details from the activity recipient.""" + recipient = getattr(context.activity, "recipient", None) + if not recipient: + return None + return AgentDetails( + agent_id=getattr(recipient, "agentic_app_id", None) or "", + agent_name=getattr(recipient, "name", None), + agent_auid=getattr(recipient, "aad_object_id", None), + agent_upn=getattr(recipient, "agentic_user_id", None), + 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/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..53cbd227 --- /dev/null +++ b/tests/observability/hosting/middleware/test_baggage_middleware.py @@ -0,0 +1,109 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from unittest.mock import AsyncMock, 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 + + +@pytest.mark.asyncio +async def test_baggage_middleware_calls_logic(): + """BaggageMiddleware should always call the downstream logic.""" + middleware = BaggageMiddleware() + ctx = _make_turn_context() + + logic_mock = AsyncMock() + await middleware.on_turn(ctx, logic_mock) + + logic_mock.assert_awaited_once() 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..91444a6d --- /dev/null +++ b/tests/observability/hosting/middleware/test_observability_hosting_manager.py @@ -0,0 +1,97 @@ +# 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_returns_instance(): + """configure() should return an ObservabilityHostingManager instance.""" + adapter = MagicMock() + instance = ObservabilityHostingManager.configure(adapter) + assert isinstance(instance, ObservabilityHostingManager) + + +def test_configure_is_singleton(): + """Subsequent configure() calls should return the same instance.""" + adapter = MagicMock() + first = ObservabilityHostingManager.configure(adapter) + second = ObservabilityHostingManager.configure(adapter) + assert first is second + + +def test_configure_registers_baggage_middleware_by_default(): + """By default, BaggageMiddleware should be registered.""" + adapter = MagicMock() + ObservabilityHostingManager.configure(adapter) + + # The adapter.use should have been called once (only BaggageMiddleware by default) + assert adapter.use.call_count == 1 + registered = adapter.use.call_args_list[0][0][0] + assert isinstance(registered, BaggageMiddleware) + + +def test_configure_registers_both_middlewares(): + """When output logging is enabled, both middlewares should be registered.""" + adapter = MagicMock() + options = ObservabilityHostingOptions(enable_baggage=True, enable_output_logging=True) + ObservabilityHostingManager.configure(adapter, options) + + assert adapter.use.call_count == 2 + registered_types = [c[0][0] for c in adapter.use.call_args_list] + assert isinstance(registered_types[0], BaggageMiddleware) + assert isinstance(registered_types[1], OutputLoggingMiddleware) + + +def test_configure_disables_baggage(): + """When baggage is disabled, only output logging should be registered (if enabled).""" + adapter = MagicMock() + options = ObservabilityHostingOptions(enable_baggage=False, enable_output_logging=True) + ObservabilityHostingManager.configure(adapter, options) + + assert adapter.use.call_count == 1 + registered = adapter.use.call_args_list[0][0][0] + assert isinstance(registered, OutputLoggingMiddleware) + + +def test_configure_disables_all(): + """When both are disabled, no middleware should be registered.""" + adapter = MagicMock() + options = ObservabilityHostingOptions(enable_baggage=False, enable_output_logging=False) + ObservabilityHostingManager.configure(adapter, options) + + adapter.use.assert_not_called() + + +def test_configure_no_adapter(): + """When no adapter is provided, no middleware should be registered.""" + instance = ObservabilityHostingManager.configure() + assert isinstance(instance, ObservabilityHostingManager) + + +def test_configure_no_adapter_subsequent_call_ignored(): + """Subsequent calls after no-adapter configure should still be no-ops.""" + first = ObservabilityHostingManager.configure() + adapter = MagicMock() + second = ObservabilityHostingManager.configure(adapter) + assert first is second + adapter.use.assert_not_called() 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..7283a7e6 --- /dev/null +++ b/tests/observability/hosting/middleware/test_output_logging_middleware.py @@ -0,0 +1,244 @@ +# 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="assistant", + 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.""" + 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() + + +@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() + + +@pytest.mark.asyncio +async def test_send_handler_disposes_scope_on_success(): + """Send handler should dispose the OutputScope even when send_next succeeds.""" + 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_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_scope.dispose.assert_called_once() + mock_scope.record_error.assert_not_called() From 64ce06e7fbf486c46ecac19bf2dd3095d4b84303 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:16:39 +0000 Subject: [PATCH 3/9] Update ObservabilityHostingManager.configure to use ChannelAdapter type and require non-None params - Replace _AdapterLike protocol with actual ChannelAdapter from microsoft_agents.hosting.core - Make adapter and options required (non-optional) parameters - Raise TypeError if either is None - Update tests to match new contract Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../observability_hosting_manager.py | 54 +++++++++---------- .../test_observability_hosting_manager.py | 27 +++++----- 2 files changed, 37 insertions(+), 44 deletions(-) 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 index 64372eed..b1e75c5f 100644 --- 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 @@ -7,9 +7,8 @@ import logging from dataclasses import dataclass -from typing import Protocol -from microsoft_agents.hosting.core import Middleware +from microsoft_agents.hosting.core import ChannelAdapter from .baggage_middleware import BaggageMiddleware from .output_logging_middleware import OutputLoggingMiddleware @@ -17,12 +16,6 @@ logger = logging.getLogger(__name__) -class _AdapterLike(Protocol): - """Protocol for adapter objects that support middleware registration.""" - - def use(self, middleware: Middleware) -> object: ... - - @dataclass class ObservabilityHostingOptions: """Configuration options for the hosting observability layer.""" @@ -53,20 +46,28 @@ def __init__(self) -> None: @classmethod def configure( cls, - adapter: _AdapterLike | None = None, - options: ObservabilityHostingOptions | None = None, + adapter: ChannelAdapter, + options: ObservabilityHostingOptions, ) -> ObservabilityHostingManager: """Configure the singleton instance and register middleware on the adapter. Subsequent calls after the first are no-ops and return the existing instance. Args: - adapter: An adapter that supports ``.use()`` for middleware registration. - options: Configuration options. Defaults are used when ``None``. + adapter: The channel adapter to register middleware on. + options: Configuration options controlling which middleware to enable. Returns: The singleton :class:`ObservabilityHostingManager` instance. + + Raises: + TypeError: If *adapter* or *options* is ``None``. """ + if adapter is None: + raise TypeError("adapter 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. " @@ -76,26 +77,19 @@ def configure( instance = cls() - if adapter is not None: - opts = options or ObservabilityHostingOptions() + if options.enable_baggage: + adapter.use(BaggageMiddleware()) + logger.info("[ObservabilityHostingManager] BaggageMiddleware registered.") - if opts.enable_baggage: - adapter.use(BaggageMiddleware()) - logger.info("[ObservabilityHostingManager] BaggageMiddleware registered.") + if options.enable_output_logging: + adapter.use(OutputLoggingMiddleware()) + logger.info("[ObservabilityHostingManager] OutputLoggingMiddleware registered.") - if opts.enable_output_logging: - adapter.use(OutputLoggingMiddleware()) - logger.info("[ObservabilityHostingManager] OutputLoggingMiddleware registered.") - - logger.info( - "[ObservabilityHostingManager] Configured. Baggage: %s, OutputLogging: %s.", - opts.enable_baggage, - opts.enable_output_logging, - ) - else: - logger.warning( - "[ObservabilityHostingManager] No adapter provided. No middleware registered." - ) + logger.info( + "[ObservabilityHostingManager] Configured. Baggage: %s, OutputLogging: %s.", + options.enable_baggage, + options.enable_output_logging, + ) cls._instance = instance return instance diff --git a/tests/observability/hosting/middleware/test_observability_hosting_manager.py b/tests/observability/hosting/middleware/test_observability_hosting_manager.py index 91444a6d..c34e1cf1 100644 --- a/tests/observability/hosting/middleware/test_observability_hosting_manager.py +++ b/tests/observability/hosting/middleware/test_observability_hosting_manager.py @@ -27,22 +27,23 @@ def _reset_singleton(): def test_configure_returns_instance(): """configure() should return an ObservabilityHostingManager instance.""" adapter = MagicMock() - instance = ObservabilityHostingManager.configure(adapter) + instance = ObservabilityHostingManager.configure(adapter, ObservabilityHostingOptions()) assert isinstance(instance, ObservabilityHostingManager) def test_configure_is_singleton(): """Subsequent configure() calls should return the same instance.""" adapter = MagicMock() - first = ObservabilityHostingManager.configure(adapter) - second = ObservabilityHostingManager.configure(adapter) + options = ObservabilityHostingOptions() + first = ObservabilityHostingManager.configure(adapter, options) + second = ObservabilityHostingManager.configure(adapter, options) assert first is second def test_configure_registers_baggage_middleware_by_default(): """By default, BaggageMiddleware should be registered.""" adapter = MagicMock() - ObservabilityHostingManager.configure(adapter) + ObservabilityHostingManager.configure(adapter, ObservabilityHostingOptions()) # The adapter.use should have been called once (only BaggageMiddleware by default) assert adapter.use.call_count == 1 @@ -82,16 +83,14 @@ def test_configure_disables_all(): adapter.use.assert_not_called() -def test_configure_no_adapter(): - """When no adapter is provided, no middleware should be registered.""" - instance = ObservabilityHostingManager.configure() - assert isinstance(instance, ObservabilityHostingManager) +def test_configure_raises_on_none_adapter(): + """configure() should raise TypeError when adapter is None.""" + with pytest.raises(TypeError, match="adapter must not be None"): + ObservabilityHostingManager.configure(None, ObservabilityHostingOptions()) -def test_configure_no_adapter_subsequent_call_ignored(): - """Subsequent calls after no-adapter configure should still be no-ops.""" - first = ObservabilityHostingManager.configure() +def test_configure_raises_on_none_options(): + """configure() should raise TypeError when options is None.""" adapter = MagicMock() - second = ObservabilityHostingManager.configure(adapter) - assert first is second - adapter.use.assert_not_called() + with pytest.raises(TypeError, match="options must not be None"): + ObservabilityHostingManager.configure(adapter, None) From deb01ed97f94480e194355b5a650986441fcc984 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:23:55 +0000 Subject: [PATCH 4/9] Use MiddlewareSet type for adapter and Activity helper methods instead of direct attribute access - Change adapter param type from ChannelAdapter to MiddlewareSet (the actual middleware registration object) - Use activity.get_agentic_instance_id() instead of recipient.agentic_app_id - Use activity.get_agentic_user() instead of recipient.agentic_user_id - Update test to use agentic role for proper helper method behavior Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../hosting/middleware/observability_hosting_manager.py | 6 +++--- .../hosting/middleware/output_logging_middleware.py | 7 ++++--- .../observability/hosting/scope_helpers/utils.py | 4 ++-- .../hosting/scope_helpers/test_scope_helper_utils.py | 4 ++-- 4 files changed, 11 insertions(+), 10 deletions(-) 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 index b1e75c5f..926d8f76 100644 --- 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 @@ -8,7 +8,7 @@ import logging from dataclasses import dataclass -from microsoft_agents.hosting.core import ChannelAdapter +from microsoft_agents.hosting.core.middleware_set import MiddlewareSet from .baggage_middleware import BaggageMiddleware from .output_logging_middleware import OutputLoggingMiddleware @@ -46,7 +46,7 @@ def __init__(self) -> None: @classmethod def configure( cls, - adapter: ChannelAdapter, + adapter: MiddlewareSet, options: ObservabilityHostingOptions, ) -> ObservabilityHostingManager: """Configure the singleton instance and register middleware on the adapter. @@ -54,7 +54,7 @@ def configure( Subsequent calls after the first are no-ops and return the existing instance. Args: - adapter: The channel adapter to register middleware on. + adapter: The middleware set to register middleware on. options: Configuration options controlling which middleware to enable. Returns: 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 index 124f385c..a6614606 100644 --- 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 @@ -43,14 +43,15 @@ def _derive_agent_details(context: TurnContext) -> AgentDetails | None: """Derive target agent details from the activity recipient.""" - recipient = getattr(context.activity, "recipient", None) + activity = context.activity + recipient = getattr(activity, "recipient", None) if not recipient: return None return AgentDetails( - agent_id=getattr(recipient, "agentic_app_id", None) or "", + agent_id=activity.get_agentic_instance_id() or "", agent_name=getattr(recipient, "name", None), agent_auid=getattr(recipient, "aad_object_id", None), - agent_upn=getattr(recipient, "agentic_user_id", None), + agent_upn=activity.get_agentic_user(), agent_description=getattr(recipient, "role", None), tenant_id=getattr(recipient, "tenant_id", None), ) 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/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(): From 87c1c120efd7a20fa3ce9507747af6b3fc73d9c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:26:10 +0000 Subject: [PATCH 5/9] Fix ObservabilityHostingManager.configure to accept MiddlewareSet instead of ChannelAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChannelAdapter is an ABC (CloudAdapter extends ChannelServiceAdapter extends ChannelAdapter). The adapter HAS-A MiddlewareSet (composition) — it is not one. The configure() method only needs the middleware registration object, so accept MiddlewareSet directly. Users pass adapter.middleware_set. Rename parameter from 'adapter' to 'middleware_set' for clarity. Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../observability_hosting_manager.py | 19 +++---- .../test_observability_hosting_manager.py | 52 +++++++++---------- 2 files changed, 36 insertions(+), 35 deletions(-) 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 index 926d8f76..4257ed3d 100644 --- 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 @@ -33,7 +33,7 @@ class ObservabilityHostingManager: Example: .. code-block:: python - ObservabilityHostingManager.configure(adapter, ObservabilityHostingOptions( + ObservabilityHostingManager.configure(adapter.middleware_set, ObservabilityHostingOptions( enable_output_logging=True, )) """ @@ -46,25 +46,26 @@ def __init__(self) -> None: @classmethod def configure( cls, - adapter: MiddlewareSet, + middleware_set: MiddlewareSet, options: ObservabilityHostingOptions, ) -> ObservabilityHostingManager: - """Configure the singleton instance and register middleware on the adapter. + """Configure the singleton instance and register middleware. Subsequent calls after the first are no-ops and return the existing instance. Args: - adapter: The middleware set to register middleware on. + 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 *adapter* or *options* is ``None``. + TypeError: If *middleware_set* or *options* is ``None``. """ - if adapter is None: - raise TypeError("adapter must not be 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") @@ -78,11 +79,11 @@ def configure( instance = cls() if options.enable_baggage: - adapter.use(BaggageMiddleware()) + middleware_set.use(BaggageMiddleware()) logger.info("[ObservabilityHostingManager] BaggageMiddleware registered.") if options.enable_output_logging: - adapter.use(OutputLoggingMiddleware()) + middleware_set.use(OutputLoggingMiddleware()) logger.info("[ObservabilityHostingManager] OutputLoggingMiddleware registered.") logger.info( diff --git a/tests/observability/hosting/middleware/test_observability_hosting_manager.py b/tests/observability/hosting/middleware/test_observability_hosting_manager.py index c34e1cf1..b1955309 100644 --- a/tests/observability/hosting/middleware/test_observability_hosting_manager.py +++ b/tests/observability/hosting/middleware/test_observability_hosting_manager.py @@ -26,71 +26,71 @@ def _reset_singleton(): def test_configure_returns_instance(): """configure() should return an ObservabilityHostingManager instance.""" - adapter = MagicMock() - instance = ObservabilityHostingManager.configure(adapter, ObservabilityHostingOptions()) + middleware_set = MagicMock() + instance = ObservabilityHostingManager.configure(middleware_set, ObservabilityHostingOptions()) assert isinstance(instance, ObservabilityHostingManager) def test_configure_is_singleton(): """Subsequent configure() calls should return the same instance.""" - adapter = MagicMock() + middleware_set = MagicMock() options = ObservabilityHostingOptions() - first = ObservabilityHostingManager.configure(adapter, options) - second = ObservabilityHostingManager.configure(adapter, options) + first = ObservabilityHostingManager.configure(middleware_set, options) + second = ObservabilityHostingManager.configure(middleware_set, options) assert first is second def test_configure_registers_baggage_middleware_by_default(): """By default, BaggageMiddleware should be registered.""" - adapter = MagicMock() - ObservabilityHostingManager.configure(adapter, ObservabilityHostingOptions()) + middleware_set = MagicMock() + ObservabilityHostingManager.configure(middleware_set, ObservabilityHostingOptions()) - # The adapter.use should have been called once (only BaggageMiddleware by default) - assert adapter.use.call_count == 1 - registered = adapter.use.call_args_list[0][0][0] + # The middleware_set.use should have been called once (only BaggageMiddleware by default) + assert middleware_set.use.call_count == 1 + registered = middleware_set.use.call_args_list[0][0][0] assert isinstance(registered, BaggageMiddleware) def test_configure_registers_both_middlewares(): """When output logging is enabled, both middlewares should be registered.""" - adapter = MagicMock() + middleware_set = MagicMock() options = ObservabilityHostingOptions(enable_baggage=True, enable_output_logging=True) - ObservabilityHostingManager.configure(adapter, options) + ObservabilityHostingManager.configure(middleware_set, options) - assert adapter.use.call_count == 2 - registered_types = [c[0][0] for c in adapter.use.call_args_list] + assert middleware_set.use.call_count == 2 + registered_types = [c[0][0] for c in middleware_set.use.call_args_list] assert isinstance(registered_types[0], BaggageMiddleware) assert isinstance(registered_types[1], OutputLoggingMiddleware) def test_configure_disables_baggage(): """When baggage is disabled, only output logging should be registered (if enabled).""" - adapter = MagicMock() + middleware_set = MagicMock() options = ObservabilityHostingOptions(enable_baggage=False, enable_output_logging=True) - ObservabilityHostingManager.configure(adapter, options) + ObservabilityHostingManager.configure(middleware_set, options) - assert adapter.use.call_count == 1 - registered = adapter.use.call_args_list[0][0][0] + assert middleware_set.use.call_count == 1 + registered = middleware_set.use.call_args_list[0][0][0] assert isinstance(registered, OutputLoggingMiddleware) def test_configure_disables_all(): """When both are disabled, no middleware should be registered.""" - adapter = MagicMock() + middleware_set = MagicMock() options = ObservabilityHostingOptions(enable_baggage=False, enable_output_logging=False) - ObservabilityHostingManager.configure(adapter, options) + ObservabilityHostingManager.configure(middleware_set, options) - adapter.use.assert_not_called() + middleware_set.use.assert_not_called() -def test_configure_raises_on_none_adapter(): - """configure() should raise TypeError when adapter is None.""" - with pytest.raises(TypeError, match="adapter must not be None"): +def test_configure_raises_on_none_middleware_set(): + """configure() should raise TypeError when middleware_set is None.""" + with pytest.raises(TypeError, match="middleware_set must not be None"): ObservabilityHostingManager.configure(None, ObservabilityHostingOptions()) def test_configure_raises_on_none_options(): """configure() should raise TypeError when options is None.""" - adapter = MagicMock() + middleware_set = MagicMock() with pytest.raises(TypeError, match="options must not be None"): - ObservabilityHostingManager.configure(adapter, None) + ObservabilityHostingManager.configure(middleware_set, None) From e88a6dd1e22b43413d7d00d39c7a2c56c83b8b72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:13:01 +0000 Subject: [PATCH 6/9] Address review comments: fix logic callback type, gate on is_agentic_request - Fix `logic` type annotation from `Callable[[TurnContext], Awaitable]` to `Callable[[], Awaitable]` in both BaggageMiddleware and OutputLoggingMiddleware. At runtime, MiddlewareSet passes a zero-arg `call_next_middleware` closure, so the annotation now matches the callsite. - Gate `_derive_agent_details` on `activity.is_agentic_request()` to avoid emitting spans with empty agent_id for non-agentic requests. - Remove unused `logging`/`logger` from baggage_middleware.py. - Update test recipient role from "assistant" to "agenticAppInstance" to exercise the intended agentic code path. Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../hosting/middleware/baggage_middleware.py | 5 +---- .../hosting/middleware/output_logging_middleware.py | 11 +++++++++-- .../middleware/test_output_logging_middleware.py | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) 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 index 822c14aa..1a151cef 100644 --- 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 @@ -5,7 +5,6 @@ from __future__ import annotations -import logging from collections.abc import Awaitable, Callable from microsoft_agents.activity import ActivityEventNames, ActivityTypes @@ -14,8 +13,6 @@ from ..scope_helpers.populate_baggage import populate -logger = logging.getLogger(__name__) - class BaggageMiddleware: """Middleware that propagates OpenTelemetry baggage context derived from TurnContext. @@ -26,7 +23,7 @@ class BaggageMiddleware: async def on_turn( self, context: TurnContext, - logic: Callable[[TurnContext], Awaitable], + logic: Callable[[], Awaitable], ) -> None: activity = context.activity is_async_reply = ( 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 index a6614606..693b3c95 100644 --- 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 @@ -42,8 +42,15 @@ def _derive_agent_details(context: TurnContext) -> AgentDetails | None: - """Derive target agent details from the activity recipient.""" + """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 @@ -119,7 +126,7 @@ class OutputLoggingMiddleware: async def on_turn( self, context: TurnContext, - logic: Callable[[TurnContext], Awaitable], + logic: Callable[[], Awaitable], ) -> None: agent_details = _derive_agent_details(context) tenant_details = _derive_tenant_details(context) diff --git a/tests/observability/hosting/middleware/test_output_logging_middleware.py b/tests/observability/hosting/middleware/test_output_logging_middleware.py index 7283a7e6..e465e2da 100644 --- a/tests/observability/hosting/middleware/test_output_logging_middleware.py +++ b/tests/observability/hosting/middleware/test_output_logging_middleware.py @@ -36,7 +36,7 @@ def _make_turn_context( ), "recipient": ChannelAccount( tenant_id=recipient_tenant_id, - role="assistant", + role="agenticAppInstance", name="Agent One", agentic_app_id=recipient_agentic_app_id, aad_object_id="agent-auid", From 480e469fc3087b7fb853a52219fe932e48872cca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:38:30 +0000 Subject: [PATCH 7/9] Revert logic type to Callable[[TurnContext], Awaitable] to match Middleware Protocol; consolidate tests Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../hosting/middleware/baggage_middleware.py | 2 +- .../middleware/output_logging_middleware.py | 2 +- .../middleware/test_baggage_middleware.py | 14 +-- .../test_observability_hosting_manager.py | 91 +++++++------------ .../test_output_logging_middleware.py | 29 +----- 5 files changed, 39 insertions(+), 99 deletions(-) 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 index 1a151cef..f1ee17cb 100644 --- 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 @@ -23,7 +23,7 @@ class BaggageMiddleware: async def on_turn( self, context: TurnContext, - logic: Callable[[], Awaitable], + logic: Callable[[TurnContext], Awaitable], ) -> None: activity = context.activity is_async_reply = ( 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 index 693b3c95..33dbb8de 100644 --- 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 @@ -126,7 +126,7 @@ class OutputLoggingMiddleware: async def on_turn( self, context: TurnContext, - logic: Callable[[], Awaitable], + logic: Callable[[TurnContext], Awaitable], ) -> None: agent_details = _derive_agent_details(context) tenant_details = _derive_tenant_details(context) diff --git a/tests/observability/hosting/middleware/test_baggage_middleware.py b/tests/observability/hosting/middleware/test_baggage_middleware.py index 53cbd227..ddc00e00 100644 --- a/tests/observability/hosting/middleware/test_baggage_middleware.py +++ b/tests/observability/hosting/middleware/test_baggage_middleware.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest from microsoft_agents.activity import ( @@ -95,15 +95,3 @@ async def logic(): assert logic_called is True # Baggage should NOT be set because the middleware skipped it assert captured_caller_id is None - - -@pytest.mark.asyncio -async def test_baggage_middleware_calls_logic(): - """BaggageMiddleware should always call the downstream logic.""" - middleware = BaggageMiddleware() - ctx = _make_turn_context() - - logic_mock = AsyncMock() - await middleware.on_turn(ctx, logic_mock) - - logic_mock.assert_awaited_once() diff --git a/tests/observability/hosting/middleware/test_observability_hosting_manager.py b/tests/observability/hosting/middleware/test_observability_hosting_manager.py index b1955309..3b00bf90 100644 --- a/tests/observability/hosting/middleware/test_observability_hosting_manager.py +++ b/tests/observability/hosting/middleware/test_observability_hosting_manager.py @@ -24,73 +24,50 @@ def _reset_singleton(): ObservabilityHostingManager.reset() -def test_configure_returns_instance(): - """configure() should return an ObservabilityHostingManager instance.""" - middleware_set = MagicMock() - instance = ObservabilityHostingManager.configure(middleware_set, ObservabilityHostingOptions()) - assert isinstance(instance, ObservabilityHostingManager) - - def test_configure_is_singleton(): - """Subsequent configure() calls should return the same instance.""" + """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 -def test_configure_registers_baggage_middleware_by_default(): - """By default, BaggageMiddleware should be registered.""" - middleware_set = MagicMock() - ObservabilityHostingManager.configure(middleware_set, ObservabilityHostingOptions()) - - # The middleware_set.use should have been called once (only BaggageMiddleware by default) - assert middleware_set.use.call_count == 1 - registered = middleware_set.use.call_args_list[0][0][0] - assert isinstance(registered, BaggageMiddleware) - - -def test_configure_registers_both_middlewares(): - """When output logging is enabled, both middlewares should be registered.""" - middleware_set = MagicMock() - options = ObservabilityHostingOptions(enable_baggage=True, enable_output_logging=True) - ObservabilityHostingManager.configure(middleware_set, options) - - assert middleware_set.use.call_count == 2 - registered_types = [c[0][0] for c in middleware_set.use.call_args_list] - assert isinstance(registered_types[0], BaggageMiddleware) - assert isinstance(registered_types[1], OutputLoggingMiddleware) - - -def test_configure_disables_baggage(): - """When baggage is disabled, only output logging should be registered (if enabled).""" - middleware_set = MagicMock() - options = ObservabilityHostingOptions(enable_baggage=False, enable_output_logging=True) - ObservabilityHostingManager.configure(middleware_set, options) - - assert middleware_set.use.call_count == 1 - registered = middleware_set.use.call_args_list[0][0][0] - assert isinstance(registered, OutputLoggingMiddleware) - - -def test_configure_disables_all(): - """When both are disabled, no middleware should be registered.""" +@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=False, enable_output_logging=False) + options = ObservabilityHostingOptions( + enable_baggage=enable_baggage, enable_output_logging=enable_output_logging + ) ObservabilityHostingManager.configure(middleware_set, options) - middleware_set.use.assert_not_called() - - -def test_configure_raises_on_none_middleware_set(): - """configure() should raise TypeError when middleware_set is None.""" - with pytest.raises(TypeError, match="middleware_set must not be None"): - ObservabilityHostingManager.configure(None, ObservabilityHostingOptions()) + 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) -def test_configure_raises_on_none_options(): - """configure() should raise TypeError when options is None.""" - middleware_set = MagicMock() - with pytest.raises(TypeError, match="options must not be None"): - ObservabilityHostingManager.configure(middleware_set, None) +@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 index e465e2da..c7d89389 100644 --- a/tests/observability/hosting/middleware/test_output_logging_middleware.py +++ b/tests/observability/hosting/middleware/test_output_logging_middleware.py @@ -136,7 +136,7 @@ async def test_send_handler_skips_non_message_activities(): @pytest.mark.asyncio async def test_send_handler_creates_output_scope_for_messages(): - """Send handler should create an OutputScope for message activities.""" + """Send handler should create an OutputScope for message activities and dispose on success.""" middleware = OutputLoggingMiddleware() ctx = _make_turn_context() @@ -159,6 +159,7 @@ async def test_send_handler_creates_output_scope_for_messages(): 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 @@ -216,29 +217,3 @@ async def test_send_handler_rethrows_errors(): mock_scope.record_error.assert_called_once_with(send_error) mock_scope.dispose.assert_called_once() - - -@pytest.mark.asyncio -async def test_send_handler_disposes_scope_on_success(): - """Send handler should dispose the OutputScope even when send_next succeeds.""" - 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_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_scope.dispose.assert_called_once() - mock_scope.record_error.assert_not_called() From 734ec1a894ebcc5cd1d675b19f10d9bc9931584f Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 3 Mar 2026 20:31:33 +0530 Subject: [PATCH 8/9] set defaults to false --- .../hosting/middleware/observability_hosting_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 4257ed3d..54f56487 100644 --- 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 @@ -20,8 +20,8 @@ class ObservabilityHostingOptions: """Configuration options for the hosting observability layer.""" - enable_baggage: bool = True - """Enable baggage propagation middleware. Defaults to ``True``.""" + 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``.""" From 0491bb9934c562c67de76cdd0845e2ed525e1b22 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Wed, 4 Mar 2026 01:24:12 +0530 Subject: [PATCH 9/9] address PR comment --- .../hosting/middleware/output_logging_middleware.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 index 33dbb8de..5bba6045 100644 --- 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 @@ -33,12 +33,7 @@ logger = logging.getLogger(__name__) A365_PARENT_SPAN_KEY = "A365ParentSpanId" -"""TurnState key for the parent span reference. - -Set this in ``turn_state`` to link OutputScope spans as children of an -InvokeAgentScope. The value should be a W3C traceparent string in the format -``"00-{trace_id}-{span_id}-{trace_flags}"``. -""" +"""TurnState key for the parent span reference.""" def _derive_agent_details(context: TurnContext) -> AgentDetails | None: