Skip to content

Commit c87f4d9

Browse files
introduce input/output spans and middleware registry
1 parent f47b62b commit c87f4d9

7 files changed

Lines changed: 315 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from dataclasses import dataclass
5+
6+
7+
@dataclass
8+
class Response:
9+
"""Response details from agent execution."""
10+
11+
messages: list[str]
12+
"""The list of response messages."""
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from ..agent_details import AgentDetails
5+
from ..constants import (
6+
GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY,
7+
GEN_AI_EXECUTION_SOURCE_NAME_KEY,
8+
GEN_AI_EXECUTION_TYPE_KEY,
9+
GEN_AI_INPUT_MESSAGES_KEY,
10+
)
11+
from ..opentelemetry_scope import OpenTelemetryScope
12+
from ..request import Request
13+
from ..tenant_details import TenantDetails
14+
from ..utils import safe_json_dumps
15+
16+
INPUT_OPERATION_NAME = "input_messages"
17+
18+
19+
class InputScope(OpenTelemetryScope):
20+
"""Provides OpenTelemetry tracing scope for input messages."""
21+
22+
@staticmethod
23+
def start(
24+
agent_details: AgentDetails,
25+
tenant_details: TenantDetails,
26+
request: Request,
27+
) -> "InputScope":
28+
"""Creates and starts a new scope for input tracing.
29+
30+
Args:
31+
agent_details: The details of the agent
32+
tenant_details: The details of the tenant
33+
request: The request details which invokes the agent
34+
35+
Returns:
36+
A new InputScope instance
37+
"""
38+
return InputScope(agent_details, tenant_details, request)
39+
40+
def __init__(
41+
self,
42+
agent_details: AgentDetails,
43+
tenant_details: TenantDetails,
44+
request: Request,
45+
):
46+
"""Initialize the input scope.
47+
48+
Args:
49+
agent_details: The details of the agent
50+
tenant_details: The details of the tenant
51+
request: The request details which invokes the agent
52+
"""
53+
super().__init__(
54+
kind="Client",
55+
operation_name=INPUT_OPERATION_NAME,
56+
activity_name=(f"{INPUT_OPERATION_NAME} {agent_details.agent_id}"),
57+
agent_details=agent_details,
58+
tenant_details=tenant_details,
59+
)
60+
61+
# Set request metadata
62+
if request.source_metadata:
63+
self.set_tag_maybe(GEN_AI_EXECUTION_SOURCE_NAME_KEY, request.source_metadata.name)
64+
self.set_tag_maybe(
65+
GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, request.source_metadata.description
66+
)
67+
68+
self.set_tag_maybe(
69+
GEN_AI_EXECUTION_TYPE_KEY,
70+
request.execution_type.value if request.execution_type else None,
71+
)
72+
self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, safe_json_dumps([request.content]))
73+
74+
def record_input_messages(self, messages: list[str]) -> None:
75+
"""Records the input messages for telemetry tracking.
76+
77+
Args:
78+
messages: List of input messages
79+
"""
80+
self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, safe_json_dumps(messages))
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from ..agent_details import AgentDetails
5+
from ..constants import GEN_AI_OUTPUT_MESSAGES_KEY
6+
from ..models.response import Response
7+
from ..opentelemetry_scope import OpenTelemetryScope
8+
from ..tenant_details import TenantDetails
9+
from ..utils import safe_json_dumps
10+
11+
OUTPUT_OPERATION_NAME = "output_messages"
12+
13+
14+
class OutputScope(OpenTelemetryScope):
15+
"""Provides OpenTelemetry tracing scope for output messages."""
16+
17+
@staticmethod
18+
def start(
19+
agent_details: AgentDetails,
20+
tenant_details: TenantDetails,
21+
response: Response,
22+
) -> "OutputScope":
23+
"""Creates and starts a new scope for output tracing.
24+
25+
Args:
26+
agent_details: The details of the agent
27+
tenant_details: The details of the tenant
28+
response: The response details from the agent
29+
30+
Returns:
31+
A new OutputScope instance
32+
"""
33+
return OutputScope(agent_details, tenant_details, response)
34+
35+
def __init__(
36+
self,
37+
agent_details: AgentDetails,
38+
tenant_details: TenantDetails,
39+
response: Response,
40+
):
41+
"""Initialize the output scope.
42+
43+
Args:
44+
agent_details: The details of the agent
45+
tenant_details: The details of the tenant
46+
response: The response details from the agent
47+
"""
48+
super().__init__(
49+
kind="Client",
50+
operation_name=OUTPUT_OPERATION_NAME,
51+
activity_name=(f"{OUTPUT_OPERATION_NAME} {agent_details.agent_id}"),
52+
agent_details=agent_details,
53+
tenant_details=tenant_details,
54+
)
55+
56+
# Set response messages
57+
self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(response.messages))
58+
59+
def record_output_messages(self, messages: list[str]) -> None:
60+
"""Records the output messages for telemetry tracking.
61+
62+
Args:
63+
messages: List of output messages
64+
"""
65+
self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(messages))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
import logging
5+
from collections.abc import Awaitable, Callable
6+
7+
from microsoft_agents.activity import Activity
8+
from microsoft_agents.hosting.core.middleware_set import Middleware, TurnContext
9+
from microsoft_agents_a365.observability.core.agent_details import AgentDetails
10+
from microsoft_agents_a365.observability.core.execution_type import ExecutionType
11+
from microsoft_agents_a365.observability.core.request import Request
12+
from microsoft_agents_a365.observability.core.spans_scopes.input_scope import InputScope
13+
from microsoft_agents_a365.observability.core.tenant_details import TenantDetails
14+
15+
16+
class MessageLoggingMiddleware(Middleware):
17+
"""
18+
Lightweight middleware for logging input and output messages.
19+
"""
20+
21+
def __init__(
22+
self,
23+
logger: logging.Logger | None = None,
24+
log_user_messages: bool = True,
25+
log_bot_messages: bool = True,
26+
):
27+
"""
28+
Initialize the message logger middleware.
29+
30+
Args:
31+
logger: Custom logger instance (defaults to module logger)
32+
log_user_messages: Whether to log incoming user messages
33+
log_bot_messages: Whether to log outgoing bot messages
34+
"""
35+
self.logger = logger or logging.getLogger("agents. observability")
36+
self.log_user_messages = log_user_messages
37+
self.log_bot_messages = log_bot_messages
38+
39+
async def on_turn(self, turn_context: TurnContext, logic: Callable[[TurnContext], Awaitable]):
40+
input_scope = None
41+
42+
# Start InputScope for the entire turn if we have user message
43+
if self.log_user_messages and turn_context.activity.text:
44+
input_scope = self._create_input_scope(turn_context.activity)
45+
input_scope.__enter__()
46+
self.logger.info(f"📥 User: {turn_context.activity.text}")
47+
48+
try:
49+
# Hook into outgoing messages
50+
if self.log_bot_messages:
51+
turn_context.on_send_activities(self._create_send_handler())
52+
53+
# Execute bot logic
54+
await logic()
55+
except Exception as exc:
56+
# Clean up and propagate exception (let __exit__ handle error recording)
57+
if input_scope:
58+
input_scope.__exit__(type(exc), exc, exc.__traceback__)
59+
input_scope = None # Prevent double cleanup
60+
raise
61+
finally:
62+
# Clean up the input scope if not already done
63+
if input_scope:
64+
input_scope.__exit__(None, None, None)
65+
66+
def _create_input_scope(self, activity: Activity) -> InputScope:
67+
"""Create InputScope for tracing the entire turn"""
68+
# Extract details from activity
69+
agent_details = AgentDetails(
70+
agent_id=activity.recipient.id if activity.recipient else "unknown",
71+
agent_name=activity.recipient.name if activity.recipient else None,
72+
conversation_id=activity.conversation.id if activity.conversation else None,
73+
)
74+
75+
tenant_details = TenantDetails(
76+
tenant_id=activity.conversation.tenant_id
77+
if activity.conversation and hasattr(activity.conversation, "tenant_id")
78+
else "unknown"
79+
)
80+
81+
request = Request(
82+
content=activity.text or "",
83+
execution_type=ExecutionType.HUMAN_TO_AGENT,
84+
session_id=activity.conversation.id if activity.conversation else None,
85+
)
86+
87+
return InputScope.start(agent_details, tenant_details, request)
88+
89+
def _create_send_handler(self):
90+
"""Create handler for outgoing bot messages"""
91+
92+
async def send_handler(ctx, activities, next_send):
93+
# Log each outgoing message
94+
for activity in activities:
95+
if activity.text:
96+
self.logger.info(f"📤 Bot: {activity.text}")
97+
98+
return await next_send()
99+
100+
return send_handler
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
5+
from microsoft_agents.hosting.aiohttp import CloudAdapter
6+
7+
from .message_logging_middleware import MessageLoggingMiddleware
8+
9+
10+
class ObservabilityMiddlewareRegistrar:
11+
"""
12+
Registrar for configuring and registering observability middleware.
13+
14+
Usage:
15+
# Quick start with defaults
16+
ObservabilityMiddlewareRegistrar().with_message_logging().apply(adapter)
17+
18+
"""
19+
20+
def __init__(self):
21+
"""Initialize the registrar."""
22+
self._middleware_configs: list = []
23+
24+
def with_message_logging(
25+
self,
26+
log_user_messages: bool = True,
27+
log_bot_messages: bool = True,
28+
) -> "ObservabilityMiddlewareRegistrar":
29+
"""Configure message logging middleware.
30+
31+
Args:
32+
log_user_messages: Whether to log user messages (default: True)
33+
log_bot_messages: Whether to log bot messages (default: True)
34+
35+
Returns:
36+
The registrar instance for chaining
37+
"""
38+
self._middleware_configs.append(
39+
lambda: MessageLoggingMiddleware(
40+
log_user_messages=log_user_messages,
41+
log_bot_messages=log_bot_messages,
42+
)
43+
)
44+
return self
45+
46+
def apply(self, adapter: CloudAdapter) -> None:
47+
"""Apply all configured middleware to the adapter.
48+
49+
Args:
50+
adapter: CloudAdapter to register middleware with
51+
"""
52+
for create_middleware in self._middleware_configs:
53+
middleware = create_middleware()
54+
adapter.use(middleware)

0 commit comments

Comments
 (0)