Skip to content

Commit 5ce0785

Browse files
CopilotnikhilNava
andauthored
Align middleware with Middleware Protocol; consolidate tests (#187)
* Initial plan * 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> * 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> * 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> * Fix ObservabilityHostingManager.configure to accept MiddlewareSet instead of ChannelAdapter 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> * 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> * Revert logic type to Callable[[TurnContext], Awaitable] to match Middleware Protocol; consolidate tests Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> Co-authored-by: Nikhil Navakiran <nikhil.navakiran@gmail.com>
1 parent a4f23f6 commit 5ce0785

File tree

11 files changed

+788
-4
lines changed

11 files changed

+788
-4
lines changed

libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,18 @@
44
"""
55
Microsoft Agent 365 Observability Hosting Library.
66
"""
7+
8+
from .middleware.baggage_middleware import BaggageMiddleware
9+
from .middleware.observability_hosting_manager import (
10+
ObservabilityHostingManager,
11+
ObservabilityHostingOptions,
12+
)
13+
from .middleware.output_logging_middleware import A365_PARENT_SPAN_KEY, OutputLoggingMiddleware
14+
15+
__all__ = [
16+
"BaggageMiddleware",
17+
"OutputLoggingMiddleware",
18+
"A365_PARENT_SPAN_KEY",
19+
"ObservabilityHostingManager",
20+
"ObservabilityHostingOptions",
21+
]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from .baggage_middleware import BaggageMiddleware
5+
from .observability_hosting_manager import ObservabilityHostingManager, ObservabilityHostingOptions
6+
from .output_logging_middleware import A365_PARENT_SPAN_KEY, OutputLoggingMiddleware
7+
8+
__all__ = [
9+
"BaggageMiddleware",
10+
"OutputLoggingMiddleware",
11+
"A365_PARENT_SPAN_KEY",
12+
"ObservabilityHostingManager",
13+
"ObservabilityHostingOptions",
14+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Middleware that propagates OpenTelemetry baggage context derived from TurnContext."""
5+
6+
from __future__ import annotations
7+
8+
from collections.abc import Awaitable, Callable
9+
10+
from microsoft_agents.activity import ActivityEventNames, ActivityTypes
11+
from microsoft_agents.hosting.core.turn_context import TurnContext
12+
from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder
13+
14+
from ..scope_helpers.populate_baggage import populate
15+
16+
17+
class BaggageMiddleware:
18+
"""Middleware that propagates OpenTelemetry baggage context derived from TurnContext.
19+
20+
Async replies (ContinueConversation) are passed through without baggage setup.
21+
"""
22+
23+
async def on_turn(
24+
self,
25+
context: TurnContext,
26+
logic: Callable[[TurnContext], Awaitable],
27+
) -> None:
28+
activity = context.activity
29+
is_async_reply = (
30+
activity is not None
31+
and activity.type == ActivityTypes.event
32+
and activity.name == ActivityEventNames.continue_conversation
33+
)
34+
35+
if is_async_reply:
36+
await logic()
37+
return
38+
39+
builder = BaggageBuilder()
40+
populate(builder, context)
41+
baggage_scope = builder.build()
42+
43+
with baggage_scope:
44+
await logic()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Singleton manager for configuring hosting-layer observability middleware."""
5+
6+
from __future__ import annotations
7+
8+
import logging
9+
from dataclasses import dataclass
10+
11+
from microsoft_agents.hosting.core.middleware_set import MiddlewareSet
12+
13+
from .baggage_middleware import BaggageMiddleware
14+
from .output_logging_middleware import OutputLoggingMiddleware
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
@dataclass
20+
class ObservabilityHostingOptions:
21+
"""Configuration options for the hosting observability layer."""
22+
23+
enable_baggage: bool = False
24+
"""Enable baggage propagation middleware. Defaults to ``False``."""
25+
26+
enable_output_logging: bool = False
27+
"""Enable output logging middleware for tracing outgoing messages. Defaults to ``False``."""
28+
29+
30+
class ObservabilityHostingManager:
31+
"""Singleton manager for configuring hosting-layer observability middleware.
32+
33+
Example:
34+
.. code-block:: python
35+
36+
ObservabilityHostingManager.configure(adapter.middleware_set, ObservabilityHostingOptions(
37+
enable_output_logging=True,
38+
))
39+
"""
40+
41+
_instance: ObservabilityHostingManager | None = None
42+
43+
def __init__(self) -> None:
44+
"""Private constructor — use :meth:`configure` instead."""
45+
46+
@classmethod
47+
def configure(
48+
cls,
49+
middleware_set: MiddlewareSet,
50+
options: ObservabilityHostingOptions,
51+
) -> ObservabilityHostingManager:
52+
"""Configure the singleton instance and register middleware.
53+
54+
Subsequent calls after the first are no-ops and return the existing instance.
55+
56+
Args:
57+
middleware_set: The middleware set to register middleware on
58+
(e.g., ``adapter.middleware_set``).
59+
options: Configuration options controlling which middleware to enable.
60+
61+
Returns:
62+
The singleton :class:`ObservabilityHostingManager` instance.
63+
64+
Raises:
65+
TypeError: If *middleware_set* or *options* is ``None``.
66+
"""
67+
if middleware_set is None:
68+
raise TypeError("middleware_set must not be None")
69+
if options is None:
70+
raise TypeError("options must not be None")
71+
72+
if cls._instance is not None:
73+
logger.warning(
74+
"[ObservabilityHostingManager] Already configured. "
75+
"Subsequent configure() calls are ignored."
76+
)
77+
return cls._instance
78+
79+
instance = cls()
80+
81+
if options.enable_baggage:
82+
middleware_set.use(BaggageMiddleware())
83+
logger.info("[ObservabilityHostingManager] BaggageMiddleware registered.")
84+
85+
if options.enable_output_logging:
86+
middleware_set.use(OutputLoggingMiddleware())
87+
logger.info("[ObservabilityHostingManager] OutputLoggingMiddleware registered.")
88+
89+
logger.info(
90+
"[ObservabilityHostingManager] Configured. Baggage: %s, OutputLogging: %s.",
91+
options.enable_baggage,
92+
options.enable_output_logging,
93+
)
94+
95+
cls._instance = instance
96+
return instance
97+
98+
@classmethod
99+
def reset(cls) -> None:
100+
"""Reset the singleton instance. Intended for testing only."""
101+
cls._instance = None

0 commit comments

Comments
 (0)