From 168bad822461b69ee4488141b340ac8ff1d5d581 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 5 Dec 2025 11:10:22 -0800 Subject: [PATCH 01/10] Creating otel boilerplate sample --- test_samples/otel/src/__init__.py | 0 test_samples/otel/src/agent.py | 68 ++++++++++ test_samples/otel/src/agent_metric.py | 165 +++++++++++++++++++++++++ test_samples/otel/src/env.TEMPLATE | 14 +++ test_samples/otel/src/main.py | 22 ++++ test_samples/otel/src/requirements.txt | 14 +++ test_samples/otel/src/start_server.py | 52 ++++++++ test_samples/otel/src/telemetry.py | 117 ++++++++++++++++++ 8 files changed, 452 insertions(+) create mode 100644 test_samples/otel/src/__init__.py create mode 100644 test_samples/otel/src/agent.py create mode 100644 test_samples/otel/src/agent_metric.py create mode 100644 test_samples/otel/src/env.TEMPLATE create mode 100644 test_samples/otel/src/main.py create mode 100644 test_samples/otel/src/requirements.txt create mode 100644 test_samples/otel/src/start_server.py create mode 100644 test_samples/otel/src/telemetry.py diff --git a/test_samples/otel/src/__init__.py b/test_samples/otel/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/otel/src/agent.py b/test_samples/otel/src/agent.py new file mode 100644 index 00000000..9d4140ac --- /dev/null +++ b/test_samples/otel/src/agent.py @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os.path as path +import re +import sys +import traceback +from dotenv import load_dotenv + +from os import environ +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + Authorization, + AgentApplication, + TurnState, + TurnContext, + MemoryStorage, +) +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.activity import load_configuration_from_env + +from .agent_metrics import agent_metrics + +load_dotenv() +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config +) + + +@AGENT_APP.conversation_update("membersAdded") +async def on_members_added(context: TurnContext, _state: TurnState): + await context.send_activity( + "Welcome to the empty agent! " + "This agent is designed to be a starting point for your own agent development." + ) + return True + + +@AGENT_APP.message(re.compile(r"^hello$")) +async def on_hello(context: TurnContext, _state: TurnState): + with agent_metrics.agent_operation("on_hello", context): + await context.send_activity("Hello!") + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, _state: TurnState): + with agent_metrics.agent_operation("on_message", context): + await context.send_activity(f"you said: {context.activity.text}") + + +@AGENT_APP.error +async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + + # Send a message to the user + await context.send_activity("The bot encountered an error or bug.") diff --git a/test_samples/otel/src/agent_metric.py b/test_samples/otel/src/agent_metric.py new file mode 100644 index 00000000..db9a8da0 --- /dev/null +++ b/test_samples/otel/src/agent_metric.py @@ -0,0 +1,165 @@ +import time +from datetime import datetime, timezone + +from contextlib import contextmanager + +from microsoft_agents.hosting.core import TurnContext + +from opentelemetry.metrics import Meter, Counter, Histogram, UpDownCounter +from opentelemetry import metrics, trace +from opentelemetry.trace import Tracer, Span +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter + + +class AgentMetrics: + + tracer: Tracer + + # not thread-safe + _message_processed_counter: Counter + _route_executed_counter: Counter + _message_processing_duration: Histogram + _route_execution_duration: Histogram + _message_processing_duration: Histogram + _active_conversations: UpDownCounter + + def __init__(self): + self.tracer = trace.get_tracer("A365.AgentFramework") + self.meter = metrics.get_meter("A365.AgentFramework", "1.0.0") + + self._message_processed_counter = self.meter.create_counter( + "agents.message.processed.count", + "messages", + description="Number of messages processed by the agent", + ) + self._route_executed_counter = self.meter.create_counter( + "agents.route.executed.count", + "routes", + description="Number of routes executed by the agent", + ) + self._message_processing_duration = self.meter.create_histogram( + "agents.message.processing.duration", + "ms", + description="Duration of message processing in milliseconds", + ) + self._route_execution_duration = self.meter.create_histogram( + "agents.route.execution.duration", + "ms", + description="Duration of route execution in milliseconds", + ) + self._active_conversations = self.meter.create_up_down_counter( + "agents.active.conversations.count", + "conversations", + description="Number of active conversations", + ) + + def _finalize_message_handling_span( + self, span: Span, context: TurnContext, duration_ms: float, success: bool + ): + self._message_processing_duration.record( + duration_ms, + { + "conversation.id": ( + context.activity.conversation.id + if context.activity.conversation + else "unknown" + ), + "channel.id": str(context.activity.channel_id), + }, + ) + self._route_executed_counter.add( + 1, + { + "route.type": "message_handler", + "conversation.id": ( + context.activity.conversation.id + if context.activity.conversation + else "unknown" + ), + }, + ) + + if success: + span.set_status(trace.Status(trace.StatusCode.OK)) + else: + span.set_status(trace.Status(trace.StatusCode.ERROR)) + + @contextmanager + def http_operation(self, operation_name: str): + + with self.tracer.start_as_current_span(operation_name) as span: + + span.set_attribute("operation.name", operation_name) + span.add_event("Agent operation started", {}) + + try: + yield # execute the operation in the with block + span.set_status(trace.Status(trace.StatusCode.OK)) + except Exception as e: + span.record_exception(e) + raise + + @contextmanager + def _init_span_from_context(self, operation_name: str, context: TurnContext): + + with self.tracer.start_as_current_span(operation_name) as span: + + span.set_attribute("activity.type", context.activity.type) + span.set_attribute( + "agent.is_agentic", context.activity.is_agentic_request() + ) + if context.activity.from_property: + span.set_attribute("caller.id", context.activity.from_property.id) + if context.activity.conversation: + span.set_attribute("conversation.id", context.activity.conversation.id) + span.set_attribute("channel_id", str(context.activity.channel_id)) + span.set_attribute( + "message.text.length", + len(context.activity.text) if context.activity.text else 0, + ) + + ts = int(datetime.now(timezone.utc).timestamp()) + span.add_event( + "message.processed", + { + "agent.is_agentic": context.activity.is_agentic_request(), + "activity.type": context.activity.type, + "channel.id": str(context.activity.channel_id), + "message.id": str(context.activity.id), + "message.text": context.activity.text, + }, + ts, + ) + + yield span + + @contextmanager + def agent_operation(self, operation_name: str, context: TurnContext): + + self._message_processed_counter.add(1) + + with self._init_span_from_context(operation_name, context) as span: + + start = time.time() + + span.set_attribute("operation.name", operation_name) + span.add_event("Agent operation started", {}) + + success = True + + try: + yield # execute the operation in the with block + except Exception as e: + success = False + span.record_exception(e) + raise + finally: + + end = time.time() + duration = (end - start) * 1000 # milliseconds + + self._finalize_message_handling_span(span, context, duration, success) + + +agent_metrics = AgentMetrics() diff --git a/test_samples/otel/src/env.TEMPLATE b/test_samples/otel/src/env.TEMPLATE new file mode 100644 index 00000000..b7b556f9 --- /dev/null +++ b/test_samples/otel/src/env.TEMPLATE @@ -0,0 +1,14 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=client-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=tenant-id + +LOGGING__LOGLEVEL__microsoft_agents.hosting.core=INFO + +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_EXPORTER_OTLP_INSECURE=true + +OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST=".*" +OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE=".*" + +OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST=".*" +OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE=".*" \ No newline at end of file diff --git a/test_samples/otel/src/main.py b/test_samples/otel/src/main.py new file mode 100644 index 00000000..51eddbbc --- /dev/null +++ b/test_samples/otel/src/main.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# enable logging for Microsoft Agents library +# for more information, see README.md for Quickstart Agent +import logging + +ms_agents_logger = logging.getLogger("microsoft_agents") +ms_agents_logger.addHandler(logging.StreamHandler()) +ms_agents_logger.setLevel(logging.INFO) + +from .telemetry import configure_telemetry + +configure_telemetry(service_name="quickstart_agent") + +from .agent import AGENT_APP, CONNECTION_MANAGER +from .start_server import start_server + +start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), +) diff --git a/test_samples/otel/src/requirements.txt b/test_samples/otel/src/requirements.txt new file mode 100644 index 00000000..879687ff --- /dev/null +++ b/test_samples/otel/src/requirements.txt @@ -0,0 +1,14 @@ +python-dotenv +aiohttp +microsoft-agents-hosting-aiohttp +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +opentelemetry-instrumentation-aiohttp-server +opentelemetry-instrumentation-aiohttp-client +opentelemetry-instrumentation-requests +opentelemetry-exporter-otlp +opentelemetry-sdk +opentelemetry-api +opentelemetry-instrumentation-logging +opentelemetry-instrumentation \ No newline at end of file diff --git a/test_samples/otel/src/start_server.py b/test_samples/otel/src/start_server.py new file mode 100644 index 00000000..4fa57e60 --- /dev/null +++ b/test_samples/otel/src/start_server.py @@ -0,0 +1,52 @@ +from os import environ +import logging + +from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.hosting.aiohttp import ( + start_agent_process, + jwt_authorization_middleware, + CloudAdapter, +) +from aiohttp.web import Request, Response, Application, run_app, json_response + +from .agent_metrics import agent_metrics + +logger = logging.getLogger(__name__) + + +def start_server( + agent_application: AgentApplication, auth_configuration: AgentAuthConfiguration +): + async def entry_point(req: Request) -> Response: + + logger.info("Request received at /api/messages endpoint.") + text = await req.text() + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + + with agent_metrics.http_operation("entry_point"): + return await start_agent_process( + req, + agent, + adapter, + ) + + APP = Application(middlewares=[]) + APP.router.add_post("/api/messages", entry_point) + # async def health(_req: Request) -> Response: + # return json_response( + # { + # "status": "ok", + # "content": "Healthy" + # } + # ) + # APP.router.add_get("/health", health) + + APP["agent_configuration"] = auth_configuration + APP["agent_app"] = agent_application + APP["adapter"] = agent_application.adapter + + try: + run_app(APP, host="localhost", port=environ.get("PORT", 3978)) + except Exception as error: + raise error diff --git a/test_samples/otel/src/telemetry.py b/test_samples/otel/src/telemetry.py new file mode 100644 index 00000000..624e2a16 --- /dev/null +++ b/test_samples/otel/src/telemetry.py @@ -0,0 +1,117 @@ +import logging +import os +import requests + +from microsoft_agents.hosting.core import TurnContext + +import aiohttp +from opentelemetry import metrics, trace +from opentelemetry.trace import Span +from opentelemetry._logs import set_logger_provider +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +from opentelemetry.instrumentation.aiohttp_server import AioHttpServerInstrumentor +from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor +from opentelemetry.instrumentation.requests import RequestsInstrumentor + + +def instrument_libraries(): + """Instrument libraries for OpenTelemetry.""" + + ## + # instrument aiohttp server + ## + AioHttpServerInstrumentor().instrument() + + ## + # instrument aiohttp client + ## + def aiohttp_client_request_hook( + span: Span, params: aiohttp.TraceRequestStartParams + ): + if span and span.is_recording(): + span.set_attribute("http.url", str(params.url)) + + def aiohttp_client_response_hook( + span: Span, + params: aiohttp.TraceRequestEndParams | aiohttp.TraceRequestExceptionParams, + ): + if span and span.is_recording(): + span.set_attribute("http.url", str(params.url)) + + AioHttpClientInstrumentor().instrument( + request_hook=aiohttp_client_request_hook, + response_hook=aiohttp_client_response_hook, + ) + + ## + # instrument requests library + ## + def requests_request_hook(span: Span, request: requests.Request): + if span and span.is_recording(): + span.set_attribute("http.url", request.url) + + def requests_response_hook( + span: Span, request: requests.Request, response: requests.Response + ): + if span and span.is_recording(): + span.set_attribute("http.url", response.url) + + RequestsInstrumentor().instrument( + request_hook=requests_request_hook, response_hook=requests_response_hook + ) + + +def configure_telemetry(service_name: str = "app"): + """Configure OpenTelemetry for FastAPI application.""" + + instrument_libraries() + + # Get OTLP endpoint from environment or use default for standalone dashboard + otlp_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + + # Create resource with service name + resource = Resource.create( + { + "service.name": service_name, + "service.version": "1.0.0", + "service.instance.id": os.getenv("HOSTNAME", "unknown"), + "telemetry.sdk.language": "python", + } + ) + + # Configure Tracing + trace_provider = TracerProvider(resource=resource) + trace_provider.add_span_processor( + BatchSpanProcessor(OTLPSpanExporter(endpoint=otlp_endpoint)) + ) + trace.set_tracer_provider(trace_provider) + + # Configure Metrics + metric_reader = PeriodicExportingMetricReader( + OTLPMetricExporter(endpoint=otlp_endpoint) + ) + meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader]) + metrics.set_meter_provider(meter_provider) + + # Configure Logging + logger_provider = LoggerProvider(resource=resource) + logger_provider.add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter(endpoint=otlp_endpoint)) + ) + set_logger_provider(logger_provider) + + # Add logging handler + handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider) + logging.getLogger().addHandler(handler) + + return trace.get_tracer(__name__) From b4d8e607e38aaf7994c1fc06f4fd4bed50e44702 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 20 Feb 2026 11:03:16 -0800 Subject: [PATCH 02/10] Basis for otel support --- .../hosting/core/observability/__init__.py | 0 .../core/observability/agent_telemetry.py | 269 ++++++++++++++++++ .../hosting/core/observability/types.py | 6 + 3 files changed, 275 insertions(+) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/agent_telemetry.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/types.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/agent_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/agent_telemetry.py new file mode 100644 index 00000000..d9d98661 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/agent_telemetry.py @@ -0,0 +1,269 @@ +import time +from typing import Callable +from datetime import datetime, timezone +from collections.abc import Iterator + +from contextlib import contextmanager + +from microsoft_agents.hosting.core import TurnContext + +from opentelemetry.metrics import Meter, Counter, Histogram, UpDownCounter +from opentelemetry import metrics, trace +from opentelemetry.trace import Tracer, Span + +from .types import StorageOperation + +def _ts() -> float: + """Helper function to get current timestamp in milliseconds""" + return datetime.now(timezone.utc).timestamp() * 1000 + +class AgentTelemetry: + + tracer: Tracer + meter: Meter + + # not thread-safe + _message_processed_counter: Counter + _route_executed_counter: Counter + _message_processing_duration: Histogram + _route_execution_duration: Histogram + _message_processing_duration: Histogram + _active_conversations: UpDownCounter + + def __init__(self): + self.tracer = trace.get_tracer("M365.agents", "1.0.0") + self.meter = metrics.get_meter("M365.agents", "1.0.0") + + self._enabled = True + + self._activities_received = self.meter.create_counter( + "agents.activities.received", + "activity", + description="Number of activities received by the agent", + ) + + self._activities_sent = self.meter.create_counter( + "agents.activities.sent", + "activity", + description="Number of activities sent by the agent", + ) + + self._activities_deleted = self.meter.create_counter( + "agents.activities.deleted", + "activity", + description="Number of activities deleted by the agent", + ) + + self._turns_total = self.meter.create_counter( + "agents.turns.total", + "turn", + description="Total number of turns processed by the agent", + ) + + self._turns_errors = self.meter.create_counter( + "agents.turns.errors", + "turn", + description="Number of turns that resulted in an error", + ) + + self._auth_token_requests = self.meter.create_counter( + "agents.auth.token.requests", + "request", + description="Number of authentication token requests made by the agent", + ) + + self._connection_requests = self.meter.create_counter( + "agents.connection.requests", + "request", + description="Number of connection requests made by the agent", + ) + + self._storage_operations = self.meter.create_counter( + "agents.storage.operations", + "operation", + description="Number of storage operations performed by the agent", + ) + + self._turn_duration = self.meter.create_histogram( + "agents.turn.duration", + "ms", + description="Duration of agent turns in milliseconds", + ) + + self._adapter_process_duration = self.meter.create_histogram( + "agents.adapter.process.duration", + "ms", + description="Duration of adapter processing in milliseconds", + ) + + self._storage_operation_duration = self.meter.create_histogram( + "agents.storage.operation.duration", + "ms", + description="Duration of storage operations in milliseconds", + ) + + self._auth_token_duration = self.meter.create_histogram( + "agents.auth.token.duration", + "ms", + description="Duration of authentication token requests in milliseconds", + ) + + def _init_span_from_context(self, operation_name: str, context: TurnContext): + + with self.tracer.start_as_current_span(operation_name) as span: + + span.set_attribute("activity.type", context.activity.type) + span.set_attribute( + "agent.is_agentic", context.activity.is_agentic_request() + ) + if context.activity.from_property: + span.set_attribute("caller.id", context.activity.from_property.id) + if context.activity.conversation: + span.set_attribute("conversation.id", context.activity.conversation.id) + span.set_attribute("channel_id", str(context.activity.channel_id)) + span.set_attribute( + "message.text.length", + len(context.activity.text) if context.activity.text else 0, + ) + + ts = int(datetime.now(timezone.utc).timestamp()) + span.add_event( + "message.processed", + { + "agent.is_agentic": context.activity.is_agentic_request(), + "activity.type": context.activity.type, + "channel.id": str(context.activity.channel_id), + "message.id": str(context.activity.id), + "message.text": context.activity.text, + }, + ts, + ) + + yield span + + def _extract_attributes_from_context(self, context: TurnContext) -> dict: + # This can be expanded to extract common attributes for spans and metrics from the context + attributes = {} + attributes["activity.type"] = context.activity.type + attributes["agent.is_agentic"] = context.activity.is_agentic_request() + if context.activity.from_property: + attributes["from.id"] = context.activity.from_property.id + if context.activity.recipient: + attributes["recipient.id"] = context.activity.recipient.id + if context.activity.conversation: + attributes["conversation.id"] = context.activity.conversation.id + attributes["channel_id"] = context.activity.channel_id + attributes["message.text.length"] = len(context.activity.text) if context.activity.text else 0 + return attributes + + @contextmanager + def start_as_current_span(self, span_name: str, context: TurnContext) -> Iterator[Span]: + + with self.tracer.start_as_current_span(span_name) as span: + attributes = self._extract_attributes_from_context(context) + span.set_attributes(attributes) + # span.add_event(f"{span_name} started", attributes) + yield span + + @contextmanager + def _timed_span( + self, + span_name: str, + context: TurnContext, + success_callback: Callable[[Span, float], None] | None = None, + failure_callback: Callable[[Span, Exception], None] | None = None, + ) -> Iterator[Span]: + + with self.start_as_current_span(span_name, context) as span: + + start = time.time() + exception: Exception | None = None + + try: + yield span # execute the operation in the with block + except Exception as e: + span.record_exception(e) + exception = e + finally: + + success = exception is None + + end = time.time() + duration = (end - start) * 1000 # milliseconds + + span.add_event(f"{span_name} completed", {"duration_ms": duration}) + + if success: + span.set_status(trace.Status(trace.StatusCode.OK)) + if success_callback: + success_callback(span, duration) + else: + + if failure_callback: + failure_callback(span, exception) + + span.set_status(trace.Status(trace.StatusCode.ERROR)) + raise exception # re-raise to ensure it's not swallowed + + @contextmanager + def auth_token_request_operation(self, context: TurnContext) -> Span: + with self._timed_span( + "auth token request", + context, + success_callback=lambda span, duration: self._auth_token_requests.add(1), + ) as span: + yield span + + @contextmanager + def agent_turn_operation(self, context: TurnContext) -> Iterator[Span]: + + def success_callback(span: Span, duration: float): + self._turns_total.add(1) + self._turn_duration.record(duration, { + "conversation.id": context.activity.conversation.id if context.activity.conversation else "unknown", + "channel.id": str(context.activity.channel_id), + }) + + ts = int(datetime.now(timezone.utc).timestamp()) + span.add_event( + "message.processed", + { + "agent.is_agentic": context.activity.is_agentic_request(), + "activity.type": context.activity.type, + "channel.id": str(context.activity.channel_id), + "message.id": str(context.activity.id), + "message.text": context.activity.text, + }, + ts, + ) + + def failure_callback(span: Span, e: Exception): + self._turns_errors.add(1) + + with self._timed_span( + "agent turn", + context, + success_callback=success_callback, + failure_callback=failure_callback + ) as span: + yield span # execute the turn operation in the with block + + @contextmanager + def adapter_process_operation(self, operation_name: str, context: TurnContext): + + def success_callback(span: Span, duration: float): + self._adapter_process_duration.record(duration, { + "conversation.id": context.activity.conversation.id if context.activity.conversation else "unknown", + "channel.id": str(context.activity.channel_id), + }) + + + with self._timed_span( + "adapter process", + context, + success_callback=success_callback + ) as span: + yield span # execute the adapter processing in the with block + + +agent_telemetry = AgentTelemetry() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/types.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/types.py new file mode 100644 index 00000000..83fa5744 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/types.py @@ -0,0 +1,6 @@ +from enum import Enum, auto + +class StorageOperation(Enum): + read = auto() + write = auto() + delete = auto() \ No newline at end of file From 8cc8757c41a4f32c34f6f6bd984a5dea84978f25 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 20 Feb 2026 11:23:56 -0800 Subject: [PATCH 03/10] Improving design --- .../core/observability/agent_telemetry.py | 114 +++++------------- 1 file changed, 28 insertions(+), 86 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/agent_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/agent_telemetry.py index d9d98661..769b82da 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/agent_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/agent_telemetry.py @@ -1,5 +1,5 @@ import time -from typing import Callable +from typing import Callable, ContextManager from datetime import datetime, timezone from collections.abc import Iterator @@ -34,26 +34,6 @@ def __init__(self): self.tracer = trace.get_tracer("M365.agents", "1.0.0") self.meter = metrics.get_meter("M365.agents", "1.0.0") - self._enabled = True - - self._activities_received = self.meter.create_counter( - "agents.activities.received", - "activity", - description="Number of activities received by the agent", - ) - - self._activities_sent = self.meter.create_counter( - "agents.activities.sent", - "activity", - description="Number of activities sent by the agent", - ) - - self._activities_deleted = self.meter.create_counter( - "agents.activities.deleted", - "activity", - description="Number of activities deleted by the agent", - ) - self._turns_total = self.meter.create_counter( "agents.turns.total", "turn", @@ -66,18 +46,6 @@ def __init__(self): description="Number of turns that resulted in an error", ) - self._auth_token_requests = self.meter.create_counter( - "agents.auth.token.requests", - "request", - description="Number of authentication token requests made by the agent", - ) - - self._connection_requests = self.meter.create_counter( - "agents.connection.requests", - "request", - description="Number of connection requests made by the agent", - ) - self._storage_operations = self.meter.create_counter( "agents.storage.operations", "operation", @@ -102,45 +70,6 @@ def __init__(self): description="Duration of storage operations in milliseconds", ) - self._auth_token_duration = self.meter.create_histogram( - "agents.auth.token.duration", - "ms", - description="Duration of authentication token requests in milliseconds", - ) - - def _init_span_from_context(self, operation_name: str, context: TurnContext): - - with self.tracer.start_as_current_span(operation_name) as span: - - span.set_attribute("activity.type", context.activity.type) - span.set_attribute( - "agent.is_agentic", context.activity.is_agentic_request() - ) - if context.activity.from_property: - span.set_attribute("caller.id", context.activity.from_property.id) - if context.activity.conversation: - span.set_attribute("conversation.id", context.activity.conversation.id) - span.set_attribute("channel_id", str(context.activity.channel_id)) - span.set_attribute( - "message.text.length", - len(context.activity.text) if context.activity.text else 0, - ) - - ts = int(datetime.now(timezone.utc).timestamp()) - span.add_event( - "message.processed", - { - "agent.is_agentic": context.activity.is_agentic_request(), - "activity.type": context.activity.type, - "channel.id": str(context.activity.channel_id), - "message.id": str(context.activity.id), - "message.text": context.activity.text, - }, - ts, - ) - - yield span - def _extract_attributes_from_context(self, context: TurnContext) -> dict: # This can be expanded to extract common attributes for spans and metrics from the context attributes = {} @@ -169,12 +98,19 @@ def start_as_current_span(self, span_name: str, context: TurnContext) -> Iterato def _timed_span( self, span_name: str, - context: TurnContext, + context: TurnContext | None = None, + *, success_callback: Callable[[Span, float], None] | None = None, failure_callback: Callable[[Span, Exception], None] | None = None, ) -> Iterator[Span]: - - with self.start_as_current_span(span_name, context) as span: + + cm: ContextManager[Span] + if context is None: + cm = self.tracer.start_as_current_span(span_name) + else: + cm = self.start_as_current_span(span_name, context) + + with cm as span: start = time.time() exception: Exception | None = None @@ -204,18 +140,10 @@ def _timed_span( span.set_status(trace.Status(trace.StatusCode.ERROR)) raise exception # re-raise to ensure it's not swallowed - - @contextmanager - def auth_token_request_operation(self, context: TurnContext) -> Span: - with self._timed_span( - "auth token request", - context, - success_callback=lambda span, duration: self._auth_token_requests.add(1), - ) as span: - yield span - + @contextmanager def agent_turn_operation(self, context: TurnContext) -> Iterator[Span]: + """Context manager for recording an agent turn, including success/failure and duration""" def success_callback(span: Span, duration: float): self._turns_total.add(1) @@ -242,7 +170,7 @@ def failure_callback(span: Span, e: Exception): with self._timed_span( "agent turn", - context, + context=context, success_callback=success_callback, failure_callback=failure_callback ) as span: @@ -250,6 +178,7 @@ def failure_callback(span: Span, e: Exception): @contextmanager def adapter_process_operation(self, operation_name: str, context: TurnContext): + """Context manager for recording adapter processing operations""" def success_callback(span: Span, duration: float): self._adapter_process_duration.record(duration, { @@ -265,5 +194,18 @@ def success_callback(span: Span, duration: float): ) as span: yield span # execute the adapter processing in the with block + @contextmanager + def storage_operation(self, operation: StorageOperation): + """Context manager for recording storage operations""" + + def success_callback(span: Span, duration: float): + self._storage_operations.add(1, {"operation": operation.value}) + self._storage_operation_duration.record(duration, {"operation": operation.value}) + + with self._timed_span( + f"storage {operation.value}", + success_callback=success_callback + ) as span: + yield span # execute the storage operation in the with block agent_telemetry = AgentTelemetry() From d17a16106aa1dae3a5c5a68874127e50e8f96a03 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 20 Feb 2026 11:33:46 -0800 Subject: [PATCH 04/10] Using telemetry hooks in storage --- .../hosting/core/app/agent_application.py | 5 +- .../hosting/core/observability/__init__.py | 3 + ...agent_telemetry.py => _agent_telemetry.py} | 11 +++- .../hosting/core/observability/types.py | 6 -- .../hosting/core/storage/memory_storage.py | 55 ++++++++++--------- .../hosting/core/storage/storage.py | 17 ++++-- .../microsoft-agents-hosting-core/setup.py | 2 + 7 files changed, 58 insertions(+), 41 deletions(-) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/{agent_telemetry.py => _agent_telemetry.py} (96%) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/types.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index d0eb6c1e..99ef5097 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -30,6 +30,8 @@ InvokeResponse, ) +from microsoft_agents.hosting.core.observability import agent_telemetry + from ..turn_context import TurnContext from ..agent import Agent from ..authorization import Connections @@ -664,7 +666,8 @@ async def on_turn(self, context: TurnContext): logger.debug( f"AgentApplication.on_turn(): Processing turn for context: {context.activity.id}" ) - await self._start_long_running_call(context, self._on_turn) + with agent_telemetry.turn_operation(context): + await self._start_long_running_call(context, self._on_turn) async def _on_turn(self, context: TurnContext): typing = None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/__init__.py index e69de29b..8deab740 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/__init__.py @@ -0,0 +1,3 @@ +from ._agent_telemetry import AgentTelemetry, agent_telemetry + +__all__ = ["AgentTelemetry", "agent_telemetry"] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/agent_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py similarity index 96% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/agent_telemetry.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py index 769b82da..f74bc530 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/agent_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py @@ -30,9 +30,14 @@ class AgentTelemetry: _message_processing_duration: Histogram _active_conversations: UpDownCounter - def __init__(self): - self.tracer = trace.get_tracer("M365.agents", "1.0.0") - self.meter = metrics.get_meter("M365.agents", "1.0.0") + def __init__(self, tracer: Tracer | None = None, meter: Meter | None = None): + if tracer is None: + tracer = trace.get_tracer("M365.agents", "1.0.0") + if meter is None: + meter = metrics.get_meter("M365.agents", "1.0.0") + + self.meter = meter + self.tracer = tracer self._turns_total = self.meter.create_counter( "agents.turns.total", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/types.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/types.py deleted file mode 100644 index 83fa5744..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/types.py +++ /dev/null @@ -1,6 +0,0 @@ -from enum import Enum, auto - -class StorageOperation(Enum): - read = auto() - write = auto() - delete = auto() \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py index 31560b27..36982d0f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py @@ -4,6 +4,8 @@ from threading import Lock from typing import TypeVar +from microsoft_agents.hosting.core.observability import agent_telemetry + from ._type_aliases import JSON from .storage import Storage from .store_item import StoreItem @@ -27,40 +29,43 @@ async def read( result: dict[str, StoreItem] = {} with self._lock: - for key in keys: - if key == "": - raise ValueError("MemoryStorage.read(): key cannot be empty") - if key in self._memory: - if not target_cls: - result[key] = self._memory[key] - else: - try: - result[key] = target_cls.from_json_to_store_item( - self._memory[key] - ) - except AttributeError as error: - raise TypeError( - f"MemoryStorage.read(): could not deserialize in-memory item into {target_cls} class. Error: {error}" - ) - return result + with agent_telemetry.storage_operation("read"): + for key in keys: + if key == "": + raise ValueError("MemoryStorage.read(): key cannot be empty") + if key in self._memory: + if not target_cls: + result[key] = self._memory[key] + else: + try: + result[key] = target_cls.from_json_to_store_item( + self._memory[key] + ) + except AttributeError as error: + raise TypeError( + f"MemoryStorage.read(): could not deserialize in-memory item into {target_cls} class. Error: {error}" + ) + return result async def write(self, changes: dict[str, StoreItem]): if not changes: raise ValueError("MemoryStorage.write(): changes cannot be None") with self._lock: - for key in changes: - if key == "": - raise ValueError("MemoryStorage.write(): key cannot be empty") - self._memory[key] = changes[key].store_item_to_json() + with agent_telemetry.storage_operation("write"): + for key in changes: + if key == "": + raise ValueError("MemoryStorage.write(): key cannot be empty") + self._memory[key] = changes[key].store_item_to_json() async def delete(self, keys: list[str]): if not keys: raise ValueError("Storage.delete(): Keys are required when deleting.") with self._lock: - for key in keys: - if key == "": - raise ValueError("MemoryStorage.delete(): key cannot be empty") - if key in self._memory: - del self._memory[key] + with agent_telemetry.storage_operation("delete"): + for key in keys: + if key == "": + raise ValueError("MemoryStorage.delete(): key cannot be empty") + if key in self._memory: + del self._memory[key] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py index 9c66ac31..0dd7dca0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py @@ -5,6 +5,8 @@ from abc import ABC, abstractmethod from asyncio import gather +from microsoft_agents.hosting.core.observability import agent_telemetry + from ._type_aliases import JSON from .store_item import StoreItem @@ -71,10 +73,11 @@ async def read( await self.initialize() - items: list[tuple[Union[str, None], Union[StoreItemT, None]]] = await gather( - *[self._read_item(key, target_cls=target_cls, **kwargs) for key in keys] - ) - return {key: value for key, value in items if key is not None} + with agent_telemetry.storage_operation("read"): + items: list[tuple[Union[str, None], Union[StoreItemT, None]]] = await gather( + *[self._read_item(key, target_cls=target_cls, **kwargs) for key in keys] + ) + return {key: value for key, value in items if key is not None} @abstractmethod async def _write_item(self, key: str, value: StoreItemT) -> None: @@ -87,7 +90,8 @@ async def write(self, changes: dict[str, StoreItemT]) -> None: await self.initialize() - await gather(*[self._write_item(key, value) for key, value in changes.items()]) + with agent_telemetry.storage_operation("write"): + await gather(*[self._write_item(key, value) for key, value in changes.items()]) @abstractmethod async def _delete_item(self, key: str) -> None: @@ -100,4 +104,5 @@ async def delete(self, keys: list[str]) -> None: await self.initialize() - await gather(*[self._delete_item(key) for key in keys]) + with agent_telemetry.storage_operation("delete"): + await gather(*[self._delete_item(key) for key in keys]) diff --git a/libraries/microsoft-agents-hosting-core/setup.py b/libraries/microsoft-agents-hosting-core/setup.py index b1da90cf..6888161d 100644 --- a/libraries/microsoft-agents-hosting-core/setup.py +++ b/libraries/microsoft-agents-hosting-core/setup.py @@ -17,5 +17,7 @@ "isodate>=0.6.1", "azure-core>=1.30.0", "python-dotenv>=1.1.1", + "opentelemetry-api>=1.17.0", # TODO -> verify this before commit + "opentelemetry-sdk>=1.17.0", ], ) From 8d2266af66631576c6a74846966a4a91541680eb Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 20 Feb 2026 11:49:50 -0800 Subject: [PATCH 05/10] Adding telemetry hooks to adapters --- .../hosting/core/app/agent_application.py | 86 +++++++++---------- .../core/observability/_agent_telemetry.py | 11 +-- 2 files changed, 46 insertions(+), 51 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 99ef5097..8360a192 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -31,8 +31,8 @@ ) from microsoft_agents.hosting.core.observability import agent_telemetry +from microsoft_agents.hosting.core.turn_context import TurnContext -from ..turn_context import TurnContext from ..agent import Agent from ..authorization import Connections from .app_error import ApplicationError @@ -666,56 +666,56 @@ async def on_turn(self, context: TurnContext): logger.debug( f"AgentApplication.on_turn(): Processing turn for context: {context.activity.id}" ) - with agent_telemetry.turn_operation(context): - await self._start_long_running_call(context, self._on_turn) + await self._start_long_running_call(context, self._on_turn) async def _on_turn(self, context: TurnContext): typing = None try: - if context.activity.type != ActivityTypes.typing: - if self._options.start_typing_timer: - typing = TypingIndicator(context) - typing.start() - - self._remove_mentions(context) - - logger.debug("Initializing turn state") - turn_state = await self._initialize_state(context) - if ( - context.activity.type == ActivityTypes.message - or context.activity.type == ActivityTypes.invoke - ): - - ( - auth_intercepts, - continuation_activity, - ) = await self._auth._on_turn_auth_intercept(context, turn_state) - if auth_intercepts: - if continuation_activity: - new_context = copy(context) - new_context.activity = continuation_activity - logger.info( - "Resending continuation activity %s", - continuation_activity.text, - ) - await self.on_turn(new_context) - await turn_state.save(context) - return + with agent_telemetry.agent_turn_operation(context): + if context.activity.type != ActivityTypes.typing: + if self._options.start_typing_timer: + typing = TypingIndicator(context) + typing.start() - logger.debug("Running before turn middleware") - if not await self._run_before_turn_middleware(context, turn_state): - return + self._remove_mentions(context) + + logger.debug("Initializing turn state") + turn_state = await self._initialize_state(context) + if ( + context.activity.type == ActivityTypes.message + or context.activity.type == ActivityTypes.invoke + ): - logger.debug("Running file downloads") - await self._handle_file_downloads(context, turn_state) + ( + auth_intercepts, + continuation_activity, + ) = await self._auth._on_turn_auth_intercept(context, turn_state) + if auth_intercepts: + if continuation_activity: + new_context = copy(context) + new_context.activity = continuation_activity + logger.info( + "Resending continuation activity %s", + continuation_activity.text, + ) + await self.on_turn(new_context) + await turn_state.save(context) + return + + logger.debug("Running before turn middleware") + if not await self._run_before_turn_middleware(context, turn_state): + return - logger.debug("Running activity handlers") - await self._on_activity(context, turn_state) + logger.debug("Running file downloads") + await self._handle_file_downloads(context, turn_state) - logger.debug("Running after turn middleware") - if await self._run_after_turn_middleware(context, turn_state): - await turn_state.save(context) - return + logger.debug("Running activity handlers") + await self._on_activity(context, turn_state) + + logger.debug("Running after turn middleware") + if await self._run_after_turn_middleware(context, turn_state): + await turn_state.save(context) + return except ApplicationError as err: logger.error( f"An application error occurred in the AgentApplication: {err}", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py index f74bc530..f42df28e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py @@ -144,7 +144,7 @@ def _timed_span( failure_callback(span, exception) span.set_status(trace.Status(trace.StatusCode.ERROR)) - raise exception # re-raise to ensure it's not swallowed + raise exception from None # re-raise to ensure it's not swallowed @contextmanager def agent_turn_operation(self, context: TurnContext) -> Iterator[Span]: @@ -182,19 +182,14 @@ def failure_callback(span: Span, e: Exception): yield span # execute the turn operation in the with block @contextmanager - def adapter_process_operation(self, operation_name: str, context: TurnContext): + def adapter_process_operation(self, operation_name: str): """Context manager for recording adapter processing operations""" def success_callback(span: Span, duration: float): - self._adapter_process_duration.record(duration, { - "conversation.id": context.activity.conversation.id if context.activity.conversation else "unknown", - "channel.id": str(context.activity.channel_id), - }) - + self._adapter_process_duration.record(duration) with self._timed_span( "adapter process", - context, success_callback=success_callback ) as span: yield span # execute the adapter processing in the with block From e306fa8669f70a8d92509a4cc947f3be5b09b9d8 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 23 Feb 2026 11:05:14 -0800 Subject: [PATCH 06/10] Setting up OTEL testing --- dev/tests/scenarios/__init__.py | 2 +- dev/tests/scenarios/quickstart.py | 6 ++- dev/tests/sdk/observability/__init__.py | 0 .../sdk/observability/test_observability.py | 38 +++++++++++++++++++ .../hosting/aiohttp/cloud_adapter.py | 15 +++++--- .../core/observability/_agent_telemetry.py | 12 +++--- .../hosting/fastapi/cloud_adapter.py | 14 ++++--- .../storage/cosmos/cosmos_db_storage.py | 2 +- 8 files changed, 66 insertions(+), 23 deletions(-) create mode 100644 dev/tests/sdk/observability/__init__.py create mode 100644 dev/tests/sdk/observability/test_observability.py diff --git a/dev/tests/scenarios/__init__.py b/dev/tests/scenarios/__init__.py index bfd4ee47..dd9b85a0 100644 --- a/dev/tests/scenarios/__init__.py +++ b/dev/tests/scenarios/__init__.py @@ -4,7 +4,7 @@ Scenario, ) -from .quickstart import init_app as init_quickstart +from .quickstart import init_agent as init_quickstart _SCENARIO_INITS = { "quickstart": init_quickstart, diff --git a/dev/tests/scenarios/quickstart.py b/dev/tests/scenarios/quickstart.py index 2f8e0a7a..a0f0375c 100644 --- a/dev/tests/scenarios/quickstart.py +++ b/dev/tests/scenarios/quickstart.py @@ -10,9 +10,11 @@ TurnState ) -from microsoft_agents.testing import AgentEnvironment +from microsoft_agents.testing import ( + AgentEnvironment, +) -async def init_app(env: AgentEnvironment): +async def init_agent(env: AgentEnvironment): """Initialize the application for the quickstart sample.""" app: AgentApplication[TurnState] = env.agent_application diff --git a/dev/tests/sdk/observability/__init__.py b/dev/tests/sdk/observability/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/tests/sdk/observability/test_observability.py b/dev/tests/sdk/observability/test_observability.py new file mode 100644 index 00000000..f1191b2d --- /dev/null +++ b/dev/tests/sdk/observability/test_observability.py @@ -0,0 +1,38 @@ +import pytest + +from contextlib import contextmanager + +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +from ...scenarios import load_scenario + +_SCENARIO = load_scenario("quickstart", use_jwt_middleware=False) + +@pytest.fixture +def test_exporter(): + """Set up fresh in-memory exporter for testing.""" + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + trace.set_tracer_provider(provider) + + yield exporter + + exporter.clear() + provider.shutdown() + +@pytest.mark.asyncio +@pytest.mark.agent_test(_SCENARIO) +async def test_basic(test_exporter, agent_client): + """Test that spans are created for a simple scenario.""" + + await agent_client.send_expect_replies("Hello!") + + spans = test_exporter.get_finished_spans() + + breakpoint() + + assert len(spans) > 0 \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py index c384dd95..7f7268f7 100644 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py @@ -11,6 +11,7 @@ HttpResponse, ) from microsoft_agents.hosting.core import ChannelServiceClientFactoryBase +from microsoft_agents.hosting.core.observability import agent_telemetry from .agent_http_adapter import AgentHttpAdapter @@ -69,14 +70,16 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: Returns: aiohttp Response object. """ - # Adapt request to protocol - adapted_request = AiohttpRequestAdapter(request) - # Process using base implementation - http_response: HttpResponse = await self.process_request(adapted_request, agent) + with agent_telemetry.adapter_process_operation(): + # Adapt request to protocol + adapted_request = AiohttpRequestAdapter(request) - # Convert HttpResponse to aiohttp Response - return self._to_aiohttp_response(http_response) + # Process using base implementation + http_response: HttpResponse = await self.process_request(adapted_request, agent) + + # Convert HttpResponse to aiohttp Response + return self._to_aiohttp_response(http_response) @staticmethod def _to_aiohttp_response(http_response: HttpResponse) -> Response: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py index f42df28e..a5673b05 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py @@ -11,8 +11,6 @@ from opentelemetry import metrics, trace from opentelemetry.trace import Tracer, Span -from .types import StorageOperation - def _ts() -> float: """Helper function to get current timestamp in milliseconds""" return datetime.now(timezone.utc).timestamp() * 1000 @@ -182,7 +180,7 @@ def failure_callback(span: Span, e: Exception): yield span # execute the turn operation in the with block @contextmanager - def adapter_process_operation(self, operation_name: str): + def adapter_process_operation(self): """Context manager for recording adapter processing operations""" def success_callback(span: Span, duration: float): @@ -195,15 +193,15 @@ def success_callback(span: Span, duration: float): yield span # execute the adapter processing in the with block @contextmanager - def storage_operation(self, operation: StorageOperation): + def storage_operation(self, operation: str): """Context manager for recording storage operations""" def success_callback(span: Span, duration: float): - self._storage_operations.add(1, {"operation": operation.value}) - self._storage_operation_duration.record(duration, {"operation": operation.value}) + self._storage_operations.add(1, {"operation": operation}) + self._storage_operation_duration.record(duration, {"operation": operation}) with self._timed_span( - f"storage {operation.value}", + f"storage {operation}", success_callback=success_callback ) as span: yield span # execute the storage operation in the with block diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py index a94f81df..29d8f009 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py @@ -12,6 +12,7 @@ HttpResponse, ) from microsoft_agents.hosting.core import ChannelServiceClientFactoryBase +from microsoft_agents.hosting.core.observability import agent_telemetry from .agent_http_adapter import AgentHttpAdapter @@ -70,14 +71,15 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: Returns: FastAPI Response object. """ - # Adapt request to protocol - adapted_request = FastApiRequestAdapter(request) + with agent_telemetry.adapter_process_operation(): + # Adapt request to protocol + adapted_request = FastApiRequestAdapter(request) - # Process using base implementation - http_response: HttpResponse = await self.process_request(adapted_request, agent) + # Process using base implementation + http_response: HttpResponse = await self.process_request(adapted_request, agent) - # Convert HttpResponse to FastAPI Response - return self._to_fastapi_response(http_response) + # Convert HttpResponse to FastAPI Response + return self._to_fastapi_response(http_response) @staticmethod def _to_fastapi_response(http_response: HttpResponse) -> Response: diff --git a/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage.py b/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage.py index 96df0352..d3d9d2bd 100644 --- a/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage.py +++ b/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage.py @@ -93,7 +93,7 @@ async def _read_item( if key == "": raise ValueError(str(storage_errors.CosmosDbKeyCannotBeEmpty)) - + escaped_key: str = self._sanitize(key) read_item_response: CosmosDict = await ignore_error( self._container.read_item( From d0acb1d5195116938fd30a8041d04f7a4373a27a Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 24 Feb 2026 09:24:30 -0800 Subject: [PATCH 07/10] Fix to ActivityTemplate --- .../testing/core/fluent/model_template.py | 24 ++++ .../sdk/observability/test_observability.py | 123 ++++++++++++++++-- 2 files changed, 135 insertions(+), 12 deletions(-) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py index 581e7434..b31d36fe 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py @@ -10,6 +10,7 @@ from __future__ import annotations from copy import deepcopy +from email.mime import base from typing import Generic, TypeVar, cast, Self from pydantic import BaseModel @@ -128,6 +129,19 @@ def __init__(self, defaults: Activity | dict | None = None, **kwargs) -> None: :param kwargs: Additional default values as keyword arguments. """ super().__init__(Activity, defaults, **kwargs) + ActivityTemplate._rename_from_property(self._defaults) + + @staticmethod + def _rename_from_property(data: dict) -> None: + """Rename keys starting with 'from.' to 'from_property.' for compatibility with Activity model.""" + mods = {} + for key in data.keys(): + if "from." in key: + new_key = key.replace("from.", "from_property.") + mods[key] = new_key + + for old_key, new_key in mods.items(): + data[new_key] = data.pop(old_key) def with_defaults(self, defaults: dict | None = None, **kwargs) -> ActivityTemplate: """Create a new ModelTemplate with additional default values. @@ -150,3 +164,13 @@ def with_updates(self, updates: dict | None = None, **kwargs) -> ActivityTemplat deep_update(new_template, flat_kwargs) # Pass already-expanded data, avoid re-expansion return ActivityTemplate(new_template) + + def create(self, original: BaseModel | dict | None = None) -> Activity: + """Create a new Activity instance based on the template.""" + if original is None: + original = {} + data = flatten_model_data(original) + ActivityTemplate._rename_from_property(data) + set_defaults(data, self._defaults) + data = expand(data) + return Activity.model_validate(data) \ No newline at end of file diff --git a/dev/tests/sdk/observability/test_observability.py b/dev/tests/sdk/observability/test_observability.py index f1191b2d..bfaf163d 100644 --- a/dev/tests/sdk/observability/test_observability.py +++ b/dev/tests/sdk/observability/test_observability.py @@ -1,28 +1,53 @@ import pytest -from contextlib import contextmanager - -from opentelemetry import trace +from opentelemetry import trace, metrics from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader from ...scenarios import load_scenario _SCENARIO = load_scenario("quickstart", use_jwt_middleware=False) -@pytest.fixture -def test_exporter(): +@pytest.fixture(scope="module") +def test_telemetry(): """Set up fresh in-memory exporter for testing.""" exporter = InMemorySpanExporter() - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(exporter)) - trace.set_tracer_provider(provider) + metric_reader = InMemoryMetricReader() + + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) + trace.set_tracer_provider(tracer_provider) + + meter_provider = MeterProvider([metric_reader]) + + metrics.set_meter_provider(meter_provider) - yield exporter + yield exporter, metric_reader exporter.clear() - provider.shutdown() + tracer_provider.shutdown() + meter_provider.shutdown() + +@pytest.fixture(scope="function") +def test_exporter(test_telemetry): + """Provide the in-memory span exporter for each test.""" + exporter, _ = test_telemetry + return exporter + +@pytest.fixture(scope="function") +def test_metric_reader(test_telemetry): + """Provide the in-memory metric reader for each test.""" + _, metric_reader = test_telemetry + return metric_reader + +@pytest.fixture(autouse=True, scope="function") +def clear(test_exporter, test_metric_reader): + """Clear spans before each test to ensure test isolation.""" + test_exporter.clear() + test_metric_reader.force_flush() @pytest.mark.asyncio @pytest.mark.agent_test(_SCENARIO) @@ -33,6 +58,80 @@ async def test_basic(test_exporter, agent_client): spans = test_exporter.get_finished_spans() - breakpoint() + # We should have a span for the overall turn + assert any( + span.name == "agent turn" + for span in spans + ) + turn_span = next(span for span in spans if span.name == "agent turn") + assert ( + "activity.type" in turn_span.attributes and + "agent.is_agentic" in turn_span.attributes and + "from.id" in turn_span.attributes and + "recipient.id" in turn_span.attributes and + "conversation.id" in turn_span.attributes and + "channel_id" in turn_span.attributes and + "message.text.length" in turn_span.attributes + ) + assert turn_span.attributes["activity.type"] == "message" + assert turn_span.attributes["agent.is_agentic"] == False + assert turn_span.attributes["message.text.length"] == len("Hello!") + + # adapter processing is a key part of the turn, so we should have a span for it + assert any( + span.name == "adapter process" + for span in spans + ) + + # storage is read when accessing conversation state + assert any( + span.name == "storage read" + for span in spans + ) + + assert len(spans) >= 3 + +@pytest.mark.asyncio +@pytest.mark.agent_test(_SCENARIO) +async def test_multiple_users(test_exporter, agent_client): + """Test that spans are created correctly for multiple users.""" + + activity1 = agent_client.template.create({ + "from.id": "user1", + "text": "Hello from user 1" + }) + + activity2 = agent_client.template.create({ + "from.id": "user2", + "text": "Hello from user 2" + }) + + await agent_client.send_expect_replies(activity1) + await agent_client.send_expect_replies(activity2) + + spans = test_exporter.get_finished_spans() + + def assert_span_for_user(user_id: str): + assert any( + span.name == "agent turn" and span.attributes.get("from.id") == user_id + for span in spans + ) + + assert_span_for_user("user1") + assert_span_for_user("user2") + + assert len([ span if span.name == "agent turn" else None for span in spans ]) == 2 + assert len([ span if span.name == "adapter process" else None for span in spans ]) == 2 + +@pytest.mark.asyncio +@pytest.mark.agent_test(_SCENARIO) +async def test_metrics(test_metric_reader, agent_client): + """Test that metrics are recorded for a simple scenario.""" + + await agent_client.send_expect_replies("Hello!") + + metrics_data = test_metric_reader.get_metrics_data() + + metrics = metrics_data.resource_metrics - assert len(spans) > 0 \ No newline at end of file + assert len(metrics) > 0 \ No newline at end of file From d7fad12c191efe4281c99f8c3c6d136f6f36c14e Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 24 Feb 2026 09:37:18 -0800 Subject: [PATCH 08/10] Fixed field resolution when provided from and from_property in templates --- .../testing/core/fluent/model_template.py | 49 +++++------ .../testing/core/fluent/utils.py | 22 ++++- .../tests/core/fluent/test_model_template.py | 82 +++++++++++++++++++ 3 files changed, 122 insertions(+), 31 deletions(-) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py index b31d36fe..8b56d613 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py @@ -23,7 +23,7 @@ set_defaults, flatten, ) -from .utils import flatten_model_data +from .utils import flatten_model_data, rename_from_property ModelT = TypeVar("ModelT", bound=BaseModel | dict) @@ -83,15 +83,17 @@ def with_defaults(self, defaults: dict | None = None, **kwargs) -> ModelTemplate :return: A new ModelTemplate instance. """ new_template = deepcopy(self._defaults) - set_defaults(new_template, defaults, **kwargs) + defaults_copy = deepcopy(defaults) if defaults else {} + rename_from_property(defaults_copy) + set_defaults(new_template, defaults_copy, **kwargs) return ModelTemplate[ModelT](self._model_class, new_template) def with_updates(self, updates: dict | None = None, **kwargs) -> ModelTemplate[ModelT]: """Create a new ModelTemplate with updated default values.""" new_template = deepcopy(self._defaults) # Expand the updates first so they merge correctly with nested structure - flat_updates = flatten(updates or {}) - flat_kwargs = flatten(kwargs) + flat_updates = flatten_model_data(updates or {}) + flat_kwargs = flatten_model_data(kwargs) deep_update(new_template, flat_updates) deep_update(new_template, flat_kwargs) # Pass already-expanded data, avoid re-expansion @@ -129,19 +131,7 @@ def __init__(self, defaults: Activity | dict | None = None, **kwargs) -> None: :param kwargs: Additional default values as keyword arguments. """ super().__init__(Activity, defaults, **kwargs) - ActivityTemplate._rename_from_property(self._defaults) - - @staticmethod - def _rename_from_property(data: dict) -> None: - """Rename keys starting with 'from.' to 'from_property.' for compatibility with Activity model.""" - mods = {} - for key in data.keys(): - if "from." in key: - new_key = key.replace("from.", "from_property.") - mods[key] = new_key - - for old_key, new_key in mods.items(): - data[new_key] = data.pop(old_key) + rename_from_property(self._defaults) def with_defaults(self, defaults: dict | None = None, **kwargs) -> ActivityTemplate: """Create a new ModelTemplate with additional default values. @@ -151,26 +141,27 @@ def with_defaults(self, defaults: dict | None = None, **kwargs) -> ActivityTempl :return: A new ModelTemplate instance. """ new_template = deepcopy(self._defaults) - set_defaults(new_template, defaults, **kwargs) + defaults_copy = deepcopy(defaults) if defaults else {} + rename_from_property(defaults_copy) + set_defaults(new_template, defaults_copy, **kwargs) return ActivityTemplate(new_template) def with_updates(self, updates: dict | None = None, **kwargs) -> ActivityTemplate: """Create a new ModelTemplate with updated default values.""" new_template = deepcopy(self._defaults) # Expand the updates first so they merge correctly with nested structure - flat_updates = flatten(updates or {}) - flat_kwargs = flatten(kwargs) + flat_updates = flatten_model_data(updates or {}) + flat_kwargs = flatten_model_data(kwargs) deep_update(new_template, flat_updates) deep_update(new_template, flat_kwargs) # Pass already-expanded data, avoid re-expansion return ActivityTemplate(new_template) - def create(self, original: BaseModel | dict | None = None) -> Activity: - """Create a new Activity instance based on the template.""" - if original is None: - original = {} - data = flatten_model_data(original) - ActivityTemplate._rename_from_property(data) - set_defaults(data, self._defaults) - data = expand(data) - return Activity.model_validate(data) \ No newline at end of file + # def create(self, original: BaseModel | dict | None = None) -> Activity: + # """Create a new Activity instance based on the template.""" + # if original is None: + # original = {} + # data = flatten_model_data(original) + # set_defaults(data, self._defaults) + # data = expand(data) + # return Activity.model_validate(data) \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py index 91d19376..aab30872 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py @@ -11,6 +11,20 @@ from pydantic import BaseModel from .backend import expand, flatten +def rename_from_property(data: dict) -> None: + """Rename keys starting with 'from.' to 'from_property.' for compatibility.""" + mods = {} + for key in data.keys(): + if key.startswith("from."): + new_key = key.replace("from.", "from_property.") + mods[key] = new_key + elif key == "from": + new_key = "from_property" + mods[key] = new_key + + for old_key, new_key in mods.items(): + data[new_key] = data.pop(old_key) + def normalize_model_data(source: BaseModel | dict) -> dict: """Normalize a BaseModel or dictionary to an expanded dictionary. @@ -25,7 +39,9 @@ def normalize_model_data(source: BaseModel | dict) -> dict: source = cast(dict, source.model_dump(exclude_unset=True, mode="json")) return source - return expand(source) + expanded = expand(source) + rename_from_property(expanded) + return expanded def flatten_model_data(source: BaseModel | dict) -> dict: """Flatten model data to a single-level dictionary with dot-notation keys. @@ -41,4 +57,6 @@ def flatten_model_data(source: BaseModel | dict) -> dict: source = cast(dict, source.model_dump(exclude_unset=True, mode="json")) return flatten(source) - return flatten(source) \ No newline at end of file + flattened = flatten(source) + rename_from_property(flattened) + return flattened \ No newline at end of file diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py b/dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py index a002475e..a378987c 100644 --- a/dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py +++ b/dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py @@ -421,6 +421,88 @@ def test_dot_notation_for_conversation(self): assert activity.conversation.name == "Test Conv" +class TestActivityTemplateFromAliases: + """Tests for ActivityTemplate alias behavior between from and from_property.""" + + def test_from_dot_notation_defaults_are_normalized(self): + """Defaults using from.* are normalized to from_property.* internally.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from.id": "user123", "from.name": "Alias User"} + ) + + assert "from.id" not in template._defaults + assert "from.name" not in template._defaults + assert template._defaults["from_property.id"] == "user123" + assert template._defaults["from_property.name"] == "Alias User" + + def test_create_accepts_top_level_from_alias_in_defaults(self): + """Top-level from alias in defaults maps to Activity.from_property.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from": {"id": "user123", "name": "Alias User"}} + ) + + activity = template.create() + assert activity.from_property is not None + assert activity.from_property.id == "user123" + assert activity.from_property.name == "Alias User" + + def test_create_original_from_alias_overrides_from_property_default(self): + """create() accepts from alias and overrides from_property defaults.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from_property.id": "default-id", "from_property.name": "Default User"} + ) + + activity = template.create({"from": {"id": "override-id", "name": "Override User"}}) + assert activity.from_property is not None + assert activity.from_property.id == "override-id" + assert activity.from_property.name == "Override User" + + def test_create_original_from_property_overrides_from_dot_default(self): + """create() accepts from_property and overrides defaults authored with from.* alias.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from.id": "default-id", "from.name": "Default User"} + ) + + activity = template.create( + { + "from_property": { + "id": "override-id", + "name": "Override User", + } + } + ) + assert activity.from_property is not None + assert activity.from_property.id == "override-id" + assert activity.from_property.name == "Override User" + + def test_with_defaults_accepts_from_alias(self): + """with_defaults() supports from alias and produces from_property on create.""" + template = ActivityTemplate(type=ActivityTypes.message).with_defaults( + **{"from.id": "user123", "from.name": "Alias User"} + ) + + activity = template.create() + assert activity.from_property is not None + assert activity.from_property.id == "user123" + assert activity.from_property.name == "Alias User" + + def test_with_updates_accepts_from_alias(self): + """with_updates() supports from alias and updates existing from_property values.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from_property.id": "default-id", "from_property.name": "Default User"} + ).with_updates(**{"from.id": "updated-id", "from.name": "Updated User"}) + + activity = template.create() + assert activity.from_property is not None + assert activity.from_property.id == "updated-id" + assert activity.from_property.name == "Updated User" + + class TestActivityTemplateEquality: """Tests for ActivityTemplate equality comparison.""" From d75395f67789d2c6be801d47e6cab777a8412526 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 24 Feb 2026 15:55:24 -0800 Subject: [PATCH 09/10] Another commit --- .../testing/core/fluent/model_template.py | 11 +-------- .../core/observability/_agent_telemetry.py | 24 +++++++++---------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py index 8b56d613..af22430b 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py @@ -155,13 +155,4 @@ def with_updates(self, updates: dict | None = None, **kwargs) -> ActivityTemplat deep_update(new_template, flat_updates) deep_update(new_template, flat_kwargs) # Pass already-expanded data, avoid re-expansion - return ActivityTemplate(new_template) - - # def create(self, original: BaseModel | dict | None = None) -> Activity: - # """Create a new Activity instance based on the template.""" - # if original is None: - # original = {} - # data = flatten_model_data(original) - # set_defaults(data, self._defaults) - # data = expand(data) - # return Activity.model_validate(data) \ No newline at end of file + return ActivityTemplate(new_template) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py index a5673b05..2df7c312 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/_agent_telemetry.py @@ -155,18 +155,18 @@ def success_callback(span: Span, duration: float): "channel.id": str(context.activity.channel_id), }) - ts = int(datetime.now(timezone.utc).timestamp()) - span.add_event( - "message.processed", - { - "agent.is_agentic": context.activity.is_agentic_request(), - "activity.type": context.activity.type, - "channel.id": str(context.activity.channel_id), - "message.id": str(context.activity.id), - "message.text": context.activity.text, - }, - ts, - ) + # ts = int(datetime.now(timezone.utc).timestamp()) + # span.add_event( + # "message.processed", + # { + # "agent.is_agentic": context.activity.is_agentic_request(), + # "activity.type": context.activity.type, + # ddd "channel.id": str(context.activity.channel_id), + # "message.id": str(context.activity.id), + # "message.text": context.activity.text, + # }, + # ts, + # ) def failure_callback(span: Span, e: Exception): self._turns_errors.add(1) From 980628d7271a6aa60c35d4ff24e17c7da5aa24f2 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 26 Feb 2026 18:11:35 -0800 Subject: [PATCH 10/10] Refining observability integration tests --- dev/tests/sdk/observability/test_observability.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dev/tests/sdk/observability/test_observability.py b/dev/tests/sdk/observability/test_observability.py index bfaf163d..baf973db 100644 --- a/dev/tests/sdk/observability/test_observability.py +++ b/dev/tests/sdk/observability/test_observability.py @@ -119,9 +119,9 @@ def assert_span_for_user(user_id: str): assert_span_for_user("user1") assert_span_for_user("user2") - - assert len([ span if span.name == "agent turn" else None for span in spans ]) == 2 - assert len([ span if span.name == "adapter process" else None for span in spans ]) == 2 + + assert len(list(filter(lambda span: span.name == "agent turn", spans))) == 2 + assert len(list(filter(lambda span: span.name == "adapter process", spans))) == 2 @pytest.mark.asyncio @pytest.mark.agent_test(_SCENARIO)