Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

from dataclasses import dataclass


@dataclass
class Response:
"""Response details from agent execution."""

messages: list[str]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Should we name this Content to keep it similar to Request?

"""The list of response messages."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
Comment on lines +1 to +2
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an inconsistency in copyright headers across the codebase. Most existing files use 'Copyright (c) Microsoft. All rights reserved.' but the new files use 'Copyright (c) Microsoft Corporation.\n# Licensed under the MIT License.' While both are valid, this inconsistency could cause confusion. Consider aligning with the existing codebase standard.

Copilot uses AI. Check for mistakes.

from ..agent_details import AgentDetails
from ..constants import (
GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY,
GEN_AI_EXECUTION_SOURCE_NAME_KEY,
GEN_AI_EXECUTION_TYPE_KEY,
GEN_AI_INPUT_MESSAGES_KEY,
)
from ..opentelemetry_scope import OpenTelemetryScope
from ..request import Request
from ..tenant_details import TenantDetails
from ..utils import safe_json_dumps

INPUT_OPERATION_NAME = "input_messages"


class InputScope(OpenTelemetryScope):
"""Provides OpenTelemetry tracing scope for input messages."""

@staticmethod
def start(
agent_details: AgentDetails,
tenant_details: TenantDetails,
request: Request,
) -> "InputScope":
"""Creates and starts a new scope for input tracing.

Args:
agent_details: The details of the agent
tenant_details: The details of the tenant
request: The request details which invokes the agent

Returns:
A new InputScope instance
"""
return InputScope(agent_details, tenant_details, request)

def __init__(
self,
agent_details: AgentDetails,
tenant_details: TenantDetails,
request: Request,
):
"""Initialize the input scope.

Args:
agent_details: The details of the agent
tenant_details: The details of the tenant
request: The request details which invokes the agent
"""
super().__init__(
kind="Client",
operation_name=INPUT_OPERATION_NAME,
activity_name=(f"{INPUT_OPERATION_NAME} {agent_details.agent_id}"),
agent_details=agent_details,
tenant_details=tenant_details,
)

# Set request metadata
if request.source_metadata:
self.set_tag_maybe(GEN_AI_EXECUTION_SOURCE_NAME_KEY, request.source_metadata.name)
self.set_tag_maybe(
GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, request.source_metadata.description
)

self.set_tag_maybe(
GEN_AI_EXECUTION_TYPE_KEY,
request.execution_type.value if request.execution_type else None,
)
self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, safe_json_dumps([request.content]))

def record_input_messages(self, messages: list[str]) -> None:
"""Records the input messages for telemetry tracking.

Args:
messages: List of input messages
Comment on lines +75 to +78
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method record_input_messages allows updating input messages after the scope has been created, but there's no documentation explaining when and why this method should be used versus passing the message during initialization. The Request object is already passed in init, so the purpose of this additional method is unclear. Consider adding documentation to explain the use case, or removing this method if it's not needed.

Suggested change
"""Records the input messages for telemetry tracking.
Args:
messages: List of input messages
"""Records or updates the input messages for telemetry tracking.
This method is intended for scenarios where the full set of input messages is
not yet available when the :class:`InputScope` is created, or when the initial
request content needs to be updated (for example, after normalization,
aggregation across multiple turns, or other preprocessing).
By default, the constructor records the initial input messages based on
``request.content``. Calling this method will overwrite the previously
recorded ``GEN_AI_INPUT_MESSAGES_KEY`` value with the provided ``messages``.
Args:
messages: The complete list of input messages to associate with this scope.

Copilot uses AI. Check for mistakes.
"""
self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, safe_json_dumps(messages))
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
Comment on lines +1 to +2
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an inconsistency in copyright headers across the codebase. Most existing files use 'Copyright (c) Microsoft. All rights reserved.' but the new files use 'Copyright (c) Microsoft Corporation.\n# Licensed under the MIT License.' While both are valid, this inconsistency could cause confusion. Consider aligning with the existing codebase standard.

Copilot uses AI. Check for mistakes.

from ..agent_details import AgentDetails
from ..constants import GEN_AI_OUTPUT_MESSAGES_KEY
from ..models.response import Response
from ..opentelemetry_scope import OpenTelemetryScope
from ..tenant_details import TenantDetails
from ..utils import safe_json_dumps

OUTPUT_OPERATION_NAME = "output_messages"


class OutputScope(OpenTelemetryScope):
"""Provides OpenTelemetry tracing scope for output messages."""

@staticmethod
def start(
agent_details: AgentDetails,
tenant_details: TenantDetails,
response: Response,
) -> "OutputScope":
"""Creates and starts a new scope for output tracing.

Args:
agent_details: The details of the agent
tenant_details: The details of the tenant
response: The response details from the agent

Returns:
A new OutputScope instance
"""
return OutputScope(agent_details, tenant_details, response)

def __init__(
self,
agent_details: AgentDetails,
tenant_details: TenantDetails,
response: Response,
):
"""Initialize the output scope.

Args:
agent_details: The details of the agent
tenant_details: The details of the tenant
response: The response details from the agent
"""
super().__init__(
kind="Client",
operation_name=OUTPUT_OPERATION_NAME,
activity_name=(f"{OUTPUT_OPERATION_NAME} {agent_details.agent_id}"),
agent_details=agent_details,
tenant_details=tenant_details,
)

# Set response messages
self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(response.messages))

def record_output_messages(self, messages: list[str]) -> None:
"""Records the output messages for telemetry tracking.

Args:
messages: List of output messages
"""
self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(messages))
Comment on lines +59 to +65
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method record_output_messages allows updating output messages after the scope has been created, but there's no documentation explaining when and why this method should be used versus passing the messages during initialization. The Response object is already passed in init, so the purpose of this additional method is unclear. Consider adding documentation to explain the use case, or removing this method if it's not needed.

Copilot uses AI. Check for mistakes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
Comment on lines +1 to +2
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an inconsistency in copyright headers across the codebase. Most existing files use 'Copyright (c) Microsoft. All rights reserved.' but the new files use 'Copyright (c) Microsoft Corporation.\n# Licensed under the MIT License.' While both are valid, this inconsistency could cause confusion. Consider aligning with the existing codebase standard.

Copilot uses AI. Check for mistakes.

import logging
from collections.abc import Awaitable, Callable

from microsoft_agents.activity import Activity
from microsoft_agents.hosting.core.middleware_set import Middleware, TurnContext
from microsoft_agents_a365.observability.core.agent_details import AgentDetails
from microsoft_agents_a365.observability.core.execution_type import ExecutionType
from microsoft_agents_a365.observability.core.models.response import Response
from microsoft_agents_a365.observability.core.request import Request
from microsoft_agents_a365.observability.core.spans_scopes.input_scope import InputScope
from microsoft_agents_a365.observability.core.spans_scopes.output_scope import OutputScope
from microsoft_agents_a365.observability.core.tenant_details import TenantDetails


class MessageLoggingMiddleware(Middleware):
"""
Lightweight middleware for logging input and output messages.
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states that the class provides message logging functionality, but it doesn't mention that it also creates OpenTelemetry spans through InputScope and OutputScope. This is a significant aspect of the middleware's behavior that should be documented, as it affects observability infrastructure and trace collection.

Suggested change
Lightweight middleware for logging input and output messages.
Lightweight middleware that logs input and output messages and creates
OpenTelemetry spans for these messages.
The middleware starts an ``InputScope`` for the turn when a user message
is present and uses an ``OutputScope`` for outgoing bot messages. This
behavior affects observability infrastructure and trace collection, in
addition to providing message logging.

Copilot uses AI. Check for mistakes.
"""

def __init__(
self,
logger: logging.Logger | None = None,
log_user_messages: bool = True,
log_bot_messages: bool = True,
):
"""
Initialize the message logger middleware.

Args:
logger: Custom logger instance (defaults to module logger)
log_user_messages: Whether to log incoming user messages
log_bot_messages: Whether to log outgoing bot messages
"""
Comment on lines +29 to +36
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring lacks an example of how to use this class within the middleware registration pattern. Given the complex interaction with InputScope and OutputScope, and the unusual pattern of manually calling enter and exit, adding a usage example would significantly help developers understand the expected behavior.

Copilot uses AI. Check for mistakes.
self.logger = logger or logging.getLogger("agents.observability")
self.log_user_messages = log_user_messages
self.log_bot_messages = log_bot_messages

async def on_turn(self, turn_context: TurnContext, logic: Callable[[TurnContext], Awaitable]):
input_scope = None

# Start InputScope for the entire turn if we have user message
if self.log_user_messages and turn_context.activity.text:
input_scope = self._create_input_scope(turn_context.activity)
input_scope.__enter__()
self.logger.info(f"📥 User input message: {turn_context.activity.text}")

try:
# Hook into outgoing messages
if self.log_bot_messages:
# Pass activity to handler so we can create agent/tenant details
turn_context.on_send_activities(self._create_send_handler(turn_context.activity))

# Execute bot logic
await logic()
except Exception as exc:
# Clean up and propagate exception (let __exit__ handle error recording)
if input_scope:
input_scope.__exit__(type(exc), exc, exc.__traceback__)
input_scope = None # Prevent double cleanup
raise
finally:
# Clean up the input scope if not already done
if input_scope:
input_scope.__exit__(None, None, None)
Comment on lines +45 to +67
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method manually calls enter and exit on the InputScope context manager, which is an unusual pattern and potentially error-prone. The standard pattern would be to use a 'with' statement. However, if manual control is required due to the async execution flow, this should be documented with a comment explaining why the standard context manager pattern cannot be used here.

Copilot uses AI. Check for mistakes.

def _create_input_scope(self, activity: Activity) -> InputScope:
"""Create InputScope for tracing the entire turn"""
# Extract details from activity
agent_details = AgentDetails(
agent_id=activity.recipient.id if activity.recipient else "unknown",
agent_name=activity.recipient.name if activity.recipient else None,
conversation_id=activity.conversation.id if activity.conversation else None,
)
Comment on lines +72 to +76
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The agent_id is set to "unknown" when activity.recipient is None, but the agent_name in the same case is set to None. This inconsistency could lead to confusion in logs and telemetry. Consider either setting both to "unknown" for consistency, or both to None to indicate missing data.

Copilot uses AI. Check for mistakes.

tenant_details = TenantDetails(
tenant_id=activity.conversation.tenant_id
if activity.conversation and hasattr(activity.conversation, "tenant_id")
else "unknown"
)

request = Request(
content=activity.text or "",
execution_type=ExecutionType.HUMAN_TO_AGENT,
session_id=activity.conversation.id if activity.conversation else None,
)

return InputScope.start(agent_details, tenant_details, request)

def _create_send_handler(self, incoming_activity: Activity):
"""Create handler for outgoing bot messages

Args:
incoming_activity: The incoming activity to extract agent/tenant details from
"""

async def send_handler(ctx, activities, next_send):
# Collect all outgoing message texts
messages = [activity.text for activity in activities if activity.text]

if messages:
# Create OutputScope as a child of the current InputScope
agent_details = AgentDetails(
agent_id=incoming_activity.recipient.id
if incoming_activity.recipient
else "unknown",
agent_name=incoming_activity.recipient.name
if incoming_activity.recipient
else None,
conversation_id=incoming_activity.conversation.id
if incoming_activity.conversation
else None,
)

tenant_details = TenantDetails(
tenant_id=incoming_activity.conversation.tenant_id
if incoming_activity.conversation
and hasattr(incoming_activity.conversation, "tenant_id")
else "unknown"
)

Comment on lines +100 to +123
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is code duplication in the creation of AgentDetails and TenantDetails objects. The same logic for extracting these details from the activity appears in both _create_input_scope (lines 72-82) and _create_send_handler (lines 105-122). This duplication makes the code harder to maintain and increases the risk of inconsistencies. Consider extracting this into a helper method like _extract_agent_details(activity) and _extract_tenant_details(activity).

Suggested change
# Collect all outgoing message texts
messages = [activity.text for activity in activities if activity.text]
if messages:
# Create OutputScope as a child of the current InputScope
agent_details = AgentDetails(
agent_id=incoming_activity.recipient.id
if incoming_activity.recipient
else "unknown",
agent_name=incoming_activity.recipient.name
if incoming_activity.recipient
else None,
conversation_id=incoming_activity.conversation.id
if incoming_activity.conversation
else None,
)
tenant_details = TenantDetails(
tenant_id=incoming_activity.conversation.tenant_id
if incoming_activity.conversation
and hasattr(incoming_activity.conversation, "tenant_id")
else "unknown"
)
def _extract_agent_details(activity: Activity) -> AgentDetails:
return AgentDetails(
agent_id=activity.recipient.id if activity.recipient else "unknown",
agent_name=activity.recipient.name if activity.recipient else None,
conversation_id=activity.conversation.id
if activity.conversation
else None,
)
def _extract_tenant_details(activity: Activity) -> TenantDetails:
return TenantDetails(
tenant_id=activity.conversation.tenant_id
if activity.conversation and hasattr(activity.conversation, "tenant_id")
else "unknown"
)
# Collect all outgoing message texts
messages = [activity.text for activity in activities if activity.text]
if messages:
# Create OutputScope as a child of the current InputScope
agent_details = _extract_agent_details(incoming_activity)
tenant_details = _extract_tenant_details(incoming_activity)

Copilot uses AI. Check for mistakes.
response = Response(messages=messages)

# Use OutputScope within a context manager - it will be a child of InputScope
with OutputScope.start(agent_details, tenant_details, response):
# Log each message
for message in messages:
self.logger.info(f"📤 Bot output message: {message}")
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The emoji usage in log messages ('📥' and '📤') may not render correctly in all logging environments and could cause issues with log parsing tools or text-based terminals. Consider using standard text prefixes like '[INPUT]' and '[OUTPUT]' instead, or make the emoji usage configurable.

Suggested change
self.logger.info(f"📤 Bot output message: {message}")
self.logger.info(f"[OUTPUT] Bot output message: {message}")

Copilot uses AI. Check for mistakes.

return await next_send()

return send_handler
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
Comment on lines +1 to +2
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an inconsistency in copyright headers across the codebase. Most existing files use 'Copyright (c) Microsoft. All rights reserved.' but the new files use 'Copyright (c) Microsoft Corporation.\n# Licensed under the MIT License.' While both are valid, this inconsistency could cause confusion. Consider aligning with the existing codebase standard.

Copilot uses AI. Check for mistakes.


from microsoft_agents.hosting.aiohttp import CloudAdapter

from .message_logging_middleware import MessageLoggingMiddleware


class ObservabilityMiddlewareRegistrar:
"""
Registrar for configuring and registering observability middleware.

Usage:
# Quick start with defaults
ObservabilityMiddlewareRegistrar().with_message_logging().apply(adapter)

"""

def __init__(self):
"""Initialize the registrar."""
self._middleware_configs: list = []

def with_message_logging(
self,
log_user_messages: bool = True,
log_bot_messages: bool = True,
) -> "ObservabilityMiddlewareRegistrar":
"""Configure message logging middleware.

Args:
log_user_messages: Whether to log user messages (default: True)
log_bot_messages: Whether to log bot messages (default: True)

Returns:
The registrar instance for chaining
"""
self._middleware_configs.append(
lambda: MessageLoggingMiddleware(
log_user_messages=log_user_messages,
log_bot_messages=log_bot_messages,
)
)
return self

def apply(self, adapter: CloudAdapter) -> None:
"""Apply all configured middleware to the adapter.

Args:
adapter: CloudAdapter to register middleware with
"""
for create_middleware in self._middleware_configs:
middleware = create_middleware()
adapter.use(middleware)
Loading