From 168bad822461b69ee4488141b340ac8ff1d5d581 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 5 Dec 2025 11:10:22 -0800 Subject: [PATCH 01/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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) From 77cb9a2248f83d1b80062f85ddb3a7920a595e90 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 27 Feb 2026 15:59:57 -0800 Subject: [PATCH 11/55] configure_telemetry helper --- .../core/connector/client/connector_client.py | 1 + .../hosting/core/observability/__init__.py | 15 ++- .../core/observability/_agent_telemetry.py | 126 ++++++++++++++---- .../core/observability/configure_telemetry.py | 48 +++++++ .../hosting/core/observability/constants.py | 15 +++ 5 files changed, 177 insertions(+), 28 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/constants.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 73b642a2..33fa0b04 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -17,6 +17,7 @@ ConversationsResult, PagedMembersResult, ) +from microsoft_agents.hosting.core.observability import agent_telemetry from microsoft_agents.hosting.core.connector import ConnectorClientBase from ..attachments_base import AttachmentsBase from ..conversations_base import ConversationsBase 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 8deab740..f40d1e21 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 @@ -1,3 +1,16 @@ from ._agent_telemetry import AgentTelemetry, agent_telemetry +from .configure_telemetry import configure_telemetry +from .constants import ( + SERVICE_NAME, + SERVICE_VERSION, + RESOURCE +) -__all__ = ["AgentTelemetry", "agent_telemetry"] \ No newline at end of file +__all__ = [ + "AgentTelemetry", + "agent_telemetry", + "configure_telemetry", + "SERVICE_NAME", + "SERVICE_VERSION", + "RESOURCE", +] \ 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 2df7c312..d0593842 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,14 +11,16 @@ from opentelemetry import metrics, trace from opentelemetry.trace import Tracer, Span +from .constants import SERVICE_NAME, SERVICE_VERSION + def _ts() -> float: """Helper function to get current timestamp in milliseconds""" return datetime.now(timezone.utc).timestamp() * 1000 class AgentTelemetry: - tracer: Tracer - meter: Meter + _tracer: Tracer + _meter: Meter # not thread-safe _message_processed_counter: Counter @@ -30,49 +32,91 @@ class AgentTelemetry: def __init__(self, tracer: Tracer | None = None, meter: Meter | None = None): if tracer is None: - tracer = trace.get_tracer("M365.agents", "1.0.0") + tracer = trace.get_tracer(SERVICE_NAME, SERVICE_VERSION) if meter is None: - meter = metrics.get_meter("M365.agents", "1.0.0") + meter = metrics.get_meter(SERVICE_NAME, SERVICE_VERSION) + + self._meter = meter + self._tracer = tracer + + # Storage + + self._storage_operations = self._meter.create_counter( + "storage.operation.total", + "operation", + description="Number of storage operations performed by the agent", + ) + + self._storage_operation_duration = self._meter.create_histogram( + "storage.operation.duration", + "ms", + description="Duration of storage operations in milliseconds", + ) - self.meter = meter - self.tracer = tracer + # AgentApplication - self._turns_total = self.meter.create_counter( - "agents.turns.total", + self._turn_total = self._meter.create_counter( + "app.turn.total", "turn", description="Total number of turns processed by the agent", ) - self._turns_errors = self.meter.create_counter( - "agents.turns.errors", + self._turn_errors = self._meter.create_counter( + "app.turn.errors", "turn", description="Number of turns that resulted in an error", ) - 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", + self._turn_duration = self._meter.create_histogram( + "app.turn.duration", "ms", description="Duration of agent turns in milliseconds", ) - self._adapter_process_duration = self.meter.create_histogram( + # Adapters + + 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", + # Connectors + + self._connector_request_total = self._meter.create_counter( + "agents.connector.request.total", + "request", + description="Total number of connector requests made by the agent", + ) + + self._connector_request_duration = self._meter.create_histogram( + "agents.connector.request.duration", "ms", - description="Duration of storage operations in milliseconds", + description="Duration of connector requests in milliseconds", + ) + + # Auth + + self._auth_token_request_total = self._meter.create_counter( + "agents.auth.request.total", + "request", + description="Total number of auth token requests made by the agent", + ) + + self._auth_token_requests_duration = self._meter.create_histogram( + "agents.auth.request.duration", + "ms", + description="Duration of auth token retrieval in milliseconds", ) + @property + def tracer(self) -> Tracer: + return self._tracer + + @property + def meter(self) -> Meter: + return self._meter + 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 = {} @@ -91,7 +135,7 @@ def _extract_attributes_from_context(self, context: TurnContext) -> dict: @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: + 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) @@ -109,7 +153,7 @@ def _timed_span( cm: ContextManager[Span] if context is None: - cm = self.tracer.start_as_current_span(span_name) + cm = self._tracer.start_as_current_span(span_name) else: cm = self.start_as_current_span(span_name, context) @@ -149,7 +193,7 @@ 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) + self._turn_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), @@ -169,7 +213,7 @@ def success_callback(span: Span, duration: float): # ) def failure_callback(span: Span, e: Exception): - self._turns_errors.add(1) + self._turn_errors.add(1) with self._timed_span( "agent turn", @@ -206,4 +250,32 @@ def success_callback(span: Span, duration: float): ) as span: yield span # execute the storage operation in the with block -agent_telemetry = AgentTelemetry() + @contextmanager + def connector_request_operation(self, operation: str): + """Context manager for recording connector requests""" + + def success_callback(span: Span, duration: float): + self._connector_request_total.add(1, {"operation": operation}) + self._connector_request_duration.record(duration, {"operation": operation}) + + with self._timed_span( + f"connector {operation}", + success_callback=success_callback + ) as span: + yield span # execute the connector request in the with block + + @contextmanager + def auth_token_request_operation(self): + """Context manager for recording auth token retrieval operations""" + + def success_callback(span: Span, duration: float): + self._auth_token_request_total.add(1) + self._auth_token_requests_duration.record(duration) + + with self._timed_span( + "auth token request", + success_callback=success_callback + ) as span: + yield span # execute the auth token retrieval operation in the with block + +agent_telemetry = AgentTelemetry() \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py new file mode 100644 index 00000000..c3439b26 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py @@ -0,0 +1,48 @@ +import logging +import os + +from opentelemetry import metrics, trace +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.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +from .constants import RESOURCE + +# TODO +def configure_telemetry() -> None: + """Configure OpenTelemetry for FastAPI application.""" + + # Get OTLP endpoint from environment or use default for standalone dashboard + otlp_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + + # 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) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/constants.py new file mode 100644 index 00000000..adaba82f --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/constants.py @@ -0,0 +1,15 @@ +import os + +from opentelemetry.sdk.resources import Resource + +SERVICE_NAME = "microsoft_agents" +SERVICE_VERSION = "1.0.0" + +RESOURCE = Resource.create( + { + "service.name": SERVICE_NAME, + "service.version": SERVICE_VERSION, + "service.instance.id": os.getenv("HOSTNAME", "unknown"), + "telemetry.sdk.language": "python", + } +) \ No newline at end of file From 7f3526139863320917eadf728a215a58a9d6aadd Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 2 Mar 2026 07:37:00 -0800 Subject: [PATCH 12/55] Small changes to dev/ directory w.r.t. testing --- dev/testing/README.md | 13 +++++++++++++ .../cross-sdk-tests/agents/basic_agent}/__init__.py | 0 .../agents/basic_agent/python/README.md | 0 .../agents/basic_agent/python}/__init__.py | 0 .../agents/basic_agent/python/env.TEMPLATE | 0 .../agents/basic_agent/python/pre_requirements.txt | 0 .../agents/basic_agent/python/requirements.txt | 0 .../agents/basic_agent/python/src}/__init__.py | 0 .../agents/basic_agent/python/src/agent.py | 0 .../agents/basic_agent/python/src/app.py | 0 .../agents/basic_agent/python/src/config.py | 0 .../basic_agent/python/src/weather}/__init__.py | 0 .../python/src/weather/agents}/__init__.py | 0 .../src/weather/agents/weather_forecast_agent.py | 0 .../python/src/weather/plugins/__init__.py | 0 .../src/weather/plugins/adaptive_card_plugin.py | 0 .../python/src/weather/plugins/date_time_plugin.py | 0 .../python/src/weather/plugins/weather_forecast.py | 0 .../src/weather/plugins/weather_forecast_plugin.py | 0 .../cross-sdk-tests/basic_agent}/__init__.py | 0 .../basic_agent/test_basic_agent_base.py | 0 .../cross-sdk-tests}/basic_agent/test_directline.py | 0 .../cross-sdk-tests}/basic_agent/test_msteams.py | 0 .../cross-sdk-tests}/basic_agent/test_webchat.py | 0 .../microsoft-agents-testing/docs/API.md | 0 .../microsoft-agents-testing/docs/MOTIVATION.md | 0 .../microsoft-agents-testing/docs/README.md | 0 .../microsoft-agents-testing/docs/SAMPLES.md | 0 .../docs/samples/__init__.py | 0 .../docs/samples/interactive.py | 0 .../docs/samples/multi_client.py | 0 .../docs/samples/pytest_plugin_usage.py | 0 .../docs/samples/quickstart.py | 0 .../docs/samples/scenario_registry_demo.py | 0 .../docs/samples/test_motivation_assertions.py | 0 .../docs/samples/transcript_formatting.py | 0 .../microsoft_agents/testing/__init__.py | 0 .../microsoft_agents/testing/aiohttp_scenario.py | 0 .../microsoft_agents/testing/cli/__init__.py | 0 .../testing/cli/commands/__init__.py | 0 .../microsoft_agents/testing/cli/commands/env.py | 0 .../testing/cli/commands/scenario.py | 0 .../microsoft_agents/testing/cli/core/__init__.py | 0 .../microsoft_agents/testing/cli/core/cli_config.py | 0 .../microsoft_agents/testing/cli/core/decorators.py | 0 .../microsoft_agents/testing/cli/core/output.py | 0 .../microsoft_agents/testing/cli/core/utils.py | 0 .../microsoft_agents/testing/cli/main.py | 0 .../testing/cli/scenarios/__init__.py | 0 .../testing/cli/scenarios/auth_scenario.py | 0 .../testing/cli/scenarios/basic_scenario.py | 0 .../microsoft_agents/testing/core/__init__.py | 0 .../testing/core/_aiohttp_client_factory.py | 0 .../microsoft_agents/testing/core/agent_client.py | 0 .../microsoft_agents/testing/core/config.py | 0 .../testing/core/external_scenario.py | 0 .../testing/core/fluent/__init__.py | 0 .../testing/core/fluent/activity.py | 0 .../testing/core/fluent/backend/__init__.py | 0 .../testing/core/fluent/backend/describe.py | 0 .../testing/core/fluent/backend/model_predicate.py | 0 .../testing/core/fluent/backend/quantifier.py | 0 .../testing/core/fluent/backend/transform.py | 0 .../testing/core/fluent/backend/types/__init__.py | 0 .../testing/core/fluent/backend/types/readonly.py | 0 .../core/fluent/backend/types/safe_object.py | 0 .../testing/core/fluent/backend/types/unset.py | 0 .../testing/core/fluent/backend/utils.py | 0 .../microsoft_agents/testing/core/fluent/expect.py | 0 .../testing/core/fluent/model_template.py | 0 .../microsoft_agents/testing/core/fluent/select.py | 0 .../microsoft_agents/testing/core/fluent/utils.py | 0 .../microsoft_agents/testing/core/scenario.py | 0 .../testing/core/transport/__init__.py | 0 .../core/transport/aiohttp_callback_server.py | 0 .../testing/core/transport/aiohttp_sender.py | 0 .../testing/core/transport/callback_server.py | 0 .../testing/core/transport/sender.py | 0 .../testing/core/transport/transcript/__init__.py | 0 .../testing/core/transport/transcript/exchange.py | 0 .../testing/core/transport/transcript/transcript.py | 0 .../microsoft_agents/testing/core/utils.py | 0 .../microsoft_agents/testing/pytest_plugin.py | 0 .../microsoft_agents/testing/scenario_registry.py | 0 .../testing/transcript_formatter.py | 0 .../microsoft_agents/testing/utils.py | 0 .../microsoft-agents-testing/payload.json | 0 .../microsoft-agents-testing/pyproject.toml | 0 .../microsoft-agents-testing/pytest.ini | 0 .../microsoft-agents-testing/tests}/__init__.py | 0 .../tests/cli/test_cli_integration.py | 0 .../tests/cli/test_output.py | 0 .../tests/core}/__init__.py | 0 .../tests/core/fluent}/__init__.py | 0 .../tests/core/fluent/backend}/__init__.py | 0 .../tests/core/fluent/backend/test_describe.py | 0 .../core/fluent/backend/test_model_predicate.py | 0 .../tests/core/fluent/backend/test_quantifier.py | 0 .../tests/core/fluent/backend/test_transform.py | 0 .../tests/core/fluent/backend/test_utils.py | 0 .../tests/core/fluent/backend/types}/__init__.py | 0 .../core/fluent/backend/types/test_readonly.py | 0 .../tests/core/fluent/backend/types/test_unset.py | 0 .../tests/core/fluent/test_expect.py | 0 .../tests/core/fluent/test_model_template.py | 0 .../tests/core/fluent/test_select.py | 0 .../tests/core/fluent/test_utils.py | 0 .../tests/core/test_agent_client.py | 0 .../tests/core/test_aiohttp_client_factory.py | 0 .../tests/core/test_config.py | 0 .../tests/core/test_external_scenario.py | 0 .../tests/core/test_integration.py | 0 .../tests/core/transport/__init__.py | 0 .../core/transport/test_aiohttp_callback_server.py | 0 .../tests/core/transport/test_aiohttp_sender.py | 0 .../tests/core/transport/transcript/__init__.py | 0 .../core/transport/transcript/test_exchange.py | 0 .../core/transport/transcript/test_transcript.py | 0 .../microsoft-agents-testing/tests/manual.py | 0 .../tests/test_aiohttp_scenario.py | 0 .../tests/test_aiohttp_scenario_integration.py | 0 .../microsoft-agents-testing/tests/test_examples.py | 0 .../tests/test_pytest_plugin.py | 0 .../tests/test_scenario_registry.py | 0 .../tests/test_scenario_registry_plugin.py | 0 .../tests/test_transcript_formatter.py | 0 .../python-sdk-tests}/__init__.py | 0 .../python-sdk-tests}/env.TEMPLATE | 0 .../python-sdk-tests/integration}/__init__.py | 0 .../integration/test_quickstart.py | 0 dev/{tests => testing/python-sdk-tests}/pytest.ini | 0 .../python-sdk-tests}/scenarios/__init__.py | 0 .../python-sdk-tests}/scenarios/quickstart.py | 0 .../python-sdk-tests}/sdk/__init__.py | 0 .../python-sdk-tests}/sdk/observability/__init__.py | 0 .../sdk/observability/test_observability.py | 0 .../python-sdk-tests}/sdk/test_expect_replies.py | 0 dev/tests/agents/__init__.py | 1 - 138 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 dev/testing/README.md rename dev/{microsoft-agents-testing/tests => testing/cross-sdk-tests/agents/basic_agent}/__init__.py (100%) rename dev/{tests => testing/cross-sdk-tests}/agents/basic_agent/python/README.md (100%) rename dev/{microsoft-agents-testing/tests/core => testing/cross-sdk-tests/agents/basic_agent/python}/__init__.py (100%) rename dev/{tests => testing/cross-sdk-tests}/agents/basic_agent/python/env.TEMPLATE (100%) rename dev/{tests => testing/cross-sdk-tests}/agents/basic_agent/python/pre_requirements.txt (100%) rename dev/{tests => testing/cross-sdk-tests}/agents/basic_agent/python/requirements.txt (100%) rename dev/{microsoft-agents-testing/tests/core/fluent => testing/cross-sdk-tests/agents/basic_agent/python/src}/__init__.py (100%) rename dev/{tests => testing/cross-sdk-tests}/agents/basic_agent/python/src/agent.py (100%) rename dev/{tests => testing/cross-sdk-tests}/agents/basic_agent/python/src/app.py (100%) rename dev/{tests => testing/cross-sdk-tests}/agents/basic_agent/python/src/config.py (100%) rename dev/{microsoft-agents-testing/tests/core/fluent/backend => testing/cross-sdk-tests/agents/basic_agent/python/src/weather}/__init__.py (100%) rename dev/{microsoft-agents-testing/tests/core/fluent/backend/types => testing/cross-sdk-tests/agents/basic_agent/python/src/weather/agents}/__init__.py (100%) rename dev/{tests => testing/cross-sdk-tests}/agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py (100%) rename dev/{tests => testing/cross-sdk-tests}/agents/basic_agent/python/src/weather/plugins/__init__.py (100%) rename dev/{tests => testing/cross-sdk-tests}/agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py (100%) rename dev/{tests => testing/cross-sdk-tests}/agents/basic_agent/python/src/weather/plugins/date_time_plugin.py (100%) rename dev/{tests => testing/cross-sdk-tests}/agents/basic_agent/python/src/weather/plugins/weather_forecast.py (100%) rename dev/{tests => testing/cross-sdk-tests}/agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py (100%) rename dev/{tests => testing/cross-sdk-tests/basic_agent}/__init__.py (100%) rename dev/{tests/integration => testing/cross-sdk-tests}/basic_agent/test_basic_agent_base.py (100%) rename dev/{tests/integration => testing/cross-sdk-tests}/basic_agent/test_directline.py (100%) rename dev/{tests/integration => testing/cross-sdk-tests}/basic_agent/test_msteams.py (100%) rename dev/{tests/integration => testing/cross-sdk-tests}/basic_agent/test_webchat.py (100%) rename dev/{ => testing}/microsoft-agents-testing/docs/API.md (100%) rename dev/{ => testing}/microsoft-agents-testing/docs/MOTIVATION.md (100%) rename dev/{ => testing}/microsoft-agents-testing/docs/README.md (100%) rename dev/{ => testing}/microsoft-agents-testing/docs/SAMPLES.md (100%) rename dev/{ => testing}/microsoft-agents-testing/docs/samples/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/docs/samples/interactive.py (100%) rename dev/{ => testing}/microsoft-agents-testing/docs/samples/multi_client.py (100%) rename dev/{ => testing}/microsoft-agents-testing/docs/samples/pytest_plugin_usage.py (100%) rename dev/{ => testing}/microsoft-agents-testing/docs/samples/quickstart.py (100%) rename dev/{ => testing}/microsoft-agents-testing/docs/samples/scenario_registry_demo.py (100%) rename dev/{ => testing}/microsoft-agents-testing/docs/samples/test_motivation_assertions.py (100%) rename dev/{ => testing}/microsoft-agents-testing/docs/samples/transcript_formatting.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/cli/main.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/config.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/activity.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/core/utils.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/transcript_formatter.py (100%) rename dev/{ => testing}/microsoft-agents-testing/microsoft_agents/testing/utils.py (100%) rename dev/{ => testing}/microsoft-agents-testing/payload.json (100%) rename dev/{ => testing}/microsoft-agents-testing/pyproject.toml (100%) rename dev/{ => testing}/microsoft-agents-testing/pytest.ini (100%) rename dev/{tests/agents/basic_agent => testing/microsoft-agents-testing/tests}/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/cli/test_cli_integration.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/cli/test_output.py (100%) rename dev/{tests/agents/basic_agent/python => testing/microsoft-agents-testing/tests/core}/__init__.py (100%) rename dev/{tests/agents/basic_agent/python/src => testing/microsoft-agents-testing/tests/core/fluent}/__init__.py (100%) rename dev/{tests/agents/basic_agent/python/src/weather => testing/microsoft-agents-testing/tests/core/fluent/backend}/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/fluent/backend/test_quantifier.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/fluent/backend/test_utils.py (100%) rename dev/{tests/agents/basic_agent/python/src/weather/agents => testing/microsoft-agents-testing/tests/core/fluent/backend/types}/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/fluent/test_expect.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/fluent/test_model_template.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/fluent/test_select.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/fluent/test_utils.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/test_agent_client.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/test_config.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/test_external_scenario.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/test_integration.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/transport/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/transport/transcript/__init__.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/manual.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/test_aiohttp_scenario.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/test_examples.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/test_pytest_plugin.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/test_scenario_registry.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/test_scenario_registry_plugin.py (100%) rename dev/{ => testing}/microsoft-agents-testing/tests/test_transcript_formatter.py (100%) rename dev/{tests/integration => testing/python-sdk-tests}/__init__.py (100%) rename dev/{tests => testing/python-sdk-tests}/env.TEMPLATE (100%) rename dev/{tests/integration/basic_agent => testing/python-sdk-tests/integration}/__init__.py (100%) rename dev/{tests => testing/python-sdk-tests}/integration/test_quickstart.py (100%) rename dev/{tests => testing/python-sdk-tests}/pytest.ini (100%) rename dev/{tests => testing/python-sdk-tests}/scenarios/__init__.py (100%) rename dev/{tests => testing/python-sdk-tests}/scenarios/quickstart.py (100%) rename dev/{tests => testing/python-sdk-tests}/sdk/__init__.py (100%) rename dev/{tests => testing/python-sdk-tests}/sdk/observability/__init__.py (100%) rename dev/{tests => testing/python-sdk-tests}/sdk/observability/test_observability.py (100%) rename dev/{tests => testing/python-sdk-tests}/sdk/test_expect_replies.py (100%) delete mode 100644 dev/tests/agents/__init__.py diff --git a/dev/testing/README.md b/dev/testing/README.md new file mode 100644 index 00000000..ff0ee91a --- /dev/null +++ b/dev/testing/README.md @@ -0,0 +1,13 @@ +## Testing + +This folder contains three test-related directories: + +`cross-sdk-tests`: End-to-end tests across all SDKs (Python, JavaScript, .NET). This is a work in progress. + +`microsoft-agents-testing`: This is the testing framework used to facilitate testing agents. This is only for internal development purposes. + +`python-sdk-tests`: These are integration tests related to the Python SDK. These are an extension of the Python SDK's unit tests. These tests are more specific that the ones in `cross-sdk-tests` because they look into the internals of Python SDK components for the running agents while the other test suite communicates purely over HTTP/HTTPS. + +## Running tests and installation + +The instructions to install the `microsoft-agents-testing` library are specificed in `microsoft-agents-testing/README.md`. To run the `python-sdk-tests`, `cd` into that directory and run `pytest` via Powershell. `cross-sdk-tests` still does not have an entry point for testing. \ No newline at end of file diff --git a/dev/microsoft-agents-testing/tests/__init__.py b/dev/testing/cross-sdk-tests/agents/basic_agent/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/__init__.py rename to dev/testing/cross-sdk-tests/agents/basic_agent/__init__.py diff --git a/dev/tests/agents/basic_agent/python/README.md b/dev/testing/cross-sdk-tests/agents/basic_agent/python/README.md similarity index 100% rename from dev/tests/agents/basic_agent/python/README.md rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/README.md diff --git a/dev/microsoft-agents-testing/tests/core/__init__.py b/dev/testing/cross-sdk-tests/agents/basic_agent/python/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/__init__.py rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/__init__.py diff --git a/dev/tests/agents/basic_agent/python/env.TEMPLATE b/dev/testing/cross-sdk-tests/agents/basic_agent/python/env.TEMPLATE similarity index 100% rename from dev/tests/agents/basic_agent/python/env.TEMPLATE rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/env.TEMPLATE diff --git a/dev/tests/agents/basic_agent/python/pre_requirements.txt b/dev/testing/cross-sdk-tests/agents/basic_agent/python/pre_requirements.txt similarity index 100% rename from dev/tests/agents/basic_agent/python/pre_requirements.txt rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/pre_requirements.txt diff --git a/dev/tests/agents/basic_agent/python/requirements.txt b/dev/testing/cross-sdk-tests/agents/basic_agent/python/requirements.txt similarity index 100% rename from dev/tests/agents/basic_agent/python/requirements.txt rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/requirements.txt diff --git a/dev/microsoft-agents-testing/tests/core/fluent/__init__.py b/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/__init__.py rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/src/__init__.py diff --git a/dev/tests/agents/basic_agent/python/src/agent.py b/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/agent.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/agent.py rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/src/agent.py diff --git a/dev/tests/agents/basic_agent/python/src/app.py b/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/app.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/app.py rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/src/app.py diff --git a/dev/tests/agents/basic_agent/python/src/config.py b/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/config.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/config.py rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/src/config.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/__init__.py b/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/__init__.py rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/__init__.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/types/__init__.py b/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/agents/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/types/__init__.py rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/agents/__init__.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py b/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/__init__.py b/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/__init__.py rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/__init__.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py b/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/date_time_plugin.py b/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/date_time_plugin.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/date_time_plugin.py rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/date_time_plugin.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/weather_forecast.py b/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/weather_forecast.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/weather_forecast.py rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/weather_forecast.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py b/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py rename to dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py diff --git a/dev/tests/__init__.py b/dev/testing/cross-sdk-tests/basic_agent/__init__.py similarity index 100% rename from dev/tests/__init__.py rename to dev/testing/cross-sdk-tests/basic_agent/__init__.py diff --git a/dev/tests/integration/basic_agent/test_basic_agent_base.py b/dev/testing/cross-sdk-tests/basic_agent/test_basic_agent_base.py similarity index 100% rename from dev/tests/integration/basic_agent/test_basic_agent_base.py rename to dev/testing/cross-sdk-tests/basic_agent/test_basic_agent_base.py diff --git a/dev/tests/integration/basic_agent/test_directline.py b/dev/testing/cross-sdk-tests/basic_agent/test_directline.py similarity index 100% rename from dev/tests/integration/basic_agent/test_directline.py rename to dev/testing/cross-sdk-tests/basic_agent/test_directline.py diff --git a/dev/tests/integration/basic_agent/test_msteams.py b/dev/testing/cross-sdk-tests/basic_agent/test_msteams.py similarity index 100% rename from dev/tests/integration/basic_agent/test_msteams.py rename to dev/testing/cross-sdk-tests/basic_agent/test_msteams.py diff --git a/dev/tests/integration/basic_agent/test_webchat.py b/dev/testing/cross-sdk-tests/basic_agent/test_webchat.py similarity index 100% rename from dev/tests/integration/basic_agent/test_webchat.py rename to dev/testing/cross-sdk-tests/basic_agent/test_webchat.py diff --git a/dev/microsoft-agents-testing/docs/API.md b/dev/testing/microsoft-agents-testing/docs/API.md similarity index 100% rename from dev/microsoft-agents-testing/docs/API.md rename to dev/testing/microsoft-agents-testing/docs/API.md diff --git a/dev/microsoft-agents-testing/docs/MOTIVATION.md b/dev/testing/microsoft-agents-testing/docs/MOTIVATION.md similarity index 100% rename from dev/microsoft-agents-testing/docs/MOTIVATION.md rename to dev/testing/microsoft-agents-testing/docs/MOTIVATION.md diff --git a/dev/microsoft-agents-testing/docs/README.md b/dev/testing/microsoft-agents-testing/docs/README.md similarity index 100% rename from dev/microsoft-agents-testing/docs/README.md rename to dev/testing/microsoft-agents-testing/docs/README.md diff --git a/dev/microsoft-agents-testing/docs/SAMPLES.md b/dev/testing/microsoft-agents-testing/docs/SAMPLES.md similarity index 100% rename from dev/microsoft-agents-testing/docs/SAMPLES.md rename to dev/testing/microsoft-agents-testing/docs/SAMPLES.md diff --git a/dev/microsoft-agents-testing/docs/samples/__init__.py b/dev/testing/microsoft-agents-testing/docs/samples/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/__init__.py rename to dev/testing/microsoft-agents-testing/docs/samples/__init__.py diff --git a/dev/microsoft-agents-testing/docs/samples/interactive.py b/dev/testing/microsoft-agents-testing/docs/samples/interactive.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/interactive.py rename to dev/testing/microsoft-agents-testing/docs/samples/interactive.py diff --git a/dev/microsoft-agents-testing/docs/samples/multi_client.py b/dev/testing/microsoft-agents-testing/docs/samples/multi_client.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/multi_client.py rename to dev/testing/microsoft-agents-testing/docs/samples/multi_client.py diff --git a/dev/microsoft-agents-testing/docs/samples/pytest_plugin_usage.py b/dev/testing/microsoft-agents-testing/docs/samples/pytest_plugin_usage.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/pytest_plugin_usage.py rename to dev/testing/microsoft-agents-testing/docs/samples/pytest_plugin_usage.py diff --git a/dev/microsoft-agents-testing/docs/samples/quickstart.py b/dev/testing/microsoft-agents-testing/docs/samples/quickstart.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/quickstart.py rename to dev/testing/microsoft-agents-testing/docs/samples/quickstart.py diff --git a/dev/microsoft-agents-testing/docs/samples/scenario_registry_demo.py b/dev/testing/microsoft-agents-testing/docs/samples/scenario_registry_demo.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/scenario_registry_demo.py rename to dev/testing/microsoft-agents-testing/docs/samples/scenario_registry_demo.py diff --git a/dev/microsoft-agents-testing/docs/samples/test_motivation_assertions.py b/dev/testing/microsoft-agents-testing/docs/samples/test_motivation_assertions.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/test_motivation_assertions.py rename to dev/testing/microsoft-agents-testing/docs/samples/test_motivation_assertions.py diff --git a/dev/microsoft-agents-testing/docs/samples/transcript_formatting.py b/dev/testing/microsoft-agents-testing/docs/samples/transcript_formatting.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/transcript_formatting.py rename to dev/testing/microsoft-agents-testing/docs/samples/transcript_formatting.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/main.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/main.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/main.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/main.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/config.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/config.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/config.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/config.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/activity.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/activity.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/activity.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/activity.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/utils.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/utils.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/transcript_formatter.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/transcript_formatter.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/transcript_formatter.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/transcript_formatter.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/utils.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/utils.py diff --git a/dev/microsoft-agents-testing/payload.json b/dev/testing/microsoft-agents-testing/payload.json similarity index 100% rename from dev/microsoft-agents-testing/payload.json rename to dev/testing/microsoft-agents-testing/payload.json diff --git a/dev/microsoft-agents-testing/pyproject.toml b/dev/testing/microsoft-agents-testing/pyproject.toml similarity index 100% rename from dev/microsoft-agents-testing/pyproject.toml rename to dev/testing/microsoft-agents-testing/pyproject.toml diff --git a/dev/microsoft-agents-testing/pytest.ini b/dev/testing/microsoft-agents-testing/pytest.ini similarity index 100% rename from dev/microsoft-agents-testing/pytest.ini rename to dev/testing/microsoft-agents-testing/pytest.ini diff --git a/dev/tests/agents/basic_agent/__init__.py b/dev/testing/microsoft-agents-testing/tests/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/__init__.py rename to dev/testing/microsoft-agents-testing/tests/__init__.py diff --git a/dev/microsoft-agents-testing/tests/cli/test_cli_integration.py b/dev/testing/microsoft-agents-testing/tests/cli/test_cli_integration.py similarity index 100% rename from dev/microsoft-agents-testing/tests/cli/test_cli_integration.py rename to dev/testing/microsoft-agents-testing/tests/cli/test_cli_integration.py diff --git a/dev/microsoft-agents-testing/tests/cli/test_output.py b/dev/testing/microsoft-agents-testing/tests/cli/test_output.py similarity index 100% rename from dev/microsoft-agents-testing/tests/cli/test_output.py rename to dev/testing/microsoft-agents-testing/tests/cli/test_output.py diff --git a/dev/tests/agents/basic_agent/python/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/__init__.py diff --git a/dev/tests/agents/basic_agent/python/src/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/__init__.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/__init__.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_quantifier.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_quantifier.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_quantifier.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_quantifier.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_utils.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_utils.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_utils.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_utils.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/agents/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/agents/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/__init__.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_expect.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/test_expect.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/test_expect.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/test_expect.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/test_model_template.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/test_model_template.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_select.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/test_select.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/test_select.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/test_select.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_utils.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/test_utils.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/test_utils.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/test_utils.py diff --git a/dev/microsoft-agents-testing/tests/core/test_agent_client.py b/dev/testing/microsoft-agents-testing/tests/core/test_agent_client.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/test_agent_client.py rename to dev/testing/microsoft-agents-testing/tests/core/test_agent_client.py diff --git a/dev/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py b/dev/testing/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py rename to dev/testing/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py diff --git a/dev/microsoft-agents-testing/tests/core/test_config.py b/dev/testing/microsoft-agents-testing/tests/core/test_config.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/test_config.py rename to dev/testing/microsoft-agents-testing/tests/core/test_config.py diff --git a/dev/microsoft-agents-testing/tests/core/test_external_scenario.py b/dev/testing/microsoft-agents-testing/tests/core/test_external_scenario.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/test_external_scenario.py rename to dev/testing/microsoft-agents-testing/tests/core/test_external_scenario.py diff --git a/dev/microsoft-agents-testing/tests/core/test_integration.py b/dev/testing/microsoft-agents-testing/tests/core/test_integration.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/test_integration.py rename to dev/testing/microsoft-agents-testing/tests/core/test_integration.py diff --git a/dev/microsoft-agents-testing/tests/core/transport/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/transport/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/__init__.py diff --git a/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py b/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py diff --git a/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py b/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py diff --git a/dev/microsoft-agents-testing/tests/core/transport/transcript/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/transport/transcript/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/transcript/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/transcript/__init__.py diff --git a/dev/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py b/dev/testing/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py diff --git a/dev/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py b/dev/testing/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py diff --git a/dev/microsoft-agents-testing/tests/manual.py b/dev/testing/microsoft-agents-testing/tests/manual.py similarity index 100% rename from dev/microsoft-agents-testing/tests/manual.py rename to dev/testing/microsoft-agents-testing/tests/manual.py diff --git a/dev/microsoft-agents-testing/tests/test_aiohttp_scenario.py b/dev/testing/microsoft-agents-testing/tests/test_aiohttp_scenario.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_aiohttp_scenario.py rename to dev/testing/microsoft-agents-testing/tests/test_aiohttp_scenario.py diff --git a/dev/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py b/dev/testing/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py rename to dev/testing/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py diff --git a/dev/microsoft-agents-testing/tests/test_examples.py b/dev/testing/microsoft-agents-testing/tests/test_examples.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_examples.py rename to dev/testing/microsoft-agents-testing/tests/test_examples.py diff --git a/dev/microsoft-agents-testing/tests/test_pytest_plugin.py b/dev/testing/microsoft-agents-testing/tests/test_pytest_plugin.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_pytest_plugin.py rename to dev/testing/microsoft-agents-testing/tests/test_pytest_plugin.py diff --git a/dev/microsoft-agents-testing/tests/test_scenario_registry.py b/dev/testing/microsoft-agents-testing/tests/test_scenario_registry.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_scenario_registry.py rename to dev/testing/microsoft-agents-testing/tests/test_scenario_registry.py diff --git a/dev/microsoft-agents-testing/tests/test_scenario_registry_plugin.py b/dev/testing/microsoft-agents-testing/tests/test_scenario_registry_plugin.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_scenario_registry_plugin.py rename to dev/testing/microsoft-agents-testing/tests/test_scenario_registry_plugin.py diff --git a/dev/microsoft-agents-testing/tests/test_transcript_formatter.py b/dev/testing/microsoft-agents-testing/tests/test_transcript_formatter.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_transcript_formatter.py rename to dev/testing/microsoft-agents-testing/tests/test_transcript_formatter.py diff --git a/dev/tests/integration/__init__.py b/dev/testing/python-sdk-tests/__init__.py similarity index 100% rename from dev/tests/integration/__init__.py rename to dev/testing/python-sdk-tests/__init__.py diff --git a/dev/tests/env.TEMPLATE b/dev/testing/python-sdk-tests/env.TEMPLATE similarity index 100% rename from dev/tests/env.TEMPLATE rename to dev/testing/python-sdk-tests/env.TEMPLATE diff --git a/dev/tests/integration/basic_agent/__init__.py b/dev/testing/python-sdk-tests/integration/__init__.py similarity index 100% rename from dev/tests/integration/basic_agent/__init__.py rename to dev/testing/python-sdk-tests/integration/__init__.py diff --git a/dev/tests/integration/test_quickstart.py b/dev/testing/python-sdk-tests/integration/test_quickstart.py similarity index 100% rename from dev/tests/integration/test_quickstart.py rename to dev/testing/python-sdk-tests/integration/test_quickstart.py diff --git a/dev/tests/pytest.ini b/dev/testing/python-sdk-tests/pytest.ini similarity index 100% rename from dev/tests/pytest.ini rename to dev/testing/python-sdk-tests/pytest.ini diff --git a/dev/tests/scenarios/__init__.py b/dev/testing/python-sdk-tests/scenarios/__init__.py similarity index 100% rename from dev/tests/scenarios/__init__.py rename to dev/testing/python-sdk-tests/scenarios/__init__.py diff --git a/dev/tests/scenarios/quickstart.py b/dev/testing/python-sdk-tests/scenarios/quickstart.py similarity index 100% rename from dev/tests/scenarios/quickstart.py rename to dev/testing/python-sdk-tests/scenarios/quickstart.py diff --git a/dev/tests/sdk/__init__.py b/dev/testing/python-sdk-tests/sdk/__init__.py similarity index 100% rename from dev/tests/sdk/__init__.py rename to dev/testing/python-sdk-tests/sdk/__init__.py diff --git a/dev/tests/sdk/observability/__init__.py b/dev/testing/python-sdk-tests/sdk/observability/__init__.py similarity index 100% rename from dev/tests/sdk/observability/__init__.py rename to dev/testing/python-sdk-tests/sdk/observability/__init__.py diff --git a/dev/tests/sdk/observability/test_observability.py b/dev/testing/python-sdk-tests/sdk/observability/test_observability.py similarity index 100% rename from dev/tests/sdk/observability/test_observability.py rename to dev/testing/python-sdk-tests/sdk/observability/test_observability.py diff --git a/dev/tests/sdk/test_expect_replies.py b/dev/testing/python-sdk-tests/sdk/test_expect_replies.py similarity index 100% rename from dev/tests/sdk/test_expect_replies.py rename to dev/testing/python-sdk-tests/sdk/test_expect_replies.py diff --git a/dev/tests/agents/__init__.py b/dev/tests/agents/__init__.py deleted file mode 100644 index 8b5f94fa..00000000 --- a/dev/tests/agents/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .basic_agent \ No newline at end of file From 20dd033c7cb05dd6b55080f762a27da225006342 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 2 Mar 2026 07:44:46 -0800 Subject: [PATCH 13/55] Adding READMEs to support new folder structure change --- .../microsoft-agents-testing/README.md | 190 ++++++++++++++++++ dev/testing/python-sdk-tests/README.md | 11 + .../test_expect_replies.py | 0 .../__init__.py | 0 .../hosting-core/observability/__init__.py | 0 .../observability/test_observability.py | 0 6 files changed, 201 insertions(+) create mode 100644 dev/testing/microsoft-agents-testing/README.md create mode 100644 dev/testing/python-sdk-tests/README.md rename dev/testing/python-sdk-tests/{sdk => integration}/test_expect_replies.py (100%) rename dev/testing/python-sdk-tests/sdk/{observability => hosting-core}/__init__.py (100%) create mode 100644 dev/testing/python-sdk-tests/sdk/hosting-core/observability/__init__.py rename dev/testing/python-sdk-tests/sdk/{ => hosting-core}/observability/test_observability.py (100%) diff --git a/dev/testing/microsoft-agents-testing/README.md b/dev/testing/microsoft-agents-testing/README.md new file mode 100644 index 00000000..4acca0c5 --- /dev/null +++ b/dev/testing/microsoft-agents-testing/README.md @@ -0,0 +1,190 @@ +# Microsoft Agents Testing Framework + +A testing framework for M365 Agents that handles auth, callback servers, +activity construction, and response collection so your tests can focus on +what the agent actually does. + +```python +async with scenario.client() as client: + await client.send("Hello!", wait=0.2) + client.expect().that_for_any(text="Echo: Hello!") +``` + +## Installation + +```bash +pip install -e ./microsoft-agents-testing/ --config-settings editable_mode=compat +``` + +## Quick Start + +Define your agent, create a scenario, and write tests. The scenario takes +care of hosting, auth tokens, and response plumbing. + +### Pytest + +```python +import pytest +from microsoft_agents.testing import AiohttpScenario, AgentEnvironment + +async def init_echo(env: AgentEnvironment): + @env.agent_application.activity("message") + async def on_message(context, state): + await context.send_activity(f"Echo: {context.activity.text}") + +scenario = AiohttpScenario(init_agent=init_echo, use_jwt_middleware=False) + +@pytest.mark.agent_test(scenario) +class TestEcho: + async def test_responds(self, agent_client): + await agent_client.send("Hi!", wait=0.2) + agent_client.expect().that_for_any(text="Echo: Hi!") +``` + +```bash +pytest test_echo.py -v +``` + +### Without pytest + +The core has no pytest dependency — use `scenario.client()` as an async +context manager anywhere. + +```python +async with scenario.client() as client: + await client.send("Hi!", wait=0.2) + client.expect().that_for_any(text="Echo: Hi!") +``` + +### External agent + +To test an agent that's already running (locally or deployed), point +`ExternalScenario` at its endpoint. + +```python +from microsoft_agents.testing import ExternalScenario + +scenario = ExternalScenario("http://localhost:3978/api/messages") +async with scenario.client() as client: + await client.send("Hello!", wait=1.0) + client.expect().that_for_any(type="message") +``` + +## Scenarios + +A Scenario manages infrastructure (servers, auth, teardown) and gives you a +client to interact with the agent. Auth credentials and general SDK config settings come from a `.env` file. The path defaults to `.\.env` but this is configurable through `ScenarioConfig` and `ClientConfig`, which are passed in during `Scenario` and `AgentClient` constructions. + +| Scenario | Description | +|----------|-------------| +| `AiohttpScenario` | Hosts the agent in-process — fast, access to internals | +| `ExternalScenario` | Connects to a running agent at a URL | + +Swap one for the other and your assertions stay the same. + +## AgentClient + +The client you get from a scenario. Send messages, collect replies, make +assertions. Pass a string and it becomes a message `Activity` automatically. +Use `wait=` to pause for async callback responses, or use +`send_expect_replies()` when the agent replies inline. + +```python +await client.send("Hello!", wait=0.5) # send + wait for callbacks +replies = await client.send_expect_replies("Hi!") # inline replies +client.expect().that_for_any(text="~Hello") # assert +``` + +Every method has an `ex_` variant (`ex_send`, `ex_invoke`, etc.) that returns +the raw `Exchange` objects instead of just the response activities. + +## Expect & Select + +Fluent API for asserting on and filtering response collections. `Expect` +raises `AssertionError` with diagnostic context — it shows what was expected, +what was received, and which items were checked. Prefix a value with `~` for +substring matching, or pass a lambda for custom logic. The variable named `x` has a special meaning and is passed in dynamically during evaluation. + +```python +client.expect().that_for_any(text="~hello") # any reply contains "hello" +client.expect().that_for_none(text="~error") # no reply contains "error" +client.expect().that_for_exactly(2, type="message") # exactly 2 messages +client.expect().that_for_any(text=lambda x: len(x) > 10) # lambda predicate +``` + +`Select` filters and slices before you assert or extract: + +```python +from microsoft_agents.testing import Select +selected = Select(client.history()).where(type="message").last(3).get() +Select(client.history()).where(type="message").expect().that(text="~hello") +``` + +## Transcript + +Every request and response is recorded in a `Transcript`. When a test fails +you can print the conversation to see exactly what happened. + +`ConversationTranscriptFormatter` gives a chat-style view; +`ActivityTranscriptFormatter` shows all activities with selectable fields. +Both support `DetailLevel` (`MINIMAL`, `STANDARD`, `DETAILED`, `FULL`) and +`TimeFormat` (`CLOCK`, `RELATIVE`, `ELAPSED`). + +```python +from microsoft_agents.testing import ConversationTranscriptFormatter, DetailLevel + +ConversationTranscriptFormatter(detail=DetailLevel.FULL).print(client.transcript) +``` + +``` +[0.000s] You: Hello! + (253ms) +[0.253s] Agent: Echo: Hello! +``` + +## Pytest Plugin + +The plugin activates automatically on install. Decorate a class or function +with `@pytest.mark.agent_test(scenario)` — pass a `Scenario` instance, a URL +(creates `ExternalScenario`), or a registered scenario name — and request any +of these fixtures: + +| Fixture | Description | +|---------|-------------| +| `agent_client` | Send and assert | +| `agent_environment` | Agent internals (in-process only) | +| `agent_application` | `AgentApplication` instance | +| `storage` | `MemoryStorage` | +| `adapter` | `ChannelServiceAdapter` | +| `authorization` | `Authorization` handler | +| `connection_manager` | `Connections` manager | + +## Scenario Registry + +Register named scenarios so they can be shared across test files and +referenced by name in pytest markers. Use dot-notation for namespacing +(e.g., `"local.echo"`, `"staging.echo"`) and `discover()` with glob patterns +to find them. + +```python +from microsoft_agents.testing import scenario_registry + +scenario_registry.register("echo", echo_scenario) +scenario = scenario_registry.get("echo") + +# In a test — just pass the name +@pytest.mark.agent_test("echo") +class TestEcho: ... +``` + +## Documentation + +| Document | Contents | +|----------|----------| +| [MOTIVATION.md](MOTIVATION.md) | Before/after code comparison | +| [API.md](API.md) | Public API reference | +| [SAMPLES.md](SAMPLES.md) | Guide to the runnable samples | + +## License + +MIT License — Microsoft Corporation diff --git a/dev/testing/python-sdk-tests/README.md b/dev/testing/python-sdk-tests/README.md new file mode 100644 index 00000000..3a3a5f2f --- /dev/null +++ b/dev/testing/python-sdk-tests/README.md @@ -0,0 +1,11 @@ +# Python SDK (Integration) Tests + +## Description + +This directory contains integration tests for the Python SDK. + +## Directory structure + +`integration`: general-purpose tests that run with automated, locally-running agents +`scenarios`: agent scenarios used for testing +`sdk`: integration for specific SDK components. This directory mirrors the structure of the Python SDK libraries. \ No newline at end of file diff --git a/dev/testing/python-sdk-tests/sdk/test_expect_replies.py b/dev/testing/python-sdk-tests/integration/test_expect_replies.py similarity index 100% rename from dev/testing/python-sdk-tests/sdk/test_expect_replies.py rename to dev/testing/python-sdk-tests/integration/test_expect_replies.py diff --git a/dev/testing/python-sdk-tests/sdk/observability/__init__.py b/dev/testing/python-sdk-tests/sdk/hosting-core/__init__.py similarity index 100% rename from dev/testing/python-sdk-tests/sdk/observability/__init__.py rename to dev/testing/python-sdk-tests/sdk/hosting-core/__init__.py diff --git a/dev/testing/python-sdk-tests/sdk/hosting-core/observability/__init__.py b/dev/testing/python-sdk-tests/sdk/hosting-core/observability/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/testing/python-sdk-tests/sdk/observability/test_observability.py b/dev/testing/python-sdk-tests/sdk/hosting-core/observability/test_observability.py similarity index 100% rename from dev/testing/python-sdk-tests/sdk/observability/test_observability.py rename to dev/testing/python-sdk-tests/sdk/hosting-core/observability/test_observability.py From fe3bc1f7707d5da5f0703501b5a868ebdb6f119c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 2 Mar 2026 08:18:21 -0800 Subject: [PATCH 14/55] Fixing package issues --- dev/README.md | 3 - .../core/connector/client/connector_client.py | 1 - .../core/observability/_agent_telemetry.py | 25 ++- .../core/observability/configure_telemetry.py | 6 +- .../microsoft-agents-hosting-core/setup.py | 3 +- tests/hosting_core/observability/__init__.py | 0 .../observability/test_agent_telemetry.py | 202 ++++++++++++++++++ .../observability/test_configure_telemetry.py | 0 8 files changed, 219 insertions(+), 21 deletions(-) delete mode 100644 dev/README.md create mode 100644 tests/hosting_core/observability/__init__.py create mode 100644 tests/hosting_core/observability/test_agent_telemetry.py create mode 100644 tests/hosting_core/observability/test_configure_telemetry.py diff --git a/dev/README.md b/dev/README.md deleted file mode 100644 index db45e4eb..00000000 --- a/dev/README.md +++ /dev/null @@ -1,3 +0,0 @@ -```bash -pip install -e ./microsoft-agents-testing/ --config-settings editable_mode=compat -``` \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 33fa0b04..73b642a2 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -17,7 +17,6 @@ ConversationsResult, PagedMembersResult, ) -from microsoft_agents.hosting.core.observability import agent_telemetry from microsoft_agents.hosting.core.connector import ConnectorClientBase from ..attachments_base import AttachmentsBase from ..conversations_base import ConversationsBase 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 d0593842..e1674820 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 @@ -5,12 +5,11 @@ 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 microsoft_agents.hosting.core.turn_context import TurnContext from .constants import SERVICE_NAME, SERVICE_VERSION def _ts() -> float: @@ -142,7 +141,7 @@ def start_as_current_span(self, span_name: str, context: TurnContext) -> Iterato yield span @contextmanager - def _timed_span( + def _start_timed_span( self, span_name: str, context: TurnContext | None = None, @@ -189,7 +188,7 @@ def _timed_span( raise exception from None # re-raise to ensure it's not swallowed @contextmanager - def agent_turn_operation(self, context: TurnContext) -> Iterator[Span]: + def invoke_agent_turn_op(self, context: TurnContext) -> Iterator[Span]: """Context manager for recording an agent turn, including success/failure and duration""" def success_callback(span: Span, duration: float): @@ -215,7 +214,7 @@ def success_callback(span: Span, duration: float): def failure_callback(span: Span, e: Exception): self._turn_errors.add(1) - with self._timed_span( + with self._start_timed_span( "agent turn", context=context, success_callback=success_callback, @@ -224,55 +223,55 @@ def failure_callback(span: Span, e: Exception): yield span # execute the turn operation in the with block @contextmanager - def adapter_process_operation(self): + def invoke_adapter_process_op(self): """Context manager for recording adapter processing operations""" def success_callback(span: Span, duration: float): self._adapter_process_duration.record(duration) - with self._timed_span( + with self._start_timed_span( "adapter process", success_callback=success_callback ) as span: yield span # execute the adapter processing in the with block @contextmanager - def storage_operation(self, operation: str): + def invoke_storage_op(self, operation: str): """Context manager for recording storage operations""" def success_callback(span: Span, duration: float): self._storage_operations.add(1, {"operation": operation}) self._storage_operation_duration.record(duration, {"operation": operation}) - with self._timed_span( + with self._start_timed_span( f"storage {operation}", success_callback=success_callback ) as span: yield span # execute the storage operation in the with block @contextmanager - def connector_request_operation(self, operation: str): + def invoke_connector_request_op(self, operation: str): """Context manager for recording connector requests""" def success_callback(span: Span, duration: float): self._connector_request_total.add(1, {"operation": operation}) self._connector_request_duration.record(duration, {"operation": operation}) - with self._timed_span( + with self._start_timed_span( f"connector {operation}", success_callback=success_callback ) as span: yield span # execute the connector request in the with block @contextmanager - def auth_token_request_operation(self): + def invoke_auth_token_request_op(self): """Context manager for recording auth token retrieval operations""" def success_callback(span: Span, duration: float): self._auth_token_request_total.add(1) self._auth_token_requests_duration.record(duration) - with self._timed_span( + with self._start_timed_span( "auth token request", success_callback=success_callback ) as span: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py index c3439b26..fe5d10ad 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py @@ -3,9 +3,9 @@ from opentelemetry import metrics, trace 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.exporter.otlp.proto.http._log_exporter import OTLPLogExporter +from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler from opentelemetry.sdk._logs.export import BatchLogRecordProcessor from opentelemetry.sdk.metrics import MeterProvider diff --git a/libraries/microsoft-agents-hosting-core/setup.py b/libraries/microsoft-agents-hosting-core/setup.py index 6888161d..299ad2fa 100644 --- a/libraries/microsoft-agents-hosting-core/setup.py +++ b/libraries/microsoft-agents-hosting-core/setup.py @@ -18,6 +18,7 @@ "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", + "opentelemetry-sdk>=1.17.0", + "opentelemetry-exporter-otlp-proto-http>=1.17.0", ], ) diff --git a/tests/hosting_core/observability/__init__.py b/tests/hosting_core/observability/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/observability/test_agent_telemetry.py b/tests/hosting_core/observability/test_agent_telemetry.py new file mode 100644 index 00000000..05fbe338 --- /dev/null +++ b/tests/hosting_core/observability/test_agent_telemetry.py @@ -0,0 +1,202 @@ +import pytest +from types import SimpleNamespace + +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 microsoft_agents.hosting.core.observability import agent_telemetry + +@pytest.fixture(scope="module") +def test_telemetry(): + """Set up fresh in-memory exporter for testing.""" + exporter = InMemorySpanExporter() + 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, metric_reader + + exporter.clear() + 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() + + +def _build_turn_context(): + activity = SimpleNamespace( + type="message", + from_property=SimpleNamespace(id="user-1"), + recipient=SimpleNamespace(id="bot-1"), + conversation=SimpleNamespace(id="conversation-1"), + channel_id="msteams", + text="Hello!", + ) + activity.is_agentic_request = lambda: False + return SimpleNamespace(activity=activity) + + +def _find_metric(metrics_data, metric_name): + for resource_metric in metrics_data.resource_metrics: + for scope_metric in resource_metric.scope_metrics: + for metric in scope_metric.metrics: + if metric.name == metric_name: + return metric + return None + + +def _sum_counter(metric, attribute_filter=None): + if metric is None: + return 0 + total = 0 + for point in metric.data.data_points: + if attribute_filter is None or all( + point.attributes.get(key) == value + for key, value in attribute_filter.items() + ): + total += point.value + return total + + +def _sum_hist_count(metric, attribute_filter=None): + if metric is None: + return 0 + total = 0 + for point in metric.data.data_points: + if attribute_filter is None or all( + point.attributes.get(key) == value + for key, value in attribute_filter.items() + ): + total += point.count + return total + + +def test_start_as_current_span(test_exporter): + """Test start_as_current_span creates a span with context attributes.""" + context = _build_turn_context() + + with agent_telemetry.start_as_current_span("test_span", context): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "test_span" + + attributes = spans[0].attributes + assert attributes["activity.type"] == "message" + assert attributes["agent.is_agentic"] is False + assert attributes["from.id"] == "user-1" + assert attributes["recipient.id"] == "bot-1" + assert attributes["conversation.id"] == "conversation-1" + assert attributes["channel_id"] == "msteams" + assert attributes["message.text.length"] == 6 + + +def test_agent_turn_operation(test_exporter, test_metric_reader): + """Test agent_turn_operation records span and turn metrics.""" + context = _build_turn_context() + + metrics_before = test_metric_reader.get_metrics_data() + before_turn_total = _sum_counter(_find_metric(metrics_before, "app.turn.total")) + before_turn_duration_count = _sum_hist_count( + _find_metric(metrics_before, "app.turn.duration") + ) + + with agent_telemetry.agent_turn_operation(context): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "agent turn" + + metrics_after = test_metric_reader.get_metrics_data() + after_turn_total = _sum_counter(_find_metric(metrics_after, "app.turn.total")) + after_turn_duration_count = _sum_hist_count( + _find_metric(metrics_after, "app.turn.duration") + ) + + assert after_turn_total == before_turn_total + 1 + assert after_turn_duration_count == before_turn_duration_count + 1 + + +def test_adapter_process_operation(test_exporter, test_metric_reader): + """Test adapter_process_operation records span and duration metric.""" + metrics_before = test_metric_reader.get_metrics_data() + before_duration_count = _sum_hist_count( + _find_metric(metrics_before, "agents.adapter.process.duration") + ) + + with agent_telemetry.adapter_process_operation(): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "adapter process" + + metrics_after = test_metric_reader.get_metrics_data() + after_duration_count = _sum_hist_count( + _find_metric(metrics_after, "agents.adapter.process.duration") + ) + + assert after_duration_count == before_duration_count + 1 + + +def test_storage_operation(test_exporter, test_metric_reader): + """Test storage_operation records span and operation-tagged metrics.""" + op_filter = {"operation": "read"} + + metrics_before = test_metric_reader.get_metrics_data() + before_total = _sum_counter( + _find_metric(metrics_before, "storage.operation.total"), + attribute_filter=op_filter, + ) + before_duration_count = _sum_hist_count( + _find_metric(metrics_before, "storage.operation.duration"), + attribute_filter=op_filter, + ) + + with agent_telemetry.storage_operation("read"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "storage read" + + metrics_after = test_metric_reader.get_metrics_data() + after_total = _sum_counter( + _find_metric(metrics_after, "storage.operation.total"), + attribute_filter=op_filter, + ) + after_duration_count = _sum_hist_count( + _find_metric(metrics_after, "storage.operation.duration"), + attribute_filter=op_filter, + ) + + assert after_total == before_total + 1 + assert after_duration_count == before_duration_count + 1 \ No newline at end of file diff --git a/tests/hosting_core/observability/test_configure_telemetry.py b/tests/hosting_core/observability/test_configure_telemetry.py new file mode 100644 index 00000000..e69de29b From 5a0aad843be72da179dca5349bcf3b42cbaae160 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 2 Mar 2026 10:57:05 -0800 Subject: [PATCH 15/55] Unit tests for all of the AgentTelemetry instrumentatin methods --- .../hosting/aiohttp/cloud_adapter.py | 2 +- .../hosting/core/app/agent_application.py | 2 +- .../core/observability/_agent_telemetry.py | 55 ++++---- .../core/observability/configure_telemetry.py | 19 ++- .../hosting/core/observability/constants.py | 30 ++++- .../hosting/core/storage/memory_storage.py | 6 +- .../hosting/core/storage/storage.py | 6 +- .../hosting/fastapi/cloud_adapter.py | 2 +- .../observability/test_agent_telemetry.py | 118 +++++++++++------- 9 files changed, 146 insertions(+), 94 deletions(-) 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 7f7268f7..460c9956 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 @@ -71,7 +71,7 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: aiohttp Response object. """ - with agent_telemetry.adapter_process_operation(): + with agent_telemetry.instrument_adapter_process(): # Adapt request to protocol adapted_request = AiohttpRequestAdapter(request) 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 8360a192..af1c4f04 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 @@ -671,7 +671,7 @@ async def on_turn(self, context: TurnContext): async def _on_turn(self, context: TurnContext): typing = None try: - with agent_telemetry.agent_turn_operation(context): + with agent_telemetry.instrument_agent_turn(context): if context.activity.type != ActivityTypes.typing: if self._options.start_typing_timer: typing = TypingIndicator(context) 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 e1674820..2c8a4990 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 @@ -10,7 +10,8 @@ from opentelemetry.trace import Tracer, Span from microsoft_agents.hosting.core.turn_context import TurnContext -from .constants import SERVICE_NAME, SERVICE_VERSION + +from . import constants def _ts() -> float: """Helper function to get current timestamp in milliseconds""" @@ -31,9 +32,9 @@ class AgentTelemetry: def __init__(self, tracer: Tracer | None = None, meter: Meter | None = None): if tracer is None: - tracer = trace.get_tracer(SERVICE_NAME, SERVICE_VERSION) + tracer = trace.get_tracer(constants.SERVICE_NAME, constants.SERVICE_VERSION) if meter is None: - meter = metrics.get_meter(SERVICE_NAME, SERVICE_VERSION) + meter = metrics.get_meter(constants.SERVICE_NAME, constants.SERVICE_VERSION) self._meter = meter self._tracer = tracer @@ -41,13 +42,13 @@ def __init__(self, tracer: Tracer | None = None, meter: Meter | None = None): # Storage self._storage_operations = self._meter.create_counter( - "storage.operation.total", + constants.STORAGE_OPERATION_TOTAL_METRIC_NAME, "operation", description="Number of storage operations performed by the agent", ) self._storage_operation_duration = self._meter.create_histogram( - "storage.operation.duration", + constants.STORAGE_OPERATION_DURATION_METRIC_NAME, "ms", description="Duration of storage operations in milliseconds", ) @@ -55,19 +56,19 @@ def __init__(self, tracer: Tracer | None = None, meter: Meter | None = None): # AgentApplication self._turn_total = self._meter.create_counter( - "app.turn.total", + constants.AGENT_TURN_TOTAL_METRIC_NAME, "turn", description="Total number of turns processed by the agent", ) self._turn_errors = self._meter.create_counter( - "app.turn.errors", + constants.AGENT_TURN_ERRORS_METRIC_NAME, "turn", description="Number of turns that resulted in an error", ) self._turn_duration = self._meter.create_histogram( - "app.turn.duration", + constants.AGENT_TURN_DURATION_METRIC_NAME, "ms", description="Duration of agent turns in milliseconds", ) @@ -75,7 +76,7 @@ def __init__(self, tracer: Tracer | None = None, meter: Meter | None = None): # Adapters self._adapter_process_duration = self._meter.create_histogram( - "agents.adapter.process.duration", + constants.ADAPTER_PROCESS_DURATION_METRIC_NAME, "ms", description="Duration of adapter processing in milliseconds", ) @@ -83,13 +84,13 @@ def __init__(self, tracer: Tracer | None = None, meter: Meter | None = None): # Connectors self._connector_request_total = self._meter.create_counter( - "agents.connector.request.total", + constants.CONNECTOR_REQUEST_TOTAL_METRIC_NAME, "request", description="Total number of connector requests made by the agent", ) self._connector_request_duration = self._meter.create_histogram( - "agents.connector.request.duration", + constants.CONNECTOR_REQUEST_DURATION_METRIC_NAME, "ms", description="Duration of connector requests in milliseconds", ) @@ -97,13 +98,13 @@ def __init__(self, tracer: Tracer | None = None, meter: Meter | None = None): # Auth self._auth_token_request_total = self._meter.create_counter( - "agents.auth.request.total", + constants.AUTH_TOKEN_REQUEST_TOTAL_METRIC_NAME, "request", description="Total number of auth token requests made by the agent", ) self._auth_token_requests_duration = self._meter.create_histogram( - "agents.auth.request.duration", + constants.AUTH_TOKEN_REQUEST_DURATION_METRIC_NAME, "ms", description="Duration of auth token retrieval in milliseconds", ) @@ -188,7 +189,7 @@ def _start_timed_span( raise exception from None # re-raise to ensure it's not swallowed @contextmanager - def invoke_agent_turn_op(self, context: TurnContext) -> Iterator[Span]: + def instrument_agent_turn(self, context: TurnContext) -> Iterator[Span]: """Context manager for recording an agent turn, including success/failure and duration""" def success_callback(span: Span, duration: float): @@ -215,7 +216,7 @@ def failure_callback(span: Span, e: Exception): self._turn_errors.add(1) with self._start_timed_span( - "agent turn", + constants.AGENT_TURN_OPERATION_NAME, context=context, success_callback=success_callback, failure_callback=failure_callback @@ -223,48 +224,48 @@ def failure_callback(span: Span, e: Exception): yield span # execute the turn operation in the with block @contextmanager - def invoke_adapter_process_op(self): + def instrument_adapter_process(self): """Context manager for recording adapter processing operations""" def success_callback(span: Span, duration: float): self._adapter_process_duration.record(duration) with self._start_timed_span( - "adapter process", + constants.ADAPTER_PROCESS_OPERATION_NAME, success_callback=success_callback ) as span: yield span # execute the adapter processing in the with block @contextmanager - def invoke_storage_op(self, operation: str): + def instrument_storage_op(self, operation_name: str): """Context manager for recording storage operations""" def success_callback(span: Span, duration: float): - self._storage_operations.add(1, {"operation": operation}) - self._storage_operation_duration.record(duration, {"operation": operation}) + self._storage_operations.add(1, {"operation": operation_name}) + self._storage_operation_duration.record(duration, {"operation": operation_name}) with self._start_timed_span( - f"storage {operation}", + constants.STORAGE_OPERATION_NAME_FORMAT.format(operation_name=operation_name), success_callback=success_callback ) as span: yield span # execute the storage operation in the with block @contextmanager - def invoke_connector_request_op(self, operation: str): + def instrument_connector_op(self, operation_name: str): """Context manager for recording connector requests""" def success_callback(span: Span, duration: float): - self._connector_request_total.add(1, {"operation": operation}) - self._connector_request_duration.record(duration, {"operation": operation}) + self._connector_request_total.add(1, {"operation": operation_name}) + self._connector_request_duration.record(duration, {"operation": operation_name}) with self._start_timed_span( - f"connector {operation}", + constants.CONNECTOR_REQUEST_OPERATION_NAME_FORMAT.format(operation_name=operation_name), success_callback=success_callback ) as span: yield span # execute the connector request in the with block @contextmanager - def invoke_auth_token_request_op(self): + def instrument_auth_token_request(self): """Context manager for recording auth token retrieval operations""" def success_callback(span: Span, duration: float): @@ -272,7 +273,7 @@ def success_callback(span: Span, duration: float): self._auth_token_requests_duration.record(duration) with self._start_timed_span( - "auth token request", + constants.AUTH_TOKEN_REQUEST_OPERATION_NAME, success_callback=success_callback ) as span: yield span # execute the auth token retrieval operation in the with block diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py index fe5d10ad..cf34d224 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py @@ -1,5 +1,4 @@ import logging -import os from opentelemetry import metrics, trace from opentelemetry._logs import set_logger_provider @@ -13,33 +12,29 @@ from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor -from .constants import RESOURCE +from . import constants -# TODO def configure_telemetry() -> None: """Configure OpenTelemetry for FastAPI application.""" - # Get OTLP endpoint from environment or use default for standalone dashboard - otlp_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") - # Configure Tracing - trace_provider = TracerProvider(resource=RESOURCE) + trace_provider = TracerProvider(resource=constants.RESOURCE) trace_provider.add_span_processor( - BatchSpanProcessor(OTLPSpanExporter(endpoint=otlp_endpoint)) + BatchSpanProcessor(OTLPSpanExporter()) ) trace.set_tracer_provider(trace_provider) # Configure Metrics metric_reader = PeriodicExportingMetricReader( - OTLPMetricExporter(endpoint=otlp_endpoint) + OTLPMetricExporter() ) - meter_provider = MeterProvider(resource=RESOURCE, metric_readers=[metric_reader]) + meter_provider = MeterProvider(resource=constants.RESOURCE, metric_readers=[metric_reader]) metrics.set_meter_provider(meter_provider) # Configure Logging - logger_provider = LoggerProvider(resource=RESOURCE) + logger_provider = LoggerProvider(resource=constants.RESOURCE) logger_provider.add_log_record_processor( - BatchLogRecordProcessor(OTLPLogExporter(endpoint=otlp_endpoint)) + BatchLogRecordProcessor(OTLPLogExporter()) ) set_logger_provider(logger_provider) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/constants.py index adaba82f..b78bad11 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/constants.py @@ -2,6 +2,8 @@ from opentelemetry.sdk.resources import Resource +# Telemetry resource information + SERVICE_NAME = "microsoft_agents" SERVICE_VERSION = "1.0.0" @@ -12,4 +14,30 @@ "service.instance.id": os.getenv("HOSTNAME", "unknown"), "telemetry.sdk.language": "python", } -) \ No newline at end of file +) + +# Span operation names + +ADAPTER_PROCESS_OPERATION_NAME = "adapter process" +AGENT_TURN_OPERATION_NAME = "agent turn" +AUTH_TOKEN_REQUEST_OPERATION_NAME = "auth token request" +CONNECTOR_REQUEST_OPERATION_NAME_FORMAT = "connector {operation_name}" +STORAGE_OPERATION_NAME_FORMAT = "storage {operation_name}" + +# Metric names + +ADAPTER_PROCESS_DURATION_METRIC_NAME = "agents.adapter.process.duration" +ADAPTER_PROCESS_TOTAL_METRIC_NAME = "agents.adapter.process.total" + +AGENT_TURN_DURATION_METRIC_NAME = "agents.turn.duration" +AGENT_TURN_TOTAL_METRIC_NAME = "agents.turn.total" +AGENT_TURN_ERRORS_METRIC_NAME = "agents.turn.errors" + +AUTH_TOKEN_REQUEST_DURATION_METRIC_NAME = "agents.auth.request.duration" +AUTH_TOKEN_REQUEST_TOTAL_METRIC_NAME = "agents.auth.request.total" + +CONNECTOR_REQUEST_TOTAL_METRIC_NAME = "agents.connector.request.total" +CONNECTOR_REQUEST_DURATION_METRIC_NAME = "agents.connector.request.duration" + +STORAGE_OPERATION_DURATION_METRIC_NAME = "storage.operation.duration" +STORAGE_OPERATION_TOTAL_METRIC_NAME = "storage.operation.total" \ 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 36982d0f..d1c13388 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 @@ -29,7 +29,7 @@ async def read( result: dict[str, StoreItem] = {} with self._lock: - with agent_telemetry.storage_operation("read"): + with agent_telemetry.instrument_storage_op("read"): for key in keys: if key == "": raise ValueError("MemoryStorage.read(): key cannot be empty") @@ -52,7 +52,7 @@ async def write(self, changes: dict[str, StoreItem]): raise ValueError("MemoryStorage.write(): changes cannot be None") with self._lock: - with agent_telemetry.storage_operation("write"): + with agent_telemetry.instrument_storage_op("write"): for key in changes: if key == "": raise ValueError("MemoryStorage.write(): key cannot be empty") @@ -63,7 +63,7 @@ async def delete(self, keys: list[str]): raise ValueError("Storage.delete(): Keys are required when deleting.") with self._lock: - with agent_telemetry.storage_operation("delete"): + with agent_telemetry.instrument_storage_op("delete"): for key in keys: if key == "": raise ValueError("MemoryStorage.delete(): key cannot be empty") 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 0dd7dca0..1ac57c8d 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 @@ -73,7 +73,7 @@ async def read( await self.initialize() - with agent_telemetry.storage_operation("read"): + with agent_telemetry.instrument_storage_op("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] ) @@ -90,7 +90,7 @@ async def write(self, changes: dict[str, StoreItemT]) -> None: await self.initialize() - with agent_telemetry.storage_operation("write"): + with agent_telemetry.instrument_storage_op("write"): await gather(*[self._write_item(key, value) for key, value in changes.items()]) @abstractmethod @@ -104,5 +104,5 @@ async def delete(self, keys: list[str]) -> None: await self.initialize() - with agent_telemetry.storage_operation("delete"): + with agent_telemetry.instrument_storage_op("delete"): await gather(*[self._delete_item(key) for key in keys]) 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 29d8f009..527e05c4 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 @@ -71,7 +71,7 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: Returns: FastAPI Response object. """ - with agent_telemetry.adapter_process_operation(): + with agent_telemetry.instrument_adapter_process(): # Adapt request to protocol adapted_request = FastApiRequestAdapter(request) diff --git a/tests/hosting_core/observability/test_agent_telemetry.py b/tests/hosting_core/observability/test_agent_telemetry.py index 05fbe338..3503be1e 100644 --- a/tests/hosting_core/observability/test_agent_telemetry.py +++ b/tests/hosting_core/observability/test_agent_telemetry.py @@ -8,7 +8,10 @@ from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import InMemoryMetricReader -from microsoft_agents.hosting.core.observability import agent_telemetry +from microsoft_agents.hosting.core.observability import ( + agent_telemetry, + constants, +) @pytest.fixture(scope="module") def test_telemetry(): @@ -122,81 +125,106 @@ def test_agent_turn_operation(test_exporter, test_metric_reader): """Test agent_turn_operation records span and turn metrics.""" context = _build_turn_context() - metrics_before = test_metric_reader.get_metrics_data() - before_turn_total = _sum_counter(_find_metric(metrics_before, "app.turn.total")) - before_turn_duration_count = _sum_hist_count( - _find_metric(metrics_before, "app.turn.duration") - ) - - with agent_telemetry.agent_turn_operation(context): + with agent_telemetry.instrument_agent_turn(context): pass spans = test_exporter.get_finished_spans() assert len(spans) == 1 - assert spans[0].name == "agent turn" + assert spans[0].name == constants.AGENT_TURN_OPERATION_NAME - metrics_after = test_metric_reader.get_metrics_data() - after_turn_total = _sum_counter(_find_metric(metrics_after, "app.turn.total")) - after_turn_duration_count = _sum_hist_count( - _find_metric(metrics_after, "app.turn.duration") + metric_data = test_metric_reader.get_metrics_data() + turn_total = _sum_counter(_find_metric(metric_data, constants.AGENT_TURN_TOTAL_METRIC_NAME)) + turn_duration_count = _sum_hist_count( + _find_metric(metric_data, constants.AGENT_TURN_DURATION_METRIC_NAME) ) - assert after_turn_total == before_turn_total + 1 - assert after_turn_duration_count == before_turn_duration_count + 1 + assert turn_total == 1 + assert turn_duration_count == 1 -def test_adapter_process_operation(test_exporter, test_metric_reader): - """Test adapter_process_operation records span and duration metric.""" - metrics_before = test_metric_reader.get_metrics_data() - before_duration_count = _sum_hist_count( - _find_metric(metrics_before, "agents.adapter.process.duration") - ) +def test_instrument_adapter_process(test_exporter, test_metric_reader): + """Test instrument_adapter_process records span and duration metric.""" - with agent_telemetry.adapter_process_operation(): + with agent_telemetry.instrument_adapter_process(): pass spans = test_exporter.get_finished_spans() assert len(spans) == 1 - assert spans[0].name == "adapter process" + assert spans[0].name == constants.ADAPTER_PROCESS_OPERATION_NAME - metrics_after = test_metric_reader.get_metrics_data() - after_duration_count = _sum_hist_count( - _find_metric(metrics_after, "agents.adapter.process.duration") + metric_data = test_metric_reader.get_metrics_data() + duration_count = _sum_hist_count( + _find_metric(metric_data, constants.ADAPTER_PROCESS_DURATION_METRIC_NAME) ) - assert after_duration_count == before_duration_count + 1 + assert duration_count == 1 -def test_storage_operation(test_exporter, test_metric_reader): - """Test storage_operation records span and operation-tagged metrics.""" +def test_instrument_storage_op(test_exporter, test_metric_reader): + """Test instrument_storage_op records span and operation-tagged metrics.""" op_filter = {"operation": "read"} - metrics_before = test_metric_reader.get_metrics_data() - before_total = _sum_counter( - _find_metric(metrics_before, "storage.operation.total"), + with agent_telemetry.instrument_storage_op("read"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.STORAGE_OPERATION_NAME_FORMAT.format(operation_name="read") + + metric_data = test_metric_reader.get_metrics_data() + total = _sum_counter( + _find_metric(metric_data, constants.STORAGE_OPERATION_TOTAL_METRIC_NAME), attribute_filter=op_filter, ) - before_duration_count = _sum_hist_count( - _find_metric(metrics_before, "storage.operation.duration"), + duration_count = _sum_hist_count( + _find_metric(metric_data, constants.STORAGE_OPERATION_DURATION_METRIC_NAME), attribute_filter=op_filter, ) - with agent_telemetry.storage_operation("read"): + assert total == 1 + assert duration_count == 1 + +def test_instrument_connector_op(test_exporter, test_metric_reader): + """Test instrument_connector_op records span and connector-tagged metrics.""" + connector_filter = {"operation": "test_connector"} + + with agent_telemetry.instrument_connector_op("test_connector"): pass spans = test_exporter.get_finished_spans() assert len(spans) == 1 - assert spans[0].name == "storage read" + assert spans[0].name == constants.CONNECTOR_REQUEST_OPERATION_NAME_FORMAT.format(operation_name="test_connector") - metrics_after = test_metric_reader.get_metrics_data() - after_total = _sum_counter( - _find_metric(metrics_after, "storage.operation.total"), - attribute_filter=op_filter, + metric_data = test_metric_reader.get_metrics_data() + total = _sum_counter( + _find_metric(metric_data, constants.CONNECTOR_REQUEST_TOTAL_METRIC_NAME), + attribute_filter=connector_filter, ) - after_duration_count = _sum_hist_count( - _find_metric(metrics_after, "storage.operation.duration"), - attribute_filter=op_filter, + duration_count = _sum_hist_count( + _find_metric(metric_data, constants.CONNECTOR_REQUEST_DURATION_METRIC_NAME), + attribute_filter=connector_filter, + ) + + assert total == 1 + assert duration_count == 1 + +def test_instrument_auth_token_request(test_exporter, test_metric_reader): + """Test instrument_auth_token_request records span and auth token request metrics.""" + + with agent_telemetry.instrument_auth_token_request(): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.AUTH_TOKEN_REQUEST_OPERATION_NAME + + metric_data = test_metric_reader.get_metrics_data() + total = _sum_counter( + _find_metric(metric_data, constants.AUTH_TOKEN_REQUEST_TOTAL_METRIC_NAME) + ) + duration_count = _sum_hist_count( + _find_metric(metric_data, constants.AUTH_TOKEN_REQUEST_DURATION_METRIC_NAME) ) - assert after_total == before_total + 1 - assert after_duration_count == before_duration_count + 1 \ No newline at end of file + assert total == 1 + assert duration_count == 1 \ No newline at end of file From 7eca80bab67aebc8833a017b5cf18596071bd129 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 2 Mar 2026 11:02:20 -0800 Subject: [PATCH 16/55] Another commit --- .../hosting/core/observability/configure_telemetry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py index cf34d224..c01743c3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py @@ -15,7 +15,7 @@ from . import constants def configure_telemetry() -> None: - """Configure OpenTelemetry for FastAPI application.""" + """Configure OpenTelemetry with default exporters.""" # Configure Tracing trace_provider = TracerProvider(resource=constants.RESOURCE) From 803465a9a717e26b3b55ddb7ef7d03ff87767a4e Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 4 Mar 2026 13:42:14 -0800 Subject: [PATCH 17/55] Renaming for consistency --- .../hosting/aiohttp/cloud_adapter.py | 4 ++-- .../hosting/core/app/agent_application.py | 4 ++-- .../hosting/core/storage/memory_storage.py | 8 ++++---- .../hosting/core/storage/storage.py | 8 ++++---- .../{observability => telemetry}/__init__.py | 4 ++-- .../_agents_telemetry.py} | 4 ++-- .../configure_telemetry.py | 0 .../{observability => telemetry}/constants.py | 0 .../hosting/fastapi/cloud_adapter.py | 4 ++-- .../observability/test_agent_telemetry.py | 16 ++++++++-------- 10 files changed, 26 insertions(+), 26 deletions(-) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/{observability => telemetry}/__init__.py (74%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/{observability/_agent_telemetry.py => telemetry/_agents_telemetry.py} (99%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/{observability => telemetry}/configure_telemetry.py (100%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/{observability => telemetry}/constants.py (100%) 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 460c9956..91152f26 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,7 +11,7 @@ HttpResponse, ) from microsoft_agents.hosting.core import ChannelServiceClientFactoryBase -from microsoft_agents.hosting.core.observability import agent_telemetry +from microsoft_agents.hosting.core.telemetry import agents_telemetry from .agent_http_adapter import AgentHttpAdapter @@ -71,7 +71,7 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: aiohttp Response object. """ - with agent_telemetry.instrument_adapter_process(): + with agents_telemetry.instrument_adapter_process(): # Adapt request to protocol adapted_request = AiohttpRequestAdapter(request) 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 af1c4f04..5f2445e0 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,7 +30,7 @@ InvokeResponse, ) -from microsoft_agents.hosting.core.observability import agent_telemetry +from microsoft_agents.hosting.core.telemetry import agents_telemetry from microsoft_agents.hosting.core.turn_context import TurnContext from ..agent import Agent @@ -671,7 +671,7 @@ async def on_turn(self, context: TurnContext): async def _on_turn(self, context: TurnContext): typing = None try: - with agent_telemetry.instrument_agent_turn(context): + with agents_telemetry.instrument_agent_turn(context): if context.activity.type != ActivityTypes.typing: if self._options.start_typing_timer: typing = TypingIndicator(context) 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 d1c13388..0e61094b 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,7 +4,7 @@ from threading import Lock from typing import TypeVar -from microsoft_agents.hosting.core.observability import agent_telemetry +from microsoft_agents.hosting.core.telemetry import agents_telemetry from ._type_aliases import JSON from .storage import Storage @@ -29,7 +29,7 @@ async def read( result: dict[str, StoreItem] = {} with self._lock: - with agent_telemetry.instrument_storage_op("read"): + with agents_telemetry.instrument_storage_op("read"): for key in keys: if key == "": raise ValueError("MemoryStorage.read(): key cannot be empty") @@ -52,7 +52,7 @@ async def write(self, changes: dict[str, StoreItem]): raise ValueError("MemoryStorage.write(): changes cannot be None") with self._lock: - with agent_telemetry.instrument_storage_op("write"): + with agents_telemetry.instrument_storage_op("write"): for key in changes: if key == "": raise ValueError("MemoryStorage.write(): key cannot be empty") @@ -63,7 +63,7 @@ async def delete(self, keys: list[str]): raise ValueError("Storage.delete(): Keys are required when deleting.") with self._lock: - with agent_telemetry.instrument_storage_op("delete"): + with agents_telemetry.instrument_storage_op("delete"): for key in keys: if key == "": raise ValueError("MemoryStorage.delete(): key cannot be empty") 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 1ac57c8d..ad83c28a 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,7 +5,7 @@ from abc import ABC, abstractmethod from asyncio import gather -from microsoft_agents.hosting.core.observability import agent_telemetry +from microsoft_agents.hosting.core.telemetry import agents_telemetry from ._type_aliases import JSON from .store_item import StoreItem @@ -73,7 +73,7 @@ async def read( await self.initialize() - with agent_telemetry.instrument_storage_op("read"): + with agents_telemetry.instrument_storage_op("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] ) @@ -90,7 +90,7 @@ async def write(self, changes: dict[str, StoreItemT]) -> None: await self.initialize() - with agent_telemetry.instrument_storage_op("write"): + with agents_telemetry.instrument_storage_op("write"): await gather(*[self._write_item(key, value) for key, value in changes.items()]) @abstractmethod @@ -104,5 +104,5 @@ async def delete(self, keys: list[str]) -> None: await self.initialize() - with agent_telemetry.instrument_storage_op("delete"): + with agents_telemetry.instrument_storage_op("delete"): await gather(*[self._delete_item(key) for key in keys]) 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/telemetry/__init__.py similarity index 74% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/__init__.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py index f40d1e21..4c25936f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py @@ -1,4 +1,4 @@ -from ._agent_telemetry import AgentTelemetry, agent_telemetry +from ._agents_telemetry import AgentTelemetry, agents_telemetry from .configure_telemetry import configure_telemetry from .constants import ( SERVICE_NAME, @@ -8,7 +8,7 @@ __all__ = [ "AgentTelemetry", - "agent_telemetry", + "agents_telemetry", "configure_telemetry", "SERVICE_NAME", "SERVICE_VERSION", 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/telemetry/_agents_telemetry.py similarity index 99% 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/telemetry/_agents_telemetry.py index 2c8a4990..a739668c 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/telemetry/_agents_telemetry.py @@ -17,7 +17,7 @@ def _ts() -> float: """Helper function to get current timestamp in milliseconds""" return datetime.now(timezone.utc).timestamp() * 1000 -class AgentTelemetry: +class _AgentsTelemetry: _tracer: Tracer _meter: Meter @@ -278,4 +278,4 @@ def success_callback(span: Span, duration: float): ) as span: yield span # execute the auth token retrieval operation in the with block -agent_telemetry = AgentTelemetry() \ No newline at end of file +agents_telemetry = _AgentsTelemetry() \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/configure_telemetry.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/configure_telemetry.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/configure_telemetry.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/observability/constants.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py 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 527e05c4..f8417b1c 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,7 +12,7 @@ HttpResponse, ) from microsoft_agents.hosting.core import ChannelServiceClientFactoryBase -from microsoft_agents.hosting.core.observability import agent_telemetry +from microsoft_agents.hosting.core.telemetry import agents_telemetry from .agent_http_adapter import AgentHttpAdapter @@ -71,7 +71,7 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: Returns: FastAPI Response object. """ - with agent_telemetry.instrument_adapter_process(): + with agents_telemetry.instrument_adapter_process(): # Adapt request to protocol adapted_request = FastApiRequestAdapter(request) diff --git a/tests/hosting_core/observability/test_agent_telemetry.py b/tests/hosting_core/observability/test_agent_telemetry.py index 3503be1e..160f0977 100644 --- a/tests/hosting_core/observability/test_agent_telemetry.py +++ b/tests/hosting_core/observability/test_agent_telemetry.py @@ -8,8 +8,8 @@ from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import InMemoryMetricReader -from microsoft_agents.hosting.core.observability import ( - agent_telemetry, +from microsoft_agents.hosting.core.telemetry import ( + agents_telemetry, constants, ) @@ -104,7 +104,7 @@ def test_start_as_current_span(test_exporter): """Test start_as_current_span creates a span with context attributes.""" context = _build_turn_context() - with agent_telemetry.start_as_current_span("test_span", context): + with agents_telemetry.start_as_current_span("test_span", context): pass spans = test_exporter.get_finished_spans() @@ -125,7 +125,7 @@ def test_agent_turn_operation(test_exporter, test_metric_reader): """Test agent_turn_operation records span and turn metrics.""" context = _build_turn_context() - with agent_telemetry.instrument_agent_turn(context): + with agents_telemetry.instrument_agent_turn(context): pass spans = test_exporter.get_finished_spans() @@ -145,7 +145,7 @@ def test_agent_turn_operation(test_exporter, test_metric_reader): def test_instrument_adapter_process(test_exporter, test_metric_reader): """Test instrument_adapter_process records span and duration metric.""" - with agent_telemetry.instrument_adapter_process(): + with agents_telemetry.instrument_adapter_process(): pass spans = test_exporter.get_finished_spans() @@ -164,7 +164,7 @@ def test_instrument_storage_op(test_exporter, test_metric_reader): """Test instrument_storage_op records span and operation-tagged metrics.""" op_filter = {"operation": "read"} - with agent_telemetry.instrument_storage_op("read"): + with agents_telemetry.instrument_storage_op("read"): pass spans = test_exporter.get_finished_spans() @@ -188,7 +188,7 @@ def test_instrument_connector_op(test_exporter, test_metric_reader): """Test instrument_connector_op records span and connector-tagged metrics.""" connector_filter = {"operation": "test_connector"} - with agent_telemetry.instrument_connector_op("test_connector"): + with agents_telemetry.instrument_connector_op("test_connector"): pass spans = test_exporter.get_finished_spans() @@ -211,7 +211,7 @@ def test_instrument_connector_op(test_exporter, test_metric_reader): def test_instrument_auth_token_request(test_exporter, test_metric_reader): """Test instrument_auth_token_request records span and auth token request metrics.""" - with agent_telemetry.instrument_auth_token_request(): + with agents_telemetry.instrument_auth_token_request(): pass spans = test_exporter.get_finished_spans() From 13a59305bbdf3922be12ddb84a23d8c6ef073006 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 5 Mar 2026 10:13:35 -0800 Subject: [PATCH 18/55] Change towards organizing telemetry like error codes --- .../authentication/msal/telemetry/__init__.py | 0 .../hosting/core/app/agent_application.py | 90 ++-- .../hosting/core/channel_service_adapter.py | 185 ++++---- .../connector/client/_telemetry/__init__.py | 0 .../core/connector/client/connector_client.py | 401 ++++++++--------- .../core/storage/_telemetry/__init__.py | 0 .../hosting/core/telemetry/__init__.py | 12 +- .../core/telemetry/_agents_telemetry.py | 286 ++++--------- .../hosting/core/telemetry/_metrics.py | 55 +++ .../core/telemetry/configure_telemetry.py | 4 +- .../hosting/core/telemetry/constants.py | 104 ++++- .../hosting/core/telemetry/spans.py | 405 ++++++++++++++++++ 12 files changed, 983 insertions(+), 559 deletions(-) create mode 100644 libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_telemetry/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_telemetry/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/__init__.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b 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 5f2445e0..f65e1476 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,7 +30,7 @@ InvokeResponse, ) -from microsoft_agents.hosting.core.telemetry import agents_telemetry +from microsoft_agents.hosting.core.telemetry import spans from microsoft_agents.hosting.core.turn_context import TurnContext from ..agent import Agent @@ -671,7 +671,7 @@ async def on_turn(self, context: TurnContext): async def _on_turn(self, context: TurnContext): typing = None try: - with agents_telemetry.instrument_agent_turn(context): + with spans.start_span_app_on_turn(context.activity): if context.activity.type != ActivityTypes.typing: if self._options.start_typing_timer: typing = TypingIndicator(context) @@ -780,23 +780,25 @@ async def _initialize_state(self, context: TurnContext) -> StateT: return turn_state async def _run_before_turn_middleware(self, context: TurnContext, state: StateT): - for before_turn in self._internal_before_turn: - is_ok = await before_turn(context, state) - if not is_ok: - await state.save(context) - return False - return True + with spans.start_span_app_before_turn(context): + for before_turn in self._internal_before_turn: + is_ok = await before_turn(context, state) + if not is_ok: + await state.save(context) + return False + return True async def _handle_file_downloads(self, context: TurnContext, state: StateT): - if self._options.file_downloaders and len(self._options.file_downloaders) > 0: - input_files = state.temp.input_files if state.temp.input_files else [] - for file_downloader in self._options.file_downloaders: - logger.info( - f"Using file downloader: {file_downloader.__class__.__name__}" - ) - files = await file_downloader.download_files(context) - input_files.extend(files) - state.temp.input_files = input_files + with spans.start_span_app_file_downloads(context): + if self._options.file_downloaders and len(self._options.file_downloaders) > 0: + input_files = state.temp.input_files if state.temp.input_files else [] + for file_downloader in self._options.file_downloaders: + logger.info( + f"Using file downloader: {file_downloader.__class__.__name__}" + ) + files = await file_downloader.download_files(context) + input_files.extend(files) + state.temp.input_files = input_files def _contains_non_text_attachments(self, context: TurnContext): non_text_attachments = filter( @@ -806,35 +808,37 @@ def _contains_non_text_attachments(self, context: TurnContext): return len(list(non_text_attachments)) > 0 async def _run_after_turn_middleware(self, context: TurnContext, state: StateT): - for after_turn in self._internal_after_turn: - is_ok = await after_turn(context, state) - if not is_ok: - await state.save(context) - return False - return True + with spans.start_span_app_after_turn(context): + for after_turn in self._internal_after_turn: + is_ok = await after_turn(context, state) + if not is_ok: + await state.save(context) + return False + return True async def _on_activity(self, context: TurnContext, state: StateT): - for route in self._route_list: - if route.selector(context): - if not route.auth_handlers: - await route.handler(context, state) - else: - sign_in_complete = True - for auth_handler_id in route.auth_handlers: - if not ( - await self._auth._start_or_continue_sign_in( - context, state, auth_handler_id - ) - ).sign_in_complete(): - sign_in_complete = False - break - - if sign_in_complete: + with spans.start_span_app_router_handler(context): + for route in self._route_list: + if route.selector(context): + if not route.auth_handlers: await route.handler(context, state) - return - logger.warning( - f"No route found for activity type: {context.activity.type} with text: {context.activity.text}" - ) + else: + sign_in_complete = True + for auth_handler_id in route.auth_handlers: + if not ( + await self._auth._start_or_continue_sign_in( + context, state, auth_handler_id + ) + ).sign_in_complete(): + sign_in_complete = False + break + + if sign_in_complete: + await route.handler(context, state) + return + logger.warning( + f"No route found for activity type: {context.activity.type} with text: {context.activity.text}" + ) async def _start_long_running_call( self, context: TurnContext, func: Callable[[TurnContext], Awaitable] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index 20539ee6..5cbb42bb 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -35,6 +35,7 @@ AuthenticationConstants, ClaimsIdentity, ) +from microsoft_agents.hosting.core.telemetry import spans from .channel_service_client_factory_base import ChannelServiceClientFactoryBase from .channel_adapter import ChannelAdapter from .turn_context import TurnContext @@ -67,56 +68,58 @@ async def send_activities( :rtype: list[:class:`microsoft_agents.activity.ResourceResponse`] :raises TypeError: If context or activities are None/invalid. """ - if not context: - raise TypeError("Expected TurnContext but got None instead") - - if activities is None: - raise TypeError("Expected Activities list but got None instead") - - if len(activities) == 0: - raise TypeError("Expecting one or more activities, but the list was empty.") - - responses = [] - - for activity in activities: - activity.id = None - - response = ResourceResponse() - - if activity.type == ActivityTypes.invoke_response: - context.turn_state[self.INVOKE_RESPONSE_KEY] = activity - elif ( - activity.type == ActivityTypes.trace - and activity.channel_id != Channels.emulator - ): - # no-op - pass - else: - connector_client = cast( - ConnectorClientBase, - context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), - ) - if not connector_client: - raise Error("Unable to extract ConnectorClient from turn context.") - - if activity.reply_to_id: - response = await connector_client.conversations.reply_to_activity( - activity.conversation.id, - activity.reply_to_id, - activity, - ) + with spans.start_span_adapter_send_activities(activities): + + if not context: + raise TypeError("Expected TurnContext but got None instead") + + if activities is None: + raise TypeError("Expected Activities list but got None instead") + + if len(activities) == 0: + raise TypeError("Expecting one or more activities, but the list was empty.") + + responses = [] + + for activity in activities: + activity.id = None + + response = ResourceResponse() + + if activity.type == ActivityTypes.invoke_response: + context.turn_state[self.INVOKE_RESPONSE_KEY] = activity + elif ( + activity.type == ActivityTypes.trace + and activity.channel_id != Channels.emulator + ): + # no-op + pass else: - response = ( - await connector_client.conversations.send_to_conversation( + connector_client = cast( + ConnectorClientBase, + context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), + ) + if not connector_client: + raise Error("Unable to extract ConnectorClient from turn context.") + + if activity.reply_to_id: + response = await connector_client.conversations.reply_to_activity( activity.conversation.id, + activity.reply_to_id, activity, ) - ) - response = response or ResourceResponse(id=activity.id or "") + else: + response = ( + await connector_client.conversations.send_to_conversation( + activity.conversation.id, + activity, + ) + ) + response = response or ResourceResponse(id=activity.id or "") - responses.append(response) + responses.append(response) - return responses + return responses async def update_activity(self, context: TurnContext, activity: Activity): """ @@ -130,22 +133,24 @@ async def update_activity(self, context: TurnContext, activity: Activity): :rtype: :class:`microsoft_agents.activity.ResourceResponse` :raises TypeError: If context or activity are None/invalid. """ - if not context: - raise TypeError("Expected TurnContext but got None instead") + with spans.start_span_adapter_update_activity(activity): - if activity is None: - raise TypeError("Expected Activity but got None instead") + if not context: + raise TypeError("Expected TurnContext but got None instead") - connector_client = cast( - ConnectorClientBase, - context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), - ) - if not connector_client: - raise Error("Unable to extract ConnectorClient from turn context.") + if activity is None: + raise TypeError("Expected Activity but got None instead") - return await connector_client.conversations.update_activity( - activity.conversation.id, activity.id, activity - ) + connector_client = cast( + ConnectorClientBase, + context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), + ) + if not connector_client: + raise Error("Unable to extract ConnectorClient from turn context.") + + return await connector_client.conversations.update_activity( + activity.conversation.id, activity.id, activity + ) async def delete_activity( self, context: TurnContext, reference: ConversationReference @@ -159,22 +164,24 @@ async def delete_activity( :type reference: :class:`microsoft_agents.activity.ConversationReference` :raises TypeError: If context or reference are None/invalid. """ - if not context: - raise TypeError("Expected TurnContext but got None instead") + with spans.start_span_adapter_delete_activity(context.activity): - if not reference: - raise TypeError("Expected ConversationReference but got None instead") + if not context: + raise TypeError("Expected TurnContext but got None instead") - connector_client = cast( - ConnectorClientBase, - context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), - ) - if not connector_client: - raise Error("Unable to extract ConnectorClient from turn context.") + if not reference: + raise TypeError("Expected ConversationReference but got None instead") - await connector_client.conversations.delete_activity( - reference.conversation.id, reference.activity_id - ) + connector_client = cast( + ConnectorClientBase, + context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), + ) + if not connector_client: + raise Error("Unable to extract ConnectorClient from turn context.") + + await connector_client.conversations.delete_activity( + reference.conversation.id, reference.activity_id + ) async def continue_conversation( # pylint: disable=arguments-differ self, @@ -196,21 +203,22 @@ async def continue_conversation( # pylint: disable=arguments-differ :param callback: The method to call for the resulting agent turn. :type callback: Callable[[:class:`microsoft_agents.hosting.core.turn_context.TurnContext`], Awaitable] """ - if not callable: - raise TypeError( - "Expected Callback (Callable[[TurnContext], Awaitable]) but got None instead" - ) + with spans.start_span_adapter_continue_conversation(continuation_activity): + if not callable: + raise TypeError( + "Expected Callback (Callable[[TurnContext], Awaitable]) but got None instead" + ) - self._validate_continuation_activity(continuation_activity) + self._validate_continuation_activity(continuation_activity) - claims_identity = self.create_claims_identity(agent_app_id) + claims_identity = self.create_claims_identity(agent_app_id) - return await self.process_proactive( - claims_identity, - continuation_activity, - claims_identity.get_token_audience(), - callback, - ) + return await self.process_proactive( + claims_identity, + continuation_activity, + claims_identity.get_token_audience(), + callback, + ) async def continue_conversation_with_claims( self, @@ -231,12 +239,13 @@ async def continue_conversation_with_claims( :param audience: The audience for the conversation. :type audience: Optional[str] """ - return await self.process_proactive( - claims_identity, - continuation_activity, - audience or claims_identity.get_token_audience(), - callback, - ) + with spans.start_span_adapter_continue_continue_conversation(continuation_activity): + return await self.process_proactive( + claims_identity, + continuation_activity, + audience or claims_identity.get_token_audience(), + callback, + ) async def create_conversation( # pylint: disable=arguments-differ self, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 73b642a2..431ee7eb 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -18,6 +18,7 @@ PagedMembersResult, ) from microsoft_agents.hosting.core.connector import ConnectorClientBase +from microsoft_agents.hosting.core.telemetry import spans from ..attachments_base import AttachmentsBase from ..conversations_base import ConversationsBase from ..get_product_info import get_product_info @@ -94,33 +95,34 @@ async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: :param view_id: The ID of the view. :return: The attachment as a readable stream. """ - if attachment_id is None: - logger.error( - "AttachmentsOperations.get_attachment(): attachmentId is required", - stack_info=True, - ) - raise ValueError("attachmentId is required") - if view_id is None: - logger.error( - "AttachmentsOperations.get_attachment(): viewId is required", - stack_info=True, - ) - raise ValueError("viewId is required") - - url = f"v3/attachments/{attachment_id}/views/{view_id}" - - logger.info( - "Getting attachment for ID: %s, View ID: %s", attachment_id, view_id - ) - async with self.client.get(url) as response: - if response.status >= 300: + with spans.start_span_connector_get_attachment(attachment_id=attachment_id): + if attachment_id is None: logger.error( - "Error getting attachment: %s", response.status, stack_info=True + "AttachmentsOperations.get_attachment(): attachmentId is required", + stack_info=True, ) - response.raise_for_status() + raise ValueError("attachmentId is required") + if view_id is None: + logger.error( + "AttachmentsOperations.get_attachment(): viewId is required", + stack_info=True, + ) + raise ValueError("viewId is required") + + url = f"v3/attachments/{attachment_id}/views/{view_id}" + + logger.info( + "Getting attachment for ID: %s, View ID: %s", attachment_id, view_id + ) + async with self.client.get(url) as response: + if response.status >= 300: + logger.error( + "Error getting attachment: %s", response.status, stack_info=True + ) + response.raise_for_status() - data = await response.read() - return BytesIO(data) + data = await response.read() + return BytesIO(data) class ConversationsOperations(ConversationsBase): @@ -193,44 +195,45 @@ async def reply_to_activity( :param body: The activity object. :return: The resource response. """ - if not conversation_id or not activity_id: - logger.error( - "ConversationsOperations.reply_to_activity(): conversationId and activityId are required", - stack_info=True, - ) - raise ValueError("conversationId and activityId are required") - - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/activities/{activity_id}" - - logger.info( - "Replying to activity: %s in conversation: %s. Activity type is %s", - activity_id, - conversation_id, - body.type, - ) - - async with self.client.post( - url, - json=body.model_dump( - by_alias=True, exclude_unset=True, exclude_none=True, mode="json" - ), - ) as response: - result = await response.json() if response.content_length else {} - - if response.status >= 300: + with spans.start_span_connector_reply_to_activity(body): + if not conversation_id or not activity_id: logger.error( - "Error replying to activity: %s", - result or response.status, + "ConversationsOperations.reply_to_activity(): conversationId and activityId are required", stack_info=True, ) - response.raise_for_status() + raise ValueError("conversationId and activityId are required") + + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/activities/{activity_id}" logger.info( - "Reply to conversation/activity: %s, %s", result.get("id"), activity_id + "Replying to activity: %s in conversation: %s. Activity type is %s", + activity_id, + conversation_id, + body.type, ) - return ResourceResponse.model_validate(result) + async with self.client.post( + url, + json=body.model_dump( + by_alias=True, exclude_unset=True, exclude_none=True, mode="json" + ), + ) as response: + result = await response.json() if response.content_length else {} + + if response.status >= 300: + logger.error( + "Error replying to activity: %s", + result or response.status, + stack_info=True, + ) + response.raise_for_status() + + logger.info( + "Reply to conversation/activity: %s, %s", result.get("id"), activity_id + ) + + return ResourceResponse.model_validate(result) async def send_to_conversation( self, conversation_id: str, body: Activity @@ -242,35 +245,36 @@ async def send_to_conversation( :param body: The activity object. :return: The resource response. """ - if not conversation_id: - logger.error( - "ConversationsOperations.sent_to_conversation(): conversationId is required", - stack_info=True, - ) - raise ValueError("conversationId is required") - - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/activities" - - logger.info( - "Sending to conversation: %s. Activity type is %s", - conversation_id, - body.type, - ) - async with self.client.post( - url, - json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), - ) as response: - if response.status >= 300: + with spans.start_span_connector_send_to_conversation(conversation_id, body.id): + if not conversation_id: logger.error( - "Error sending to conversation: %s", - response.status, + "ConversationsOperations.sent_to_conversation(): conversationId is required", stack_info=True, ) - response.raise_for_status() + raise ValueError("conversationId is required") - data = await response.json() - return ResourceResponse.model_validate(data) + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/activities" + + logger.info( + "Sending to conversation: %s. Activity type is %s", + conversation_id, + body.type, + ) + async with self.client.post( + url, + json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), + ) as response: + if response.status >= 300: + logger.error( + "Error sending to conversation: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() + + data = await response.json() + return ResourceResponse.model_validate(data) async def update_activity( self, conversation_id: str, activity_id: str, body: Activity @@ -283,34 +287,35 @@ async def update_activity( :param body: The activity object. :return: The resource response. """ - if not conversation_id or not activity_id: - logger.error( - "ConversationsOperations.update_activity(): conversationId and activityId are required", - stack_info=True, - ) - raise ValueError("conversationId and activityId are required") - - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/activities/{activity_id}" - - logger.info( - "Updating activity: %s in conversation: %s. Activity type is %s", - activity_id, - conversation_id, - body.type, - ) - async with self.client.put( - url, - json=body.model_dump(by_alias=True, exclude_unset=True), - ) as response: - if response.status >= 300: + with spans.start_span_connector_update_activity(body): + if not conversation_id or not activity_id: logger.error( - "Error updating activity: %s", response.status, stack_info=True + "ConversationsOperations.update_activity(): conversationId and activityId are required", + stack_info=True, ) - response.raise_for_status() + raise ValueError("conversationId and activityId are required") - data = await response.json() - return ResourceResponse.model_validate(data) + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/activities/{activity_id}" + + logger.info( + "Updating activity: %s in conversation: %s. Activity type is %s", + activity_id, + conversation_id, + body.type, + ) + async with self.client.put( + url, + json=body.model_dump(by_alias=True, exclude_unset=True), + ) as response: + if response.status >= 300: + logger.error( + "Error updating activity: %s", response.status, stack_info=True + ) + response.raise_for_status() + + data = await response.json() + return ResourceResponse.model_validate(data) async def delete_activity(self, conversation_id: str, activity_id: str) -> None: """ @@ -319,27 +324,30 @@ async def delete_activity(self, conversation_id: str, activity_id: str) -> None: :param conversation_id: The ID of the conversation. :param activity_id: The ID of the activity. """ - if not conversation_id or not activity_id: - logger.error( - "ConversationsOperations.delete_activity(): conversationId and activityId are required", - stack_info=True, - ) - raise ValueError("conversationId and activityId are required") - - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/activities/{activity_id}" - - logger.info( - "Deleting activity: %s from conversation: %s", - activity_id, - conversation_id, - ) - async with self.client.delete(url) as response: - if response.status >= 300: + with spans.start_span_connector_delete_activity( + activity_id=activity_id, conversation_id=conversation_id + ): + if not conversation_id or not activity_id: logger.error( - "Error deleting activity: %s", response.status, stack_info=True + "ConversationsOperations.delete_activity(): conversationId and activityId are required", + stack_info=True, ) - response.raise_for_status() + raise ValueError("conversationId and activityId are required") + + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/activities/{activity_id}" + + logger.info( + "Deleting activity: %s from conversation: %s", + activity_id, + conversation_id, + ) + async with self.client.delete(url) as response: + if response.status >= 300: + logger.error( + "Error deleting activity: %s", response.status, stack_info=True + ) + response.raise_for_status() async def upload_attachment( self, conversation_id: str, body: AttachmentData @@ -351,38 +359,39 @@ async def upload_attachment( :param body: The attachment data. :return: The resource response. """ - if conversation_id is None: - logger.error( - "ConversationsOperations.upload_attachment(): conversationId is required", - stack_info=True, - ) - raise ValueError("conversationId is required") - - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/attachments" - - # Convert the AttachmentData to a dictionary - attachment_dict = { - "name": body.name, - "originalBase64": body.original_base64, - "type": body.type, - "thumbnailBase64": body.thumbnail_base64, - } - - logger.info( - "Uploading attachment to conversation: %s, Attachment name: %s", - conversation_id, - body.name, - ) - async with self.client.post(url, json=attachment_dict) as response: - if response.status >= 300: + with spans.start_span_connector_upload_attachment(conversation_id): + if conversation_id is None: logger.error( - "Error uploading attachment: %s", response.status, stack_info=True + "ConversationsOperations.upload_attachment(): conversationId is required", + stack_info=True, ) - response.raise_for_status() + raise ValueError("conversationId is required") - data = await response.json() - return ResourceResponse.model_validate(data) + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/attachments" + + # Convert the AttachmentData to a dictionary + attachment_dict = { + "name": body.name, + "originalBase64": body.original_base64, + "type": body.type, + "thumbnailBase64": body.thumbnail_base64, + } + + logger.info( + "Uploading attachment to conversation: %s, Attachment name: %s", + conversation_id, + body.name, + ) + async with self.client.post(url, json=attachment_dict) as response: + if response.status >= 300: + logger.error( + "Error uploading attachment: %s", response.status, stack_info=True + ) + response.raise_for_status() + + data = await response.json() + return ResourceResponse.model_validate(data) async def get_conversation_members( self, conversation_id: str @@ -393,30 +402,31 @@ async def get_conversation_members( :param conversation_id: The ID of the conversation. :return: A list of members. """ - if not conversation_id: - logger.error( - "ConversationsOperations.get_conversation_members(): conversationId is required", - stack_info=True, - ) - raise ValueError("conversationId is required") - - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/members" - - logger.info( - "Getting conversation members for conversation: %s", conversation_id - ) - async with self.client.get(url) as response: - if response.status >= 300: + with spans.start_span_connector_get_conversation_members(): + if not conversation_id: logger.error( - "Error getting conversation members: %s", - response.status, + "ConversationsOperations.get_conversation_members(): conversationId is required", stack_info=True, ) - response.raise_for_status() + raise ValueError("conversationId is required") - data = await response.json() - return [ChannelAccount.model_validate(member) for member in data] + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/members" + + logger.info( + "Getting conversation members for conversation: %s", conversation_id + ) + async with self.client.get(url) as response: + if response.status >= 300: + logger.error( + "Error getting conversation members: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() + + data = await response.json() + return [ChannelAccount.model_validate(member) for member in data] async def get_conversation_member( self, conversation_id: str, member_id: str @@ -428,32 +438,33 @@ async def get_conversation_member( :param member_id: The ID of the member. :return: The member. """ - if not conversation_id or not member_id: - logger.error( - "ConversationsOperations.get_conversation_member(): conversationId and memberId are required", - stack_info=True, - ) - raise ValueError("conversationId and memberId are required") - - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/members/{member_id}" - - logger.info( - "Getting conversation member: %s from conversation: %s", - member_id, - conversation_id, - ) - async with self.client.get(url) as response: - if response.status >= 300: + with spans.start_span_connector_get_conversation_members(): + if not conversation_id or not member_id: logger.error( - "Error getting conversation member: %s", - response.status, + "ConversationsOperations.get_conversation_member(): conversationId and memberId are required", stack_info=True, ) - response.raise_for_status() + raise ValueError("conversationId and memberId are required") - data = await response.json() - return ChannelAccount.model_validate(data) + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/members/{member_id}" + + logger.info( + "Getting conversation member: %s from conversation: %s", + member_id, + conversation_id, + ) + async with self.client.get(url) as response: + if response.status >= 300: + logger.error( + "Error getting conversation member: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() + + data = await response.json() + return ChannelAccount.model_validate(data) async def delete_conversation_member( self, conversation_id: str, member_id: str diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py index 4c25936f..1620c240 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py @@ -1,4 +1,13 @@ -from ._agents_telemetry import AgentTelemetry, agents_telemetry + +## DESIGN +# This design is similar to how error codes are implemented and maintained. +# The alternative was to inject all of this telemetry logic inline with the code it instruments. +# While some spans are simple, others require more involved mapping of attributes or +# even emitting metrics. +# +# This design hides the "mess" of telemetry to one location rather than throughout the codebase. + +from ._agents_temetry import agents_telemetry from .configure_telemetry import configure_telemetry from .constants import ( SERVICE_NAME, @@ -7,7 +16,6 @@ ) __all__ = [ - "AgentTelemetry", "agents_telemetry", "configure_telemetry", "SERVICE_NAME", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py index a739668c..45f3cded 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py @@ -1,11 +1,12 @@ import time -from typing import Callable, ContextManager +import logging +from typing import Callable from datetime import datetime, timezone from collections.abc import Iterator from contextlib import contextmanager -from opentelemetry.metrics import Meter, Counter, Histogram, UpDownCounter +from opentelemetry.metrics import Meter from opentelemetry import metrics, trace from opentelemetry.trace import Tracer, Span @@ -13,151 +14,102 @@ from . import constants +logger = logging.getLogger(__name__) + def _ts() -> float: """Helper function to get current timestamp in milliseconds""" return datetime.now(timezone.utc).timestamp() * 1000 -class _AgentsTelemetry: - - _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, tracer: Tracer | None = None, meter: Meter | None = None): - if tracer is None: - tracer = trace.get_tracer(constants.SERVICE_NAME, constants.SERVICE_VERSION) - if meter is None: - meter = metrics.get_meter(constants.SERVICE_NAME, constants.SERVICE_VERSION) - - self._meter = meter - self._tracer = tracer - - # Storage - - self._storage_operations = self._meter.create_counter( - constants.STORAGE_OPERATION_TOTAL_METRIC_NAME, - "operation", - description="Number of storage operations performed by the agent", - ) - - self._storage_operation_duration = self._meter.create_histogram( - constants.STORAGE_OPERATION_DURATION_METRIC_NAME, - "ms", - description="Duration of storage operations in milliseconds", - ) - - # AgentApplication - - self._turn_total = self._meter.create_counter( - constants.AGENT_TURN_TOTAL_METRIC_NAME, - "turn", - description="Total number of turns processed by the agent", - ) - - self._turn_errors = self._meter.create_counter( - constants.AGENT_TURN_ERRORS_METRIC_NAME, - "turn", - description="Number of turns that resulted in an error", - ) - - self._turn_duration = self._meter.create_histogram( - constants.AGENT_TURN_DURATION_METRIC_NAME, - "ms", - description="Duration of agent turns in milliseconds", - ) +_TimedSpanCallback = Callable[[Span, float, Exception | None], None] - # Adapters +def _remove_nones(d: dict) -> None: + for key in list(d.keys()): # list conversion to avoid iterating over changing data structure + if d[key] is None: + del d[key] - self._adapter_process_duration = self._meter.create_histogram( - constants.ADAPTER_PROCESS_DURATION_METRIC_NAME, - "ms", - description="Duration of adapter processing in milliseconds", - ) - - # Connectors - - self._connector_request_total = self._meter.create_counter( - constants.CONNECTOR_REQUEST_TOTAL_METRIC_NAME, - "request", - description="Total number of connector requests made by the agent", - ) - - self._connector_request_duration = self._meter.create_histogram( - constants.CONNECTOR_REQUEST_DURATION_METRIC_NAME, - "ms", - description="Duration of connector requests in milliseconds", - ) - - # Auth - - self._auth_token_request_total = self._meter.create_counter( - constants.AUTH_TOKEN_REQUEST_TOTAL_METRIC_NAME, - "request", - description="Total number of auth token requests made by the agent", - ) +class _AgentsTelemetry: - self._auth_token_requests_duration = self._meter.create_histogram( - constants.AUTH_TOKEN_REQUEST_DURATION_METRIC_NAME, - "ms", - description="Duration of auth token retrieval in milliseconds", - ) + def __init__(self): + """ Initializes the AgentsTelemetry instance with the given tracer and meter, or creates new ones if not provided + + :param tracer: Optional OpenTelemetry Tracer instance to use for creating spans. If not provided, a new tracer will be created with the service name and version from constants. + :param meter: Optional OpenTelemetry Meter instance to use for recording metrics. If not provided, a new meter will be created with the service name and version from constants. + """ + self._tracer = trace.get_tracer(constants.SERVICE_NAME, constants.SERVICE_VERSION) + self._meter = metrics.get_meter(constants.SERVICE_NAME, constants.SERVICE_VERSION) @property def tracer(self) -> Tracer: + """Returns the OpenTelemetry tracer instance for creating spans""" return self._tracer @property def meter(self) -> Meter: + """Returns the OpenTelemetry meter instance for recording metrics""" return self._meter - def _extract_attributes_from_context(self, context: TurnContext) -> dict: + def _extract_attributes_from_context(self, turn_context: TurnContext) -> dict: + """Helper method to extract common attributes from the TurnContext for span and metric recording""" + # 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 + attributes["activity.type"] = turn_context.activity.type + attributes["agent.is_agentic"] = turn_context.activity.is_agentic_request() + if turn_context.activity.from_property: + attributes["from.id"] = turn_context.activity.from_property.id + if turn_context.activity.recipient: + attributes["recipient.id"] = turn_context.activity.recipient.id + if turn_context.activity.conversation: + attributes["conversation.id"] = turn_context.activity.conversation.id + attributes["channel_id"] = turn_context.activity.channel_id + attributes["message.text.length"] = len(turn_context.activity.text) if turn_context.activity.text else 0 return attributes @contextmanager - def start_as_current_span(self, span_name: str, context: TurnContext) -> Iterator[Span]: + def start_as_current_span( + self, + span_name: str, + turn_context: TurnContext | None = None, + ) -> Iterator[Span]: + """Context manager for starting a new span with the given name and setting attributes from the TurnContext if provided + + :param span_name: The name of the span to start + :param turn_context Optional TurnContext to extract attributes from and set on the span + :return: An iterator that yields the started span, which will be ended when the context manager exits + + :example usage: + with agents_telemetry.start_as_current_span("my_operation", context) as span: + # perform some operations here, and the span will automatically end when the block is exited + # any exceptions raised will be recorded on the span and re-raised after the span is ended + + :note: This method is lower-level and can be used for any custom instrumentation needs. + For common operations like instrumenting an agent turn, adapter processing, storage operations, etc., + use the provided context managers like `instrument_agent_turn`, `instrument_adapter_process`, etc., + which will automatically record relevant metrics and handle success/failure cases. + """ 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) + if turn_context is not None: + attributes = self._extract_attributes_from_context(turn_context) + span.set_attributes(attributes) yield span @contextmanager - def _start_timed_span( + def start_timed_span( self, span_name: str, - context: TurnContext | None = None, - *, - success_callback: Callable[[Span, float], None] | None = None, - failure_callback: Callable[[Span, Exception], None] | None = None, + turn_context: TurnContext | None = None, + callback: _TimedSpanCallback | None = None ) -> Iterator[Span]: + """Context manager for starting a timed span that records duration and success/failure status, and invokes a callback with the results - 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) + :param span_name: The name of the span to start + :param turn_context Optional TurnContext to extract attributes from and set on the span + :param callback: Optional callback function that will be called with the span, duration in milliseconds, and any exception that was raised (or None if successful) when the span is ended + :return: An iterator that yields the started span, which will be ended when the context manager exits + """ - with cm as span: + with self.start_as_current_span(span_name, turn_context) as span: start = time.time() exception: Exception | None = None @@ -174,108 +126,16 @@ def _start_timed_span( end = time.time() duration = (end - start) * 1000 # milliseconds - span.add_event(f"{span_name} completed", {"duration_ms": duration}) - if success: + span.add_event(f"{span_name} completed", {"duration_ms": duration}) span.set_status(trace.Status(trace.StatusCode.OK)) - if success_callback: - success_callback(span, duration) + if callback: + callback(span, duration, None) else: - - if failure_callback: - failure_callback(span, exception) + if callback: + callback(span, duration, exception) span.set_status(trace.Status(trace.StatusCode.ERROR)) raise exception from None # re-raise to ensure it's not swallowed - - @contextmanager - def instrument_agent_turn(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._turn_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, - # 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._turn_errors.add(1) - - with self._start_timed_span( - constants.AGENT_TURN_OPERATION_NAME, - context=context, - success_callback=success_callback, - failure_callback=failure_callback - ) as span: - yield span # execute the turn operation in the with block - - @contextmanager - def instrument_adapter_process(self): - """Context manager for recording adapter processing operations""" - - def success_callback(span: Span, duration: float): - self._adapter_process_duration.record(duration) - - with self._start_timed_span( - constants.ADAPTER_PROCESS_OPERATION_NAME, - success_callback=success_callback - ) as span: - yield span # execute the adapter processing in the with block - - @contextmanager - def instrument_storage_op(self, operation_name: str): - """Context manager for recording storage operations""" - - def success_callback(span: Span, duration: float): - self._storage_operations.add(1, {"operation": operation_name}) - self._storage_operation_duration.record(duration, {"operation": operation_name}) - - with self._start_timed_span( - constants.STORAGE_OPERATION_NAME_FORMAT.format(operation_name=operation_name), - success_callback=success_callback - ) as span: - yield span # execute the storage operation in the with block - - @contextmanager - def instrument_connector_op(self, operation_name: str): - """Context manager for recording connector requests""" - - def success_callback(span: Span, duration: float): - self._connector_request_total.add(1, {"operation": operation_name}) - self._connector_request_duration.record(duration, {"operation": operation_name}) - - with self._start_timed_span( - constants.CONNECTOR_REQUEST_OPERATION_NAME_FORMAT.format(operation_name=operation_name), - success_callback=success_callback - ) as span: - yield span # execute the connector request in the with block - - @contextmanager - def instrument_auth_token_request(self): - """Context manager for recording auth token retrieval operations""" - - def success_callback(span: Span, duration: float): - self._auth_token_request_total.add(1) - self._auth_token_requests_duration.record(duration) - - with self._start_timed_span( - constants.AUTH_TOKEN_REQUEST_OPERATION_NAME, - success_callback=success_callback - ) as span: - yield span # execute the auth token retrieval operation in the with block agents_telemetry = _AgentsTelemetry() \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py new file mode 100644 index 00000000..89745b17 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py @@ -0,0 +1,55 @@ +from . import constants +from ._agents_telemetry import agents_telemetry + +STORAGE_OPERATIONS = agents_telemetry.meter.create_counter( + constants.METRIC_STORAGE_OPERATION_TOTAL, + "operation", + description="Number of storage operations performed by the agent", +) +STORAGE_OPERATIONS_DURATION = agents_telemetry.meter.create_histogram( + constants.METRIC_STORAGE_OPERATION_DURATION, + "ms", + description="Duration of storage operations in milliseconds", +) + +# AgentApplication + +TURN_TOTAL = agents_telemetry.meter.create_counter( + constants.METRIC_TURN_TOTAL, + "turn", + description="Total number of turns processed by the agent", +) + +TURN_ERRORS = agents_telemetry.meter.create_counter( + constants.METRIC_TURN_ERRORS, + "turn", + description="Number of turns that resulted in an error", +) + +TURN_DURATION = agents_telemetry.meter.create_histogram( + constants.METRIC_TURN_DURATION, + "ms", + description="Duration of agent turns in milliseconds", +) + +# Adapters + +ADAPTER_PROCESS_DURATION = agents_telemetry.meter.create_histogram( + constants.METRIC_ADAPTER_PROCESS_DURATION, + "ms", + description="Duration of adapter processing in milliseconds", +) + +# Connectors + +CONNECTOR_REQUEST_TOTAL = agents_telemetry.meter.create_counter( + constants.METRIC_CONNECTOR_REQUEST_TOTAL, + "request", + description="Total number of connector requests made by the agent", +) + +CONNECTOR_REQUEST_DURATION = agents_telemetry.meter.create_histogram( + constants.METRIC_CONNECTOR_REQUEST_DURATION, + "ms", + description="Duration of connector requests in milliseconds", +) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/configure_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/configure_telemetry.py index c01743c3..9eee512e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/configure_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/configure_telemetry.py @@ -40,4 +40,6 @@ def configure_telemetry() -> None: # Add logging handler handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider) - logging.getLogger().addHandler(handler) \ No newline at end of file + logging.getLogger().addHandler(handler) + + \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py index b78bad11..e1187161 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py @@ -18,26 +18,96 @@ # Span operation names -ADAPTER_PROCESS_OPERATION_NAME = "adapter process" -AGENT_TURN_OPERATION_NAME = "agent turn" -AUTH_TOKEN_REQUEST_OPERATION_NAME = "auth token request" -CONNECTOR_REQUEST_OPERATION_NAME_FORMAT = "connector {operation_name}" -STORAGE_OPERATION_NAME_FORMAT = "storage {operation_name}" +SPAN_ADAPTER_PROCESS = "agents.adapter.process" +SPAN_ADAPTER_SEND_ACTIVITIES = "agents.adapter.sendActivities" +SPAN_ADAPTER_UPDATE_ACTIVITY = "agents.adapter.updateActivity" +SPAN_ADAPTER_DELETE_ACTIVITY = "agents.adapter.deleteActivity" +SPAN_ADAPTER_CONTINUE_CONVERSATION = "agents.adapter.continueConversation" +SPAN_ADAPTER_CREATE_CONNECTOR_CLIENT = "agents.adapter.createConnectorClient" -# Metric names +SPAN_APP_RUN = "agents.app.run" +SPAN_APP_ROUTE_HANDLER = "agents.app.routeHandler" +SPAN_APP_BEFORE_TURN = "agents.app.beforeTurn" +SPAN_APP_AFTER_TURN = "agents.app.afterTurn" +SPAN_APP_DOWNLOAD_FILES = "agents.app.downloadFiles" -ADAPTER_PROCESS_DURATION_METRIC_NAME = "agents.adapter.process.duration" -ADAPTER_PROCESS_TOTAL_METRIC_NAME = "agents.adapter.process.total" +SPAN_CONNECTOR_REPLY_TO_ACTIVITY = "agents.connector.replyToActivity" +SPAN_CONNECTOR_SEND_TO_CONVERSATION = "agents.connector.sendToConversation" +SPAN_CONNECTOR_UPDATE_ACTIVITY = "agents.connector.updateActivity" +SPAN_CONNECTOR_DELETE_ACTIVITY = "agents.connector.deleteActivity" +SPAN_CONNECTOR_CREATE_CONVERSATION = "agents.connector.createConversation" +SPAN_CONNECTOR_GET_CONVERSATIONS = "agents.connector.getConversations" +SPAN_CONNECTOR_GET_CONVERSATION_MEMBERS = "agents.connector.getConversationMembers" +SPAN_CONNECTOR_UPDLOAD_ATTACHMENT = "agents.connector.uploadAttachment" +SPAN_CONNECTOR_GET_ATTACHMENT = "agents.connector.getAttachment" -AGENT_TURN_DURATION_METRIC_NAME = "agents.turn.duration" -AGENT_TURN_TOTAL_METRIC_NAME = "agents.turn.total" -AGENT_TURN_ERRORS_METRIC_NAME = "agents.turn.errors" +SPAN_STORAGE_READ = "agents.storage.read" +SPAN_STORAGE_WRITE = "agents.storage.write" +SPAN_STORAGE_DELETE = "agents.storage.delete" -AUTH_TOKEN_REQUEST_DURATION_METRIC_NAME = "agents.auth.request.duration" -AUTH_TOKEN_REQUEST_TOTAL_METRIC_NAME = "agents.auth.request.total" +SPAN_AUTH_GET_ACCESS_TOKEN = "agents.auth.getAccessToken" +SPAN_AUTH_ACQUIRE_TOKEN_ON_BEHALF_OF = "agents.auth.acquireTokenOnBehalfOf" +SPAN_AUTH_GET_AGENTIC_INSTANCE_TOKEN = "agents.auth.getAgenticInstanceToken" +SPAN_AUTH_GET_AGENTIC_USER_TOKEN = "agents.auth.getAgenticUserToken" -CONNECTOR_REQUEST_TOTAL_METRIC_NAME = "agents.connector.request.total" -CONNECTOR_REQUEST_DURATION_METRIC_NAME = "agents.connector.request.duration" +SPAN_TURN_SEND_ACTIVITY = "agents.turn.sendActivity" +SPAN_TURN_UPDATE_ACTIVITY = "agents.turn.updateActivity" +SPAN_TURN_DELETE_ACTIVITY = "agents.turn.deleteActivity" -STORAGE_OPERATION_DURATION_METRIC_NAME = "storage.operation.duration" -STORAGE_OPERATION_TOTAL_METRIC_NAME = "storage.operation.total" \ No newline at end of file +# Metrics + +# counters + +METRIC_ACTIVITIES_RECEIVED = "agents.activities.received" +METRIC_ACTIVITIES_SENT = "agents.activities.sent" +METRIC_ACTIVITIES_UPDATED = "agents.activities.updated" +METRIC_ACTIVITIES_DELETED = "agents.activities.deleted" + +METRIC_TURN_TOTAL = "agents.turn.total" +METRIC_TURN_ERRORS = "agents.turn.errors" + +METRIC_AUTH_TOKEN_REQUESTS = "agents.auth.token.requests" +METRIC_CONNECTOR_REQUESTS_TOTAL = "agents.connector.requests" +METRIC_STORAGE_OPERATION_TOTAL = "agents.storage.operations.total" + +# histograms + +METRIC_TURN_DURATION = "agents.turn.duration" +METRIC_ADAPTER_PROCESS_DURATION = "agents.adapter.process.duration" +METRIC_CONNECTOR_REQUEST_DURATION = "agents.connector.request.duration" +METRIC_STORAGE_OPERATION_DURATION = "agents.storage.operation.duration" +METRIC_AUTH_TOKEN_REQUEST_DURATION = "agents.auth.token.request.duration" + + +# Attributes +# +# This section represents a mapping of internal attribute names to standardized telemetry attribute names. +# There are two major reasons for this: +# 1. Consistency with the other SDKs and the docs. Each language/SDK has different conventions for variable naming. +# 2. Flexibility: This mapping allows us to change the internal attribute names without affecting the telemetry data. +# 3. Efficiency: avoid snake case to camel case conversions (or any other convention) + +ATTR_ACTIVITY_DELIVERY_MODE = "activity.deliveryMode" +ATTR_ACTIVITY_CHANNEL_ID = "activity.channelId" +ATTR_ACTIVITY_ID = "activity.id" +ATTR_ACTIVITY_COUNT = "activities.count" +ATTR_ACTIVITY_TYPE = "activity.type" +ATTR_AGENTIC_USER_ID = "agentic.user.id" +ATTR_AGENTIC_APP_INSTANCE_ID = "agentic.instance.id" +ATTR_ATTACHMENT_ID = "attachment.id" +ATTR_AUTH_SCOPES = "auth.scopes" + +ATTR_CONVERSATION_ID = "conversation.id" + +ATTR_IS_AGENTIC_REQUEST = "isAgenticRequest" + +ATTR_NUM_KEYS = "keys.num" + +ATTR_ROUTE_IS_INVOKE = "route.isInvoke" +ATTR_ROUTE_IS_AGENTIC = "route.isAgentic" + +ATTR_SERVICE_URL = "serviceUrl" + +# VALUES + +UNKNOWN = "unknown" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py new file mode 100644 index 00000000..0804b6d1 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py @@ -0,0 +1,405 @@ +from collections.abc import Iterator +from contextlib import contextmanager + +from opentelemetry.trace import Span + +from microsoft_agents.activity import Activity +from microsoft_agents.hosting.core.turn_context import TurnContext + +from . import _metrics, constants +from ._agents_telemetry import agents_telemetry + +# +# Adapter +# + +def _get_conversation_id(activity: Activity) -> str: + return activity.conversation.id if activity.conversation else "unknown" + +@contextmanager +def start_span_adapter_process(activity: Activity) -> Iterator[None]: + """Context manager for recording adapter process call""" + with agents_telemetry.start_as_current_span(constants.SPAN_ADAPTER_PROCESS) as span: + span.set_attributes({ + constants.ATTR_ACTIVITY_TYPE: activity.type, + constants.ATTR_ACTIVITY_CHANNEL_ID: activity.channel_id or constants.UNKNOWN, + constants.ATTR_ACTIVITY_DELIVERY_MODE: activity.delivery_mode, + constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), + constants.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), + }) + yield + +@contextmanager +def start_span_adapter_send_activities(activities: list[Activity]) -> Iterator[None]: + """Context manager for recording adapter send_activities call""" + with agents_telemetry.start_as_current_span(constants.SPAN_ADAPTER_SEND_ACTIVITIES) as span: + span.set_attributes({ + constants.ATTR_ACTIVITY_COUNT: len(activities), + constants.ATTR_CONVERSATION_ID: _get_conversation_id(activities[0]) if activities else constants.UNKNOWN, + }) + yield + +@contextmanager +def start_span_adapter_update_activity(activity: Activity) -> Iterator[None]: + """Context manager for recording adapter update_activity call""" + with agents_telemetry.start_as_current_span(constants.SPAN_ADAPTER_UPDATE_ACTIVITY) as span: + span.set_attributes({ + constants.ATTR_ACTIVITY_ID: activity.id or constants.UNKNOWN, + constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), + }) + yield + +@contextmanager +def start_span_adapter_delete_activity(activity: Activity) -> Iterator[None]: + """Context manager for recording adapter delete_activity call""" + with agents_telemetry.start_as_current_span(constants.SPAN_ADAPTER_DELETE_ACTIVITY) as span: + span.set_attributes({ + constants.ATTR_ACTIVITY_ID: activity.id or constants.UNKNOWN, + constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), + }) + yield + +@contextmanager +def start_span_adapter_continue_conversation(activity: Activity) -> Iterator[None]: + """Context manager for recording adapter continue_conversation call""" + with agents_telemetry.start_as_current_span(constants.SPAN_ADAPTER_CONTINUE_CONVERSATION) as span: + span.set_attributes({ + constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), + constants.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), + }) + yield + +# +# AgentApplication +# + +@contextmanager +def start_span_app_on_turn(turn_context: TurnContextProtocolProtocol) -> Iterator[None]: + """Context manager for recording an app on_turn call, including success/failure and duration""" + + activity = turn_context.activity + + def callback(span: Span, duration: float, error: Exception | None): + if error is None: + _metrics.TURN_TOTAL.add(1) + _metrics.TURN_DURATION.record(duration, { + "conversation.id": activity.conversation.id if activity.conversation else "unknown", + "channel.id": str(activity.channel_id), + }) + else: + _metrics.TURN_ERRORS.add(1) + + with agents_telemetry.start_timed_span( + constants.SPAN_APP_RUN, + turn_context=turn_context, + callback=callback, + ) as span: + span.set_attributes({ + constants.ATTR_ACTIVITY_TYPE: activity.type, + constants.ATTR_ACTIVITY_ID: activity.id or constants.UNKNOWN, + }) + yield + +@contextmanager +def start_span_app_route_handler(turn_context: TurnContextProtocol) -> Iterator[None]: + """Context manager for recording the app route handler span""" + with agents_telemetry.start_as_current_span(constants.SPAN_APP_ROUTE_HANDLER, turn_context): + yield + +@contextmanager +def start_span_app_before_turn(turn_context: TurnContextProtocol) -> Iterator[None]: + """Context manager for recording the app before turn span""" + with agents_telemetry.start_as_current_span(constants.SPAN_APP_BEFORE_TURN, turn_context): + yield + +@contextmanager +def start_span_app_after_turn(turn_context: TurnContextProtocol) -> Iterator[None]: + """Context manager for recording the app after turn span""" + with agents_telemetry.start_as_current_span(constants.SPAN_APP_AFTER_TURN, turn_context): + yield + +@contextmanager +def start_span_app_download_files(turn_context: TurnContextProtocol) -> Iterator[None]: + """Context manager for recording the app download files span""" + with agents_telemetry.start_as_current_span(constants.SPAN_APP_DOWNLOAD_FILES, turn_context): + yield + +# +# ConnectorClient +# + +@contextmanager +def _start_span_connector_activity_op(span_name: str, conversation_id: str, activity_id: str) -> Iterator[None]: + with agents_telemetry.start_as_current_span(span_name) as span: + span.set_attribute(constants.ATTR_ACTIVITY_ID, activity_id) + span.set_attribute(constants.ATTR_CONVERSATION_ID, conversation_id) + yield + +@contextmanager +def start_span_connector_reply_to_activity(conversation_id: str, activity_id: str) -> Iterator[None]: + with _start_span_connector_activity_op(constants.SPAN_CONNECTOR_REPLY_TO_ACTIVITY, conversation_id, activity_id): + yield + +@contextmanager +def start_span_connector_send_to_conversation(conversation_id: str, activity_id: str) -> Iterator[None]: + with _start_span_connector_activity_op(constants.SPAN_CONNECTOR_SEND_TO_CONVERSATION, conversation_id, activity_id): + yield + +@contextmanager +def start_span_connector_update_activity(conversation_id: str, activity_id: str) -> Iterator[None]: + with _start_span_connector_activity_op(constants.SPAN_CONNECTOR_UPDATE_ACTIVITY, conversation_id, activity_id): + yield + +@contextmanager +def start_span_connector_delete_activity(conversation_id: str, activity_id: str) -> Iterator[None]: + with _start_span_connector_activity_op(constants.SPAN_CONNECTOR_DELETE_ACTIVITY, conversation_id, activity_id): + yield + +@contextmanager +def start_span_connector_create_conversation() -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_CONNECTOR_CREATE_CONVERSATION): + yield + +@contextmanager +def start_span_connector_get_conversations() -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_CONNECTOR_GET_CONVERSATIONS): + yield + + +@contextmanager +def start_span_connector_get_conversation_members() -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_CONNECTOR_GET_CONVERSATION_MEMBERS): + yield + + +@contextmanager +def start_span_connector_upload_attachment(conversation_id: str) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_CONNECTOR_UPDLOAD_ATTACHMENT) as span: + span.set_attribute(constants.ATTR_CONVERSATION_ID, conversation_id) + yield + + +@contextmanager +def start_span_connector_get_attachment(attachment_id: str) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_CONNECTOR_GET_ATTACHMENT) as span: + span.set_attribute(constants.ATTR_ATTACHMENT_ID, attachment_id) + yield + +# +# Storage +# + + +@contextmanager +def _start_span_storage_op(span_name: str, num_keys: int) -> Iterator[None]: + with agents_telemetry.start_as_current_span(span_name) as span: + span.set_attribute(constants.ATTR_NUM_KEYS, num_keys) + yield + +@contextmanager +def start_span_storage_read(num_keys: int) -> Iterator[None]: + with _start_span_storage_op(constants.SPAN_STORAGE_READ, num_keys): yield + +@contextmanager +def start_span_storage_write(self) -> Iterator[None]: + with _start_span_storage_op(constants.SPAN_STORAGE_WRITE, num_keys): yield + +@contextmanager +def start_span_storage_delete(self) -> Iterator[None]: + with _start_span_storage_op(constants.SPAN_STORAGE_DELETE, num_keys): yield + +# +# Auth +# + +@contextmanager +def start_span_auth_get_access_token(self) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_GET_ACCESS_TOKEN): + yield + + +@contextmanager +def start_span_auth_acquire_token_on_behalf_of(self) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_ACQUIRE_TOKEN_ON_BEHALF_OF): + yield + + +@contextmanager +def start_span_auth_get_agentic_instance_token(self) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_GET_AGENTIC_INSTANCE_TOKEN): + yield + + +@contextmanager +def start_span_auth_get_agentic_user_token(self) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_GET_AGENTIC_USER_TOKEN): + yield + + +@contextmanager +def start_span_agent_post_activity(self) -> Iterator[None]: + with agents_telemetry.start_as_current_span("agents.agentClient.postActivity"): + yield + + +@contextmanager +def start_span_turn_send_activity(self) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_TURN_SEND_ACTIVITY): + yield + + +@contextmanager +def start_span_turn_send_activities(self) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_TURN_SEND_ACTIVITY): + yield + + +@contextmanager +def start_span_turn_update_activity(self) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_TURN_UPDATE_ACTIVITY): + yield + + +@contextmanager +def start_span_turn_delete_activity(self) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_TURN_DELETE_ACTIVITY): + yield + +# def start_span_agent_turn(turn_context: TurnContextProtocol) -> Iterator[Span]: +# """Context manager for recording an agent turn, including success/failure and duration""" + +# def callback(span: Span, duration: float, error: Exception | None): +# if error is None: +# _metrics.TURN_TOTAL.add(1) +# _metrics.TURN_DURATION.record(duration, { +# "conversation.id": turn_context.activity.conversation.id if turn_context.activity.conversation else "unknown", +# "channel.id": str(turn_context.activity.channel_id), +# }) +# else: +# self._turn_errors.add(1) + +# with self._start_timed_span( +# constants.AGENT_TURN_OPERATION_NAME, +# turn_context=turn_context, +# callback=callback, +# ) as span: +# yield span # execute the turn operation in the with block + +# @contextmanager +# def start_span_app_run(turn_context: TurnContextProtocol) -> Iterator[Span]: +# """Context manager for recording an app run, including success/failure and duration""" + +# with agents_telemetry.start_as_current_span( +# constants.SPAN_APP_RUN, +# turn_context=turn_context +# ) +# with self._start_timed_span( +# constants.APP_RUN_OPERATION_NAME, +# callback=callback +# ) as span: +# span.set_attribute("app.name", app_name) +# yield span # execute the app run operation in the with block + + + +# def start_span_adapter_process(self): +# """Context manager for recording adapter processing operations""" + +# def callback(span: Span, duration: float, error: Exception | None): +# if error is None: +# self._adapter_process_duration.record(duration) + +# with self._start_timed_span( +# constants.ADAPTER_PROCESS_OPERATION_NAME, +# callback=callback +# ) as span: +# yield span # execute the adapter processing in the with block + +# def _start_span_adapter_activity_op(span_name: str, conversation_id: str | None = None): + + +# @contextmanager +# def _instrument_adapter_activity_op(span_name: str): +# """Context manager for recording adapter activity operations ()""" +# with self.start_as_current_span(span_name) as span: +# yield span + +# @contextmanager +# def instrument_adapter_send_activities(activity_count: int, conversation_id: str) -> None: +# """Context manager for recording adapter SendActivities calls""" +# with self._instrument_adapter_activity_op(constants.SPAN_ADAPTER_SEND_ACTIVITIES): +# yield + +# @contextmanger +# def instrument_adapter_update_activity(activity_id: str, conversation_id: str) -> None: +# """Context manager for recording adapter UpdateActivity calls""" +# with self._instrument_adapter_activity_op(constants.SPAN_ADAPTER_UPDATE_ACTIVITY): +# yield + +# @contextmanager + + +# @contextmanager +# def instrument_storage_op( +# +# operation_type: str, +# num_keys: int, +# ): +# """Context manager for recording storage operations""" + +# def callback(span: Span, duration: float, error: Exception | None): +# if error is None: +# self._storage_operations.add(1, {"operation": operation_type}) +# self._storage_operation_duration.record(duration, {"operation": operation_type}) + +# with self._start_timed_span( +# constants.STORAGE_OPERATION_NAME_FORMAT.format(operation_type=operation_type), +# callback=callback +# ) as span: +# span.set_attribute(constants.ATTR_NUM_KEYS, num_keys) +# yield span # execute the storage operation in the with block + +# @contextmanager +# def instrument_connector_op(operation_name: str): +# """Context manager for recording connector requests""" + +# def callback(span: Span, duration: float, error: Exception | None): +# if error is None: +# self._connector_request_total.add(1, {"operation": operation_name}) +# self._connector_request_duration.record(duration, {"operation": operation_name}) + +# with self._start_timed_span( +# constants.CONNECTOR_REQUEST_OPERATION_NAME_FORMAT.format(operation_name=operation_name), +# callback=callback +# ) as span: +# yield span # execute the connector request in the with block + +# @contextmanager +# def instrument_auth_token_request( +# +# scopes: list[str] | None = None, +# agentic_user_id: str | None = None, +# agentic_app_instance_id: str | None = None +# ): +# """Context manager for recording auth token retrieval operations""" + +# def callback(span: Span, duration: float, error: Exception | None): +# if error is None: +# self._auth_token_request_total.add(1) +# self._auth_token_requests_duration.record(duration) + +# with self._start_timed_span( +# constants.AUTH_TOKEN_REQUEST_OPERATION_NAME, +# callback=callback +# ) as span: + +# attributes = { +# constants.ATTR_AUTH_SCOPES: scopes, +# constants.ATTR_AGENTIC_USER_ID: agentic_user_id, +# constants.ATTR_AGENTIC_APP_INSTANCE_ID: agentic_app_instance_id, +# } +# _remove_nones(attributes) + +# span.set_attributes(attributes) + +# yield span # execute the auth token retrieval operation in the with block \ No newline at end of file From 7adb513fb4c116bfee45584dd1334dbe5b27cc91 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 5 Mar 2026 13:52:40 -0800 Subject: [PATCH 19/55] Adding auth telemetry hooks and module --- .../authentication/msal/msal_auth.py | 361 +++++++++--------- .../authentication/msal/telemetry/_metrics.py | 0 .../msal/telemetry/constants.py | 11 + .../authentication/msal/telemetry/spans.py | 43 +++ .../hosting/core/storage/memory_storage.py | 20 +- .../hosting/core/storage/storage.py | 18 +- .../hosting/core/telemetry/__init__.py | 2 +- .../hosting/core/telemetry/constants.py | 12 +- .../hosting/core/telemetry/spans.py | 23 -- 9 files changed, 261 insertions(+), 229 deletions(-) create mode 100644 libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/_metrics.py create mode 100644 libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py create mode 100644 libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 8c46a7f9..fa04ebcc 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -29,6 +29,7 @@ AgentAuthConfiguration, ) from microsoft_agents.authentication.msal.errors import authentication_errors +from .telemetry import spans logger = logging.getLogger(__name__) @@ -68,40 +69,44 @@ def __init__(self, msal_configuration: AgentAuthConfiguration): async def get_access_token( self, resource_url: str, scopes: list[str], force_refresh: bool = False ) -> str: - logger.debug( - f"Requesting access token for resource: {resource_url}, scopes: {scopes}" - ) - valid_uri, instance_uri = self._uri_validator(resource_url) - if not valid_uri: - raise ValueError(str(authentication_errors.InvalidInstanceUrl)) - assert instance_uri is not None # for mypy - - local_scopes = self._resolve_scopes_list(instance_uri, scopes) - msal_auth_client = self._get_client() - - if isinstance(msal_auth_client, ManagedIdentityClient): - logger.info("Acquiring token using Managed Identity Client.") - auth_result_payload = await _async_acquire_token_for_client( - msal_auth_client, resource=resource_url - ) - elif isinstance(msal_auth_client, ConfidentialClientApplication): - logger.info("Acquiring token using Confidential Client Application.") - auth_result_payload = await _async_acquire_token_for_client( - msal_auth_client, scopes=local_scopes + with spans.start_span_auth_get_access_token( + scopes, + self._msal_configuration.AUTH_TYPE, + ): + logger.debug( + f"Requesting access token for resource: {resource_url}, scopes: {scopes}" ) - else: - auth_result_payload = None + valid_uri, instance_uri = self._uri_validator(resource_url) + if not valid_uri: + raise ValueError(str(authentication_errors.InvalidInstanceUrl)) + assert instance_uri is not None # for mypy + + local_scopes = self._resolve_scopes_list(instance_uri, scopes) + msal_auth_client = self._get_client() + + if isinstance(msal_auth_client, ManagedIdentityClient): + logger.info("Acquiring token using Managed Identity Client.") + auth_result_payload = await _async_acquire_token_for_client( + msal_auth_client, resource=resource_url + ) + elif isinstance(msal_auth_client, ConfidentialClientApplication): + logger.info("Acquiring token using Confidential Client Application.") + auth_result_payload = await _async_acquire_token_for_client( + msal_auth_client, scopes=local_scopes + ) + else: + auth_result_payload = None - res = auth_result_payload.get("access_token") if auth_result_payload else None - if not res: - logger.error("Failed to acquire token for resource %s", auth_result_payload) - raise ValueError( - authentication_errors.FailedToAcquireToken.format( - str(auth_result_payload) + res = auth_result_payload.get("access_token") if auth_result_payload else None + if not res: + logger.error("Failed to acquire token for resource %s", auth_result_payload) + raise ValueError( + authentication_errors.FailedToAcquireToken.format( + str(auth_result_payload) + ) ) - ) - return res + return res async def acquire_token_on_behalf_of( self, scopes: list[str], user_assertion: str @@ -112,44 +117,44 @@ async def acquire_token_on_behalf_of( :param user_assertion: The user assertion token. :return: The access token as a string. """ - - msal_auth_client = self._get_client() - if isinstance(msal_auth_client, ManagedIdentityClient): - logger.error( - "Attempted on-behalf-of flow with Managed Identity authentication." - ) - raise NotImplementedError( - str(authentication_errors.OnBehalfOfFlowNotSupportedManagedIdentity) - ) - elif isinstance(msal_auth_client, ConfidentialClientApplication): - # TODO: Handling token error / acquisition failed - - # MSAL in Python does not support async, so we use asyncio.to_thread to run it in - # a separate thread and avoid blocking the event loop - token = await asyncio.to_thread( - lambda: msal_auth_client.acquire_token_on_behalf_of( - scopes=scopes, user_assertion=user_assertion - ) - ) - - if "access_token" not in token: + with spans.start_span_auth_acquire_token_on_behalf_of(): + msal_auth_client = self._get_client() + if isinstance(msal_auth_client, ManagedIdentityClient): logger.error( - f"Failed to acquire token on behalf of user: {user_assertion}" + "Attempted on-behalf-of flow with Managed Identity authentication." ) - raise ValueError( - authentication_errors.FailedToAcquireToken.format(str(token)) + raise NotImplementedError( + str(authentication_errors.OnBehalfOfFlowNotSupportedManagedIdentity) + ) + elif isinstance(msal_auth_client, ConfidentialClientApplication): + # TODO: Handling token error / acquisition failed + + # MSAL in Python does not support async, so we use asyncio.to_thread to run it in + # a separate thread and avoid blocking the event loop + token = await asyncio.to_thread( + lambda: msal_auth_client.acquire_token_on_behalf_of( + scopes=scopes, user_assertion=user_assertion + ) ) - return token["access_token"] + if "access_token" not in token: + logger.error( + f"Failed to acquire token on behalf of user: {user_assertion}" + ) + raise ValueError( + authentication_errors.FailedToAcquireToken.format(str(token)) + ) - logger.error( - f"On-behalf-of flow is not supported with the current authentication type: {msal_auth_client.__class__.__name__}" - ) - raise NotImplementedError( - authentication_errors.OnBehalfOfFlowNotSupportedAuthType.format( - msal_auth_client.__class__.__name__ + return token["access_token"] + + logger.error( + f"On-behalf-of flow is not supported with the current authentication type: {msal_auth_client.__class__.__name__}" + ) + raise NotImplementedError( + authentication_errors.OnBehalfOfFlowNotSupportedAuthType.format( + msal_auth_client.__class__.__name__ + ) ) - ) @staticmethod def _resolve_authority( @@ -339,78 +344,79 @@ async def get_agentic_instance_token( :return: A tuple containing the agentic instance token and the agent application token. :rtype: tuple[str, str] """ + with spans.start_span_auth_get_agentic_instance_token(agent_app_instance_id): - if not agent_app_instance_id: - raise ValueError( - str(authentication_errors.AgentApplicationInstanceIdRequired) - ) - - logger.info( - "Attempting to get agentic instance token from agent_app_instance_id %s", - agent_app_instance_id, - ) - agent_token_result = await self.get_agentic_application_token( - tenant_id, agent_app_instance_id - ) + if not agent_app_instance_id: + raise ValueError( + str(authentication_errors.AgentApplicationInstanceIdRequired) + ) - if not agent_token_result: - logger.error( - "Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", + logger.info( + "Attempting to get agentic instance token from agent_app_instance_id %s", agent_app_instance_id, ) - raise Exception( - authentication_errors.FailedToAcquireAgenticInstanceToken.format( - agent_app_instance_id - ) + agent_token_result = await self.get_agentic_application_token( + tenant_id, agent_app_instance_id ) - authority = MsalAuth._resolve_authority(self._msal_configuration, tenant_id) + if not agent_token_result: + logger.error( + "Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", + agent_app_instance_id, + ) + raise Exception( + authentication_errors.FailedToAcquireAgenticInstanceToken.format( + agent_app_instance_id + ) + ) - instance_app = ConfidentialClientApplication( - client_id=agent_app_instance_id, - authority=authority, - client_credential={"client_assertion": agent_token_result}, - # token_cache=self._token_cache, - ) + authority = MsalAuth._resolve_authority(self._msal_configuration, tenant_id) - agentic_instance_token = await _async_acquire_token_for_client( - instance_app, ["api://AzureAdTokenExchange/.default"] - ) + instance_app = ConfidentialClientApplication( + client_id=agent_app_instance_id, + authority=authority, + client_credential={"client_assertion": agent_token_result}, + # token_cache=self._token_cache, + ) - if not agentic_instance_token: - logger.error( - "Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", - agent_app_instance_id, + agentic_instance_token = await _async_acquire_token_for_client( + instance_app, ["api://AzureAdTokenExchange/.default"] ) - raise Exception( - authentication_errors.FailedToAcquireAgenticInstanceToken.format( - agent_app_instance_id + + if not agentic_instance_token: + logger.error( + "Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", + agent_app_instance_id, + ) + raise Exception( + authentication_errors.FailedToAcquireAgenticInstanceToken.format( + agent_app_instance_id + ) ) - ) - # future scenario where we don't know the blueprint id upfront + # future scenario where we don't know the blueprint id upfront - token = agentic_instance_token.get("access_token") - if not token: - logger.error( - "Failed to acquire agentic instance token, %s", agentic_instance_token - ) - raise ValueError( - authentication_errors.FailedToAcquireToken.format( - str(agentic_instance_token) + token = agentic_instance_token.get("access_token") + if not token: + logger.error( + "Failed to acquire agentic instance token, %s", agentic_instance_token ) - ) - - logger.debug( - "Agentic blueprint id: %s", - _DeferredString( - lambda: jwt.decode(token, options={"verify_signature": False}).get( - "xms_par_app_azp" + raise ValueError( + authentication_errors.FailedToAcquireToken.format( + str(agentic_instance_token) + ) ) - ), - ) - return agentic_instance_token["access_token"], agent_token_result + logger.debug( + "Agentic blueprint id: %s", + _DeferredString( + lambda: jwt.decode(token, options={"verify_signature": False}).get( + "xms_par_app_azp" + ) + ), + ) + + return agentic_instance_token["access_token"], agent_token_result async def get_agentic_user_token( self, @@ -430,76 +436,77 @@ async def get_agentic_user_token( :return: The agentic user token, or None if not found. :rtype: Optional[str] """ - if not agent_app_instance_id or not agentic_user_id: - raise ValueError( - str(authentication_errors.AgentApplicationInstanceIdAndUserIdRequired) - ) - - logger.info( - "Attempting to get agentic user token from agent_app_instance_id %s and agentic_user_id %s", - agent_app_instance_id, - agentic_user_id, - ) - instance_token, agent_token = await self.get_agentic_instance_token( - tenant_id, agent_app_instance_id - ) + with spans.start_span_get_agentic_user_token(agent_app_instance_id, agentic_user_id, scopes): + if not agent_app_instance_id or not agentic_user_id: + raise ValueError( + str(authentication_errors.AgentApplicationInstanceIdAndUserIdRequired) + ) - if not instance_token or not agent_token: - logger.error( - "Failed to acquire instance token or agent token for agent_app_instance_id %s and agentic_user_id %s", + logger.info( + "Attempting to get agentic user token from agent_app_instance_id %s and agentic_user_id %s", agent_app_instance_id, agentic_user_id, ) - raise Exception( - authentication_errors.FailedToAcquireInstanceOrAgentToken.format( - agent_app_instance_id, agentic_user_id - ) + instance_token, agent_token = await self.get_agentic_instance_token( + tenant_id, agent_app_instance_id ) - authority = MsalAuth._resolve_authority(self._msal_configuration, tenant_id) - - instance_app = ConfidentialClientApplication( - client_id=agent_app_instance_id, - authority=authority, - client_credential={"client_assertion": agent_token}, - # token_cache=self._token_cache, - ) + if not instance_token or not agent_token: + logger.error( + "Failed to acquire instance token or agent token for agent_app_instance_id %s and agentic_user_id %s", + agent_app_instance_id, + agentic_user_id, + ) + raise Exception( + authentication_errors.FailedToAcquireInstanceOrAgentToken.format( + agent_app_instance_id, agentic_user_id + ) + ) - logger.info( - "Acquiring agentic user token for agent_app_instance_id %s and agentic_user_id %s", - agent_app_instance_id, - agentic_user_id, - ) - # MSAL in Python does not support async, so we use asyncio.to_thread to run it in - # a separate thread and avoid blocking the event loop - auth_result_payload = await _async_acquire_token_for_client( - instance_app, - scopes, - data={ - "user_id": agentic_user_id, - "user_federated_identity_credential": instance_token, - "grant_type": "user_fic", - }, - ) + authority = MsalAuth._resolve_authority(self._msal_configuration, tenant_id) - if not auth_result_payload: - logger.error( - "Failed to acquire agentic user token for agent_app_instance_id %s and agentic_user_id %s, %s", - agent_app_instance_id, - agentic_user_id, - auth_result_payload, + instance_app = ConfidentialClientApplication( + client_id=agent_app_instance_id, + authority=authority, + client_credential={"client_assertion": agent_token}, + # token_cache=self._token_cache, ) - return None - access_token = auth_result_payload.get("access_token") - if not access_token: - logger.error( - "Failed to acquire agentic user token for agent_app_instance_id %s and agentic_user_id %s, %s", + logger.info( + "Acquiring agentic user token for agent_app_instance_id %s and agentic_user_id %s", agent_app_instance_id, agentic_user_id, - auth_result_payload, ) - return None + # MSAL in Python does not support async, so we use asyncio.to_thread to run it in + # a separate thread and avoid blocking the event loop + auth_result_payload = await _async_acquire_token_for_client( + instance_app, + scopes, + data={ + "user_id": agentic_user_id, + "user_federated_identity_credential": instance_token, + "grant_type": "user_fic", + }, + ) + + if not auth_result_payload: + logger.error( + "Failed to acquire agentic user token for agent_app_instance_id %s and agentic_user_id %s, %s", + agent_app_instance_id, + agentic_user_id, + auth_result_payload, + ) + return None + + access_token = auth_result_payload.get("access_token") + if not access_token: + logger.error( + "Failed to acquire agentic user token for agent_app_instance_id %s and agentic_user_id %s, %s", + agent_app_instance_id, + agentic_user_id, + auth_result_payload, + ) + return None - logger.info("Acquired agentic user token response.") - return access_token + logger.info("Acquired agentic user token response.") + return access_token diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/_metrics.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/_metrics.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py new file mode 100644 index 00000000..25c958c2 --- /dev/null +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py @@ -0,0 +1,11 @@ +# Spans + +SPAN_AUTH_GET_ACCESS_TOKEN = "agents.auth.getAccessToken" +SPAN_AUTH_ACQUIRE_TOKEN_ON_BEHALF_OF = "agents.auth.acquireTokenOnBehalfOf" +SPAN_AUTH_GET_AGENTIC_INSTANCE_TOKEN = "agents.auth.getAgenticInstanceToken" +SPAN_AUTH_GET_AGENTIC_USER_TOKEN = "agents.auth.getAgenticUserToken" + +# Metrics + +METRIC_AUTH_TOKEN_REQUEST_DURATION = "agents.auth.token.request.duration" +METRIC_AUTH_TOKEN_REQUESTS = "agents.auth.token.requests" \ No newline at end of file diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py new file mode 100644 index 00000000..cde0804d --- /dev/null +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py @@ -0,0 +1,43 @@ +from contextlib import contextmanager +from collections.abc import Iterator + +from microsoft_agents.hosting.core.telemetry import agents_telemetry, constants as common_constants + +from . import constants, _metrics + +def _format_scopes(scopes: list[str]) -> str: + return ",".join(scopes) + +@contextmanager +def start_span_auth_get_access_token(scopes: list[str], auth_type: str) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_GET_ACCESS_TOKEN) as span: + span.set_attributes({ + common_constants.ATTR_AUTH_SCOPES: _format_scopes(scopes), + common_constants.ATTR_AUTH_TYPE: auth_type, + }) + yield + + +@contextmanager +def start_span_auth_acquire_token_on_behalf_of(scopes: list[str]) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_ACQUIRE_TOKEN_ON_BEHALF_OF) as span: + span.set_attribute(common_constants.ATTR_AUTH_SCOPES, _format_scopes(scopes)) + yield + + +@contextmanager +def start_span_auth_get_agentic_instance_token(agentic_instance_id: str) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_GET_AGENTIC_INSTANCE_TOKEN) as span: + span.set_attribute(common_constants.ATTR_AGENTIC_INSTANCE_ID, agentic_instance_id) + yield + + +@contextmanager +def start_span_auth_get_agentic_user_token(agentic_instance_id: str, agentic_user_id: str, scopes: list[str]) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_GET_AGENTIC_USER_TOKEN): + span.set_attributes({ + common_constants.ATTR_AGENTIC_INSTANCE_ID: agentic_instance_id, + common_constants.ATTR_AGENTIC_USER_ID: agentic_user_id, + common_constants.ATTR_AUTH_SCOPES: scopes, + }) + yield \ 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 0e61094b..3103f6df 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,7 +4,7 @@ from threading import Lock from typing import TypeVar -from microsoft_agents.hosting.core.telemetry import agents_telemetry +from microsoft_agents.hosting.core.telemetry import spans from ._type_aliases import JSON from .storage import Storage @@ -26,10 +26,10 @@ async def read( raise ValueError("Storage.read(): Keys are required when reading.") if not target_cls: raise ValueError("Storage.read(): target_cls cannot be None.") - - result: dict[str, StoreItem] = {} - with self._lock: - with agents_telemetry.instrument_storage_op("read"): + + with spans.start_span_storage_read(len(keys)): + result: dict[str, StoreItem] = {} + with self._lock: for key in keys: if key == "": raise ValueError("MemoryStorage.read(): key cannot be empty") @@ -50,9 +50,9 @@ async def read( async def write(self, changes: dict[str, StoreItem]): if not changes: raise ValueError("MemoryStorage.write(): changes cannot be None") - - with self._lock: - with agents_telemetry.instrument_storage_op("write"): + + with spans.start_spans_storage_write(len(changes)): + with self._lock: for key in changes: if key == "": raise ValueError("MemoryStorage.write(): key cannot be empty") @@ -62,8 +62,8 @@ async def delete(self, keys: list[str]): if not keys: raise ValueError("Storage.delete(): Keys are required when deleting.") - with self._lock: - with agents_telemetry.instrument_storage_op("delete"): + with spans.start_span_storage_delete(len(keys)): + with self._lock: for key in keys: if key == "": raise ValueError("MemoryStorage.delete(): key cannot be empty") 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 ad83c28a..339f24e9 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,7 +5,7 @@ from abc import ABC, abstractmethod from asyncio import gather -from microsoft_agents.hosting.core.telemetry import agents_telemetry +from microsoft_agents.hosting.core.telemetry import spans from ._type_aliases import JSON from .store_item import StoreItem @@ -70,10 +70,10 @@ async def read( raise ValueError("Storage.read(): Keys are required when reading.") if not target_cls: raise ValueError("Storage.read(): target_cls cannot be None.") + + with spans.start_span_storage_read(len(keys)): + await self.initialize() - await self.initialize() - - with agents_telemetry.instrument_storage_op("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] ) @@ -87,10 +87,10 @@ async def _write_item(self, key: str, value: StoreItemT) -> None: async def write(self, changes: dict[str, StoreItemT]) -> None: if not changes: raise ValueError("Storage.write(): Changes are required when writing.") + + with spans.start_span_storage_write(len(changes)): + await self.initialize() - await self.initialize() - - with agents_telemetry.instrument_storage_op("write"): await gather(*[self._write_item(key, value) for key, value in changes.items()]) @abstractmethod @@ -102,7 +102,7 @@ async def delete(self, keys: list[str]) -> None: if not keys: raise ValueError("Storage.delete(): Keys are required when deleting.") - await self.initialize() + with spans.start_span_storage_delete(len(keys)): + await self.initialize() - with agents_telemetry.instrument_storage_op("delete"): await gather(*[self._delete_item(key) for key in keys]) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py index 1620c240..e9a7a4e5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py @@ -7,7 +7,7 @@ # # This design hides the "mess" of telemetry to one location rather than throughout the codebase. -from ._agents_temetry import agents_telemetry +from ._agents_telemetry import agents_telemetry from .configure_telemetry import configure_telemetry from .constants import ( SERVICE_NAME, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py index e1187161..7d47cbbf 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py @@ -45,11 +45,6 @@ SPAN_STORAGE_WRITE = "agents.storage.write" SPAN_STORAGE_DELETE = "agents.storage.delete" -SPAN_AUTH_GET_ACCESS_TOKEN = "agents.auth.getAccessToken" -SPAN_AUTH_ACQUIRE_TOKEN_ON_BEHALF_OF = "agents.auth.acquireTokenOnBehalfOf" -SPAN_AUTH_GET_AGENTIC_INSTANCE_TOKEN = "agents.auth.getAgenticInstanceToken" -SPAN_AUTH_GET_AGENTIC_USER_TOKEN = "agents.auth.getAgenticUserToken" - SPAN_TURN_SEND_ACTIVITY = "agents.turn.sendActivity" SPAN_TURN_UPDATE_ACTIVITY = "agents.turn.updateActivity" SPAN_TURN_DELETE_ACTIVITY = "agents.turn.deleteActivity" @@ -66,7 +61,6 @@ METRIC_TURN_TOTAL = "agents.turn.total" METRIC_TURN_ERRORS = "agents.turn.errors" -METRIC_AUTH_TOKEN_REQUESTS = "agents.auth.token.requests" METRIC_CONNECTOR_REQUESTS_TOTAL = "agents.connector.requests" METRIC_STORAGE_OPERATION_TOTAL = "agents.storage.operations.total" @@ -76,7 +70,6 @@ METRIC_ADAPTER_PROCESS_DURATION = "agents.adapter.process.duration" METRIC_CONNECTOR_REQUEST_DURATION = "agents.connector.request.duration" METRIC_STORAGE_OPERATION_DURATION = "agents.storage.operation.duration" -METRIC_AUTH_TOKEN_REQUEST_DURATION = "agents.auth.token.request.duration" # Attributes @@ -93,9 +86,10 @@ ATTR_ACTIVITY_COUNT = "activities.count" ATTR_ACTIVITY_TYPE = "activity.type" ATTR_AGENTIC_USER_ID = "agentic.user.id" -ATTR_AGENTIC_APP_INSTANCE_ID = "agentic.instance.id" +ATTR_AGENTIC_INSTANCE_ID = "agentic.instanceId" ATTR_ATTACHMENT_ID = "attachment.id" ATTR_AUTH_SCOPES = "auth.scopes" +ATTR_AUTH_TYPE = "auth.method" ATTR_CONVERSATION_ID = "conversation.id" @@ -110,4 +104,4 @@ # VALUES -UNKNOWN = "unknown" \ No newline at end of file +UNKNOWN = "unknown" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py index 0804b6d1..800f0fa1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py @@ -212,29 +212,6 @@ def start_span_storage_delete(self) -> Iterator[None]: # Auth # -@contextmanager -def start_span_auth_get_access_token(self) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_GET_ACCESS_TOKEN): - yield - - -@contextmanager -def start_span_auth_acquire_token_on_behalf_of(self) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_ACQUIRE_TOKEN_ON_BEHALF_OF): - yield - - -@contextmanager -def start_span_auth_get_agentic_instance_token(self) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_GET_AGENTIC_INSTANCE_TOKEN): - yield - - -@contextmanager -def start_span_auth_get_agentic_user_token(self) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_GET_AGENTIC_USER_TOKEN): - yield - @contextmanager def start_span_agent_post_activity(self) -> Iterator[None]: From 8752b0dace99219ca381ba17f18ba5395c83f4bc Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 5 Mar 2026 14:20:49 -0800 Subject: [PATCH 20/55] Adding telemetry hooks to TurnContext --- .../core/telemetry/_agents_telemetry.py | 8 +- .../hosting/core/telemetry/_metrics.py | 2 +- .../hosting/core/telemetry/spans.py | 164 ++---------------- .../hosting/core/turn_context.py | 41 +++-- 4 files changed, 39 insertions(+), 176 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py index 45f3cded..e82d72af 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py @@ -10,7 +10,7 @@ from opentelemetry import metrics, trace from opentelemetry.trace import Tracer, Span -from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents.activity import TurnContextProtocol from . import constants @@ -48,7 +48,7 @@ def meter(self) -> Meter: """Returns the OpenTelemetry meter instance for recording metrics""" return self._meter - def _extract_attributes_from_context(self, turn_context: TurnContext) -> dict: + def _extract_attributes_from_context(self, turn_context: TurnContextProtocol) -> dict: """Helper method to extract common attributes from the TurnContext for span and metric recording""" # This can be expanded to extract common attributes for spans and metrics from the context @@ -69,7 +69,7 @@ def _extract_attributes_from_context(self, turn_context: TurnContext) -> dict: def start_as_current_span( self, span_name: str, - turn_context: TurnContext | None = None, + turn_context: TurnContextProtocol | None = None, ) -> Iterator[Span]: """Context manager for starting a new span with the given name and setting attributes from the TurnContext if provided @@ -98,7 +98,7 @@ def start_as_current_span( def start_timed_span( self, span_name: str, - turn_context: TurnContext | None = None, + turn_context: TurnContextProtocol | None = None, callback: _TimedSpanCallback | None = None ) -> Iterator[Span]: """Context manager for starting a timed span that records duration and success/failure status, and invokes a callback with the results diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py index 89745b17..33b31abe 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py @@ -43,7 +43,7 @@ # Connectors CONNECTOR_REQUEST_TOTAL = agents_telemetry.meter.create_counter( - constants.METRIC_CONNECTOR_REQUEST_TOTAL, + constants.METRIC_CONNECTOR_REQUESTS_TOTAL, "request", description="Total number of connector requests made by the agent", ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py index 800f0fa1..17933022 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py @@ -3,8 +3,7 @@ from opentelemetry.trace import Span -from microsoft_agents.activity import Activity -from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents.activity import Activity, TurnContextProtocol from . import _metrics, constants from ._agents_telemetry import agents_telemetry @@ -74,7 +73,7 @@ def start_span_adapter_continue_conversation(activity: Activity) -> Iterator[Non # @contextmanager -def start_span_app_on_turn(turn_context: TurnContextProtocolProtocol) -> Iterator[None]: +def start_span_app_on_turn(turn_context: TurnContextProtocol) -> Iterator[None]: """Context manager for recording an app on_turn call, including success/failure and duration""" activity = turn_context.activity @@ -201,11 +200,11 @@ def start_span_storage_read(num_keys: int) -> Iterator[None]: with _start_span_storage_op(constants.SPAN_STORAGE_READ, num_keys): yield @contextmanager -def start_span_storage_write(self) -> Iterator[None]: +def start_span_storage_write(num_keys: int) -> Iterator[None]: with _start_span_storage_op(constants.SPAN_STORAGE_WRITE, num_keys): yield @contextmanager -def start_span_storage_delete(self) -> Iterator[None]: +def start_span_storage_delete(num_keys: int) -> Iterator[None]: with _start_span_storage_op(constants.SPAN_STORAGE_DELETE, num_keys): yield # @@ -219,164 +218,23 @@ def start_span_agent_post_activity(self) -> Iterator[None]: yield -@contextmanager -def start_span_turn_send_activity(self) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_TURN_SEND_ACTIVITY): - yield - +# +# TurnContext +# @contextmanager -def start_span_turn_send_activities(self) -> Iterator[None]: +def start_span_turn_context_send_activity(self) -> Iterator[None]: with agents_telemetry.start_as_current_span(constants.SPAN_TURN_SEND_ACTIVITY): yield @contextmanager -def start_span_turn_update_activity(self) -> Iterator[None]: +def start_span_turn_context_update_activity(self) -> Iterator[None]: with agents_telemetry.start_as_current_span(constants.SPAN_TURN_UPDATE_ACTIVITY): yield @contextmanager -def start_span_turn_delete_activity(self) -> Iterator[None]: +def start_span_turn_context_delete_activity(self) -> Iterator[None]: with agents_telemetry.start_as_current_span(constants.SPAN_TURN_DELETE_ACTIVITY): - yield - -# def start_span_agent_turn(turn_context: TurnContextProtocol) -> Iterator[Span]: -# """Context manager for recording an agent turn, including success/failure and duration""" - -# def callback(span: Span, duration: float, error: Exception | None): -# if error is None: -# _metrics.TURN_TOTAL.add(1) -# _metrics.TURN_DURATION.record(duration, { -# "conversation.id": turn_context.activity.conversation.id if turn_context.activity.conversation else "unknown", -# "channel.id": str(turn_context.activity.channel_id), -# }) -# else: -# self._turn_errors.add(1) - -# with self._start_timed_span( -# constants.AGENT_TURN_OPERATION_NAME, -# turn_context=turn_context, -# callback=callback, -# ) as span: -# yield span # execute the turn operation in the with block - -# @contextmanager -# def start_span_app_run(turn_context: TurnContextProtocol) -> Iterator[Span]: -# """Context manager for recording an app run, including success/failure and duration""" - -# with agents_telemetry.start_as_current_span( -# constants.SPAN_APP_RUN, -# turn_context=turn_context -# ) -# with self._start_timed_span( -# constants.APP_RUN_OPERATION_NAME, -# callback=callback -# ) as span: -# span.set_attribute("app.name", app_name) -# yield span # execute the app run operation in the with block - - - -# def start_span_adapter_process(self): -# """Context manager for recording adapter processing operations""" - -# def callback(span: Span, duration: float, error: Exception | None): -# if error is None: -# self._adapter_process_duration.record(duration) - -# with self._start_timed_span( -# constants.ADAPTER_PROCESS_OPERATION_NAME, -# callback=callback -# ) as span: -# yield span # execute the adapter processing in the with block - -# def _start_span_adapter_activity_op(span_name: str, conversation_id: str | None = None): - - -# @contextmanager -# def _instrument_adapter_activity_op(span_name: str): -# """Context manager for recording adapter activity operations ()""" -# with self.start_as_current_span(span_name) as span: -# yield span - -# @contextmanager -# def instrument_adapter_send_activities(activity_count: int, conversation_id: str) -> None: -# """Context manager for recording adapter SendActivities calls""" -# with self._instrument_adapter_activity_op(constants.SPAN_ADAPTER_SEND_ACTIVITIES): -# yield - -# @contextmanger -# def instrument_adapter_update_activity(activity_id: str, conversation_id: str) -> None: -# """Context manager for recording adapter UpdateActivity calls""" -# with self._instrument_adapter_activity_op(constants.SPAN_ADAPTER_UPDATE_ACTIVITY): -# yield - -# @contextmanager - - -# @contextmanager -# def instrument_storage_op( -# -# operation_type: str, -# num_keys: int, -# ): -# """Context manager for recording storage operations""" - -# def callback(span: Span, duration: float, error: Exception | None): -# if error is None: -# self._storage_operations.add(1, {"operation": operation_type}) -# self._storage_operation_duration.record(duration, {"operation": operation_type}) - -# with self._start_timed_span( -# constants.STORAGE_OPERATION_NAME_FORMAT.format(operation_type=operation_type), -# callback=callback -# ) as span: -# span.set_attribute(constants.ATTR_NUM_KEYS, num_keys) -# yield span # execute the storage operation in the with block - -# @contextmanager -# def instrument_connector_op(operation_name: str): -# """Context manager for recording connector requests""" - -# def callback(span: Span, duration: float, error: Exception | None): -# if error is None: -# self._connector_request_total.add(1, {"operation": operation_name}) -# self._connector_request_duration.record(duration, {"operation": operation_name}) - -# with self._start_timed_span( -# constants.CONNECTOR_REQUEST_OPERATION_NAME_FORMAT.format(operation_name=operation_name), -# callback=callback -# ) as span: -# yield span # execute the connector request in the with block - -# @contextmanager -# def instrument_auth_token_request( -# -# scopes: list[str] | None = None, -# agentic_user_id: str | None = None, -# agentic_app_instance_id: str | None = None -# ): -# """Context manager for recording auth token retrieval operations""" - -# def callback(span: Span, duration: float, error: Exception | None): -# if error is None: -# self._auth_token_request_total.add(1) -# self._auth_token_requests_duration.record(duration) - -# with self._start_timed_span( -# constants.AUTH_TOKEN_REQUEST_OPERATION_NAME, -# callback=callback -# ) as span: - -# attributes = { -# constants.ATTR_AUTH_SCOPES: scopes, -# constants.ATTR_AGENTIC_USER_ID: agentic_user_id, -# constants.ATTR_AGENTIC_APP_INSTANCE_ID: agentic_app_instance_id, -# } -# _remove_nones(attributes) - -# span.set_attributes(attributes) - -# yield span # execute the auth token retrieval operation in the with block \ No newline at end of file + yield \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index a3320b4b..e1974635 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -20,6 +20,7 @@ ) from microsoft_agents.activity.entity.entity_types import EntityTypes from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity +from microsoft_agents.hosting.core.telemetry import spans class TurnContext(TurnContextProtocol): @@ -207,8 +208,10 @@ async def send_activity( if speak: activity_or_text.speak = speak - result = await self.send_activities([activity_or_text]) - return result[0] if result else None + with spans.start_span_turn_context_send_activity(self.activity): + + result = await self.send_activities([activity_or_text]) + return result[0] if result else None async def send_activities( self, activities: list[Activity] @@ -268,13 +271,14 @@ async def update_activity(self, activity: Activity): :param activity: :return: """ - reference = self.activity.get_conversation_reference() + with spans.start_span_turn_context_update_activity(self.activity): + reference = self.activity.get_conversation_reference() - return await self._emit( - self._on_update_activity, - TurnContext.apply_conversation_reference(activity, reference), - self.adapter.update_activity(self, activity), - ) + return await self._emit( + self._on_update_activity, + TurnContext.apply_conversation_reference(activity, reference), + self.adapter.update_activity(self, activity), + ) async def delete_activity(self, id_or_reference: str | ConversationReference): """ @@ -282,16 +286,17 @@ async def delete_activity(self, id_or_reference: str | ConversationReference): :param id_or_reference: :return: """ - if isinstance(id_or_reference, str): - reference = self.activity.get_conversation_reference() - reference.activity_id = id_or_reference - else: - reference = id_or_reference - return await self._emit( - self._on_delete_activity, - reference, - self.adapter.delete_activity(self, reference), - ) + with spans.start_span_turn_context_delete_activity(self.activity): + if isinstance(id_or_reference, str): + reference = self.activity.get_conversation_reference() + reference.activity_id = id_or_reference + else: + reference = id_or_reference + return await self._emit( + self._on_delete_activity, + reference, + self.adapter.delete_activity(self, reference), + ) def on_send_activities(self, handler) -> "TurnContext": """ From f03fd05d59b73f16e989e79454d3c4576a08504a Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 5 Mar 2026 14:37:44 -0800 Subject: [PATCH 21/55] Addressing test cases --- .../core/telemetry/_agents_telemetry.py | 1 - .../hosting/core/telemetry/spans.py | 23 +-- .../hosting/core/turn_context.py | 6 +- .../{observability => telemetry}/__init__.py | 0 .../test_agents_telemetry.py} | 139 ++++++------------ .../test_configure_telemetry.py | 0 6 files changed, 53 insertions(+), 116 deletions(-) rename tests/hosting_core/{observability => telemetry}/__init__.py (100%) rename tests/hosting_core/{observability/test_agent_telemetry.py => telemetry/test_agents_telemetry.py} (53%) rename tests/hosting_core/{observability => telemetry}/test_configure_telemetry.py (100%) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py index e82d72af..59c24fc0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py @@ -87,7 +87,6 @@ def start_as_current_span( use the provided context managers like `instrument_agent_turn`, `instrument_adapter_process`, etc., which will automatically record relevant metrics and handle success/failure cases. """ - with self._tracer.start_as_current_span(span_name) as span: if turn_context is not None: attributes = self._extract_attributes_from_context(turn_context) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py index 17933022..f4ee2689 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py @@ -207,34 +207,23 @@ def start_span_storage_write(num_keys: int) -> Iterator[None]: def start_span_storage_delete(num_keys: int) -> Iterator[None]: with _start_span_storage_op(constants.SPAN_STORAGE_DELETE, num_keys): yield -# -# Auth -# - - -@contextmanager -def start_span_agent_post_activity(self) -> Iterator[None]: - with agents_telemetry.start_as_current_span("agents.agentClient.postActivity"): - yield - - # # TurnContext # @contextmanager -def start_span_turn_context_send_activity(self) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_TURN_SEND_ACTIVITY): +def start_span_turn_context_send_activity(turn_context: TurnContextProtocol) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_TURN_SEND_ACTIVITY, turn_context): yield @contextmanager -def start_span_turn_context_update_activity(self) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_TURN_UPDATE_ACTIVITY): +def start_span_turn_context_update_activity(turn_context: TurnContextProtocol) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_TURN_UPDATE_ACTIVITY, turn_context): yield @contextmanager -def start_span_turn_context_delete_activity(self) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_TURN_DELETE_ACTIVITY): +def start_span_turn_context_delete_activity(turn_context: TurnContextProtocol) -> Iterator[None]: + with agents_telemetry.start_as_current_span(constants.SPAN_TURN_DELETE_ACTIVITY, turn_context): yield \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index e1974635..6c7cb10c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -208,7 +208,7 @@ async def send_activity( if speak: activity_or_text.speak = speak - with spans.start_span_turn_context_send_activity(self.activity): + with spans.start_span_turn_context_send_activity(self): result = await self.send_activities([activity_or_text]) return result[0] if result else None @@ -271,7 +271,7 @@ async def update_activity(self, activity: Activity): :param activity: :return: """ - with spans.start_span_turn_context_update_activity(self.activity): + with spans.start_span_turn_context_update_activity(self): reference = self.activity.get_conversation_reference() return await self._emit( @@ -286,7 +286,7 @@ async def delete_activity(self, id_or_reference: str | ConversationReference): :param id_or_reference: :return: """ - with spans.start_span_turn_context_delete_activity(self.activity): + with spans.start_span_turn_context_delete_activity(self): if isinstance(id_or_reference, str): reference = self.activity.get_conversation_reference() reference.activity_id = id_or_reference diff --git a/tests/hosting_core/observability/__init__.py b/tests/hosting_core/telemetry/__init__.py similarity index 100% rename from tests/hosting_core/observability/__init__.py rename to tests/hosting_core/telemetry/__init__.py diff --git a/tests/hosting_core/observability/test_agent_telemetry.py b/tests/hosting_core/telemetry/test_agents_telemetry.py similarity index 53% rename from tests/hosting_core/observability/test_agent_telemetry.py rename to tests/hosting_core/telemetry/test_agents_telemetry.py index 160f0977..3aa1494f 100644 --- a/tests/hosting_core/observability/test_agent_telemetry.py +++ b/tests/hosting_core/telemetry/test_agents_telemetry.py @@ -8,9 +8,11 @@ from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import InMemoryMetricReader +from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.core.telemetry import ( agents_telemetry, constants, + spans as _spans, ) @pytest.fixture(scope="module") @@ -52,9 +54,10 @@ def clear(test_exporter, test_metric_reader): test_metric_reader.force_flush() -def _build_turn_context(): +def _build_turn_context(mocker): activity = SimpleNamespace( type="message", + id="activity-1", from_property=SimpleNamespace(id="user-1"), recipient=SimpleNamespace(id="bot-1"), conversation=SimpleNamespace(id="conversation-1"), @@ -62,7 +65,10 @@ def _build_turn_context(): text="Hello!", ) activity.is_agentic_request = lambda: False - return SimpleNamespace(activity=activity) + + context = mocker.Mock(spec=TurnContext) + context.activity = activity + return context def _find_metric(metrics_data, metric_name): @@ -100,9 +106,9 @@ def _sum_hist_count(metric, attribute_filter=None): return total -def test_start_as_current_span(test_exporter): +def test_start_as_current_span(mocker, test_exporter): """Test start_as_current_span creates a span with context attributes.""" - context = _build_turn_context() + context = _build_turn_context(mocker) with agents_telemetry.start_as_current_span("test_span", context): pass @@ -120,111 +126,54 @@ def test_start_as_current_span(test_exporter): assert attributes["channel_id"] == "msteams" assert attributes["message.text.length"] == 6 +def test_start_timed_span(mocker, test_exporter): + """Test start_timed_span records success status and callback payload.""" + context = _build_turn_context(mocker) + callback = mocker.Mock() -def test_agent_turn_operation(test_exporter, test_metric_reader): - """Test agent_turn_operation records span and turn metrics.""" - context = _build_turn_context() - - with agents_telemetry.instrument_agent_turn(context): - pass - - spans = test_exporter.get_finished_spans() - assert len(spans) == 1 - assert spans[0].name == constants.AGENT_TURN_OPERATION_NAME - - metric_data = test_metric_reader.get_metrics_data() - turn_total = _sum_counter(_find_metric(metric_data, constants.AGENT_TURN_TOTAL_METRIC_NAME)) - turn_duration_count = _sum_hist_count( - _find_metric(metric_data, constants.AGENT_TURN_DURATION_METRIC_NAME) - ) - - assert turn_total == 1 - assert turn_duration_count == 1 - - -def test_instrument_adapter_process(test_exporter, test_metric_reader): - """Test instrument_adapter_process records span and duration metric.""" - - with agents_telemetry.instrument_adapter_process(): + with agents_telemetry.start_timed_span( + "test_timed_span", + context, + callback=callback, + ): pass - spans = test_exporter.get_finished_spans() - assert len(spans) == 1 - assert spans[0].name == constants.ADAPTER_PROCESS_OPERATION_NAME - - metric_data = test_metric_reader.get_metrics_data() - duration_count = _sum_hist_count( - _find_metric(metric_data, constants.ADAPTER_PROCESS_DURATION_METRIC_NAME) - ) + finished_spans = test_exporter.get_finished_spans() + assert len(finished_spans) == 1 - assert duration_count == 1 + finished_span = finished_spans[0] + assert finished_span.name == "test_timed_span" + assert finished_span.status.status_code == trace.StatusCode.OK + completion_events = [ + event for event in finished_span.events if event.name == "test_timed_span completed" + ] + assert len(completion_events) == 1 + assert completion_events[0].attributes["duration_ms"] >= 0 -def test_instrument_storage_op(test_exporter, test_metric_reader): - """Test instrument_storage_op records span and operation-tagged metrics.""" - op_filter = {"operation": "read"} + callback.assert_called_once() + callback_span, duration_ms, callback_exception = callback.call_args.args + assert callback_span.name == "test_timed_span" + assert duration_ms >= 0 + assert callback_exception is None - with agents_telemetry.instrument_storage_op("read"): - pass - spans = test_exporter.get_finished_spans() - assert len(spans) == 1 - assert spans[0].name == constants.STORAGE_OPERATION_NAME_FORMAT.format(operation_name="read") - - metric_data = test_metric_reader.get_metrics_data() - total = _sum_counter( - _find_metric(metric_data, constants.STORAGE_OPERATION_TOTAL_METRIC_NAME), - attribute_filter=op_filter, - ) - duration_count = _sum_hist_count( - _find_metric(metric_data, constants.STORAGE_OPERATION_DURATION_METRIC_NAME), - attribute_filter=op_filter, - ) - - assert total == 1 - assert duration_count == 1 - -def test_instrument_connector_op(test_exporter, test_metric_reader): - """Test instrument_connector_op records span and connector-tagged metrics.""" - connector_filter = {"operation": "test_connector"} - - with agents_telemetry.instrument_connector_op("test_connector"): - pass - - spans = test_exporter.get_finished_spans() - assert len(spans) == 1 - assert spans[0].name == constants.CONNECTOR_REQUEST_OPERATION_NAME_FORMAT.format(operation_name="test_connector") - - metric_data = test_metric_reader.get_metrics_data() - total = _sum_counter( - _find_metric(metric_data, constants.CONNECTOR_REQUEST_TOTAL_METRIC_NAME), - attribute_filter=connector_filter, - ) - duration_count = _sum_hist_count( - _find_metric(metric_data, constants.CONNECTOR_REQUEST_DURATION_METRIC_NAME), - attribute_filter=connector_filter, - ) - - assert total == 1 - assert duration_count == 1 - -def test_instrument_auth_token_request(test_exporter, test_metric_reader): - """Test instrument_auth_token_request records span and auth token request metrics.""" +def test_start_span_app_on_turn(mocker, test_exporter, test_metric_reader): + """Test agent_turn_operation records span and turn metrics.""" + context = _build_turn_context(mocker) - with agents_telemetry.instrument_auth_token_request(): + with _spans.start_span_app_on_turn(context): pass spans = test_exporter.get_finished_spans() assert len(spans) == 1 - assert spans[0].name == constants.AUTH_TOKEN_REQUEST_OPERATION_NAME + assert spans[0].name == constants.SPAN_APP_RUN metric_data = test_metric_reader.get_metrics_data() - total = _sum_counter( - _find_metric(metric_data, constants.AUTH_TOKEN_REQUEST_TOTAL_METRIC_NAME) - ) - duration_count = _sum_hist_count( - _find_metric(metric_data, constants.AUTH_TOKEN_REQUEST_DURATION_METRIC_NAME) + turn_total = _sum_counter(_find_metric(metric_data, constants.METRIC_TURN_TOTAL)) + turn_duration_count = _sum_hist_count( + _find_metric(metric_data, constants.METRIC_TURN_DURATION) ) - assert total == 1 - assert duration_count == 1 \ No newline at end of file + assert turn_total == 1 + assert turn_duration_count == 1 diff --git a/tests/hosting_core/observability/test_configure_telemetry.py b/tests/hosting_core/telemetry/test_configure_telemetry.py similarity index 100% rename from tests/hosting_core/observability/test_configure_telemetry.py rename to tests/hosting_core/telemetry/test_configure_telemetry.py From 79f9fd14c373776a8f12ef2edef86b6cdc60ae19 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 5 Mar 2026 14:44:48 -0800 Subject: [PATCH 22/55] Cleaning up tests and OTEL integration --- .../{observability => telemetry}/__init__.py | 0 .../test_telemetry.py} | 12 +++++++----- .../authentication/msal/msal_auth.py | 2 +- .../hosting/core/storage/memory_storage.py | 2 +- .../telemetry/test_configure_telemetry.py | 0 5 files changed, 9 insertions(+), 7 deletions(-) rename dev/testing/python-sdk-tests/sdk/hosting-core/{observability => telemetry}/__init__.py (100%) rename dev/testing/python-sdk-tests/sdk/hosting-core/{observability/test_observability.py => telemetry/test_telemetry.py} (90%) delete mode 100644 tests/hosting_core/telemetry/test_configure_telemetry.py diff --git a/dev/testing/python-sdk-tests/sdk/hosting-core/observability/__init__.py b/dev/testing/python-sdk-tests/sdk/hosting-core/telemetry/__init__.py similarity index 100% rename from dev/testing/python-sdk-tests/sdk/hosting-core/observability/__init__.py rename to dev/testing/python-sdk-tests/sdk/hosting-core/telemetry/__init__.py diff --git a/dev/testing/python-sdk-tests/sdk/hosting-core/observability/test_observability.py b/dev/testing/python-sdk-tests/sdk/hosting-core/telemetry/test_telemetry.py similarity index 90% rename from dev/testing/python-sdk-tests/sdk/hosting-core/observability/test_observability.py rename to dev/testing/python-sdk-tests/sdk/hosting-core/telemetry/test_telemetry.py index baf973db..c55c17a4 100644 --- a/dev/testing/python-sdk-tests/sdk/hosting-core/observability/test_observability.py +++ b/dev/testing/python-sdk-tests/sdk/hosting-core/telemetry/test_telemetry.py @@ -7,6 +7,8 @@ from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import InMemoryMetricReader +from microsoft_agents.hosting.core.telemetry import constants + from ...scenarios import load_scenario _SCENARIO = load_scenario("quickstart", use_jwt_middleware=False) @@ -79,13 +81,13 @@ async def test_basic(test_exporter, agent_client): # adapter processing is a key part of the turn, so we should have a span for it assert any( - span.name == "adapter process" + span.name == constants.SPAN_ADAPTER_PROCESS for span in spans ) # storage is read when accessing conversation state assert any( - span.name == "storage read" + span.name == constants.SPAN_STORAGE_READ for span in spans ) @@ -113,15 +115,15 @@ async def test_multiple_users(test_exporter, agent_client): def assert_span_for_user(user_id: str): assert any( - span.name == "agent turn" and span.attributes.get("from.id") == user_id + span.name == constants.SPAN_APP_ON_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(list(filter(lambda span: span.name == "agent turn", spans))) == 2 - assert len(list(filter(lambda span: span.name == "adapter process", spans))) == 2 + assert len(list(filter(lambda span: span.name == "agent", spans))) == 2 + assert len(list(filter(lambda span: span.name == constants.SPANS_ADAPTER_PROCESS, spans))) == 2 @pytest.mark.asyncio @pytest.mark.agent_test(_SCENARIO) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index fa04ebcc..db89b070 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -117,7 +117,7 @@ async def acquire_token_on_behalf_of( :param user_assertion: The user assertion token. :return: The access token as a string. """ - with spans.start_span_auth_acquire_token_on_behalf_of(): + with spans.start_span_auth_acquire_token_on_behalf_of(scopes): msal_auth_client = self._get_client() if isinstance(msal_auth_client, ManagedIdentityClient): logger.error( 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 3103f6df..3ba57a63 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 @@ -51,7 +51,7 @@ async def write(self, changes: dict[str, StoreItem]): if not changes: raise ValueError("MemoryStorage.write(): changes cannot be None") - with spans.start_spans_storage_write(len(changes)): + with spans.start_span_storage_write(len(changes)): with self._lock: for key in changes: if key == "": diff --git a/tests/hosting_core/telemetry/test_configure_telemetry.py b/tests/hosting_core/telemetry/test_configure_telemetry.py deleted file mode 100644 index e69de29b..00000000 From bc2f6925e581241f2397c676b196dbb5dfb22c13 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 5 Mar 2026 16:11:26 -0800 Subject: [PATCH 23/55] Polishing and passing integration tests --- dev/testing/python-sdk-tests/pytest.ini | 2 +- .../{integration => tests}/__init__.py | 0 .../{sdk => tests/integration}/__init__.py | 0 .../integration/test_expect_replies.py | 2 +- .../integration/test_quickstart.py | 3 +- .../{ => tests}/scenarios/__init__.py | 0 .../{ => tests}/scenarios/quickstart.py | 0 .../hosting-core => tests/sdk}/__init__.py | 0 .../sdk/hosting-core}/__init__.py | 0 .../sdk/hosting-core/telemetry/__init__.py | 0 .../hosting-core/telemetry/test_telemetry.py | 10 +-- .../hosting/aiohttp/cloud_adapter.py | 15 ++-- .../hosting/core/app/agent_application.py | 6 +- .../hosting/core/http/_http_adapter_base.py | 57 ++++++------ .../core/telemetry/_agents_telemetry.py | 10 --- .../hosting/core/telemetry/_metrics.py | 16 ++-- .../hosting/core/telemetry/constants.py | 2 +- .../hosting/core/telemetry/spans.py | 88 ++++++++++++++----- .../hosting/fastapi/cloud_adapter.py | 13 ++- .../telemetry/test_agents_telemetry.py | 2 +- 20 files changed, 132 insertions(+), 94 deletions(-) rename dev/testing/python-sdk-tests/{integration => tests}/__init__.py (100%) rename dev/testing/python-sdk-tests/{sdk => tests/integration}/__init__.py (100%) rename dev/testing/python-sdk-tests/{ => tests}/integration/test_expect_replies.py (95%) rename dev/testing/python-sdk-tests/{ => tests}/integration/test_quickstart.py (98%) rename dev/testing/python-sdk-tests/{ => tests}/scenarios/__init__.py (100%) rename dev/testing/python-sdk-tests/{ => tests}/scenarios/quickstart.py (100%) rename dev/testing/python-sdk-tests/{sdk/hosting-core => tests/sdk}/__init__.py (100%) rename dev/testing/python-sdk-tests/{sdk/hosting-core/telemetry => tests/sdk/hosting-core}/__init__.py (100%) create mode 100644 dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/__init__.py rename dev/testing/python-sdk-tests/{ => tests}/sdk/hosting-core/telemetry/test_telemetry.py (93%) diff --git a/dev/testing/python-sdk-tests/pytest.ini b/dev/testing/python-sdk-tests/pytest.ini index 2c3d00cb..9908f4bf 100644 --- a/dev/testing/python-sdk-tests/pytest.ini +++ b/dev/testing/python-sdk-tests/pytest.ini @@ -7,7 +7,7 @@ filterwarnings = ignore::aiohttp.web.NotAppKeyWarning # Test discovery configuration -testpaths = ./ +testpaths = tests python_files = test_*.py *_test.py python_classes = Test* python_functions = test_* diff --git a/dev/testing/python-sdk-tests/integration/__init__.py b/dev/testing/python-sdk-tests/tests/__init__.py similarity index 100% rename from dev/testing/python-sdk-tests/integration/__init__.py rename to dev/testing/python-sdk-tests/tests/__init__.py diff --git a/dev/testing/python-sdk-tests/sdk/__init__.py b/dev/testing/python-sdk-tests/tests/integration/__init__.py similarity index 100% rename from dev/testing/python-sdk-tests/sdk/__init__.py rename to dev/testing/python-sdk-tests/tests/integration/__init__.py diff --git a/dev/testing/python-sdk-tests/integration/test_expect_replies.py b/dev/testing/python-sdk-tests/tests/integration/test_expect_replies.py similarity index 95% rename from dev/testing/python-sdk-tests/integration/test_expect_replies.py rename to dev/testing/python-sdk-tests/tests/integration/test_expect_replies.py index 3a338f32..993e56c0 100644 --- a/dev/testing/python-sdk-tests/integration/test_expect_replies.py +++ b/dev/testing/python-sdk-tests/tests/integration/test_expect_replies.py @@ -1,7 +1,7 @@ import pytest from microsoft_agents.activity import Activity from microsoft_agents.testing import AgentClient -from ..scenarios import load_scenario +from tests.scenarios import load_scenario @pytest.mark.agent_test(load_scenario("quickstart")) diff --git a/dev/testing/python-sdk-tests/integration/test_quickstart.py b/dev/testing/python-sdk-tests/tests/integration/test_quickstart.py similarity index 98% rename from dev/testing/python-sdk-tests/integration/test_quickstart.py rename to dev/testing/python-sdk-tests/tests/integration/test_quickstart.py index a1053775..a5765325 100644 --- a/dev/testing/python-sdk-tests/integration/test_quickstart.py +++ b/dev/testing/python-sdk-tests/tests/integration/test_quickstart.py @@ -1,5 +1,4 @@ import pytest -from ..scenarios import load_scenario from microsoft_agents.testing import ( ActivityTemplate, @@ -8,6 +7,8 @@ ScenarioConfig, ) +from tests.scenarios import load_scenario + _TEMPLATE = { "channel_id": "webchat", "locale": "en-US", diff --git a/dev/testing/python-sdk-tests/scenarios/__init__.py b/dev/testing/python-sdk-tests/tests/scenarios/__init__.py similarity index 100% rename from dev/testing/python-sdk-tests/scenarios/__init__.py rename to dev/testing/python-sdk-tests/tests/scenarios/__init__.py diff --git a/dev/testing/python-sdk-tests/scenarios/quickstart.py b/dev/testing/python-sdk-tests/tests/scenarios/quickstart.py similarity index 100% rename from dev/testing/python-sdk-tests/scenarios/quickstart.py rename to dev/testing/python-sdk-tests/tests/scenarios/quickstart.py diff --git a/dev/testing/python-sdk-tests/sdk/hosting-core/__init__.py b/dev/testing/python-sdk-tests/tests/sdk/__init__.py similarity index 100% rename from dev/testing/python-sdk-tests/sdk/hosting-core/__init__.py rename to dev/testing/python-sdk-tests/tests/sdk/__init__.py diff --git a/dev/testing/python-sdk-tests/sdk/hosting-core/telemetry/__init__.py b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/__init__.py similarity index 100% rename from dev/testing/python-sdk-tests/sdk/hosting-core/telemetry/__init__.py rename to dev/testing/python-sdk-tests/tests/sdk/hosting-core/__init__.py diff --git a/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/__init__.py b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/testing/python-sdk-tests/sdk/hosting-core/telemetry/test_telemetry.py b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/test_telemetry.py similarity index 93% rename from dev/testing/python-sdk-tests/sdk/hosting-core/telemetry/test_telemetry.py rename to dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/test_telemetry.py index c55c17a4..695c4fb9 100644 --- a/dev/testing/python-sdk-tests/sdk/hosting-core/telemetry/test_telemetry.py +++ b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/test_telemetry.py @@ -9,7 +9,7 @@ from microsoft_agents.hosting.core.telemetry import constants -from ...scenarios import load_scenario +from tests.scenarios import load_scenario _SCENARIO = load_scenario("quickstart", use_jwt_middleware=False) @@ -62,10 +62,10 @@ async def test_basic(test_exporter, agent_client): # We should have a span for the overall turn assert any( - span.name == "agent turn" + span.name == constants.SPAN_APP_ON_TURN for span in spans ) - turn_span = next(span for span in spans if span.name == "agent turn") + turn_span = next(span for span in spans if span.name == constants.SPAN_APP_ON_TURN) assert ( "activity.type" in turn_span.attributes and "agent.is_agentic" in turn_span.attributes and @@ -122,8 +122,8 @@ def assert_span_for_user(user_id: str): assert_span_for_user("user1") assert_span_for_user("user2") - assert len(list(filter(lambda span: span.name == "agent", spans))) == 2 - assert len(list(filter(lambda span: span.name == constants.SPANS_ADAPTER_PROCESS, spans))) == 2 + assert len(list(filter(lambda span: span.name == constants.SPAN_APP_ON_TURN, spans))) == 2 + assert len(list(filter(lambda span: span.name == constants.SPAN_ADAPTER_PROCESS, spans))) == 2 @pytest.mark.asyncio @pytest.mark.agent_test(_SCENARIO) 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 91152f26..13b55ae3 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,7 +11,7 @@ HttpResponse, ) from microsoft_agents.hosting.core import ChannelServiceClientFactoryBase -from microsoft_agents.hosting.core.telemetry import agents_telemetry +from microsoft_agents.hosting.core.telemetry import spans from .agent_http_adapter import AgentHttpAdapter @@ -71,15 +71,14 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: aiohttp Response object. """ - with agents_telemetry.instrument_adapter_process(): - # Adapt request to protocol - adapted_request = AiohttpRequestAdapter(request) + # Adapt request to protocol + adapted_request = AiohttpRequestAdapter(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 aiohttp Response - return self._to_aiohttp_response(http_response) + # 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/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index f65e1476..515f262f 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 @@ -671,7 +671,7 @@ async def on_turn(self, context: TurnContext): async def _on_turn(self, context: TurnContext): typing = None try: - with spans.start_span_app_on_turn(context.activity): + with spans.start_span_app_on_turn(context): if context.activity.type != ActivityTypes.typing: if self._options.start_typing_timer: typing = TypingIndicator(context) @@ -789,7 +789,7 @@ async def _run_before_turn_middleware(self, context: TurnContext, state: StateT) return True async def _handle_file_downloads(self, context: TurnContext, state: StateT): - with spans.start_span_app_file_downloads(context): + with spans.start_span_app_download_files(context): if self._options.file_downloaders and len(self._options.file_downloaders) > 0: input_files = state.temp.input_files if state.temp.input_files else [] for file_downloader in self._options.file_downloaders: @@ -817,7 +817,7 @@ async def _run_after_turn_middleware(self, context: TurnContext, state: StateT): return True async def _on_activity(self, context: TurnContext, state: StateT): - with spans.start_span_app_router_handler(context): + with spans.start_span_app_route_handler(context): for route in self._route_list: if route.selector(context): if not route.auth_handlers: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py index e86142a2..4ad4e1f1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py @@ -16,6 +16,7 @@ RestChannelServiceClientFactory, TurnContext, ) +from microsoft_agents.hosting.core.telemetry import spans from ._http_request_protocol import HttpRequestProtocol from ._http_response import HttpResponse, HttpResponseFactory @@ -96,38 +97,40 @@ async def process_request( activity: Activity = Activity.model_validate(body) - # Get claims identity (default to anonymous if not set by middleware) - claims_identity: ClaimsIdentity = ( - request.get_claims_identity() or ClaimsIdentity({}, False) - ) - - # Validate required activity fields - if ( - not activity.type - or not activity.conversation - or not activity.conversation.id - ): - return HttpResponseFactory.bad_request( - "Activity must have type and conversation.id" - ) + with spans.start_span_adapter_process(activity): - try: - # Process the inbound activity with the agent - invoke_response = await self.process_activity( - claims_identity, activity, agent.on_turn + # Get claims identity (default to anonymous if not set by middleware) + claims_identity: ClaimsIdentity = ( + request.get_claims_identity() or ClaimsIdentity({}, False) ) - # Check if we need to return a synchronous response + # Validate required activity fields if ( - activity.type == "invoke" - or activity.delivery_mode == DeliveryModes.expect_replies + not activity.type + or not activity.conversation + or not activity.conversation.id ): - # Invoke and ExpectReplies cannot be performed async - return HttpResponseFactory.json( - invoke_response.body, invoke_response.status + return HttpResponseFactory.bad_request( + "Activity must have type and conversation.id" + ) + + try: + # Process the inbound activity with the agent + invoke_response = await self.process_activity( + claims_identity, activity, agent.on_turn ) - return HttpResponseFactory.accepted() + # Check if we need to return a synchronous response + if ( + activity.type == "invoke" + or activity.delivery_mode == DeliveryModes.expect_replies + ): + # Invoke and ExpectReplies cannot be performed async + return HttpResponseFactory.json( + invoke_response.body, invoke_response.status + ) + + return HttpResponseFactory.accepted() - except PermissionError: - return HttpResponseFactory.unauthorized() + except PermissionError: + return HttpResponseFactory.unauthorized() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py index 59c24fc0..54ded884 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py @@ -76,16 +76,6 @@ def start_as_current_span( :param span_name: The name of the span to start :param turn_context Optional TurnContext to extract attributes from and set on the span :return: An iterator that yields the started span, which will be ended when the context manager exits - - :example usage: - with agents_telemetry.start_as_current_span("my_operation", context) as span: - # perform some operations here, and the span will automatically end when the block is exited - # any exceptions raised will be recorded on the span and re-raised after the span is ended - - :note: This method is lower-level and can be used for any custom instrumentation needs. - For common operations like instrumenting an agent turn, adapter processing, storage operations, etc., - use the provided context managers like `instrument_agent_turn`, `instrument_adapter_process`, etc., - which will automatically record relevant metrics and handle success/failure cases. """ with self._tracer.start_as_current_span(span_name) as span: if turn_context is not None: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py index 33b31abe..232adbbe 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py @@ -1,12 +1,12 @@ from . import constants from ._agents_telemetry import agents_telemetry -STORAGE_OPERATIONS = agents_telemetry.meter.create_counter( +storage_operation_total = agents_telemetry.meter.create_counter( constants.METRIC_STORAGE_OPERATION_TOTAL, "operation", description="Number of storage operations performed by the agent", ) -STORAGE_OPERATIONS_DURATION = agents_telemetry.meter.create_histogram( +storage_operation_duration = agents_telemetry.meter.create_histogram( constants.METRIC_STORAGE_OPERATION_DURATION, "ms", description="Duration of storage operations in milliseconds", @@ -14,19 +14,19 @@ # AgentApplication -TURN_TOTAL = agents_telemetry.meter.create_counter( +turn_total = agents_telemetry.meter.create_counter( constants.METRIC_TURN_TOTAL, "turn", description="Total number of turns processed by the agent", ) -TURN_ERRORS = agents_telemetry.meter.create_counter( +turn_errors = agents_telemetry.meter.create_counter( constants.METRIC_TURN_ERRORS, "turn", description="Number of turns that resulted in an error", ) -TURN_DURATION = agents_telemetry.meter.create_histogram( +turn_duration = agents_telemetry.meter.create_histogram( constants.METRIC_TURN_DURATION, "ms", description="Duration of agent turns in milliseconds", @@ -34,7 +34,7 @@ # Adapters -ADAPTER_PROCESS_DURATION = agents_telemetry.meter.create_histogram( +adapter_process_duration = agents_telemetry.meter.create_histogram( constants.METRIC_ADAPTER_PROCESS_DURATION, "ms", description="Duration of adapter processing in milliseconds", @@ -42,13 +42,13 @@ # Connectors -CONNECTOR_REQUEST_TOTAL = agents_telemetry.meter.create_counter( +connector_request_total = agents_telemetry.meter.create_counter( constants.METRIC_CONNECTOR_REQUESTS_TOTAL, "request", description="Total number of connector requests made by the agent", ) -CONNECTOR_REQUEST_DURATION = agents_telemetry.meter.create_histogram( +connector_request_duration = agents_telemetry.meter.create_histogram( constants.METRIC_CONNECTOR_REQUEST_DURATION, "ms", description="Duration of connector requests in milliseconds", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py index 7d47cbbf..28f0b44e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py @@ -25,7 +25,7 @@ SPAN_ADAPTER_CONTINUE_CONVERSATION = "agents.adapter.continueConversation" SPAN_ADAPTER_CREATE_CONNECTOR_CLIENT = "agents.adapter.createConnectorClient" -SPAN_APP_RUN = "agents.app.run" +SPAN_APP_ON_TURN = "agents.app.run" SPAN_APP_ROUTE_HANDLER = "agents.app.routeHandler" SPAN_APP_BEFORE_TURN = "agents.app.beforeTurn" SPAN_APP_AFTER_TURN = "agents.app.afterTurn" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py index f4ee2689..acd046ad 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py @@ -18,7 +18,14 @@ def _get_conversation_id(activity: Activity) -> str: @contextmanager def start_span_adapter_process(activity: Activity) -> Iterator[None]: """Context manager for recording adapter process call""" - with agents_telemetry.start_as_current_span(constants.SPAN_ADAPTER_PROCESS) as span: + + def callback(span: Span, duration: float, error: Exception | None): + _metrics.adapter_process_duration.record(duration) + + with agents_telemetry.start_timed_span( + constants.SPAN_ADAPTER_PROCESS, + callback=callback + ) as span: span.set_attributes({ constants.ATTR_ACTIVITY_TYPE: activity.type, constants.ATTR_ACTIVITY_CHANNEL_ID: activity.channel_id or constants.UNKNOWN, @@ -80,16 +87,16 @@ def start_span_app_on_turn(turn_context: TurnContextProtocol) -> Iterator[None]: def callback(span: Span, duration: float, error: Exception | None): if error is None: - _metrics.TURN_TOTAL.add(1) - _metrics.TURN_DURATION.record(duration, { + _metrics.turn_total.add(1) + _metrics.turn_duration.record(duration, { "conversation.id": activity.conversation.id if activity.conversation else "unknown", "channel.id": str(activity.channel_id), }) else: - _metrics.TURN_ERRORS.add(1) + _metrics.turn_errors.add(1) with agents_telemetry.start_timed_span( - constants.SPAN_APP_RUN, + constants.SPAN_APP_ON_TURN, turn_context=turn_context, callback=callback, ) as span: @@ -128,59 +135,90 @@ def start_span_app_download_files(turn_context: TurnContextProtocol) -> Iterator # @contextmanager -def _start_span_connector_activity_op(span_name: str, conversation_id: str, activity_id: str) -> Iterator[None]: - with agents_telemetry.start_as_current_span(span_name) as span: - span.set_attribute(constants.ATTR_ACTIVITY_ID, activity_id) - span.set_attribute(constants.ATTR_CONVERSATION_ID, conversation_id) - yield +def _start_span_connector_op( + span_name: str, + *, + conversation_id: str | None = None, + activity_id: str | None = None, +) -> Iterator[Span]: + + def callback(span: Span, duration: int, error: Exception | None): + _metrics.connector_request_total.add(1) + _metrics.connector_request_duration.record(duration) + + with agents_telemetry.start_timed_span( + span_name, + callback=callback + ) as span: + if activity_id: span.set_attribute(constants.ATTR_ACTIVITY_ID, activity_id) + if conversation_id: span.set_attribute(constants.ATTR_CONVERSATION_ID, conversation_id) + yield span @contextmanager def start_span_connector_reply_to_activity(conversation_id: str, activity_id: str) -> Iterator[None]: - with _start_span_connector_activity_op(constants.SPAN_CONNECTOR_REPLY_TO_ACTIVITY, conversation_id, activity_id): + with _start_span_connector_op( + constants.SPAN_CONNECTOR_REPLY_TO_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id + ): yield @contextmanager def start_span_connector_send_to_conversation(conversation_id: str, activity_id: str) -> Iterator[None]: - with _start_span_connector_activity_op(constants.SPAN_CONNECTOR_SEND_TO_CONVERSATION, conversation_id, activity_id): + with _start_span_connector_op( + constants.SPAN_CONNECTOR_SEND_TO_CONVERSATION, + conversation_id=conversation_id, + activity_id=activity_id + ): yield @contextmanager def start_span_connector_update_activity(conversation_id: str, activity_id: str) -> Iterator[None]: - with _start_span_connector_activity_op(constants.SPAN_CONNECTOR_UPDATE_ACTIVITY, conversation_id, activity_id): + with _start_span_connector_op( + constants.SPAN_CONNECTOR_UPDATE_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id + ): yield @contextmanager def start_span_connector_delete_activity(conversation_id: str, activity_id: str) -> Iterator[None]: - with _start_span_connector_activity_op(constants.SPAN_CONNECTOR_DELETE_ACTIVITY, conversation_id, activity_id): + with _start_span_connector_op( + constants.SPAN_CONNECTOR_DELETE_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id + ): yield @contextmanager def start_span_connector_create_conversation() -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_CONNECTOR_CREATE_CONVERSATION): + with _start_span_connector_op(constants.SPAN_CONNECTOR_CREATE_CONVERSATION): yield @contextmanager def start_span_connector_get_conversations() -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_CONNECTOR_GET_CONVERSATIONS): + with _start_span_connector_op(constants.SPAN_CONNECTOR_GET_CONVERSATIONS): yield @contextmanager def start_span_connector_get_conversation_members() -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_CONNECTOR_GET_CONVERSATION_MEMBERS): + with _start_span_connector_op(constants.SPAN_CONNECTOR_GET_CONVERSATION_MEMBERS): yield @contextmanager def start_span_connector_upload_attachment(conversation_id: str) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_CONNECTOR_UPDLOAD_ATTACHMENT) as span: - span.set_attribute(constants.ATTR_CONVERSATION_ID, conversation_id) + with _start_span_connector_op( + constants.SPAN_CONNECTOR_UPDLOAD_ATTACHMENT, + conversation_id=conversation_id + ): yield @contextmanager def start_span_connector_get_attachment(attachment_id: str) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_CONNECTOR_GET_ATTACHMENT) as span: + with _start_span_connector_op(constants.SPAN_CONNECTOR_GET_ATTACHMENT) as span: span.set_attribute(constants.ATTR_ATTACHMENT_ID, attachment_id) yield @@ -191,7 +229,15 @@ def start_span_connector_get_attachment(attachment_id: str) -> Iterator[None]: @contextmanager def _start_span_storage_op(span_name: str, num_keys: int) -> Iterator[None]: - with agents_telemetry.start_as_current_span(span_name) as span: + + def callback(span: Span, duration: int, error: Exception | None): + _metrics.storage_operation_total.add(1) + _metrics.storage_operation_duration.record(duration) + + with agents_telemetry.start_timed_span( + span_name, + callback=callback + ) as span: span.set_attribute(constants.ATTR_NUM_KEYS, num_keys) yield 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 f8417b1c..41ce1776 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 @@ -71,15 +71,14 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: Returns: FastAPI Response object. """ - with agents_telemetry.instrument_adapter_process(): - # Adapt request to protocol - adapted_request = FastApiRequestAdapter(request) + # 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/tests/hosting_core/telemetry/test_agents_telemetry.py b/tests/hosting_core/telemetry/test_agents_telemetry.py index 3aa1494f..f21b5f92 100644 --- a/tests/hosting_core/telemetry/test_agents_telemetry.py +++ b/tests/hosting_core/telemetry/test_agents_telemetry.py @@ -167,7 +167,7 @@ def test_start_span_app_on_turn(mocker, test_exporter, test_metric_reader): spans = test_exporter.get_finished_spans() assert len(spans) == 1 - assert spans[0].name == constants.SPAN_APP_RUN + assert spans[0].name == constants.SPAN_APP_ON_TURN metric_data = test_metric_reader.get_metrics_data() turn_total = _sum_counter(_find_metric(metric_data, constants.METRIC_TURN_TOTAL)) From 6c9cabad551754cbb12aa12041e49af2209dfd17 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 6 Mar 2026 13:48:28 -0800 Subject: [PATCH 24/55] Adding createConnectorClient span --- .../authentication/msal/telemetry/spans.py | 9 +-- .../rest_channel_service_client_factory.py | 57 +++++++++++-------- .../hosting/core/telemetry/__init__.py | 6 +- .../core/telemetry/_agents_telemetry.py | 12 ++-- .../hosting/core/telemetry/constants.py | 4 +- .../hosting/core/telemetry/spans.py | 20 ++++++- 6 files changed, 67 insertions(+), 41 deletions(-) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py index cde0804d..7b80f1a8 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py @@ -1,13 +1,14 @@ from contextlib import contextmanager from collections.abc import Iterator -from microsoft_agents.hosting.core.telemetry import agents_telemetry, constants as common_constants +from microsoft_agents.hosting.core.telemetry import ( + agents_telemetry, + constants as common_constants, + _format_scopes +) from . import constants, _metrics -def _format_scopes(scopes: list[str]) -> str: - return ",".join(scopes) - @contextmanager def start_span_auth_get_access_token(scopes: list[str], auth_type: str) -> Iterator[None]: with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_GET_ACCESS_TOKEN) as span: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py index 4855b3ea..e843b2ac 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py @@ -16,6 +16,7 @@ from microsoft_agents.hosting.core.connector.client import UserTokenClient from microsoft_agents.hosting.core.connector.teams import TeamsConnectorClient from microsoft_agents.hosting.core.connector.mcs import MCSConnectorClient +from microsoft_agents.hosting.core.telemetry import spans from .channel_service_client_factory_base import ChannelServiceClientFactoryBase from .turn_context import TurnContext @@ -105,36 +106,44 @@ async def create_connector_client( "RestChannelServiceClientFactory.create_connector_client: audience can't be None or Empty" ) - if context and context.activity.is_agentic_request(): - token = await self._get_agentic_token(context, service_url) - else: - token_provider: AccessTokenProviderBase = ( - self._connection_manager.get_token_provider( - claims_identity, service_url + is_agentic_request = context.activity.is_agentic_request() if context else False + + with spans.start_span_adapter_create_connector_client( + service_url=service_url, + scopes=scopes, + is_agentic_request=is_agentic_request + ): + + if context and is_agentic_request: + token = await self._get_agentic_token(context, service_url) + else: + token_provider: AccessTokenProviderBase = ( + self._connection_manager.get_token_provider( + claims_identity, service_url + ) + if not use_anonymous + else self._ANONYMOUS_TOKEN_PROVIDER ) - if not use_anonymous - else self._ANONYMOUS_TOKEN_PROVIDER - ) - token = await token_provider.get_access_token( - audience, scopes or claims_identity.get_token_scope() - ) + token = await token_provider.get_access_token( + audience, scopes or claims_identity.get_token_scope() + ) - # Check if this is a connector request (e.g., from Copilot Studio) - if ( - context - and context.activity.recipient - and context.activity.recipient.role == RoleTypes.connector_user - ) or service_url.startswith("https://pvaruntime"): - return MCSConnectorClient( + # Check if this is a connector request (e.g., from Copilot Studio) + if ( + context + and context.activity.recipient + and context.activity.recipient.role == RoleTypes.connector_user + ) or service_url.startswith("https://pvaruntime"): + return MCSConnectorClient( + endpoint=service_url, + ) + + return TeamsConnectorClient( endpoint=service_url, + token=token, ) - return TeamsConnectorClient( - endpoint=service_url, - token=token, - ) - async def create_user_token_client( self, context: TurnContext, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py index e9a7a4e5..0c87d93f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py @@ -7,7 +7,10 @@ # # This design hides the "mess" of telemetry to one location rather than throughout the codebase. -from ._agents_telemetry import agents_telemetry +from ._agents_telemetry import ( + agents_telemetry, + _format_scopes +) from .configure_telemetry import configure_telemetry from .constants import ( SERVICE_NAME, @@ -18,6 +21,7 @@ __all__ = [ "agents_telemetry", "configure_telemetry", + "_format_scopes", "SERVICE_NAME", "SERVICE_VERSION", "RESOURCE", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py index 54ded884..b29d2ebf 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py @@ -16,16 +16,12 @@ logger = logging.getLogger(__name__) -def _ts() -> float: - """Helper function to get current timestamp in milliseconds""" - return datetime.now(timezone.utc).timestamp() * 1000 - _TimedSpanCallback = Callable[[Span, float, Exception | None], None] -def _remove_nones(d: dict) -> None: - for key in list(d.keys()): # list conversion to avoid iterating over changing data structure - if d[key] is None: - del d[key] +def _format_scopes(scopes: list[str] | None) -> str: + if not scopes: + return constants.UNKNOWN + return ",".join(scopes) class _AgentsTelemetry: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py index 28f0b44e..d82416f9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py @@ -62,7 +62,7 @@ METRIC_TURN_ERRORS = "agents.turn.errors" METRIC_CONNECTOR_REQUESTS_TOTAL = "agents.connector.requests" -METRIC_STORAGE_OPERATION_TOTAL = "agents.storage.operations.total" +METRIC_STORAGE_OPERATION_TOTAL = "agents.storage.operation.total" # histograms @@ -85,7 +85,7 @@ ATTR_ACTIVITY_ID = "activity.id" ATTR_ACTIVITY_COUNT = "activities.count" ATTR_ACTIVITY_TYPE = "activity.type" -ATTR_AGENTIC_USER_ID = "agentic.user.id" +ATTR_AGENTIC_USER_ID = "agentic.userId" ATTR_AGENTIC_INSTANCE_ID = "agentic.instanceId" ATTR_ATTACHMENT_ID = "attachment.id" ATTR_AUTH_SCOPES = "auth.scopes" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py index acd046ad..5ef8dbdb 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py @@ -6,7 +6,7 @@ from microsoft_agents.activity import Activity, TurnContextProtocol from . import _metrics, constants -from ._agents_telemetry import agents_telemetry +from ._agents_telemetry import agents_telemetry, _format_scopes # # Adapter @@ -17,7 +17,7 @@ def _get_conversation_id(activity: Activity) -> str: @contextmanager def start_span_adapter_process(activity: Activity) -> Iterator[None]: - """Context manager for recording adapter process call""" + """Context manager for reording adapter process call""" def callback(span: Span, duration: float, error: Exception | None): _metrics.adapter_process_duration.record(duration) @@ -75,6 +75,22 @@ def start_span_adapter_continue_conversation(activity: Activity) -> Iterator[Non }) yield +@contextmanager +def start_span_adapter_create_connector_client( + *, + service_url: str, + scopes: list[list] | None, + is_agentic_request: bool, +) -> Iterator[None]: + """Context manager for recording adapter create_connector_client call""" + with agents_telemetry.start_as_current_span(constants.SPAN_ADAPTER_CREATE_CONNECTOR_CLIENT) as span: + span.set_attributes({ + constants.ATTR_SERVICE_URL: service_url, + constants.ATTR_AUTH_SCOPES: _format_scopes(scopes), + constants.ATTR_IS_AGENTIC_REQUEST: is_agentic_request + }) + yield + # # AgentApplication # From b42583b154418db6ef6ee6137a550ff3362cfe6f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 10 Mar 2026 14:58:11 -0700 Subject: [PATCH 25/55] OTEL sample updated to use WIP built-in telemetry layer --- .../authentication/msal/msal_auth.py | 19 +- .../msal/telemetry/constants.py | 2 +- .../authentication/msal/telemetry/spans.py | 59 ++-- .../hosting/core/app/agent_application.py | 5 +- .../hosting/core/channel_service_adapter.py | 26 +- .../core/connector/client/connector_client.py | 16 +- .../rest_channel_service_client_factory.py | 4 +- .../hosting/core/storage/memory_storage.py | 4 +- .../hosting/core/storage/storage.py | 17 +- .../hosting/core/telemetry/__init__.py | 15 +- .../core/telemetry/_agents_telemetry.py | 39 +-- .../hosting/core/telemetry/_metrics.py | 2 +- .../core/telemetry/configure_telemetry.py | 45 --- .../hosting/core/telemetry/constants.py | 18 +- .../hosting/core/telemetry/spans.py | 262 ++++++++++++------ .../hosting/core/telemetry/utils.py | 7 + .../microsoft-agents-hosting-core/setup.py | 3 +- .../storage/cosmos/cosmos_db_storage.py | 2 +- test_samples/otel/dashboard.ps1 | 1 + test_samples/otel/requirements.txt | 14 + test_samples/otel/src/agent.py | 9 +- test_samples/otel/src/agent_metric.py | 165 ----------- test_samples/otel/src/main.py | 12 +- test_samples/otel/src/start_server.py | 17 +- test_samples/otel/src/telemetry.py | 47 ++-- 25 files changed, 372 insertions(+), 438 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/configure_telemetry.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py create mode 100644 test_samples/otel/dashboard.ps1 create mode 100644 test_samples/otel/requirements.txt delete mode 100644 test_samples/otel/src/agent_metric.py diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index db89b070..b509ed0a 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -97,9 +97,13 @@ async def get_access_token( else: auth_result_payload = None - res = auth_result_payload.get("access_token") if auth_result_payload else None + res = ( + auth_result_payload.get("access_token") if auth_result_payload else None + ) if not res: - logger.error("Failed to acquire token for resource %s", auth_result_payload) + logger.error( + "Failed to acquire token for resource %s", auth_result_payload + ) raise ValueError( authentication_errors.FailedToAcquireToken.format( str(auth_result_payload) @@ -399,7 +403,8 @@ async def get_agentic_instance_token( token = agentic_instance_token.get("access_token") if not token: logger.error( - "Failed to acquire agentic instance token, %s", agentic_instance_token + "Failed to acquire agentic instance token, %s", + agentic_instance_token, ) raise ValueError( authentication_errors.FailedToAcquireToken.format( @@ -436,10 +441,14 @@ async def get_agentic_user_token( :return: The agentic user token, or None if not found. :rtype: Optional[str] """ - with spans.start_span_get_agentic_user_token(agent_app_instance_id, agentic_user_id, scopes): + with spans.start_span_get_agentic_user_token( + agent_app_instance_id, agentic_user_id, scopes + ): if not agent_app_instance_id or not agentic_user_id: raise ValueError( - str(authentication_errors.AgentApplicationInstanceIdAndUserIdRequired) + str( + authentication_errors.AgentApplicationInstanceIdAndUserIdRequired + ) ) logger.info( diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py index 25c958c2..1437308b 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py @@ -8,4 +8,4 @@ # Metrics METRIC_AUTH_TOKEN_REQUEST_DURATION = "agents.auth.token.request.duration" -METRIC_AUTH_TOKEN_REQUESTS = "agents.auth.token.requests" \ No newline at end of file +METRIC_AUTH_TOKEN_REQUESTS = "agents.auth.token.requests" diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py index 7b80f1a8..69cc3e7b 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py @@ -4,41 +4,62 @@ from microsoft_agents.hosting.core.telemetry import ( agents_telemetry, constants as common_constants, - _format_scopes + _format_scopes, ) from . import constants, _metrics + @contextmanager -def start_span_auth_get_access_token(scopes: list[str], auth_type: str) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_GET_ACCESS_TOKEN) as span: - span.set_attributes({ - common_constants.ATTR_AUTH_SCOPES: _format_scopes(scopes), - common_constants.ATTR_AUTH_TYPE: auth_type, - }) +def start_span_auth_get_access_token( + scopes: list[str], auth_type: str +) -> Iterator[None]: + with agents_telemetry.start_as_current_span( + constants.SPAN_AUTH_GET_ACCESS_TOKEN + ) as span: + span.set_attributes( + { + common_constants.ATTR_AUTH_SCOPES: _format_scopes(scopes), + common_constants.ATTR_AUTH_TYPE: auth_type, + } + ) yield @contextmanager def start_span_auth_acquire_token_on_behalf_of(scopes: list[str]) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_ACQUIRE_TOKEN_ON_BEHALF_OF) as span: + with agents_telemetry.start_as_current_span( + constants.SPAN_AUTH_ACQUIRE_TOKEN_ON_BEHALF_OF + ) as span: span.set_attribute(common_constants.ATTR_AUTH_SCOPES, _format_scopes(scopes)) yield @contextmanager -def start_span_auth_get_agentic_instance_token(agentic_instance_id: str) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_GET_AGENTIC_INSTANCE_TOKEN) as span: - span.set_attribute(common_constants.ATTR_AGENTIC_INSTANCE_ID, agentic_instance_id) +def start_span_auth_get_agentic_instance_token( + agentic_instance_id: str, +) -> Iterator[None]: + with agents_telemetry.start_as_current_span( + constants.SPAN_AUTH_GET_AGENTIC_INSTANCE_TOKEN + ) as span: + span.set_attribute( + common_constants.ATTR_AGENTIC_INSTANCE_ID, agentic_instance_id + ) yield @contextmanager -def start_span_auth_get_agentic_user_token(agentic_instance_id: str, agentic_user_id: str, scopes: list[str]) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_AUTH_GET_AGENTIC_USER_TOKEN): - span.set_attributes({ - common_constants.ATTR_AGENTIC_INSTANCE_ID: agentic_instance_id, - common_constants.ATTR_AGENTIC_USER_ID: agentic_user_id, - common_constants.ATTR_AUTH_SCOPES: scopes, - }) - yield \ No newline at end of file +def start_span_auth_get_agentic_user_token( + agentic_instance_id: str, agentic_user_id: str, scopes: list[str] +) -> Iterator[None]: + with agents_telemetry.start_as_current_span( + constants.SPAN_AUTH_GET_AGENTIC_USER_TOKEN + ): + span.set_attributes( + { + common_constants.ATTR_AGENTIC_INSTANCE_ID: agentic_instance_id, + common_constants.ATTR_AGENTIC_USER_ID: agentic_user_id, + common_constants.ATTR_AUTH_SCOPES: scopes, + } + ) + yield 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 515f262f..760547c7 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 @@ -790,7 +790,10 @@ async def _run_before_turn_middleware(self, context: TurnContext, state: StateT) async def _handle_file_downloads(self, context: TurnContext, state: StateT): with spans.start_span_app_download_files(context): - if self._options.file_downloaders and len(self._options.file_downloaders) > 0: + if ( + self._options.file_downloaders + and len(self._options.file_downloaders) > 0 + ): input_files = state.temp.input_files if state.temp.input_files else [] for file_downloader in self._options.file_downloaders: logger.info( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index 5cbb42bb..2e700854 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -69,7 +69,7 @@ async def send_activities( :raises TypeError: If context or activities are None/invalid. """ with spans.start_span_adapter_send_activities(activities): - + if not context: raise TypeError("Expected TurnContext but got None instead") @@ -77,8 +77,10 @@ async def send_activities( raise TypeError("Expected Activities list but got None instead") if len(activities) == 0: - raise TypeError("Expecting one or more activities, but the list was empty.") - + raise TypeError( + "Expecting one or more activities, but the list was empty." + ) + responses = [] for activity in activities: @@ -100,13 +102,17 @@ async def send_activities( context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), ) if not connector_client: - raise Error("Unable to extract ConnectorClient from turn context.") + raise Error( + "Unable to extract ConnectorClient from turn context." + ) if activity.reply_to_id: - response = await connector_client.conversations.reply_to_activity( - activity.conversation.id, - activity.reply_to_id, - activity, + response = ( + await connector_client.conversations.reply_to_activity( + activity.conversation.id, + activity.reply_to_id, + activity, + ) ) else: response = ( @@ -239,7 +245,9 @@ async def continue_conversation_with_claims( :param audience: The audience for the conversation. :type audience: Optional[str] """ - with spans.start_span_adapter_continue_continue_conversation(continuation_activity): + with spans.start_span_adapter_continue_continue_conversation( + continuation_activity + ): return await self.process_proactive( claims_identity, continuation_activity, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 431ee7eb..f7299601 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -195,7 +195,7 @@ async def reply_to_activity( :param body: The activity object. :return: The resource response. """ - with spans.start_span_connector_reply_to_activity(body): + with spans.start_span_connector_reply_to_activity(conversation_id, activity_id): if not conversation_id or not activity_id: logger.error( "ConversationsOperations.reply_to_activity(): conversationId and activityId are required", @@ -230,7 +230,9 @@ async def reply_to_activity( response.raise_for_status() logger.info( - "Reply to conversation/activity: %s, %s", result.get("id"), activity_id + "Reply to conversation/activity: %s, %s", + result.get("id"), + activity_id, ) return ResourceResponse.model_validate(result) @@ -287,7 +289,7 @@ async def update_activity( :param body: The activity object. :return: The resource response. """ - with spans.start_span_connector_update_activity(body): + with spans.start_span_connector_update_activity(conversation_id, activity_id): if not conversation_id or not activity_id: logger.error( "ConversationsOperations.update_activity(): conversationId and activityId are required", @@ -324,9 +326,7 @@ async def delete_activity(self, conversation_id: str, activity_id: str) -> None: :param conversation_id: The ID of the conversation. :param activity_id: The ID of the activity. """ - with spans.start_span_connector_delete_activity( - activity_id=activity_id, conversation_id=conversation_id - ): + with spans.start_span_connector_delete_activity(conversation_id, activity_id): if not conversation_id or not activity_id: logger.error( "ConversationsOperations.delete_activity(): conversationId and activityId are required", @@ -386,7 +386,9 @@ async def upload_attachment( async with self.client.post(url, json=attachment_dict) as response: if response.status >= 300: logger.error( - "Error uploading attachment: %s", response.status, stack_info=True + "Error uploading attachment: %s", + response.status, + stack_info=True, ) response.raise_for_status() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py index e843b2ac..c9f9a786 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py @@ -107,11 +107,11 @@ async def create_connector_client( ) is_agentic_request = context.activity.is_agentic_request() if context else False - + with spans.start_span_adapter_create_connector_client( service_url=service_url, scopes=scopes, - is_agentic_request=is_agentic_request + is_agentic_request=is_agentic_request, ): if context and is_agentic_request: 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 3ba57a63..23d6b189 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 @@ -26,7 +26,7 @@ async def read( raise ValueError("Storage.read(): Keys are required when reading.") if not target_cls: raise ValueError("Storage.read(): target_cls cannot be None.") - + with spans.start_span_storage_read(len(keys)): result: dict[str, StoreItem] = {} with self._lock: @@ -50,7 +50,7 @@ async def read( async def write(self, changes: dict[str, StoreItem]): if not changes: raise ValueError("MemoryStorage.write(): changes cannot be None") - + with spans.start_span_storage_write(len(changes)): with self._lock: for key in changes: 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 339f24e9..f2de6b9a 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 @@ -70,12 +70,17 @@ async def read( raise ValueError("Storage.read(): Keys are required when reading.") if not target_cls: raise ValueError("Storage.read(): target_cls cannot be None.") - + with spans.start_span_storage_read(len(keys)): 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] + 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} @@ -87,11 +92,13 @@ async def _write_item(self, key: str, value: StoreItemT) -> None: async def write(self, changes: dict[str, StoreItemT]) -> None: if not changes: raise ValueError("Storage.write(): Changes are required when writing.") - + with spans.start_span_storage_write(len(changes)): await self.initialize() - await gather(*[self._write_item(key, value) for key, value in changes.items()]) + await gather( + *[self._write_item(key, value) for key, value in changes.items()] + ) @abstractmethod async def _delete_item(self, key: str) -> None: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py index 0c87d93f..5a62790c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py @@ -1,4 +1,3 @@ - ## DESIGN # This design is similar to how error codes are implemented and maintained. # The alternative was to inject all of this telemetry logic inline with the code it instruments. @@ -6,23 +5,19 @@ # even emitting metrics. # # This design hides the "mess" of telemetry to one location rather than throughout the codebase. +# +# NOTE: this module should not be auto-loaded from __init__.py in order to avoid from ._agents_telemetry import ( agents_telemetry, - _format_scopes -) -from .configure_telemetry import configure_telemetry -from .constants import ( - SERVICE_NAME, - SERVICE_VERSION, - RESOURCE ) +from .constants import SERVICE_NAME, SERVICE_VERSION, RESOURCE +from .utils import _format_scopes __all__ = [ "agents_telemetry", - "configure_telemetry", "_format_scopes", "SERVICE_NAME", "SERVICE_VERSION", "RESOURCE", -] \ No newline at end of file +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py index b29d2ebf..73d5699f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py @@ -1,7 +1,6 @@ import time import logging from typing import Callable -from datetime import datetime, timezone from collections.abc import Iterator from contextlib import contextmanager @@ -18,33 +17,35 @@ _TimedSpanCallback = Callable[[Span, float, Exception | None], None] -def _format_scopes(scopes: list[str] | None) -> str: - if not scopes: - return constants.UNKNOWN - return ",".join(scopes) class _AgentsTelemetry: def __init__(self): - """ Initializes the AgentsTelemetry instance with the given tracer and meter, or creates new ones if not provided - + """Initializes the AgentsTelemetry instance with the given tracer and meter, or creates new ones if not provided + :param tracer: Optional OpenTelemetry Tracer instance to use for creating spans. If not provided, a new tracer will be created with the service name and version from constants. :param meter: Optional OpenTelemetry Meter instance to use for recording metrics. If not provided, a new meter will be created with the service name and version from constants. """ - self._tracer = trace.get_tracer(constants.SERVICE_NAME, constants.SERVICE_VERSION) - self._meter = metrics.get_meter(constants.SERVICE_NAME, constants.SERVICE_VERSION) + self._tracer = trace.get_tracer( + constants.SERVICE_NAME, constants.SERVICE_VERSION + ) + self._meter = metrics.get_meter( + constants.SERVICE_NAME, constants.SERVICE_VERSION + ) @property def tracer(self) -> Tracer: """Returns the OpenTelemetry tracer instance for creating spans""" return self._tracer - + @property def meter(self) -> Meter: """Returns the OpenTelemetry meter instance for recording metrics""" return self._meter - def _extract_attributes_from_context(self, turn_context: TurnContextProtocol) -> dict: + def _extract_attributes_from_context( + self, turn_context: TurnContextProtocol + ) -> dict: """Helper method to extract common attributes from the TurnContext for span and metric recording""" # This can be expanded to extract common attributes for spans and metrics from the context @@ -58,7 +59,9 @@ def _extract_attributes_from_context(self, turn_context: TurnContextProtocol) -> if turn_context.activity.conversation: attributes["conversation.id"] = turn_context.activity.conversation.id attributes["channel_id"] = turn_context.activity.channel_id - attributes["message.text.length"] = len(turn_context.activity.text) if turn_context.activity.text else 0 + attributes["message.text.length"] = ( + len(turn_context.activity.text) if turn_context.activity.text else 0 + ) return attributes @contextmanager @@ -68,7 +71,7 @@ def start_as_current_span( turn_context: TurnContextProtocol | None = None, ) -> Iterator[Span]: """Context manager for starting a new span with the given name and setting attributes from the TurnContext if provided - + :param span_name: The name of the span to start :param turn_context Optional TurnContext to extract attributes from and set on the span :return: An iterator that yields the started span, which will be ended when the context manager exits @@ -78,16 +81,17 @@ def start_as_current_span( attributes = self._extract_attributes_from_context(turn_context) span.set_attributes(attributes) yield span + # self._tracer._tracer_provider._active_span_processor._span_processors[0].span_exporter._endpoint @contextmanager def start_timed_span( self, span_name: str, turn_context: TurnContextProtocol | None = None, - callback: _TimedSpanCallback | None = None + callback: _TimedSpanCallback | None = None, ) -> Iterator[Span]: """Context manager for starting a timed span that records duration and success/failure status, and invokes a callback with the results - + :param span_name: The name of the span to start :param turn_context Optional TurnContext to extract attributes from and set on the span :param callback: Optional callback function that will be called with the span, duration in milliseconds, and any exception that was raised (or None if successful) when the span is ended @@ -121,6 +125,7 @@ def start_timed_span( callback(span, duration, exception) span.set_status(trace.Status(trace.StatusCode.ERROR)) - raise exception from None # re-raise to ensure it's not swallowed + raise exception from None # re-raise to ensure it's not swallowed + -agents_telemetry = _AgentsTelemetry() \ No newline at end of file +agents_telemetry = _AgentsTelemetry() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py index 232adbbe..02b31d0f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py @@ -52,4 +52,4 @@ constants.METRIC_CONNECTOR_REQUEST_DURATION, "ms", description="Duration of connector requests in milliseconds", -) \ No newline at end of file +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/configure_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/configure_telemetry.py deleted file mode 100644 index 9eee512e..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/configure_telemetry.py +++ /dev/null @@ -1,45 +0,0 @@ -import logging - -from opentelemetry import metrics, trace -from opentelemetry._logs import set_logger_provider -from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter -from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter -from opentelemetry.exporter.otlp.proto.http.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.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor - -from . import constants - -def configure_telemetry() -> None: - """Configure OpenTelemetry with default exporters.""" - - # Configure Tracing - trace_provider = TracerProvider(resource=constants.RESOURCE) - trace_provider.add_span_processor( - BatchSpanProcessor(OTLPSpanExporter()) - ) - trace.set_tracer_provider(trace_provider) - - # Configure Metrics - metric_reader = PeriodicExportingMetricReader( - OTLPMetricExporter() - ) - meter_provider = MeterProvider(resource=constants.RESOURCE, metric_readers=[metric_reader]) - metrics.set_meter_provider(meter_provider) - - # Configure Logging - logger_provider = LoggerProvider(resource=constants.RESOURCE) - logger_provider.add_log_record_processor( - BatchLogRecordProcessor(OTLPLogExporter()) - ) - set_logger_provider(logger_provider) - - # Add logging handler - handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider) - logging.getLogger().addHandler(handler) - - \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py index d82416f9..160a358c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py @@ -80,27 +80,27 @@ # 2. Flexibility: This mapping allows us to change the internal attribute names without affecting the telemetry data. # 3. Efficiency: avoid snake case to camel case conversions (or any other convention) -ATTR_ACTIVITY_DELIVERY_MODE = "activity.deliveryMode" -ATTR_ACTIVITY_CHANNEL_ID = "activity.channelId" +ATTR_ACTIVITY_DELIVERY_MODE = "activity.delivery_mode" +ATTR_ACTIVITY_CHANNEL_ID = "activity.channel_id" ATTR_ACTIVITY_ID = "activity.id" ATTR_ACTIVITY_COUNT = "activities.count" ATTR_ACTIVITY_TYPE = "activity.type" -ATTR_AGENTIC_USER_ID = "agentic.userId" -ATTR_AGENTIC_INSTANCE_ID = "agentic.instanceId" +ATTR_AGENTIC_USER_ID = "agentic.user_id" +ATTR_AGENTIC_INSTANCE_ID = "agentic.instance_id" ATTR_ATTACHMENT_ID = "attachment.id" ATTR_AUTH_SCOPES = "auth.scopes" ATTR_AUTH_TYPE = "auth.method" -ATTR_CONVERSATION_ID = "conversation.id" +ATTR_CONVERSATION_ID = "activity.conversation.id" -ATTR_IS_AGENTIC_REQUEST = "isAgenticRequest" +ATTR_IS_AGENTIC_REQUEST = "is_agentic_request" ATTR_NUM_KEYS = "keys.num" -ATTR_ROUTE_IS_INVOKE = "route.isInvoke" -ATTR_ROUTE_IS_AGENTIC = "route.isAgentic" +ATTR_ROUTE_IS_INVOKE = "route.is_invoke" +ATTR_ROUTE_IS_AGENTIC = "route.is_agentic" -ATTR_SERVICE_URL = "serviceUrl" +ATTR_SERVICE_URL = "service_url" # VALUES diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py index 5ef8dbdb..d0a6849f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py @@ -3,17 +3,29 @@ from opentelemetry.trace import Span -from microsoft_agents.activity import Activity, TurnContextProtocol +from microsoft_agents.activity import Activity, TurnContextProtocol, DeliveryModes from . import _metrics, constants -from ._agents_telemetry import agents_telemetry, _format_scopes +from ._agents_telemetry import agents_telemetry +from .utils import _format_scopes # # Adapter # + def _get_conversation_id(activity: Activity) -> str: - return activity.conversation.id if activity.conversation else "unknown" + return activity.conversation.id if activity.conversation else constants.UNKNOWN + + +def _get_delivery_mode(activity: Activity) -> str: + if activity.delivery_mode: + if isinstance(activity.delivery_mode, DeliveryModes): + return activity.delivery_mode.value + else: + return activity.delivery_mode + return constants.UNKNOWN + @contextmanager def start_span_adapter_process(activity: Activity) -> Iterator[None]: @@ -23,78 +35,111 @@ def callback(span: Span, duration: float, error: Exception | None): _metrics.adapter_process_duration.record(duration) with agents_telemetry.start_timed_span( - constants.SPAN_ADAPTER_PROCESS, - callback=callback + constants.SPAN_ADAPTER_PROCESS, callback=callback ) as span: - span.set_attributes({ - constants.ATTR_ACTIVITY_TYPE: activity.type, - constants.ATTR_ACTIVITY_CHANNEL_ID: activity.channel_id or constants.UNKNOWN, - constants.ATTR_ACTIVITY_DELIVERY_MODE: activity.delivery_mode, - constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), - constants.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), - }) + span.set_attributes( + { + constants.ATTR_ACTIVITY_TYPE: activity.type, + constants.ATTR_ACTIVITY_CHANNEL_ID: activity.channel_id + or constants.UNKNOWN, + constants.ATTR_ACTIVITY_DELIVERY_MODE: _get_delivery_mode(activity), + constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), + constants.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), + } + ) yield + @contextmanager def start_span_adapter_send_activities(activities: list[Activity]) -> Iterator[None]: """Context manager for recording adapter send_activities call""" - with agents_telemetry.start_as_current_span(constants.SPAN_ADAPTER_SEND_ACTIVITIES) as span: - span.set_attributes({ - constants.ATTR_ACTIVITY_COUNT: len(activities), - constants.ATTR_CONVERSATION_ID: _get_conversation_id(activities[0]) if activities else constants.UNKNOWN, - }) + with agents_telemetry.start_as_current_span( + constants.SPAN_ADAPTER_SEND_ACTIVITIES + ) as span: + span.set_attributes( + { + constants.ATTR_ACTIVITY_COUNT: len(activities), + constants.ATTR_CONVERSATION_ID: ( + _get_conversation_id(activities[0]) + if activities + else constants.UNKNOWN + ), + } + ) yield + @contextmanager def start_span_adapter_update_activity(activity: Activity) -> Iterator[None]: """Context manager for recording adapter update_activity call""" - with agents_telemetry.start_as_current_span(constants.SPAN_ADAPTER_UPDATE_ACTIVITY) as span: - span.set_attributes({ - constants.ATTR_ACTIVITY_ID: activity.id or constants.UNKNOWN, - constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), - }) + with agents_telemetry.start_as_current_span( + constants.SPAN_ADAPTER_UPDATE_ACTIVITY + ) as span: + span.set_attributes( + { + constants.ATTR_ACTIVITY_ID: activity.id or constants.UNKNOWN, + constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), + } + ) yield + @contextmanager def start_span_adapter_delete_activity(activity: Activity) -> Iterator[None]: """Context manager for recording adapter delete_activity call""" - with agents_telemetry.start_as_current_span(constants.SPAN_ADAPTER_DELETE_ACTIVITY) as span: - span.set_attributes({ - constants.ATTR_ACTIVITY_ID: activity.id or constants.UNKNOWN, - constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), - }) + with agents_telemetry.start_as_current_span( + constants.SPAN_ADAPTER_DELETE_ACTIVITY + ) as span: + span.set_attributes( + { + constants.ATTR_ACTIVITY_ID: activity.id or constants.UNKNOWN, + constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), + } + ) yield + @contextmanager def start_span_adapter_continue_conversation(activity: Activity) -> Iterator[None]: """Context manager for recording adapter continue_conversation call""" - with agents_telemetry.start_as_current_span(constants.SPAN_ADAPTER_CONTINUE_CONVERSATION) as span: - span.set_attributes({ - constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), - constants.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), - }) + with agents_telemetry.start_as_current_span( + constants.SPAN_ADAPTER_CONTINUE_CONVERSATION + ) as span: + span.set_attributes( + { + constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), + constants.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), + } + ) yield + @contextmanager def start_span_adapter_create_connector_client( *, service_url: str, - scopes: list[list] | None, + scopes: list[str] | None, is_agentic_request: bool, ) -> Iterator[None]: """Context manager for recording adapter create_connector_client call""" - with agents_telemetry.start_as_current_span(constants.SPAN_ADAPTER_CREATE_CONNECTOR_CLIENT) as span: - span.set_attributes({ - constants.ATTR_SERVICE_URL: service_url, - constants.ATTR_AUTH_SCOPES: _format_scopes(scopes), - constants.ATTR_IS_AGENTIC_REQUEST: is_agentic_request - }) + with agents_telemetry.start_as_current_span( + constants.SPAN_ADAPTER_CREATE_CONNECTOR_CLIENT + ) as span: + span.set_attributes( + { + constants.ATTR_SERVICE_URL: service_url, + constants.ATTR_AUTH_SCOPES: _format_scopes(scopes), + constants.ATTR_IS_AGENTIC_REQUEST: is_agentic_request, + } + ) yield + # # AgentApplication # + @contextmanager def start_span_app_on_turn(turn_context: TurnContextProtocol) -> Iterator[None]: """Context manager for recording an app on_turn call, including success/failure and duration""" @@ -104,10 +149,15 @@ def start_span_app_on_turn(turn_context: TurnContextProtocol) -> Iterator[None]: def callback(span: Span, duration: float, error: Exception | None): if error is None: _metrics.turn_total.add(1) - _metrics.turn_duration.record(duration, { - "conversation.id": activity.conversation.id if activity.conversation else "unknown", - "channel.id": str(activity.channel_id), - }) + _metrics.turn_duration.record( + duration, + { + "conversation.id": ( + activity.conversation.id if activity.conversation else "unknown" + ), + "channel.id": str(activity.channel_id), + }, + ) else: _metrics.turn_errors.add(1) @@ -116,40 +166,56 @@ def callback(span: Span, duration: float, error: Exception | None): turn_context=turn_context, callback=callback, ) as span: - span.set_attributes({ - constants.ATTR_ACTIVITY_TYPE: activity.type, - constants.ATTR_ACTIVITY_ID: activity.id or constants.UNKNOWN, - }) + span.set_attributes( + { + constants.ATTR_ACTIVITY_TYPE: activity.type, + constants.ATTR_ACTIVITY_ID: activity.id or constants.UNKNOWN, + } + ) yield + @contextmanager def start_span_app_route_handler(turn_context: TurnContextProtocol) -> Iterator[None]: """Context manager for recording the app route handler span""" - with agents_telemetry.start_as_current_span(constants.SPAN_APP_ROUTE_HANDLER, turn_context): + with agents_telemetry.start_as_current_span( + constants.SPAN_APP_ROUTE_HANDLER, turn_context + ): yield + @contextmanager def start_span_app_before_turn(turn_context: TurnContextProtocol) -> Iterator[None]: """Context manager for recording the app before turn span""" - with agents_telemetry.start_as_current_span(constants.SPAN_APP_BEFORE_TURN, turn_context): + with agents_telemetry.start_as_current_span( + constants.SPAN_APP_BEFORE_TURN, turn_context + ): yield + @contextmanager def start_span_app_after_turn(turn_context: TurnContextProtocol) -> Iterator[None]: """Context manager for recording the app after turn span""" - with agents_telemetry.start_as_current_span(constants.SPAN_APP_AFTER_TURN, turn_context): + with agents_telemetry.start_as_current_span( + constants.SPAN_APP_AFTER_TURN, turn_context + ): yield - + + @contextmanager def start_span_app_download_files(turn_context: TurnContextProtocol) -> Iterator[None]: """Context manager for recording the app download files span""" - with agents_telemetry.start_as_current_span(constants.SPAN_APP_DOWNLOAD_FILES, turn_context): + with agents_telemetry.start_as_current_span( + constants.SPAN_APP_DOWNLOAD_FILES, turn_context + ): yield + # # ConnectorClient # + @contextmanager def _start_span_connector_op( span_name: str, @@ -158,59 +224,72 @@ def _start_span_connector_op( activity_id: str | None = None, ) -> Iterator[Span]: - def callback(span: Span, duration: int, error: Exception | None): + def callback(span: Span, duration: float, error: Exception | None): _metrics.connector_request_total.add(1) _metrics.connector_request_duration.record(duration) - with agents_telemetry.start_timed_span( - span_name, - callback=callback - ) as span: - if activity_id: span.set_attribute(constants.ATTR_ACTIVITY_ID, activity_id) - if conversation_id: span.set_attribute(constants.ATTR_CONVERSATION_ID, conversation_id) + with agents_telemetry.start_timed_span(span_name, callback=callback) as span: + if activity_id: + span.set_attribute(constants.ATTR_ACTIVITY_ID, activity_id) + if conversation_id: + span.set_attribute(constants.ATTR_CONVERSATION_ID, conversation_id) yield span + @contextmanager -def start_span_connector_reply_to_activity(conversation_id: str, activity_id: str) -> Iterator[None]: +def start_span_connector_reply_to_activity( + conversation_id: str, activity_id: str +) -> Iterator[None]: with _start_span_connector_op( constants.SPAN_CONNECTOR_REPLY_TO_ACTIVITY, conversation_id=conversation_id, - activity_id=activity_id + activity_id=activity_id, ): yield + @contextmanager -def start_span_connector_send_to_conversation(conversation_id: str, activity_id: str) -> Iterator[None]: +def start_span_connector_send_to_conversation( + conversation_id: str, activity_id: str | None +) -> Iterator[None]: with _start_span_connector_op( constants.SPAN_CONNECTOR_SEND_TO_CONVERSATION, conversation_id=conversation_id, - activity_id=activity_id + activity_id=activity_id, ): yield + @contextmanager -def start_span_connector_update_activity(conversation_id: str, activity_id: str) -> Iterator[None]: +def start_span_connector_update_activity( + conversation_id: str, activity_id: str +) -> Iterator[None]: with _start_span_connector_op( constants.SPAN_CONNECTOR_UPDATE_ACTIVITY, conversation_id=conversation_id, - activity_id=activity_id + activity_id=activity_id, ): yield + @contextmanager -def start_span_connector_delete_activity(conversation_id: str, activity_id: str) -> Iterator[None]: +def start_span_connector_delete_activity( + conversation_id: str, activity_id: str +) -> Iterator[None]: with _start_span_connector_op( constants.SPAN_CONNECTOR_DELETE_ACTIVITY, conversation_id=conversation_id, - activity_id=activity_id + activity_id=activity_id, ): yield + @contextmanager def start_span_connector_create_conversation() -> Iterator[None]: with _start_span_connector_op(constants.SPAN_CONNECTOR_CREATE_CONVERSATION): yield + @contextmanager def start_span_connector_get_conversations() -> Iterator[None]: with _start_span_connector_op(constants.SPAN_CONNECTOR_GET_CONVERSATIONS): @@ -226,8 +305,7 @@ def start_span_connector_get_conversation_members() -> Iterator[None]: @contextmanager def start_span_connector_upload_attachment(conversation_id: str) -> Iterator[None]: with _start_span_connector_op( - constants.SPAN_CONNECTOR_UPDLOAD_ATTACHMENT, - conversation_id=conversation_id + constants.SPAN_CONNECTOR_UPDLOAD_ATTACHMENT, conversation_id=conversation_id ): yield @@ -238,6 +316,7 @@ def start_span_connector_get_attachment(attachment_id: str) -> Iterator[None]: span.set_attribute(constants.ATTR_ATTACHMENT_ID, attachment_id) yield + # # Storage # @@ -250,42 +329,59 @@ def callback(span: Span, duration: int, error: Exception | None): _metrics.storage_operation_total.add(1) _metrics.storage_operation_duration.record(duration) - with agents_telemetry.start_timed_span( - span_name, - callback=callback - ) as span: + with agents_telemetry.start_timed_span(span_name, callback=callback) as span: span.set_attribute(constants.ATTR_NUM_KEYS, num_keys) yield + @contextmanager def start_span_storage_read(num_keys: int) -> Iterator[None]: - with _start_span_storage_op(constants.SPAN_STORAGE_READ, num_keys): yield + with _start_span_storage_op(constants.SPAN_STORAGE_READ, num_keys): + yield + @contextmanager def start_span_storage_write(num_keys: int) -> Iterator[None]: - with _start_span_storage_op(constants.SPAN_STORAGE_WRITE, num_keys): yield + with _start_span_storage_op(constants.SPAN_STORAGE_WRITE, num_keys): + yield + @contextmanager def start_span_storage_delete(num_keys: int) -> Iterator[None]: - with _start_span_storage_op(constants.SPAN_STORAGE_DELETE, num_keys): yield + with _start_span_storage_op(constants.SPAN_STORAGE_DELETE, num_keys): + yield + # # TurnContext # + @contextmanager -def start_span_turn_context_send_activity(turn_context: TurnContextProtocol) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_TURN_SEND_ACTIVITY, turn_context): +def start_span_turn_context_send_activity( + turn_context: TurnContextProtocol, +) -> Iterator[None]: + with agents_telemetry.start_as_current_span( + constants.SPAN_TURN_SEND_ACTIVITY, turn_context + ): yield @contextmanager -def start_span_turn_context_update_activity(turn_context: TurnContextProtocol) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_TURN_UPDATE_ACTIVITY, turn_context): +def start_span_turn_context_update_activity( + turn_context: TurnContextProtocol, +) -> Iterator[None]: + with agents_telemetry.start_as_current_span( + constants.SPAN_TURN_UPDATE_ACTIVITY, turn_context + ): yield @contextmanager -def start_span_turn_context_delete_activity(turn_context: TurnContextProtocol) -> Iterator[None]: - with agents_telemetry.start_as_current_span(constants.SPAN_TURN_DELETE_ACTIVITY, turn_context): - yield \ No newline at end of file +def start_span_turn_context_delete_activity( + turn_context: TurnContextProtocol, +) -> Iterator[None]: + with agents_telemetry.start_as_current_span( + constants.SPAN_TURN_DELETE_ACTIVITY, turn_context + ): + yield diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py new file mode 100644 index 00000000..e32a5a71 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py @@ -0,0 +1,7 @@ +from . import constants + + +def _format_scopes(scopes: list[str] | None) -> str: + if not scopes: + return constants.UNKNOWN + return ",".join(scopes) diff --git a/libraries/microsoft-agents-hosting-core/setup.py b/libraries/microsoft-agents-hosting-core/setup.py index 299ad2fa..84d43898 100644 --- a/libraries/microsoft-agents-hosting-core/setup.py +++ b/libraries/microsoft-agents-hosting-core/setup.py @@ -17,8 +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-api>=1.17.0", # TODO -> verify this before commit "opentelemetry-sdk>=1.17.0", - "opentelemetry-exporter-otlp-proto-http>=1.17.0", ], ) 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 d3d9d2bd..96df0352 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( diff --git a/test_samples/otel/dashboard.ps1 b/test_samples/otel/dashboard.ps1 new file mode 100644 index 00000000..de2dd386 --- /dev/null +++ b/test_samples/otel/dashboard.ps1 @@ -0,0 +1 @@ +docker run --rm -it -p 18888:18888 -p 4317:18889 --name aspire-dashboard mcr.microsoft.com/dotnet/aspire-dashboard:latest \ No newline at end of file diff --git a/test_samples/otel/requirements.txt b/test_samples/otel/requirements.txt new file mode 100644 index 00000000..879687ff --- /dev/null +++ b/test_samples/otel/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/agent.py b/test_samples/otel/src/agent.py index 9d4140ac..037a6e76 100644 --- a/test_samples/otel/src/agent.py +++ b/test_samples/otel/src/agent.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import os.path as path import re import sys import traceback @@ -19,8 +18,6 @@ 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) @@ -46,14 +43,12 @@ async def on_members_added(context: TurnContext, _state: TurnState): @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!") + 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}") + await context.send_activity(f"you said: {context.activity.text}") @AGENT_APP.error diff --git a/test_samples/otel/src/agent_metric.py b/test_samples/otel/src/agent_metric.py deleted file mode 100644 index db9a8da0..00000000 --- a/test_samples/otel/src/agent_metric.py +++ /dev/null @@ -1,165 +0,0 @@ -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/main.py b/test_samples/otel/src/main.py index 51eddbbc..bfd1ce41 100644 --- a/test_samples/otel/src/main.py +++ b/test_samples/otel/src/main.py @@ -1,17 +1,9 @@ # 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 +from .telemetry import configure_otel_providers -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") +configure_otel_providers(service_name="quickstart_agent") from .agent import AGENT_APP, CONNECTION_MANAGER from .start_server import start_server diff --git a/test_samples/otel/src/start_server.py b/test_samples/otel/src/start_server.py index 4fa57e60..b781b208 100644 --- a/test_samples/otel/src/start_server.py +++ b/test_samples/otel/src/start_server.py @@ -4,12 +4,9 @@ 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 +from aiohttp.web import Request, Response, Application, run_app logger = logging.getLogger(__name__) @@ -20,16 +17,14 @@ def start_server( 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, - ) + return await start_agent_process( + req, + agent, + adapter, + ) APP = Application(middlewares=[]) APP.router.add_post("/api/messages", entry_point) diff --git a/test_samples/otel/src/telemetry.py b/test_samples/otel/src/telemetry.py index 624e2a16..4b4e2ccc 100644 --- a/test_samples/otel/src/telemetry.py +++ b/test_samples/otel/src/telemetry.py @@ -2,8 +2,6 @@ import os import requests -from microsoft_agents.hosting.core import TurnContext - import aiohttp from opentelemetry import metrics, trace from opentelemetry.trace import Span @@ -17,24 +15,23 @@ 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.sdk.trace.export import SimpleSpanProcessor 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 server -> causes problems + # ## + # AioHttpServerInstrumentor().instrument(tracer_provider=tracer_provider) - ## - # instrument aiohttp client - ## + # ## + # # instrument aiohttp client + # ## def aiohttp_client_request_hook( span: Span, params: aiohttp.TraceRequestStartParams ): @@ -53,7 +50,7 @@ def aiohttp_client_response_hook( response_hook=aiohttp_client_response_hook, ) - ## + # # instrument requests library ## def requests_request_hook(span: Span, request: requests.Request): @@ -70,15 +67,9 @@ def requests_response_hook( request_hook=requests_request_hook, response_hook=requests_response_hook ) - -def configure_telemetry(service_name: str = "app"): +def configure_otel_providers(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( { @@ -89,16 +80,18 @@ def configure_telemetry(service_name: str = "app"): } ) + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317/") + # Configure Tracing - trace_provider = TracerProvider(resource=resource) - trace_provider.add_span_processor( - BatchSpanProcessor(OTLPSpanExporter(endpoint=otlp_endpoint)) + tracer_provider = TracerProvider(resource=resource) + tracer_provider.add_span_processor( + SimpleSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) ) - trace.set_tracer_provider(trace_provider) + trace.set_tracer_provider(tracer_provider) # Configure Metrics metric_reader = PeriodicExportingMetricReader( - OTLPMetricExporter(endpoint=otlp_endpoint) + OTLPMetricExporter(endpoint=endpoint) ) meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader]) metrics.set_meter_provider(meter_provider) @@ -106,7 +99,7 @@ def configure_telemetry(service_name: str = "app"): # Configure Logging logger_provider = LoggerProvider(resource=resource) logger_provider.add_log_record_processor( - BatchLogRecordProcessor(OTLPLogExporter(endpoint=otlp_endpoint)) + BatchLogRecordProcessor(OTLPLogExporter(endpoint=endpoint)) ) set_logger_provider(logger_provider) @@ -114,4 +107,6 @@ def configure_telemetry(service_name: str = "app"): handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider) logging.getLogger().addHandler(handler) - return trace.get_tracer(__name__) + logging.getLogger().info("OpenTelemetry providers configured with endpoint: %s", endpoint) + + instrument_libraries() \ No newline at end of file From c11a0a2a6e5b051eaacf1a78369f65d0f6b3e61a Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 11 Mar 2026 10:16:30 -0700 Subject: [PATCH 26/55] Beginning work on ScriptScenario --- .../testing/source_scenario.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 dev/testing/microsoft-agents-testing/microsoft_agents/testing/source_scenario.py diff --git a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/source_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/source_scenario.py new file mode 100644 index 00000000..301973e4 --- /dev/null +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/source_scenario.py @@ -0,0 +1,58 @@ +# from enum import Enum + +# from abc import ABC, abstractmethod +# from collections.abc import AsyncIterator +# from contextlib import asynccontextmanager + +# from dotenv import dotenv_values + +# from microsoft_agents.activity import load_configuration_from_env + +# from .core import ( +# _AiohttpClientFactory, +# AiohttpCallbackServer, +# ClientFactory, +# Scenario, +# ScenarioConfig, +# ) + +# class SDKType(Enum, str): +# PYTHON = "python" +# JS = "js" +# NET = "net" + +# class ScriptScenario(Scenario, ABC): + +# def __init__(self, sdk_type: SDKType, config: ScenarioConfig | None = None) -> None: +# super().__init__(config) +# self._sdk_type = sdk_type + +# async def _run_script(self): +# raise NotImplementedError("Subclasses must implement _run_code to execute the test scenario.") + +# @asynccontextmanager +# async def run(self) -> AsyncIterator[ClientFactory]: +# """Start callback server and yield a client factory.""" + +# res = await self._run_script() + +# env_vars = dotenv_values(self._config.env_file_path) +# sdk_config = load_configuration_from_env(env_vars) + +# callback_server = AiohttpCallbackServer(self._config.callback_server_port) + +# async with callback_server.listen() as transcript: +# # Create a factory that binds the agent URL, callback endpoint, +# # and SDK config so callers can create configured clients +# factory = _AiohttpClientFactory( +# agent_url=self._endpoint, +# response_endpoint=callback_server.service_endpoint, +# sdk_config=sdk_config, +# default_config=self._config.client_config, +# transcript=transcript, +# ) + +# try: +# yield factory +# finally: +# await factory.cleanup() \ No newline at end of file From c785d834dcc135dfdc5995fbe72e54a45040fc31 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 12 Mar 2026 11:09:32 -0700 Subject: [PATCH 27/55] Commit --- .../microsoft_agents/hosting/core/storage/memory_storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 23d6b189..8e6ad37c 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 @@ -26,7 +26,7 @@ async def read( raise ValueError("Storage.read(): Keys are required when reading.") if not target_cls: raise ValueError("Storage.read(): target_cls cannot be None.") - + with spans.start_span_storage_read(len(keys)): result: dict[str, StoreItem] = {} with self._lock: From 0664b8f857496dd09c0276789d9ebae4a447a4b8 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 12 Mar 2026 15:26:34 -0700 Subject: [PATCH 28/55] Beginning of cross-sdk end-to-end tests --- dev/testing/cross-sdk-tests/.gitignore | 244 ++ .../{agents/basic_agent => }/__init__.py | 0 .../basic_agent}/__init__.py | 0 .../basic_agent/python/README.md | 0 .../basic_agent/python}/__init__.py | 0 .../basic_agent/python/env.TEMPLATE | 0 .../basic_agent/python/pre_requirements.txt | 0 .../basic_agent/python/requirements.txt | 0 .../basic_agent/python/src}/__init__.py | 0 .../basic_agent/python/src/agent.py | 0 .../basic_agent/python/src/app.py | 0 .../basic_agent/python/src/config.py | 0 .../python/src/weather}/__init__.py | 0 .../python/src/weather/agents}/__init__.py | 0 .../weather/agents/weather_forecast_agent.py | 0 .../python/src/weather/plugins/__init__.py | 0 .../weather/plugins/adaptive_card_plugin.py | 0 .../src/weather/plugins/date_time_plugin.py | 0 .../src/weather/plugins/weather_forecast.py | 0 .../plugins/weather_forecast_plugin.py | 0 .../_agents/quickstart/README.md | 3 + .../_agents/quickstart/js/_run_agent.ps1 | 4 + .../_agents/quickstart/js/env.TEMPLATE | 9 + .../_agents/quickstart/js/package-lock.json | 3077 +++++++++++++++++ .../_agents/quickstart/js/package.json | 29 + .../_agents/quickstart/js/src/index.ts | 42 + .../_agents/quickstart/js/tsconfig.json | 20 + .../quickstart/net/AspNetExtensions.cs | 270 ++ .../_agents/quickstart/net/MyAgent.cs | 36 + .../_agents/quickstart/net/Program.cs | 51 + .../_agents/quickstart/net/Quickstart.csproj | 15 + .../_agents/quickstart/net/_run_agent.ps1 | 2 + .../_agents/quickstart/python/_run_agent.ps1 | 6 + .../_agents/quickstart/python/env.TEMPLATE | 0 .../quickstart/python/requirements.txt | 3 + .../_agents/quickstart/python/src/__init__.py | 0 .../_agents/quickstart/python/src/agent.py | 62 + .../_agents/quickstart/python/src/main.py | 10 + .../quickstart/python/src/start_server.py | 39 + .../cross-sdk-tests/tests/_common/__init__.py | 10 + .../tests/_common/constants.py | 7 + .../tests/_common/source_scenario.py | 60 + .../cross-sdk-tests/tests/_common/types.py | 7 + .../cross-sdk-tests/tests/_common/utils.py | 21 + .../cross-sdk-tests/tests/basic/__init__.py | 0 .../cross-sdk-tests/tests/core/__init__.py | 0 .../core}/test_basic_agent_base.py | 0 .../core}/test_directline.py | 0 .../core}/test_msteams.py | 0 .../core}/test_webchat.py | 0 .../tests/telemetry/__init__.py | 0 .../tests/telemetry/test_basic_telemetry.py | 27 + .../testing/source_scenario.py | 58 - 53 files changed, 4054 insertions(+), 58 deletions(-) create mode 100644 dev/testing/cross-sdk-tests/.gitignore rename dev/testing/cross-sdk-tests/{agents/basic_agent => }/__init__.py (100%) rename dev/testing/cross-sdk-tests/{agents/basic_agent/python => _agents/basic_agent}/__init__.py (100%) rename dev/testing/cross-sdk-tests/{agents => _agents}/basic_agent/python/README.md (100%) rename dev/testing/cross-sdk-tests/{agents/basic_agent/python/src => _agents/basic_agent/python}/__init__.py (100%) rename dev/testing/cross-sdk-tests/{agents => _agents}/basic_agent/python/env.TEMPLATE (100%) rename dev/testing/cross-sdk-tests/{agents => _agents}/basic_agent/python/pre_requirements.txt (100%) rename dev/testing/cross-sdk-tests/{agents => _agents}/basic_agent/python/requirements.txt (100%) rename dev/testing/cross-sdk-tests/{agents/basic_agent/python/src/weather => _agents/basic_agent/python/src}/__init__.py (100%) rename dev/testing/cross-sdk-tests/{agents => _agents}/basic_agent/python/src/agent.py (100%) rename dev/testing/cross-sdk-tests/{agents => _agents}/basic_agent/python/src/app.py (100%) rename dev/testing/cross-sdk-tests/{agents => _agents}/basic_agent/python/src/config.py (100%) rename dev/testing/cross-sdk-tests/{agents/basic_agent/python/src/weather/agents => _agents/basic_agent/python/src/weather}/__init__.py (100%) rename dev/testing/cross-sdk-tests/{basic_agent => _agents/basic_agent/python/src/weather/agents}/__init__.py (100%) rename dev/testing/cross-sdk-tests/{agents => _agents}/basic_agent/python/src/weather/agents/weather_forecast_agent.py (100%) rename dev/testing/cross-sdk-tests/{agents => _agents}/basic_agent/python/src/weather/plugins/__init__.py (100%) rename dev/testing/cross-sdk-tests/{agents => _agents}/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py (100%) rename dev/testing/cross-sdk-tests/{agents => _agents}/basic_agent/python/src/weather/plugins/date_time_plugin.py (100%) rename dev/testing/cross-sdk-tests/{agents => _agents}/basic_agent/python/src/weather/plugins/weather_forecast.py (100%) rename dev/testing/cross-sdk-tests/{agents => _agents}/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py (100%) create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/README.md create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/js/_run_agent.ps1 create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/js/env.TEMPLATE create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/js/package-lock.json create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/js/package.json create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/js/src/index.ts create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/js/tsconfig.json create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/net/AspNetExtensions.cs create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/net/MyAgent.cs create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/net/Program.cs create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/net/Quickstart.csproj create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/net/_run_agent.ps1 create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/python/_run_agent.ps1 create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/python/env.TEMPLATE create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/python/requirements.txt create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/python/src/__init__.py create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/python/src/agent.py create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/python/src/main.py create mode 100644 dev/testing/cross-sdk-tests/_agents/quickstart/python/src/start_server.py create mode 100644 dev/testing/cross-sdk-tests/tests/_common/__init__.py create mode 100644 dev/testing/cross-sdk-tests/tests/_common/constants.py create mode 100644 dev/testing/cross-sdk-tests/tests/_common/source_scenario.py create mode 100644 dev/testing/cross-sdk-tests/tests/_common/types.py create mode 100644 dev/testing/cross-sdk-tests/tests/_common/utils.py create mode 100644 dev/testing/cross-sdk-tests/tests/basic/__init__.py create mode 100644 dev/testing/cross-sdk-tests/tests/core/__init__.py rename dev/testing/cross-sdk-tests/{basic_agent => tests/core}/test_basic_agent_base.py (100%) rename dev/testing/cross-sdk-tests/{basic_agent => tests/core}/test_directline.py (100%) rename dev/testing/cross-sdk-tests/{basic_agent => tests/core}/test_msteams.py (100%) rename dev/testing/cross-sdk-tests/{basic_agent => tests/core}/test_webchat.py (100%) create mode 100644 dev/testing/cross-sdk-tests/tests/telemetry/__init__.py create mode 100644 dev/testing/cross-sdk-tests/tests/telemetry/test_basic_telemetry.py delete mode 100644 dev/testing/microsoft-agents-testing/microsoft_agents/testing/source_scenario.py diff --git a/dev/testing/cross-sdk-tests/.gitignore b/dev/testing/cross-sdk-tests/.gitignore new file mode 100644 index 00000000..510e2f50 --- /dev/null +++ b/dev/testing/cross-sdk-tests/.gitignore @@ -0,0 +1,244 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates +target/ + +# Cake +/.cake +/version.txt +/PSRunCmds*.ps1 + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +/bin/ +/binSigned/ +/obj/ +Drop/ +target/ +Symbols/ +objd/ +.config/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +#nodeJS stuff +/node_modules/ + +#local development +appsettings.local.json +appsettings.Development.json +appsettings.Development* +appsettings.Production.json +**/[Aa]ppManifest/*.zip +.deployment + +# JetBrains Rider +*.sln.iml +.idea + +# Mac files +.DS_Store + +# VS Code files +.vscode +src/samples/ModelContextProtocol/GitHubMCPServer/Properties/ServiceDependencies/GitHubMCPServer20250311143114 - Web Deploy/profile.arm.json + +# Claude Code temporary files +tmpclaude* + + +node_modules/ +dist/ +*.env +*.key +*.pem +test-report.xml +tsconfig.tsbuildinfo +devTools/ \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/__init__.py b/dev/testing/cross-sdk-tests/__init__.py similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/__init__.py rename to dev/testing/cross-sdk-tests/__init__.py diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/__init__.py b/dev/testing/cross-sdk-tests/_agents/basic_agent/__init__.py similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/__init__.py rename to dev/testing/cross-sdk-tests/_agents/basic_agent/__init__.py diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/README.md b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/README.md similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/README.md rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/README.md diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/__init__.py b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/__init__.py similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/src/__init__.py rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/__init__.py diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/env.TEMPLATE b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/env.TEMPLATE similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/env.TEMPLATE rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/env.TEMPLATE diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/pre_requirements.txt b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/pre_requirements.txt similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/pre_requirements.txt rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/pre_requirements.txt diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/requirements.txt b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/requirements.txt similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/requirements.txt rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/requirements.txt diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/__init__.py b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/__init__.py similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/__init__.py rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/__init__.py diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/agent.py b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/agent.py similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/src/agent.py rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/agent.py diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/app.py b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/app.py similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/src/app.py rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/app.py diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/config.py b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/config.py similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/src/config.py rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/config.py diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/agents/__init__.py b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/__init__.py similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/agents/__init__.py rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/__init__.py diff --git a/dev/testing/cross-sdk-tests/basic_agent/__init__.py b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/agents/__init__.py similarity index 100% rename from dev/testing/cross-sdk-tests/basic_agent/__init__.py rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/agents/__init__.py diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/__init__.py b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/__init__.py similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/__init__.py rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/__init__.py diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/date_time_plugin.py b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/date_time_plugin.py similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/date_time_plugin.py rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/date_time_plugin.py diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/weather_forecast.py b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/weather_forecast.py similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/weather_forecast.py rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/weather_forecast.py diff --git a/dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py b/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py similarity index 100% rename from dev/testing/cross-sdk-tests/agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py rename to dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/README.md b/dev/testing/cross-sdk-tests/_agents/quickstart/README.md new file mode 100644 index 00000000..e37735c2 --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/README.md @@ -0,0 +1,3 @@ +# Quickstart Agent + +This agent echos responses back to the user. As presently configured, the agent enables JWT token validation. \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/js/_run_agent.ps1 b/dev/testing/cross-sdk-tests/_agents/quickstart/js/_run_agent.ps1 new file mode 100644 index 00000000..0970ac91 --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/js/_run_agent.ps1 @@ -0,0 +1,4 @@ +npm install + +npm run build +npm run start:anon \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/js/env.TEMPLATE b/dev/testing/cross-sdk-tests/_agents/quickstart/js/env.TEMPLATE new file mode 100644 index 00000000..e170043b --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/js/env.TEMPLATE @@ -0,0 +1,9 @@ +# rename to .env +connections__serviceConnection__settings__clientId= # App ID of the App Registration used to log in. +connections__serviceConnection__settings__clientSecret= # Client secret of the App Registration used to log in +connections__serviceConnection__settings__tenantId= # Tenant ID of the App Registration used to log in + +connectionsMap__0__connection=serviceConnection +connectionsMap__0__serviceUrl=* + +DEBUG=agents:*:error \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/js/package-lock.json b/dev/testing/cross-sdk-tests/_agents/quickstart/js/package-lock.json new file mode 100644 index 00000000..f8f28dce --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/js/package-lock.json @@ -0,0 +1,3077 @@ +{ + "name": "node-empty-agent", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-empty-agent", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@microsoft/agents-hosting-express": "^1.1.0" + }, + "devDependencies": { + "@microsoft/m365agentsplayground": "^0.2.16", + "@types/node": "^22.15.18", + "npm-run-all": "^4.1.5", + "typescript": "^5.8.3" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.13.3", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.3.tgz", + "integrity": "sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.4.tgz", + "integrity": "sha512-lvuAwsDpPDE/jSuVQOBMpLbXuVuLsPNRwWCyK3/6bPlBk0fGWegqoZ0qjZclMWyQ2JNvIY3vHY7hoFmFmFQcOw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.13.3", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@microsoft/agents-activity": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@microsoft/agents-activity/-/agents-activity-1.1.1.tgz", + "integrity": "sha512-L7PHEHKFge99aIxV9eA7uFY3n9goYKzxcWaqLXGmxq3wMsau8hdsPzZgpV77LOQWQynLO3M5cbD8AavcVZszlQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "uuid": "^11.1.0", + "zod": "3.25.75" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@microsoft/agents-activity/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/@microsoft/agents-hosting": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@microsoft/agents-hosting/-/agents-hosting-1.1.1.tgz", + "integrity": "sha512-ZO/BU0d/NxSlbg/W4SvtHDvwS4GDYrMG5CpBh+m2vnqkl6tphM0kkfbSYZFef0BoftrinOdPZcSvdvmVqpbM2w==", + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.10.1", + "@azure/msal-node": "^3.8.2", + "@microsoft/agents-activity": "1.1.1", + "axios": "^1.13.2", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.2.0", + "object-path": "^0.11.8" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@microsoft/agents-hosting-express": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@microsoft/agents-hosting-express/-/agents-hosting-express-1.1.1.tgz", + "integrity": "sha512-CDStIx23U2zyS/4nZoeVgrVlVbQ+EasoqR2dLq7IfU4rUyuUrKGPdlO55rcfS6Z/spLkhCnX35jbD6EBqrTkJg==", + "license": "MIT", + "dependencies": { + "@microsoft/agents-hosting": "1.1.1", + "express": "^5.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@microsoft/m365agentsplayground": { + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@microsoft/m365agentsplayground/-/m365agentsplayground-0.2.18.tgz", + "integrity": "sha512-8okNQ+fNQPPMBW/OSIudoCApBKqKxADNFIMivUGy/eaX9v8tFIG/gFo7DLwpaCzNuc1M8oOW9Sse1mX08Dxo0A==", + "dev": true, + "bin": { + "agentsplayground": "cli.js", + "teamsapptester": "cli.js" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.18.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.1.tgz", + "integrity": "sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", + "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/cross-spawn/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", + "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "license": "MIT", + "dependencies": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object-path": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/object-path/-/object-path-0.11.8.tgz", + "integrity": "sha512-YJjNZrlXJFM42wTBn6zgOJVar9KFJvzx6sTWDte8sWZF//cnjl0BxHNpfZx+ZffXX63A9q0b1zsFiBX4g4X5KA==", + "license": "MIT", + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", + "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "dev": true + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.padend": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.75", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.75.tgz", + "integrity": "sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/js/package.json b/dev/testing/cross-sdk-tests/_agents/quickstart/js/package.json new file mode 100644 index 00000000..e4cdab59 --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/js/package.json @@ -0,0 +1,29 @@ +{ + "name": "node-empty-agent", + "version": "1.0.0", + "private": true, + "description": "Agents echo bot sample", + "author": "Microsoft", + "license": "MIT", + "main": "./dist/index.js", + "scripts": { + "prebuild": "npm ci", + "build": "tsc --build", + "prestart": "npm run build", + "prestart:anon": "npm run build", + "start:anon": "node ./dist/index.js", + "start": "node --env-file .env ./dist/index.js", + "test-tool": "agentsplayground", + "test": "npm-run-all -p -r start:anon test-tool" + }, + "dependencies": { + "@microsoft/agents-hosting-express": "^1.1.0" + }, + "devDependencies": { + "@microsoft/m365agentsplayground": "^0.2.16", + "@types/node": "^22.15.18", + "npm-run-all": "^4.1.5", + "typescript": "^5.8.3" + }, + "keywords": [] +} \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/js/src/index.ts b/dev/testing/cross-sdk-tests/_agents/quickstart/js/src/index.ts new file mode 100644 index 00000000..5796e398 --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/js/src/index.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import { ActivityTypes } from '@microsoft/agents-activity'; +import { AgentApplication, AttachmentDownloader, MemoryStorage, TurnContext, TurnState } from '@microsoft/agents-hosting'; +import { startServer } from '@microsoft/agents-hosting-express'; + +// Create custom conversation state properties. This is +// used to store customer properties in conversation state. +interface ConversationState { + count: number; +} +type ApplicationTurnState = TurnState + +// Register IStorage. For development, MemoryStorage is suitable. +// For production Agents, persisted storage should be used so +// that state survives Agent restarts, and operates correctly +// in a cluster of Agent instances. +const storage = new MemoryStorage() + +const downloader = new AttachmentDownloader() + +const agentApp = new AgentApplication({ + storage, + fileDownloaders: [downloader] +}) + +// Display a welcome message when members are added +agentApp.onConversationUpdate('membersAdded', async (context: TurnContext, state: ApplicationTurnState) => { + await context.sendActivity('Hello and Welcome!') +}) + +// Listen for ANY message to be received. MUST BE AFTER ANY OTHER MESSAGE HANDLERS +agentApp.onActivity(ActivityTypes.Message, async (context: TurnContext, state: ApplicationTurnState) => { + // Increment count state + let count = state.conversation.count ?? 0 + state.conversation.count = ++count + + // Echo back users message + await context.sendActivity(`[${count}] You said: ${context.activity.text}`) +}) + +startServer(agentApp) \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/js/tsconfig.json b/dev/testing/cross-sdk-tests/_agents/quickstart/js/tsconfig.json new file mode 100644 index 00000000..0e188450 --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/js/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "incremental": true, + "lib": ["ES2021"], + "target": "es2019", + "module": "commonjs", + "declaration": true, + "sourceMap": true, + "composite": true, + "strict": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo" + } +} \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/net/AspNetExtensions.cs b/dev/testing/cross-sdk-tests/_agents/quickstart/net/AspNetExtensions.cs new file mode 100644 index 00000000..944e6844 --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/net/AspNetExtensions.cs @@ -0,0 +1,270 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Authentication; +using Microsoft.Agents.Core; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Validators; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +public static class AspNetExtensions +{ + private static readonly ConcurrentDictionary> _openIdMetadataCache = new(); + + /// + /// Adds AspNet token validation typical for ABS/SMBA and agent-to-agent using settings in configuration. + /// + /// + /// + /// Name of the config section to read. + /// Optional logger to use for authentication event logging. + /// + /// This extension reads settings from configuration. If configuration is missing JWT token + /// is not enabled. + ///

The minimum, but typical, configuration is:

+ /// + /// "TokenValidation": { + /// "Enabled": boolean, + /// "Audiences": [ + /// "{{ClientId}}" // this is the Client ID used for the Azure Bot + /// ], + /// "TenantId": "{{TenantId}}" + /// } + /// + /// The full options are: + /// + /// "TokenValidation": { + /// "Enabled": boolean, + /// "Audiences": [ + /// "{required:agent-appid}" + /// ], + /// "TenantId": "{recommended:tenant-id}", + /// "ValidIssuers": [ + /// "{default:Public-AzureBotService}" + /// ], + /// "IsGov": {optional:false}, + /// "AzureBotServiceOpenIdMetadataUrl": optional, + /// "OpenIdMetadataUrl": optional, + /// "AzureBotServiceTokenHandling": "{optional:true}" + /// "OpenIdMetadataRefresh": "optional-12:00:00" + /// } + /// + ///
+ public static void AddAgentAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = "TokenValidation") + { + IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName); + + if (!tokenValidationSection.Exists() || !tokenValidationSection.GetValue("Enabled", true)) + { + // Noop if TokenValidation section missing or disabled. + System.Diagnostics.Trace.WriteLine("AddAgentAspNetAuthentication: Auth disabled"); + return; + } + + services.AddAgentAspNetAuthentication(tokenValidationSection.Get()!); + } + + /// + /// Adds AspNet token validation typical for ABS/SMBA and agent-to-agent. + /// + public static void AddAgentAspNetAuthentication(this IServiceCollection services, TokenValidationOptions validationOptions) + { + AssertionHelpers.ThrowIfNull(validationOptions, nameof(validationOptions)); + + // Must have at least one Audience. + if (validationOptions.Audiences == null || validationOptions.Audiences.Count == 0) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences requires at least one ClientId"); + } + + // Audience values must be GUID's + foreach (var audience in validationOptions.Audiences) + { + if (!Guid.TryParse(audience, out _)) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences values must be a GUID"); + } + } + + // If ValidIssuers is empty, default for ABS Public Cloud + if (validationOptions.ValidIssuers == null || validationOptions.ValidIssuers.Count == 0) + { + validationOptions.ValidIssuers = + [ + "https://api.botframework.com", + "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", + "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", + "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", + "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", + "https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/", + "https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0", + ]; + + if (!string.IsNullOrEmpty(validationOptions.TenantId) && Guid.TryParse(validationOptions.TenantId, out _)) + { + validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV1, validationOptions.TenantId)); + validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV2, validationOptions.TenantId)); + } + } + + // If the `AzureBotServiceOpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate ABS tokens. + if (string.IsNullOrEmpty(validationOptions.AzureBotServiceOpenIdMetadataUrl)) + { + validationOptions.AzureBotServiceOpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl : AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl; + } + + // If the `OpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate Entra ID tokens. + if (string.IsNullOrEmpty(validationOptions.OpenIdMetadataUrl)) + { + validationOptions.OpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovOpenIdMetadataUrl : AuthenticationConstants.PublicOpenIdMetadataUrl; + } + + var openIdMetadataRefresh = validationOptions.OpenIdMetadataRefresh ?? BaseConfigurationManager.DefaultAutomaticRefreshInterval; + + _ = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.SaveToken = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(5), + ValidIssuers = validationOptions.ValidIssuers, + ValidAudiences = validationOptions.Audiences, + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + }; + + // Using Microsoft.IdentityModel.Validators + options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); + + options.Events = new JwtBearerEvents + { + // Create a ConfigurationManager based on the requestor. This is to handle ABS non-Entra tokens. + OnMessageReceived = async context => + { + string authorizationHeader = context.Request.Headers.Authorization.ToString(); + + if (string.IsNullOrEmpty(authorizationHeader)) + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + string[] parts = authorizationHeader?.Split(' ')!; + if (parts.Length != 2 || parts[0] != "Bearer") + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + JwtSecurityToken token = new(parts[1]); + string issuer = token.Claims.FirstOrDefault(claim => claim.Type == AuthenticationConstants.IssuerClaim)?.Value!; + + if (validationOptions.AzureBotServiceTokenHandling && AuthenticationConstants.BotFrameworkTokenIssuer.Equals(issuer)) + { + // Use the Azure Bot authority for this configuration manager + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.AzureBotServiceOpenIdMetadataUrl, key => + { + return new ConfigurationManager(validationOptions.AzureBotServiceOpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdMetadataRefresh + }; + }); + } + else + { + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.OpenIdMetadataUrl, key => + { + return new ConfigurationManager(validationOptions.OpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdMetadataRefresh + }; + }); + } + + await Task.CompletedTask.ConfigureAwait(false); + }, + + OnTokenValidated = context => + { + return Task.CompletedTask; + }, + OnForbidden = context => + { + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + return Task.CompletedTask; + } + }; + }); + } + + public class TokenValidationOptions + { + public IList? Audiences { get; set; } + + /// + /// TenantId of the Azure Bot. Optional but recommended. + /// + public string? TenantId { get; set; } + + /// + /// Additional valid issuers. Optional, in which case the Public Azure Bot Service issuers are used. + /// + public IList? ValidIssuers { get; set; } + + /// + /// Can be omitted, in which case public Azure Bot Service and Azure Cloud metadata urls are used. + /// + public bool IsGov { get; set; } = false; + + /// + /// Azure Bot Service OpenIdMetadataUrl. Optional, in which case default value depends on IsGov. + /// + /// + /// + public string? AzureBotServiceOpenIdMetadataUrl { get; set; } + + /// + /// Entra OpenIdMetadataUrl. Optional, in which case default value depends on IsGov. + /// + /// + /// + public string? OpenIdMetadataUrl { get; set; } + + /// + /// Determines if Azure Bot Service tokens are handled. Defaults to true and should always be true until Azure Bot Service sends Entra ID token. + /// + public bool AzureBotServiceTokenHandling { get; set; } = true; + + /// + /// OpenIdMetadata refresh interval. Defaults to 12 hours. + /// + public TimeSpan? OpenIdMetadataRefresh { get; set; } + } +} \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/net/MyAgent.cs b/dev/testing/cross-sdk-tests/_agents/quickstart/net/MyAgent.cs new file mode 100644 index 00000000..9e65e5f1 --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/net/MyAgent.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App; +using Microsoft.Agents.Builder.State; +using Microsoft.Agents.Core.Models; +using System.Threading.Tasks; +using System.Threading; + +namespace QuickStart; + +public class MyAgent : AgentApplication +{ + public MyAgent(AgentApplicationOptions options) : base(options) + { + OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync); + OnActivity(ActivityTypes.Message, OnMessageAsync, rank: RouteRank.Last); + } + + private async Task WelcomeMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + foreach (ChannelAccount member in turnContext.Activity.MembersAdded) + { + if (member.Id != turnContext.Activity.Recipient.Id) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Hello and Welcome!"), cancellationToken); + } + } + } + + private async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync($"You said: {turnContext.Activity.Text}", cancellationToken: cancellationToken); + } +} \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/net/Program.cs b/dev/testing/cross-sdk-tests/_agents/quickstart/net/Program.cs new file mode 100644 index 00000000..24375d2b --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/net/Program.cs @@ -0,0 +1,51 @@ + +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using QuickStart; +using Microsoft.Agents.Hosting.AspNetCore; +using Microsoft.Agents.Storage; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddHttpClient(); + +// Add AgentApplicationOptions from appsettings section "AgentApplication". +builder.AddAgentApplicationOptions(); + +// Add the AgentApplication, which contains the logic for responding to +// user messages. +builder.AddAgent(); + +// Register IStorage. For development, MemoryStorage is suitable. +// For production Agents, persisted storage should be used so +// that state survives Agent restarts, and operates correctly +// in a cluster of Agent instances. +builder.Services.AddSingleton(); + +// Configure the HTTP request pipeline. + +// Add AspNet token validation for Azure Bot Service and Entra. Authentication is +// configured in the appsettings.json "TokenValidation" section. +builder.Services.AddControllers(); +builder.Services.AddAgentAspNetAuthentication(builder.Configuration); + +WebApplication app = builder.Build(); + +// Enable AspNet authentication and authorization +app.UseAuthentication(); +app.UseAuthorization(); + +// Map GET "/" +app.MapAgentRootEndpoint(); + +// Map the endpoints for all agents using the [AgentInterface] attribute. +// If there is a single IAgent/AgentApplication, the endpoints will be mapped to (e.g. "/api/message"). +app.MapAgentApplicationEndpoints(requireAuth: !app.Environment.IsDevelopment()); + +app.Urls.Add($"http://localhost:3978"); + +app.Run(); diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/net/Quickstart.csproj b/dev/testing/cross-sdk-tests/_agents/quickstart/net/Quickstart.csproj new file mode 100644 index 00000000..14a818ca --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/net/Quickstart.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + latest + disable + enable + + + + + + + + \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/net/_run_agent.ps1 b/dev/testing/cross-sdk-tests/_agents/quickstart/net/_run_agent.ps1 new file mode 100644 index 00000000..bca86d74 --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/net/_run_agent.ps1 @@ -0,0 +1,2 @@ +dotnet build +dotnet run \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/python/_run_agent.ps1 b/dev/testing/cross-sdk-tests/_agents/quickstart/python/_run_agent.ps1 new file mode 100644 index 00000000..3dace17f --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/python/_run_agent.ps1 @@ -0,0 +1,6 @@ +python -m venv venv +.\venv\Scripts\Activate.ps1 + +pip install -r requirements.txt + +python -m src.main \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/python/env.TEMPLATE b/dev/testing/cross-sdk-tests/_agents/quickstart/python/env.TEMPLATE new file mode 100644 index 00000000..e69de29b diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/python/requirements.txt b/dev/testing/cross-sdk-tests/_agents/quickstart/python/requirements.txt new file mode 100644 index 00000000..0ca6c392 --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/python/requirements.txt @@ -0,0 +1,3 @@ +microsoft-agents-hosting-core +microsoft-agents-hosting-aiohttp +microsoft-agents-authentication-msal \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/__init__.py b/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/agent.py b/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/agent.py new file mode 100644 index 00000000..33571cbf --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/agent.py @@ -0,0 +1,62 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +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 + +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): + await context.send_activity("Hello!") + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, _state: TurnState): + 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/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/main.py b/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/main.py new file mode 100644 index 00000000..9139fe33 --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/main.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +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/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/start_server.py b/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/start_server.py new file mode 100644 index 00000000..9a747e75 --- /dev/null +++ b/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/start_server.py @@ -0,0 +1,39 @@ +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 + +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.") + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + + return await start_agent_process( + req, + agent, + adapter, + ) + + APP = Application(middlewares=[jwt_authorization_middleware]) + APP.router.add_post("/api/messages", entry_point) + + 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/dev/testing/cross-sdk-tests/tests/_common/__init__.py b/dev/testing/cross-sdk-tests/tests/_common/__init__.py new file mode 100644 index 00000000..408ae54f --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/__init__.py @@ -0,0 +1,10 @@ +from .utils import ( + create_scenario +) + +from .types import SDKVersion + +__all__ = [ + "create_scenario", + "SDKVersion" +] \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/_common/constants.py b/dev/testing/cross-sdk-tests/tests/_common/constants.py new file mode 100644 index 00000000..8af8e802 --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/constants.py @@ -0,0 +1,7 @@ +import pathlib + +_AGENTS_DIR_NAME + "_agents" +AGENTS_PATH = pathlib.Path(__file__).parent.parent.resolve() / _AGENTS_DIR_NAME +ENTRY_POINT_NAME = "run_agent.ps1" + +breakpoint() \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/_common/source_scenario.py b/dev/testing/cross-sdk-tests/tests/_common/source_scenario.py new file mode 100644 index 00000000..7b5941bc --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/source_scenario.py @@ -0,0 +1,60 @@ +import asyncio +import subprocess + +from enum import Enum +from pathlib import Path + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from .core import ( + ClientFactory, + ExternalScenario, + ScenarioConfig, +) + +class SDKType(Enum, str): + PYTHON = "python" + JS = "js" + NET = "net" + +class SourceScenario(ExternalScenario): + """Base class for script-based test scenarios.""" + + def __init__( + self, + script_path: str, + delay: float = 0.0, + config: ScenarioConfig | None = None + ) -> None: + super().__init__(config) + self._script_path = Path(script_path) + self._delay = delay + + @asynccontextmanager + async def _run_script(self) -> AsyncIterator[None]: + + + + script_name = self._script_path.name + script_parent = self._script_path.parent + + proc = subprocess.Popen( + [f"./{script_name}"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=script_parent + ) + + # wait for the agent to start running + await asyncio.sleep(self._delay) + + yield + + proc.terminate() + + @asynccontextmanager + async def run(self) -> AsyncIterator[ClientFactory]: + """Start callback server and yield a client factory.""" + async with self._run_script(): + yield super().run() \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/_common/types.py b/dev/testing/cross-sdk-tests/tests/_common/types.py new file mode 100644 index 00000000..8dd66393 --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/types.py @@ -0,0 +1,7 @@ +from enum import Enum + +class SDKVersion(Enum, str): + + PYTHON = "python" + JS = "js" + NET = "net" \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/_common/utils.py b/dev/testing/cross-sdk-tests/tests/_common/utils.py new file mode 100644 index 00000000..d32b6248 --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/utils.py @@ -0,0 +1,21 @@ +from microsoft_agents.testing import Scenario + +from . import constants +from .source_scenario import SourceScenario +from .types import SDKVersion + +def create_agent_path(agent_name: str, sdk_version: SDKVersion) -> str: + + agents_path = constants.AGENTS_PATH / agent_name / sdk_version.value / constants.ENTRY_POINT_NAME + if not agents_path.exists(): + raise FileNotFoundError(f"Agent path does not exist: {agents_path}") + + return agents_path + +def create_scenario(agent_name: str, sdk_version: SDKVersion, delay: float = 0.0) -> Scenario: + + agent_path = create_agent_path(agent_name, sdk_version) + return SourceScenario( + agent_path, + delay=delay, + ) \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/basic/__init__.py b/dev/testing/cross-sdk-tests/tests/basic/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/testing/cross-sdk-tests/tests/core/__init__.py b/dev/testing/cross-sdk-tests/tests/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/testing/cross-sdk-tests/basic_agent/test_basic_agent_base.py b/dev/testing/cross-sdk-tests/tests/core/test_basic_agent_base.py similarity index 100% rename from dev/testing/cross-sdk-tests/basic_agent/test_basic_agent_base.py rename to dev/testing/cross-sdk-tests/tests/core/test_basic_agent_base.py diff --git a/dev/testing/cross-sdk-tests/basic_agent/test_directline.py b/dev/testing/cross-sdk-tests/tests/core/test_directline.py similarity index 100% rename from dev/testing/cross-sdk-tests/basic_agent/test_directline.py rename to dev/testing/cross-sdk-tests/tests/core/test_directline.py diff --git a/dev/testing/cross-sdk-tests/basic_agent/test_msteams.py b/dev/testing/cross-sdk-tests/tests/core/test_msteams.py similarity index 100% rename from dev/testing/cross-sdk-tests/basic_agent/test_msteams.py rename to dev/testing/cross-sdk-tests/tests/core/test_msteams.py diff --git a/dev/testing/cross-sdk-tests/basic_agent/test_webchat.py b/dev/testing/cross-sdk-tests/tests/core/test_webchat.py similarity index 100% rename from dev/testing/cross-sdk-tests/basic_agent/test_webchat.py rename to dev/testing/cross-sdk-tests/tests/core/test_webchat.py diff --git a/dev/testing/cross-sdk-tests/tests/telemetry/__init__.py b/dev/testing/cross-sdk-tests/tests/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/testing/cross-sdk-tests/tests/telemetry/test_basic_telemetry.py b/dev/testing/cross-sdk-tests/tests/telemetry/test_basic_telemetry.py new file mode 100644 index 00000000..5be2d706 --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/telemetry/test_basic_telemetry.py @@ -0,0 +1,27 @@ +from tests._common import ( + create_scenario, + SDKVersion, +) + +AGENT_NAME = "quickstart" +PYTHON_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.PYTHON) +NET_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.NET) +JS_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.JS) + +class BaseTelemetryTests: + def test_telemetry(self): + # This is a placeholder test. The actual telemetry tests will be implemented here. + assert True + + +@pytest.mark.agent_test(PYTHON_SCENARIO) +class TestPythonTelemetry(BaseTelemetryTests): + pass + +@pytest.mark.agent_test(NET_SCENARIO) +class TestNetTelemetry(BaseTelemetryTests): + pass + +@pytest.mark.agent_test(JS_SCENARIO) +class TestJSTelemetry(BaseTelemetryTests): + pass \ No newline at end of file diff --git a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/source_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/source_scenario.py deleted file mode 100644 index 301973e4..00000000 --- a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/source_scenario.py +++ /dev/null @@ -1,58 +0,0 @@ -# from enum import Enum - -# from abc import ABC, abstractmethod -# from collections.abc import AsyncIterator -# from contextlib import asynccontextmanager - -# from dotenv import dotenv_values - -# from microsoft_agents.activity import load_configuration_from_env - -# from .core import ( -# _AiohttpClientFactory, -# AiohttpCallbackServer, -# ClientFactory, -# Scenario, -# ScenarioConfig, -# ) - -# class SDKType(Enum, str): -# PYTHON = "python" -# JS = "js" -# NET = "net" - -# class ScriptScenario(Scenario, ABC): - -# def __init__(self, sdk_type: SDKType, config: ScenarioConfig | None = None) -> None: -# super().__init__(config) -# self._sdk_type = sdk_type - -# async def _run_script(self): -# raise NotImplementedError("Subclasses must implement _run_code to execute the test scenario.") - -# @asynccontextmanager -# async def run(self) -> AsyncIterator[ClientFactory]: -# """Start callback server and yield a client factory.""" - -# res = await self._run_script() - -# env_vars = dotenv_values(self._config.env_file_path) -# sdk_config = load_configuration_from_env(env_vars) - -# callback_server = AiohttpCallbackServer(self._config.callback_server_port) - -# async with callback_server.listen() as transcript: -# # Create a factory that binds the agent URL, callback endpoint, -# # and SDK config so callers can create configured clients -# factory = _AiohttpClientFactory( -# agent_url=self._endpoint, -# response_endpoint=callback_server.service_endpoint, -# sdk_config=sdk_config, -# default_config=self._config.client_config, -# transcript=transcript, -# ) - -# try: -# yield factory -# finally: -# await factory.cleanup() \ No newline at end of file From db01bdcd595e92accb8011787fa5d5e9de7b3d97 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 16 Mar 2026 10:57:44 -0700 Subject: [PATCH 29/55] Fixed bug with AiohttpScenario endpoint --- .../microsoft-agents-testing/env.TEMPLATE | 5 ++ .../testing/aiohttp_scenario.py | 6 +- .../testing/core/_aiohttp_client_factory.py | 8 +-- .../testing/core/external_scenario.py | 10 +-- .../testing/core/transport/aiohttp_sender.py | 23 +++++-- .../core/transport/transcript/exchange.py | 10 ++- .../microsoft-agents-testing/pytest.ini | 3 +- .../tests/core/test_aiohttp_client_factory.py | 39 ++++++----- .../tests/core/test_external_scenario.py | 4 +- .../tests/core/test_integration.py | 66 ++++++++++++------- .../core/transport/test_aiohttp_sender.py | 44 ++++++++----- 11 files changed, 138 insertions(+), 80 deletions(-) create mode 100644 dev/testing/microsoft-agents-testing/env.TEMPLATE diff --git a/dev/testing/microsoft-agents-testing/env.TEMPLATE b/dev/testing/microsoft-agents-testing/env.TEMPLATE new file mode 100644 index 00000000..df82361b --- /dev/null +++ b/dev/testing/microsoft-agents-testing/env.TEMPLATE @@ -0,0 +1,5 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= + +LOGGING__LOGLEVEL__microsoft_agents.hosting.core=INFO \ No newline at end of file diff --git a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py index aa148e34..8fb57d7c 100644 --- a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py @@ -113,7 +113,7 @@ async def _init_agent_environment(self) -> dict: :return: The SDK configuration dictionary. """ - env_vars = dotenv_values(self._config.env_file_path) + env_vars = dotenv_values(self._config.env_file_path or ".env") sdk_config = load_configuration_from_env(env_vars) storage = MemoryStorage() @@ -179,10 +179,10 @@ async def run(self) -> AsyncIterator[ClientFactory]: async with callback_server.listen() as transcript: async with TestServer(app, port=3978) as server: - agent_url = f"http://{server.host}:{server.port}/" + agent_endpoint = f"http://127.0.0.1:{server.port}/api/messages" factory = _AiohttpClientFactory( - agent_url=agent_url, + agent_endpoint=agent_endpoint, response_endpoint=callback_server.service_endpoint, sdk_config=sdk_config, default_config=self._config.client_config, diff --git a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py index 51a0a5e4..db2276cf 100644 --- a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py @@ -32,14 +32,14 @@ class _AiohttpClientFactory: def __init__( self, - agent_url: str, + agent_endpoint: str, response_endpoint: str, sdk_config: dict, default_template: ActivityTemplate | None = None, default_config: ClientConfig | None = None, transcript: Transcript | None = None, ): - self._agent_url = agent_url + self._agent_endpoint = agent_endpoint self._response_endpoint = response_endpoint self._sdk_config = sdk_config self._default_template = default_template or ActivityTemplate() @@ -66,7 +66,7 @@ async def __call__(self, config: ClientConfig | None = None) -> AgentClient: pass # No auth available # Create session - session = ClientSession(base_url=self._agent_url, headers=headers) + session = ClientSession(headers=headers) self._sessions.append(session) # Build activity template with user identity @@ -76,7 +76,7 @@ async def __call__(self, config: ClientConfig | None = None) -> AgentClient: ) # Create sender and client - sender = AiohttpSender(session) + sender = AiohttpSender(self._agent_endpoint, session) return AgentClient(sender, self._transcript, template=template) async def cleanup(self): diff --git a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py index e384a393..85ef100b 100644 --- a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py @@ -31,11 +31,11 @@ class ExternalScenario(Scenario): Example:: - scenario = ExternalScenario("http://localhost:3978/api/messages") + scenario = ExternalScenario("http://localhost:3978/api/messages/") async with scenario.client() as client: replies = await client.send("Hello!") - :param endpoint: The URL of the agent's message endpoint. + :param agent_url: The URL of the agent's message endpoint. :param config: Optional scenario configuration. """ @@ -48,8 +48,8 @@ def __init__(self, endpoint: str, config: ScenarioConfig | None = None) -> None: @asynccontextmanager async def run(self) -> AsyncIterator[ClientFactory]: """Start callback server and yield a client factory.""" - - env_vars = dotenv_values(self._config.env_file_path) + + env_vars = dotenv_values(self._config.env_file_path or ".env") sdk_config = load_configuration_from_env(env_vars) callback_server = AiohttpCallbackServer(self._config.callback_server_port) @@ -58,7 +58,7 @@ async def run(self) -> AsyncIterator[ClientFactory]: # Create a factory that binds the agent URL, callback endpoint, # and SDK config so callers can create configured clients factory = _AiohttpClientFactory( - agent_url=self._endpoint, + agent_endpoint=self._endpoint, response_endpoint=callback_server.service_endpoint, sdk_config=sdk_config, default_config=self._config.client_config, diff --git a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py index 6e38efc7..f2527f2f 100644 --- a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py @@ -10,6 +10,7 @@ from typing import AsyncContextManager from aiohttp import ClientSession +from aiohttp.client_exceptions import ClientError from microsoft_agents.activity import Activity @@ -24,9 +25,14 @@ class AiohttpSender(Sender): the response in an Exchange object. """ - def __init__(self, session: ClientSession): + def __init__(self, endpoint, session: ClientSession): + self._endpoint = endpoint self._session = session + @property + def endpoint(self) -> str: + return self._endpoint + async def send(self, activity: Activity, transcript: Transcript | None = None, **kwargs) -> Exchange: """Send an activity and return the Exchange containing the response. @@ -41,7 +47,7 @@ async def send(self, activity: Activity, transcript: Transcript | None = None, * request_at = datetime.now(timezone.utc) try: async with self._session.post( - "api/messages", + self._endpoint, json=activity.model_dump( by_alias=True, exclude_unset=True, exclude_none=True, mode="json" ), @@ -49,17 +55,26 @@ async def send(self, activity: Activity, transcript: Transcript | None = None, * ) as response: response_at = datetime.now(timezone.utc) response_or_exception = response + + if response.status >= 300: + raise ClientError(f"Received non-success status code: {response.status}") + exchange = await Exchange.from_request( request_activity=activity, response_or_exception=response_or_exception, request_at=request_at, response_at=response_at, + status=response.status, **kwargs ) - except Exception as e: + except ClientError as e: + + if response_or_exception is not None: + raise # If we got a response but it was an error status, re-raise the exception + response_at = datetime.now(timezone.utc) - response_or_exception = e + response_or_exception = e exchange = await Exchange.from_request( request_activity=activity, diff --git a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py index 3d98f197..6f12b7a3 100644 --- a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py @@ -89,6 +89,7 @@ def is_allowed_exception(exception: Exception) -> bool: async def from_request( request_activity: Activity, response_or_exception: Exception | ResponseT, + status: int | None = None, **kwargs ) -> Exchange: """Create an Exchange from a request activity and its outcome. @@ -115,6 +116,13 @@ async def from_request( error=str(response_or_exception), **kwargs, ) + elif isinstance(status, int) and status >= 300: + text = await response_or_exception.text() + return Exchange( + request=request_activity, + error=text, + **kwargs + ) if isinstance(response_or_exception, aiohttp.ClientResponse): @@ -129,7 +137,7 @@ async def from_request( body = await response.text() activity_list = json.loads(body)["activities"] activities = [ Activity.model_validate(activity) for activity in activity_list ] - + elif request_activity.type == ActivityTypes.invoke: body = await response.text() body_json = json.loads(body) diff --git a/dev/testing/microsoft-agents-testing/pytest.ini b/dev/testing/microsoft-agents-testing/pytest.ini index dae63b29..997500e6 100644 --- a/dev/testing/microsoft-agents-testing/pytest.ini +++ b/dev/testing/microsoft-agents-testing/pytest.ini @@ -41,4 +41,5 @@ markers = slow: Slow tests that may take longer to run requires_network: Tests that require network access requires_auth: Tests that require authentication - failure_demo: Intentionally failing tests for assertion/transcript formatting review \ No newline at end of file + failure_demo: Intentionally failing tests for assertion/transcript formatting review + agent_test: Tests that involve agent interactions \ No newline at end of file diff --git a/dev/testing/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py b/dev/testing/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py index 80833d3c..dd31e0a1 100644 --- a/dev/testing/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py +++ b/dev/testing/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py @@ -30,7 +30,7 @@ def test_initialization_stores_all_parameters(self): sdk_config = {"CONNECTIONS": {}} factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config=sdk_config, default_template=template, @@ -38,7 +38,7 @@ def test_initialization_stores_all_parameters(self): transcript=transcript, ) - assert factory._agent_url == "http://localhost:3978" + assert factory._agent_endpoint == "http://localhost:3978" assert factory._response_endpoint == "http://localhost:9378/api/callback" assert factory._sdk_config is sdk_config assert factory._default_template is template @@ -48,7 +48,7 @@ def test_initialization_stores_all_parameters(self): def test_initialization_creates_empty_sessions_list(self): """Factory initializes with empty sessions list.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -60,17 +60,17 @@ def test_initialization_creates_empty_sessions_list(self): # ============================================================================ -# _AiohttpClientfactory Tests +# _AiohttpClientFactory Tests # ============================================================================ class TestAiohttpClientFactoryCreateClient: - """Tests for _AiohttpClientfactory method.""" + """Tests for _AiohttpClientFactory method.""" @pytest.fixture def factory(self): """Create a factory with default configuration.""" return _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(type="message"), @@ -210,7 +210,7 @@ class TestAiohttpClientFactoryAuthorization: async def test_explicit_authorization_header_preserved(self): """Explicit Authorization header is preserved.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -232,7 +232,7 @@ async def test_explicit_authorization_header_preserved(self): async def test_auth_token_overrides_when_no_explicit_authorization(self): """auth_token is used when no explicit Authorization header.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -254,7 +254,7 @@ async def test_auth_token_overrides_when_no_explicit_authorization(self): async def test_no_auth_when_no_token_and_no_sdk_config(self): """No Authorization header when no token and sdk_config fails.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, # Empty, will cause generate_token_from_config to fail default_template=ActivityTemplate(), @@ -278,7 +278,7 @@ async def test_sdk_config_token_generation_on_failure(self): invalid_sdk_config = {"CONNECTIONS": {"SERVICE_CONNECTION": {"SETTINGS": {}}}} factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config=invalid_sdk_config, default_template=ActivityTemplate(), @@ -306,7 +306,7 @@ class TestAiohttpClientFactoryCleanup: async def test_cleanup_closes_all_sessions(self): """cleanup closes all created sessions.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -331,7 +331,7 @@ async def test_cleanup_closes_all_sessions(self): async def test_cleanup_clears_sessions_list(self): """cleanup clears the sessions list.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -352,7 +352,7 @@ async def test_cleanup_clears_sessions_list(self): async def test_cleanup_on_empty_sessions_list(self): """cleanup handles empty sessions list gracefully.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -369,7 +369,7 @@ async def test_cleanup_on_empty_sessions_list(self): async def test_cleanup_can_be_called_multiple_times(self): """cleanup can be called multiple times safely.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -398,7 +398,7 @@ async def test_default_template_used_when_config_has_none(self): default_template = ActivityTemplate(type="message", text="Default") factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=default_template, @@ -421,7 +421,7 @@ async def test_config_template_used_when_provided(self): config = ClientConfig(activity_template=custom_template) factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=default_template, @@ -448,7 +448,7 @@ class TestAiohttpClientFactoryIntegration: async def test_full_workflow_create_and_cleanup(self): """Full workflow: create multiple clients, then cleanup.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -481,7 +481,7 @@ async def test_full_workflow_create_and_cleanup(self): async def test_session_base_url_is_set_correctly(self): """Sessions are created with correct base_url.""" factory = _AiohttpClientFactory( - agent_url="http://my-agent:3978", + agent_endpoint="http://my-agent:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -493,7 +493,6 @@ async def test_session_base_url_is_set_correctly(self): try: session = factory._sessions[0] - # aiohttp stores base_url as a URL object - assert str(session._base_url) == "http://my-agent:3978" + assert session._base_url is None finally: await factory.cleanup() diff --git a/dev/testing/microsoft-agents-testing/tests/core/test_external_scenario.py b/dev/testing/microsoft-agents-testing/tests/core/test_external_scenario.py index 85d21649..5fd29869 100644 --- a/dev/testing/microsoft-agents-testing/tests/core/test_external_scenario.py +++ b/dev/testing/microsoft-agents-testing/tests/core/test_external_scenario.py @@ -238,7 +238,7 @@ async def test_run_passes_endpoint_to_factory(self): mock_server_class.return_value = mock_server async with scenario.run() as factory: - assert factory._agent_url == "http://my-agent:3978/api/messages" + assert factory._agent_endpoint == "http://my-agent:3978/api/messages" @pytest.mark.asyncio async def test_run_passes_service_endpoint_to_factory(self): @@ -539,7 +539,7 @@ async def test_run_with_none_env_file_path(self): mock_server_class.return_value = mock_server async with scenario.run() as factory: - mock_dotenv.assert_called_once_with(None) + mock_dotenv.assert_called_once_with(".env") # ============================================================================ diff --git a/dev/testing/microsoft-agents-testing/tests/core/test_integration.py b/dev/testing/microsoft-agents-testing/tests/core/test_integration.py index b9facb30..f8133628 100644 --- a/dev/testing/microsoft-agents-testing/tests/core/test_integration.py +++ b/dev/testing/microsoft-agents-testing/tests/core/test_integration.py @@ -169,10 +169,12 @@ async def test_sender_posts_to_real_server(self): """AiohttpSender posts to a real HTTP server.""" mock_server = MockAgentServer(port=9901) mock_server.default_response(Activity(type=ActivityTypes.message, text="Reply")) + + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -189,10 +191,11 @@ async def test_sender_with_expect_replies(self): Activity(type=ActivityTypes.message, text="Reply 1"), Activity(type=ActivityTypes.message, text="Reply 2") ) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) activity = Activity( type=ActivityTypes.message, text="Hello", @@ -210,10 +213,11 @@ async def test_sender_with_invoke(self): """AiohttpSender handles invoke activities.""" mock_server = MockAgentServer(port=9903) mock_server.on_invoke("action/test", 200, {"result": "success"}) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) activity = Activity(type=ActivityTypes.invoke, name="action/test") exchange = await sender.send(activity) @@ -226,10 +230,11 @@ async def test_sender_with_invoke(self): async def test_sender_records_to_transcript(self): """AiohttpSender records exchanges to transcript.""" mock_server = MockAgentServer(port=9904) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) transcript = Transcript() activity1 = Activity(type=ActivityTypes.message, text="First") @@ -255,10 +260,11 @@ async def test_client_sends_via_http(self): """AgentClient sends activities via real HTTP.""" mock_server = MockAgentServer(port=9905) mock_server.default_response(Activity(type=ActivityTypes.message, text="OK")) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) template = ActivityTemplate( channel_id="test", **{"conversation.id": "conv-1", "from.id": "user-1"} @@ -285,8 +291,9 @@ async def test_client_full_conversation_flow(self): mock_server.default_response(Activity(type=ActivityTypes.message, text="I don't understand")) async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + agent_endpoint = f"{mock_server.endpoint}/api/messages" + sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) # Greeting @@ -311,8 +318,9 @@ async def test_client_invoke_via_http(self): mock_server.on_invoke("submit/form", 200, {"submitted": True, "id": "form-123"}) async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + agent_endpoint = f"{mock_server.endpoint}/api/messages" + sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) invoke_response = await client.invoke( @@ -406,11 +414,12 @@ async def test_factory_creates_working_client(self): """Factory creates clients that can communicate with agent.""" mock_server = MockAgentServer(port=9911) mock_server.default_response(Activity(type=ActivityTypes.message, text="Factory test OK")) - + agent_endpoint = f"{mock_server.endpoint}/api/messages" + async with mock_server.run(): transcript = Transcript() factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate(channel_id="test"), @@ -440,9 +449,10 @@ async def test_factory_applies_default_template(self): ) async with mock_server.run(): + agent_endpoint = f"{mock_server.endpoint}/api/messages" transcript = Transcript() factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=default_template, @@ -467,9 +477,10 @@ async def test_factory_creates_multiple_clients(self): mock_server.default_response(Activity(type=ActivityTypes.message, text="OK")) async with mock_server.run(): + agent_endpoint = f"{mock_server.endpoint}/api/messages" transcript = Transcript() factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -500,8 +511,9 @@ async def test_factory_cleanup_closes_sessions(self): mock_server = MockAgentServer(port=9914) async with mock_server.run(): + agent_endpoint = f"{mock_server.endpoint}/api/messages" factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -581,8 +593,9 @@ async def test_complete_http_conversation_flow(self): async with mock_server.run(): # Setup infrastructure transcript = Transcript() + agent_endpoint = f"{mock_server.endpoint}/api/messages" factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate( @@ -633,11 +646,12 @@ async def test_multi_user_http_conversation(self): """Multiple users in same conversation via HTTP.""" mock_server = MockAgentServer(port=9921) mock_server.default_response(Activity(type=ActivityTypes.message, text="Received")) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): transcript = Transcript() factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate(**{"conversation.id": "multi-user-conv"}), @@ -683,8 +697,9 @@ async def test_invoke_and_message_mixed_flow(self): mock_server.default_response(Activity(type=ActivityTypes.message, text="Message received")) async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + agent_endpoint = f"{mock_server.endpoint}/api/messages" + sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) # Regular message @@ -729,8 +744,9 @@ async def test_select_and_expect_with_http_responses(self): ) async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + agent_endpoint = f"{mock_server.endpoint}/api/messages" + sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) responses = await client.send_expect_replies("report") diff --git a/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py b/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py index c040696a..f6e0fca0 100644 --- a/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py +++ b/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py @@ -11,6 +11,7 @@ import pytest import aiohttp from aiohttp import ClientSession, ClientResponse +from aiohttp.client_exceptions import ClientError from microsoft_agents.activity import Activity, ActivityTypes, DeliveryModes from microsoft_agents.testing.core.transport import AiohttpSender @@ -36,6 +37,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post return mock_session +ENDPOINT = "http://localhost:9999/api/messages" class TestAiohttpSenderInitialization: """Tests for AiohttpSender initialization.""" @@ -44,7 +46,7 @@ def test_aiohttp_sender_stores_session(self): """AiohttpSender should store the provided session.""" mock_session = MagicMock(spec=ClientSession) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) assert sender._session is mock_session @@ -67,13 +69,13 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") await sender.send(activity) assert len(post_calls) == 1 - assert post_calls[0][0][0] == "api/messages" + assert post_calls[0][0][0] == "http://localhost:9999/api/messages" @pytest.mark.asyncio async def test_send_serializes_activity_correctly(self): @@ -90,7 +92,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") await sender.send(activity) @@ -107,7 +109,7 @@ async def test_send_returns_exchange(self): mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -122,7 +124,7 @@ async def test_send_records_timestamps(self): mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -138,7 +140,7 @@ async def test_send_records_to_transcript(self): mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") transcript = Transcript() @@ -153,7 +155,7 @@ async def test_send_without_transcript_does_not_record(self): mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") # Should not raise @@ -175,7 +177,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") await sender.send(activity, timeout=30) @@ -198,7 +200,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -218,7 +220,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -237,7 +239,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") transcript = Transcript() @@ -258,7 +260,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") with pytest.raises(ValueError, match="Unexpected error"): @@ -279,7 +281,7 @@ async def test_send_expect_replies_parses_responses(self): mock_response = create_mock_response(200, responses_json) mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity( type=ActivityTypes.message, text="Hello", @@ -304,7 +306,7 @@ async def test_send_invoke_parses_invoke_response(self): mock_response = create_mock_response(200, invoke_response_json) mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity( type=ActivityTypes.invoke, name="testAction" @@ -315,3 +317,15 @@ async def test_send_invoke_parses_invoke_response(self): assert exchange.invoke_response is not None assert exchange.invoke_response.status == 200 assert exchange.invoke_response.body == {"result": "success"} + + @pytest.mark.asyncio + async def test_send_raises_on_non_success_status(self): + """send should raise ClientError when response status >= 300.""" + mock_response = create_mock_response(status=404, text="Not Found") + mock_session = create_mock_session(mock_response) + + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) + activity = Activity(type=ActivityTypes.message, text="Hello") + + with pytest.raises(ClientError, match="404"): + await sender.send(activity) \ No newline at end of file From ec5f9e09f2729ddba268ea016b35815382389507 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 16 Mar 2026 11:19:05 -0700 Subject: [PATCH 30/55] First batch of cross-sdk tests functioning for quickstart scenario --- .../agents/core-agent/README.md | 3 + .../core-agent}/python/README.md | 0 .../core-agent/python}/__init__.py | 0 .../core-agent}/python/env.TEMPLATE | 0 .../core-agent}/python/pre_requirements.txt | 0 .../core-agent}/python/requirements.txt | 0 .../core-agent/python/src}/__init__.py | 0 .../core-agent}/python/src/agent.py | 0 .../core-agent}/python/src/app.py | 0 .../core-agent}/python/src/config.py | 0 .../python/src/weather}/__init__.py | 0 .../python/src/weather/agents}/__init__.py | 0 .../weather/agents/weather_forecast_agent.py | 0 .../python/src/weather/plugins/__init__.py | 0 .../weather/plugins/adaptive_card_plugin.py | 0 .../src/weather/plugins/date_time_plugin.py | 0 .../src/weather/plugins/weather_forecast.py | 0 .../plugins/weather_forecast_plugin.py | 0 .../{_agents => agents}/quickstart/README.md | 0 .../quickstart/js/_run_agent.ps1 | 0 .../quickstart/js/env.TEMPLATE | 0 .../quickstart/js/package-lock.json | 2 +- .../quickstart/js/package.json | 0 .../quickstart/js/src/index.ts | 0 .../quickstart/js/tsconfig.json | 0 .../quickstart/net/AspNetExtensions.cs | 0 .../quickstart/net/MyAgent.cs | 0 .../quickstart/net/Program.cs | 0 .../quickstart/net/Quickstart.csproj | 0 .../quickstart/net/_run_agent.ps1 | 0 .../quickstart/python/_run_agent.ps1 | 0 .../quickstart/python/env.TEMPLATE | 0 .../quickstart/python/requirements.txt | 0 .../quickstart/python/src}/__init__.py | 0 .../quickstart/python/src/agent.py | 0 .../quickstart/python/src/main.py | 0 .../quickstart/python/src/start_server.py | 0 dev/testing/cross-sdk-tests/env.TEMPLATE | 5 ++ dev/testing/cross-sdk-tests/pytest.ini | 33 +++++++++ .../python/src => tests}/__init__.py | 0 .../cross-sdk-tests/tests/_common/__init__.py | 10 +-- .../tests/_common/constants.py | 8 +-- .../tests/_common/source_scenario.py | 62 ++++++++++------ .../cross-sdk-tests/tests/_common/types.py | 2 +- .../cross-sdk-tests/tests/_common/utils.py | 10 +-- .../tests/basic/test_quickstart.py | 71 +++++++++++++++++++ .../cross-sdk-tests/tests/my_script.py | 48 +++++++++++++ .../tests/telemetry/test_basic_telemetry.py | 5 +- .../testing/core/external_scenario.py | 2 +- .../core/transport/test_aiohttp_sender.py | 2 +- .../tests/integration/test_quickstart.py | 1 + 51 files changed, 223 insertions(+), 41 deletions(-) create mode 100644 dev/testing/cross-sdk-tests/agents/core-agent/README.md rename dev/testing/cross-sdk-tests/{_agents/basic_agent => agents/core-agent}/python/README.md (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent => agents/core-agent/python}/__init__.py (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent => agents/core-agent}/python/env.TEMPLATE (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent => agents/core-agent}/python/pre_requirements.txt (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent => agents/core-agent}/python/requirements.txt (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent/python => agents/core-agent/python/src}/__init__.py (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent => agents/core-agent}/python/src/agent.py (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent => agents/core-agent}/python/src/app.py (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent => agents/core-agent}/python/src/config.py (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent/python/src => agents/core-agent/python/src/weather}/__init__.py (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent/python/src/weather => agents/core-agent/python/src/weather/agents}/__init__.py (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent => agents/core-agent}/python/src/weather/agents/weather_forecast_agent.py (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent => agents/core-agent}/python/src/weather/plugins/__init__.py (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent => agents/core-agent}/python/src/weather/plugins/adaptive_card_plugin.py (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent => agents/core-agent}/python/src/weather/plugins/date_time_plugin.py (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent => agents/core-agent}/python/src/weather/plugins/weather_forecast.py (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent => agents/core-agent}/python/src/weather/plugins/weather_forecast_plugin.py (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/README.md (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/js/_run_agent.ps1 (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/js/env.TEMPLATE (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/js/package-lock.json (99%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/js/package.json (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/js/src/index.ts (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/js/tsconfig.json (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/net/AspNetExtensions.cs (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/net/MyAgent.cs (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/net/Program.cs (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/net/Quickstart.csproj (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/net/_run_agent.ps1 (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/python/_run_agent.ps1 (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/python/env.TEMPLATE (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/python/requirements.txt (100%) rename dev/testing/cross-sdk-tests/{_agents/basic_agent/python/src/weather/agents => agents/quickstart/python/src}/__init__.py (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/python/src/agent.py (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/python/src/main.py (100%) rename dev/testing/cross-sdk-tests/{_agents => agents}/quickstart/python/src/start_server.py (100%) create mode 100644 dev/testing/cross-sdk-tests/env.TEMPLATE create mode 100644 dev/testing/cross-sdk-tests/pytest.ini rename dev/testing/cross-sdk-tests/{_agents/quickstart/python/src => tests}/__init__.py (100%) create mode 100644 dev/testing/cross-sdk-tests/tests/basic/test_quickstart.py create mode 100644 dev/testing/cross-sdk-tests/tests/my_script.py diff --git a/dev/testing/cross-sdk-tests/agents/core-agent/README.md b/dev/testing/cross-sdk-tests/agents/core-agent/README.md new file mode 100644 index 00000000..2c07f981 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/core-agent/README.md @@ -0,0 +1,3 @@ +# Core Agent + +An agent that has various routes to test diverse the Agents SDK core functionalities. \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/README.md b/dev/testing/cross-sdk-tests/agents/core-agent/python/README.md similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/README.md rename to dev/testing/cross-sdk-tests/agents/core-agent/python/README.md diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/__init__.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/__init__.py diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/env.TEMPLATE b/dev/testing/cross-sdk-tests/agents/core-agent/python/env.TEMPLATE similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/env.TEMPLATE rename to dev/testing/cross-sdk-tests/agents/core-agent/python/env.TEMPLATE diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/pre_requirements.txt b/dev/testing/cross-sdk-tests/agents/core-agent/python/pre_requirements.txt similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/pre_requirements.txt rename to dev/testing/cross-sdk-tests/agents/core-agent/python/pre_requirements.txt diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/requirements.txt b/dev/testing/cross-sdk-tests/agents/core-agent/python/requirements.txt similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/requirements.txt rename to dev/testing/cross-sdk-tests/agents/core-agent/python/requirements.txt diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/__init__.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/__init__.py diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/agent.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/agent.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/agent.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/agent.py diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/app.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/app.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/app.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/app.py diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/config.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/config.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/config.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/config.py diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/__init__.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/__init__.py diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/agents/__init__.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/agents/__init__.py diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/agents/weather_forecast_agent.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/agents/weather_forecast_agent.py diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/__init__.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/__init__.py diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/adaptive_card_plugin.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/adaptive_card_plugin.py diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/date_time_plugin.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/date_time_plugin.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/date_time_plugin.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/date_time_plugin.py diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/weather_forecast.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/weather_forecast.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/weather_forecast.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/weather_forecast.py diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/weather_forecast_plugin.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/weather_forecast_plugin.py diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/README.md b/dev/testing/cross-sdk-tests/agents/quickstart/README.md similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/README.md rename to dev/testing/cross-sdk-tests/agents/quickstart/README.md diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/js/_run_agent.ps1 b/dev/testing/cross-sdk-tests/agents/quickstart/js/_run_agent.ps1 similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/js/_run_agent.ps1 rename to dev/testing/cross-sdk-tests/agents/quickstart/js/_run_agent.ps1 diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/js/env.TEMPLATE b/dev/testing/cross-sdk-tests/agents/quickstart/js/env.TEMPLATE similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/js/env.TEMPLATE rename to dev/testing/cross-sdk-tests/agents/quickstart/js/env.TEMPLATE diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/js/package-lock.json b/dev/testing/cross-sdk-tests/agents/quickstart/js/package-lock.json similarity index 99% rename from dev/testing/cross-sdk-tests/_agents/quickstart/js/package-lock.json rename to dev/testing/cross-sdk-tests/agents/quickstart/js/package-lock.json index f8f28dce..64fc102a 100644 --- a/dev/testing/cross-sdk-tests/_agents/quickstart/js/package-lock.json +++ b/dev/testing/cross-sdk-tests/agents/quickstart/js/package-lock.json @@ -3074,4 +3074,4 @@ } } } -} \ No newline at end of file +} diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/js/package.json b/dev/testing/cross-sdk-tests/agents/quickstart/js/package.json similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/js/package.json rename to dev/testing/cross-sdk-tests/agents/quickstart/js/package.json diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/js/src/index.ts b/dev/testing/cross-sdk-tests/agents/quickstart/js/src/index.ts similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/js/src/index.ts rename to dev/testing/cross-sdk-tests/agents/quickstart/js/src/index.ts diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/js/tsconfig.json b/dev/testing/cross-sdk-tests/agents/quickstart/js/tsconfig.json similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/js/tsconfig.json rename to dev/testing/cross-sdk-tests/agents/quickstart/js/tsconfig.json diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/net/AspNetExtensions.cs b/dev/testing/cross-sdk-tests/agents/quickstart/net/AspNetExtensions.cs similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/net/AspNetExtensions.cs rename to dev/testing/cross-sdk-tests/agents/quickstart/net/AspNetExtensions.cs diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/net/MyAgent.cs b/dev/testing/cross-sdk-tests/agents/quickstart/net/MyAgent.cs similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/net/MyAgent.cs rename to dev/testing/cross-sdk-tests/agents/quickstart/net/MyAgent.cs diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/net/Program.cs b/dev/testing/cross-sdk-tests/agents/quickstart/net/Program.cs similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/net/Program.cs rename to dev/testing/cross-sdk-tests/agents/quickstart/net/Program.cs diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/net/Quickstart.csproj b/dev/testing/cross-sdk-tests/agents/quickstart/net/Quickstart.csproj similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/net/Quickstart.csproj rename to dev/testing/cross-sdk-tests/agents/quickstart/net/Quickstart.csproj diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/net/_run_agent.ps1 b/dev/testing/cross-sdk-tests/agents/quickstart/net/_run_agent.ps1 similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/net/_run_agent.ps1 rename to dev/testing/cross-sdk-tests/agents/quickstart/net/_run_agent.ps1 diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/python/_run_agent.ps1 b/dev/testing/cross-sdk-tests/agents/quickstart/python/_run_agent.ps1 similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/python/_run_agent.ps1 rename to dev/testing/cross-sdk-tests/agents/quickstart/python/_run_agent.ps1 diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/python/env.TEMPLATE b/dev/testing/cross-sdk-tests/agents/quickstart/python/env.TEMPLATE similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/python/env.TEMPLATE rename to dev/testing/cross-sdk-tests/agents/quickstart/python/env.TEMPLATE diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/python/requirements.txt b/dev/testing/cross-sdk-tests/agents/quickstart/python/requirements.txt similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/python/requirements.txt rename to dev/testing/cross-sdk-tests/agents/quickstart/python/requirements.txt diff --git a/dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/agents/__init__.py b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/__init__.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/basic_agent/python/src/weather/agents/__init__.py rename to dev/testing/cross-sdk-tests/agents/quickstart/python/src/__init__.py diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/agent.py b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/agent.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/python/src/agent.py rename to dev/testing/cross-sdk-tests/agents/quickstart/python/src/agent.py diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/main.py b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/main.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/python/src/main.py rename to dev/testing/cross-sdk-tests/agents/quickstart/python/src/main.py diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/start_server.py b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/start_server.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/python/src/start_server.py rename to dev/testing/cross-sdk-tests/agents/quickstart/python/src/start_server.py diff --git a/dev/testing/cross-sdk-tests/env.TEMPLATE b/dev/testing/cross-sdk-tests/env.TEMPLATE new file mode 100644 index 00000000..df82361b --- /dev/null +++ b/dev/testing/cross-sdk-tests/env.TEMPLATE @@ -0,0 +1,5 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= + +LOGGING__LOGLEVEL__microsoft_agents.hosting.core=INFO \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/pytest.ini b/dev/testing/cross-sdk-tests/pytest.ini new file mode 100644 index 00000000..9908f4bf --- /dev/null +++ b/dev/testing/cross-sdk-tests/pytest.ini @@ -0,0 +1,33 @@ +[pytest] +# Pytest configuration for Microsoft Agents for Python + +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning + ignore::aiohttp.web.NotAppKeyWarning + +# Test discovery configuration +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* +asyncio_mode=auto + +# Output configuration +addopts = + --strict-markers + --strict-config + --verbose + --tb=short + --durations=10 + +# Minimum version requirement +minversion = 6.0 + +# Markers for test categorization +markers = + unit: Unit tests + integration: Integration tests + slow: Slow tests that may take longer to run + requires_network: Tests that require network access + requires_auth: Tests that require authentication \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/_agents/quickstart/python/src/__init__.py b/dev/testing/cross-sdk-tests/tests/__init__.py similarity index 100% rename from dev/testing/cross-sdk-tests/_agents/quickstart/python/src/__init__.py rename to dev/testing/cross-sdk-tests/tests/__init__.py diff --git a/dev/testing/cross-sdk-tests/tests/_common/__init__.py b/dev/testing/cross-sdk-tests/tests/_common/__init__.py index 408ae54f..b04ac09c 100644 --- a/dev/testing/cross-sdk-tests/tests/_common/__init__.py +++ b/dev/testing/cross-sdk-tests/tests/_common/__init__.py @@ -1,10 +1,12 @@ +from .types import SDKVersion + from .utils import ( - create_scenario + create_agent_path, + create_scenario, ) -from .types import SDKVersion - __all__ = [ + "create_agent_path", "create_scenario", - "SDKVersion" + "SDKVersion", ] \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/_common/constants.py b/dev/testing/cross-sdk-tests/tests/_common/constants.py index 8af8e802..49f5acda 100644 --- a/dev/testing/cross-sdk-tests/tests/_common/constants.py +++ b/dev/testing/cross-sdk-tests/tests/_common/constants.py @@ -1,7 +1,7 @@ import pathlib -_AGENTS_DIR_NAME + "_agents" -AGENTS_PATH = pathlib.Path(__file__).parent.parent.resolve() / _AGENTS_DIR_NAME -ENTRY_POINT_NAME = "run_agent.ps1" +_AGENTS_DIR_NAME = "agents" +AGENTS_PATH = pathlib.Path.cwd() / _AGENTS_DIR_NAME +ENTRY_POINT_NAME = "_run_agent.ps1" -breakpoint() \ No newline at end of file +DEFAULT_LOCAL_AGENT_ENDPOINT = "http://localhost:3978/api/messages" diff --git a/dev/testing/cross-sdk-tests/tests/_common/source_scenario.py b/dev/testing/cross-sdk-tests/tests/_common/source_scenario.py index 7b5941bc..c19cb8fd 100644 --- a/dev/testing/cross-sdk-tests/tests/_common/source_scenario.py +++ b/dev/testing/cross-sdk-tests/tests/_common/source_scenario.py @@ -1,22 +1,33 @@ import asyncio +import shutil import subprocess -from enum import Enum from pathlib import Path from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from .core import ( - ClientFactory, +from microsoft_agents.testing import ( + ActivityTemplate, + ClientConfig, ExternalScenario, ScenarioConfig, ) +from microsoft_agents.testing.core import ClientFactory -class SDKType(Enum, str): - PYTHON = "python" - JS = "js" - NET = "net" +from .constants import DEFAULT_LOCAL_AGENT_ENDPOINT + +_TEMPLATE = { + "channel_id": "webchat", + "locale": "en-US", + "conversation": {"id": "conv1"}, + "from": {"id": "user1", "name": "User"}, + "recipient": {"id": "bot", "name": "Bot"}, +} + +client_config=ClientConfig( + activity_template=ActivityTemplate(_TEMPLATE) +) class SourceScenario(ExternalScenario): """Base class for script-based test scenarios.""" @@ -27,34 +38,41 @@ def __init__( delay: float = 0.0, config: ScenarioConfig | None = None ) -> None: - super().__init__(config) + super().__init__(DEFAULT_LOCAL_AGENT_ENDPOINT, config) self._script_path = Path(script_path) self._delay = delay @asynccontextmanager async def _run_script(self) -> AsyncIterator[None]: + script_path = self._script_path.resolve() + runner = shutil.which("pwsh") or shutil.which("powershell") + if runner is None: + raise FileNotFoundError("Could not find pwsh or powershell in PATH") - script_name = self._script_path.name - script_parent = self._script_path.parent - - proc = subprocess.Popen( - [f"./{script_name}"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=script_parent - ) + try: + process = subprocess.Popen( + [runner, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script_path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=script_path.parent, + shell=True, # Needed to ensure the process group is correctly set up for termination + ) - # wait for the agent to start running - await asyncio.sleep(self._delay) + # wait for the agent to start running + await asyncio.sleep(self._delay) - yield + yield - proc.terminate() + process.terminate() + except Exception as ex: + process.kill() + raise ex @asynccontextmanager async def run(self) -> AsyncIterator[ClientFactory]: """Start callback server and yield a client factory.""" async with self._run_script(): - yield super().run() \ No newline at end of file + async with super().run() as factory: + yield factory \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/_common/types.py b/dev/testing/cross-sdk-tests/tests/_common/types.py index 8dd66393..305ef1a3 100644 --- a/dev/testing/cross-sdk-tests/tests/_common/types.py +++ b/dev/testing/cross-sdk-tests/tests/_common/types.py @@ -1,6 +1,6 @@ from enum import Enum -class SDKVersion(Enum, str): +class SDKVersion(str, Enum): PYTHON = "python" JS = "js" diff --git a/dev/testing/cross-sdk-tests/tests/_common/utils.py b/dev/testing/cross-sdk-tests/tests/_common/utils.py index d32b6248..a545d3f6 100644 --- a/dev/testing/cross-sdk-tests/tests/_common/utils.py +++ b/dev/testing/cross-sdk-tests/tests/_common/utils.py @@ -6,13 +6,13 @@ def create_agent_path(agent_name: str, sdk_version: SDKVersion) -> str: - agents_path = constants.AGENTS_PATH / agent_name / sdk_version.value / constants.ENTRY_POINT_NAME - if not agents_path.exists(): - raise FileNotFoundError(f"Agent path does not exist: {agents_path}") + agent_path = constants.AGENTS_PATH / agent_name / sdk_version.value / constants.ENTRY_POINT_NAME + if not agent_path.exists(): + raise FileNotFoundError(f"Agent path does not exist: {agent_path}") - return agents_path + return str(agent_path.resolve()) -def create_scenario(agent_name: str, sdk_version: SDKVersion, delay: float = 0.0) -> Scenario: +def create_scenario(agent_name: str, sdk_version: SDKVersion, delay: float = 5.0) -> Scenario: agent_path = create_agent_path(agent_name, sdk_version) return SourceScenario( diff --git a/dev/testing/cross-sdk-tests/tests/basic/test_quickstart.py b/dev/testing/cross-sdk-tests/tests/basic/test_quickstart.py new file mode 100644 index 00000000..121cdbb9 --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/basic/test_quickstart.py @@ -0,0 +1,71 @@ +import pytest + +from microsoft_agents.testing import AgentClient + +from tests._common import ( + create_scenario, + SDKVersion, +) + +AGENT_NAME = "quickstart" +PYTHON_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.PYTHON) +NET_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.NET) +JS_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.JS) + +class BaseTestQuickstart: + """Integration tests for the Quickstart scenario.""" + + @pytest.mark.asyncio + async def test_conversation_update(self, agent_client: AgentClient): + """Test sending a conversation update activity.""" + input_activity = agent_client.template.create({ + "type": "conversationUpdate", + "members_added": [ + {"id": "bot-id", "name": "Bot"}, + {"id": "user1", "name": "User"}, + ], + "textFormat": "plain", + "entities": [ + { + "type": "ClientCapabilities", + "requiresBotState": True, + "supportsTts": True + } + ], + "channel_data": {"clientActivityId": 123} + }) + + await agent_client.send(input_activity, wait=10) + agent_client.expect().that_for_one(type="message", text="~Welcome") + + # @pytest.mark.asyncio + async def test_send_hello(self, agent_client: AgentClient): + """Test sending a 'hello' message and receiving a response.""" + await agent_client.send("hello", wait=10) + agent_client.expect().that_for_one(type="message", text="Hello!") + + @pytest.mark.asyncio + async def test_send_hi(self, agent_client: AgentClient): + """Test sending a 'hi' message and receiving a response.""" + + await agent_client.send("hi", wait=10) + responses = agent_client.recent() + + assert len(responses) == 2 + assert len(agent_client.history()) == 2 + + agent_client.expect().that_for_one(type="message", text="you said: hi") + agent_client.expect().that_for_one(type="typing") + + +@pytest.mark.agent_test(PYTHON_SCENARIO) +class TestQuickstartPython(BaseTestQuickstart): + pass + +@pytest.mark.agent_test(NET_SCENARIO) +class TestQuickstartNet(BaseTestQuickstart): + pass + +@pytest.mark.agent_test(JS_SCENARIO) +class TestQuickstartJS(BaseTestQuickstart): + pass \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/my_script.py b/dev/testing/cross-sdk-tests/tests/my_script.py new file mode 100644 index 00000000..ad0455bd --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/my_script.py @@ -0,0 +1,48 @@ +import asyncio +import shutil +import subprocess +import datetime + +from pathlib import Path + +from ._common import ( + create_agent_path, + SDKVersion, +) +from ._common.utils import create_agent_path + +async def main(): + + script_path = Path(create_agent_path("quickstart", SDKVersion.PYTHON)) + + runner = shutil.which("pwsh") or shutil.which("powershell") + if runner is None: + raise FileNotFoundError("Could not find pwsh or powershell in PATH") + + # proc = subprocess.Popen( + # [runner, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script_path)], + # stdout=subprocess.PIPE, + # cwd=script_path.parent + # ) + + import io, sys + filename = "test.log" + with io.open(filename, "w", encoding="utf-8") as writer, io.open(filename, "rb", 128) as reader: + process = subprocess.Popen( + [runner, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script_path)], + stdout=writer, + stderr=writer, + cwd=script_path.parent, + shell=True, + ) + while process.poll() is None: + sys.stdout.write(reader.read().decode()) + await asyncio.sleep(0.5) + + # wait for the agent to start running + # print("Waiting for agent to start...") + + process.terminate() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/telemetry/test_basic_telemetry.py b/dev/testing/cross-sdk-tests/tests/telemetry/test_basic_telemetry.py index 5be2d706..f4f2b5ac 100644 --- a/dev/testing/cross-sdk-tests/tests/telemetry/test_basic_telemetry.py +++ b/dev/testing/cross-sdk-tests/tests/telemetry/test_basic_telemetry.py @@ -1,3 +1,5 @@ +import pytest + from tests._common import ( create_scenario, SDKVersion, @@ -9,11 +11,10 @@ JS_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.JS) class BaseTelemetryTests: - def test_telemetry(self): + def test_telemetry(self, agent_client): # This is a placeholder test. The actual telemetry tests will be implemented here. assert True - @pytest.mark.agent_test(PYTHON_SCENARIO) class TestPythonTelemetry(BaseTelemetryTests): pass diff --git a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py index 85ef100b..a20a2fcc 100644 --- a/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py @@ -31,7 +31,7 @@ class ExternalScenario(Scenario): Example:: - scenario = ExternalScenario("http://localhost:3978/api/messages/") + scenario = ExternalScenario("http://localhost:3978/api/messages") async with scenario.client() as client: replies = await client.send("Hello!") diff --git a/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py b/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py index f6e0fca0..b90af39b 100644 --- a/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py +++ b/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py @@ -5,7 +5,7 @@ import json from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock from contextlib import asynccontextmanager import pytest diff --git a/dev/testing/python-sdk-tests/tests/integration/test_quickstart.py b/dev/testing/python-sdk-tests/tests/integration/test_quickstart.py index a5765325..58e07e5d 100644 --- a/dev/testing/python-sdk-tests/tests/integration/test_quickstart.py +++ b/dev/testing/python-sdk-tests/tests/integration/test_quickstart.py @@ -30,6 +30,7 @@ class TestQuickstart: @pytest.mark.asyncio async def test_conversation_update(self, agent_client: AgentClient): """Test sending a conversation update activity.""" + input_activity = agent_client.template.create({ "type": "conversationUpdate", "members_added": [ From c6bcb4b944bb8b8ebe0884d721b29f05a2615b89 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 17 Mar 2026 14:38:51 -0700 Subject: [PATCH 31/55] Fixed comment --- .../cross-sdk-tests/tests/my_script.py | 48 ------------------- 1 file changed, 48 deletions(-) delete mode 100644 dev/testing/cross-sdk-tests/tests/my_script.py diff --git a/dev/testing/cross-sdk-tests/tests/my_script.py b/dev/testing/cross-sdk-tests/tests/my_script.py deleted file mode 100644 index ad0455bd..00000000 --- a/dev/testing/cross-sdk-tests/tests/my_script.py +++ /dev/null @@ -1,48 +0,0 @@ -import asyncio -import shutil -import subprocess -import datetime - -from pathlib import Path - -from ._common import ( - create_agent_path, - SDKVersion, -) -from ._common.utils import create_agent_path - -async def main(): - - script_path = Path(create_agent_path("quickstart", SDKVersion.PYTHON)) - - runner = shutil.which("pwsh") or shutil.which("powershell") - if runner is None: - raise FileNotFoundError("Could not find pwsh or powershell in PATH") - - # proc = subprocess.Popen( - # [runner, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script_path)], - # stdout=subprocess.PIPE, - # cwd=script_path.parent - # ) - - import io, sys - filename = "test.log" - with io.open(filename, "w", encoding="utf-8") as writer, io.open(filename, "rb", 128) as reader: - process = subprocess.Popen( - [runner, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script_path)], - stdout=writer, - stderr=writer, - cwd=script_path.parent, - shell=True, - ) - while process.poll() is None: - sys.stdout.write(reader.read().decode()) - await asyncio.sleep(0.5) - - # wait for the agent to start running - # print("Waiting for agent to start...") - - process.terminate() - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file From a1b63b6fd06c78361d2d4c432c17c3d5ef4fba4f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 23 Mar 2026 14:08:25 -0700 Subject: [PATCH 32/55] Revision to scoping --- .../activity/token_exchange_request.py | 15 ++ .../authentication/msal/telemetry/spans.py | 2 +- .../hosting/core/telemetry/__init__.py | 4 +- .../hosting/core/telemetry/_metrics.py | 20 +- .../hosting/core/telemetry/core/__init__.py | 21 ++ .../telemetry/{ => core}/_agents_telemetry.py | 43 ++-- .../core/telemetry/core/base_span_wrapper.py | 71 +++++++ .../core/telemetry/{ => core}/constants.py | 12 +- .../telemetry/core/simple_span_wrapper.py | 34 +++ .../hosting/core/telemetry/core/utils.py | 25 +++ .../hosting/core/telemetry/spans.py | 197 ++++++++++-------- .../hosting/core/telemetry/spans/__init__.py | 0 .../hosting/core/telemetry/spans/adapter.py | 132 ++++++++++++ .../hosting/core/telemetry/spans/app.py | 98 +++++++++ .../hosting/core/telemetry/spans/connector.py | 114 ++++++++++ .../hosting/core/telemetry/utils.py | 18 +- 16 files changed, 675 insertions(+), 131 deletions(-) create mode 100644 libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_request.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/{ => core}/_agents_telemetry.py (75%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/{ => core}/constants.py (89%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/utils.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/adapter.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/app.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/connector.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_request.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_request.py new file mode 100644 index 00000000..560908aa --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_request.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agents_model import AgentsModel + +from ._type_aliases import NonEmptyString + + +class TokenExchangeResource(AgentsModel): + """ + A type containing information for token exchange. + """ + + uri: NonEmptyString = None + token: NonEmptyString = None diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py index 69cc3e7b..c9106d38 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py @@ -3,7 +3,7 @@ from microsoft_agents.hosting.core.telemetry import ( agents_telemetry, - constants as common_constants, + core as common_constants, _format_scopes, ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py index 5a62790c..95cb63cc 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py @@ -8,10 +8,10 @@ # # NOTE: this module should not be auto-loaded from __init__.py in order to avoid -from ._agents_telemetry import ( +from .core._agents_telemetry import ( agents_telemetry, ) -from .constants import SERVICE_NAME, SERVICE_VERSION, RESOURCE +from .core import SERVICE_NAME, SERVICE_VERSION, RESOURCE from .utils import _format_scopes __all__ = [ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py index 02b31d0f..c8b02779 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py @@ -1,13 +1,13 @@ -from . import constants -from ._agents_telemetry import agents_telemetry +from . import core +from .core._agents_telemetry import agents_telemetry storage_operation_total = agents_telemetry.meter.create_counter( - constants.METRIC_STORAGE_OPERATION_TOTAL, + core.METRIC_STORAGE_OPERATION_TOTAL, "operation", description="Number of storage operations performed by the agent", ) storage_operation_duration = agents_telemetry.meter.create_histogram( - constants.METRIC_STORAGE_OPERATION_DURATION, + core.METRIC_STORAGE_OPERATION_DURATION, "ms", description="Duration of storage operations in milliseconds", ) @@ -15,19 +15,19 @@ # AgentApplication turn_total = agents_telemetry.meter.create_counter( - constants.METRIC_TURN_TOTAL, + core.METRIC_TURN_TOTAL, "turn", description="Total number of turns processed by the agent", ) turn_errors = agents_telemetry.meter.create_counter( - constants.METRIC_TURN_ERRORS, + core.METRIC_TURN_ERRORS, "turn", description="Number of turns that resulted in an error", ) turn_duration = agents_telemetry.meter.create_histogram( - constants.METRIC_TURN_DURATION, + core.METRIC_TURN_DURATION, "ms", description="Duration of agent turns in milliseconds", ) @@ -35,7 +35,7 @@ # Adapters adapter_process_duration = agents_telemetry.meter.create_histogram( - constants.METRIC_ADAPTER_PROCESS_DURATION, + core.METRIC_ADAPTER_PROCESS_DURATION, "ms", description="Duration of adapter processing in milliseconds", ) @@ -43,13 +43,13 @@ # Connectors connector_request_total = agents_telemetry.meter.create_counter( - constants.METRIC_CONNECTOR_REQUESTS_TOTAL, + core.METRIC_CONNECTOR_REQUESTS_TOTAL, "request", description="Total number of connector requests made by the agent", ) connector_request_duration = agents_telemetry.meter.create_histogram( - constants.METRIC_CONNECTOR_REQUEST_DURATION, + core.METRIC_CONNECTOR_REQUEST_DURATION, "ms", description="Duration of connector requests in milliseconds", ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py new file mode 100644 index 00000000..5695acec --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py @@ -0,0 +1,21 @@ +from . import constants +from ._agents_telemetry import agents_telemetry +from .simple_span_wrapper import SimpleSpanWrapper +from .base_span_wrapper import BaseSpanWrapper +from .utils import ( + AttributeMap, + format_scopes, + get_conversation_id, + get_delivery_mode, +) + +__all__ = [ + "agents_telemetry", + "constants", + "SimpleSpanWrapper", + "BaseSpanWrapper", + "AttributeMap", + "format_scopes", + "get_conversation_id", + "get_delivery_mode", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py similarity index 75% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py index 73d5699f..0b741ebc 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_agents_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py @@ -11,12 +11,10 @@ from microsoft_agents.activity import TurnContextProtocol -from . import constants - +from .. import core logger = logging.getLogger(__name__) -_TimedSpanCallback = Callable[[Span, float, Exception | None], None] - +SpanCallback = Callable[[Span, float, Exception | None], None] class _AgentsTelemetry: @@ -27,10 +25,10 @@ def __init__(self): :param meter: Optional OpenTelemetry Meter instance to use for recording metrics. If not provided, a new meter will be created with the service name and version from constants. """ self._tracer = trace.get_tracer( - constants.SERVICE_NAME, constants.SERVICE_VERSION + core.SERVICE_NAME, core.SERVICE_VERSION ) self._meter = metrics.get_meter( - constants.SERVICE_NAME, constants.SERVICE_VERSION + core.SERVICE_NAME, core.SERVICE_VERSION ) @property @@ -63,42 +61,29 @@ def _extract_attributes_from_context( len(turn_context.activity.text) if turn_context.activity.text else 0 ) return attributes + + def set_attributes_from_context(self, span: Span, turn_context: TurnContextProtocol) -> None: + """Extracts attributes from the TurnContext and sets them on the given span - @contextmanager - def start_as_current_span( - self, - span_name: str, - turn_context: TurnContextProtocol | None = None, - ) -> Iterator[Span]: - """Context manager for starting a new span with the given name and setting attributes from the TurnContext if provided - - :param span_name: The name of the span to start - :param turn_context Optional TurnContext to extract attributes from and set on the span - :return: An iterator that yields the started span, which will be ended when the context manager exits + :param span: The OpenTelemetry span to set attributes on + :param turn_context: The TurnContext to extract attributes from """ - with self._tracer.start_as_current_span(span_name) as span: - if turn_context is not None: - attributes = self._extract_attributes_from_context(turn_context) - span.set_attributes(attributes) - yield span - # self._tracer._tracer_provider._active_span_processor._span_processors[0].span_exporter._endpoint - + span.set_attributes(self._extract_attributes_from_context(turn_context)) + @contextmanager - def start_timed_span( + def start_as_current_span( self, span_name: str, - turn_context: TurnContextProtocol | None = None, - callback: _TimedSpanCallback | None = None, + callback: SpanCallback | None = None, ) -> Iterator[Span]: """Context manager for starting a timed span that records duration and success/failure status, and invokes a callback with the results :param span_name: The name of the span to start - :param turn_context Optional TurnContext to extract attributes from and set on the span :param callback: Optional callback function that will be called with the span, duration in milliseconds, and any exception that was raised (or None if successful) when the span is ended :return: An iterator that yields the started span, which will be ended when the context manager exits """ - with self.start_as_current_span(span_name, turn_context) as span: + with self._tracer.start_as_current_span(span_name) as span: start = time.time() exception: Exception | None = None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py new file mode 100644 index 00000000..bfe4c344 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import logging + +from abc import ABC, abstractmethod +from contextlib import ExitStack +from typing import ContextManager +from venv import logger + +from opentelemetry.trace import Span + +logger = logging.getLogger(__name__) + +class BaseSpanWrapper(ABC): + """Wrapper around OTEL spans for SDK-specific telemetry""" + + def __init__(self): + self._span: Span | None = None + self._active: bool = False + + self._exit_stack = ExitStack() + + @property + def otel_span(self) -> Span | None: + """Returns the underlying OTEL span if it is active, or None if the span has not been started or has already ended. This can be used to access OTEL-specific functionality or attributes of the span when needed, while still providing a higher-level abstraction through the BaseSpanWrapper class.""" + if self._span is None: + raise RuntimeError("BaseSpanWrapper has not been started yet") + return self._span + + @property + def active(self) -> bool: + """Indicates whether the BaseSpanWrapper is currently active. This can be used to prevent operations on an inactive BaseSpanWrapper, and to check the BaseSpanWrapper's lifecycle state.""" + return self._span is not None + + @abstractmethod + def _start_span(self) -> ContextManager[Span]: + """Abstract method that must be implemented by subclasses to define how the BaseSpanWrapper is started and what attributes are set on the BaseSpanWrapper. This method should return a context manager that yields the started BaseSpanWrapper, allowing the base BaseSpanWrapper class to manage the BaseSpanWrapper's lifecycle and ensure proper cleanup when the BaseSpanWrapper is ended.""" + raise NotImplementedError + + @staticmethod + def _log_lifespan_error(desc: str) -> None: + """Helper method to log a warning when an operation is attempted on an inactive BaseSpanWrapper. This can be used in methods that require an active BaseSpanWrapper to indicate potential misuse of the BaseSpanWrapper lifecycle.""" + logger.warning("Attempting to perform an operation on an inactive BaseSpanWrapper. This may indicate a bug in the telemetry implementation or misuse of the BaseSpanWrapper lifecycle.") + logger.warning("Description: %s", desc) + + @abstractmethod + def __enter__(self) -> BaseSpanWrapper: + """Starts the BaseSpanWrapper and returns the BaseSpanWrapper instance for chaining. This method should check if the BaseSpanWrapper is already active and log a warning if an attempt is made to start an already active BaseSpanWrapper, to help identify potential issues with BaseSpanWrapper lifecycle management.""" + if self.active: + BaseSpanWrapper._log_lifespan_error("Attempting to start a BaseSpanWrapper that is already active.") + + self._span = self._exit_stack.enter_context(self._start_span()) + self._active = True + + return self + + def start(self) -> BaseSpanWrapper: + """Starts the BaseSpanWrapper and returns the BaseSpanWrapper instance for chaining""" + return self.__enter__() + + def __exit__(self, exc_type, exc_val, exc_tb): + """Stops the BaseSpanWrapper if it is active, and logs a warning if an attempt is made to stop a BaseSpanWrapper that is not active. This ensures that BaseSpanWrappers are properly cleaned up and that potential issues with BaseSpanWrapper lifecycle management are logged for debugging purposes.""" + if self.active: + self._exit_stack.__exit__(exc_type, exc_val, exc_tb) + self._span = None + else: + BaseSpanWrapper._log_lifespan_error("BaseSpanWrapper is not active and cannot be exited") + + def end(self) -> None: + """Stops the BaseSpanWrapper if it is active""" + self.__exit__(None, None, None) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/constants.py similarity index 89% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/constants.py index 160a358c..1a396fc5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/constants.py @@ -24,6 +24,7 @@ SPAN_ADAPTER_DELETE_ACTIVITY = "agents.adapter.deleteActivity" SPAN_ADAPTER_CONTINUE_CONVERSATION = "agents.adapter.continueConversation" SPAN_ADAPTER_CREATE_CONNECTOR_CLIENT = "agents.adapter.createConnectorClient" +SPAN_ADAPTER_CREATE_USER_TOKEN_CLIENT = "agents.adapter.createUserTokenClient" SPAN_APP_ON_TURN = "agents.app.run" SPAN_APP_ROUTE_HANDLER = "agents.app.routeHandler" @@ -38,7 +39,7 @@ SPAN_CONNECTOR_CREATE_CONVERSATION = "agents.connector.createConversation" SPAN_CONNECTOR_GET_CONVERSATIONS = "agents.connector.getConversations" SPAN_CONNECTOR_GET_CONVERSATION_MEMBERS = "agents.connector.getConversationMembers" -SPAN_CONNECTOR_UPDLOAD_ATTACHMENT = "agents.connector.uploadAttachment" +SPAN_CONNECTOR_UPLOAD_ATTACHMENT = "agents.connector.uploadAttachment" SPAN_CONNECTOR_GET_ATTACHMENT = "agents.connector.getAttachment" SPAN_STORAGE_READ = "agents.storage.read" @@ -87,21 +88,28 @@ ATTR_ACTIVITY_TYPE = "activity.type" ATTR_AGENTIC_USER_ID = "agentic.user_id" ATTR_AGENTIC_INSTANCE_ID = "agentic.instance_id" -ATTR_ATTACHMENT_ID = "attachment.id" +ATTR_APP_ID = "agent.app_id" +ATTR_ATTACHMENT_ID = "activity.attachment.id" +ATTR_ATTACHMENT_COUNT = "activity.attachments.count" ATTR_AUTH_SCOPES = "auth.scopes" ATTR_AUTH_TYPE = "auth.method" ATTR_CONVERSATION_ID = "activity.conversation.id" +# TODO -> rename to ATTR_IS_AGENTIC ATTR_IS_AGENTIC_REQUEST = "is_agentic_request" ATTR_NUM_KEYS = "keys.num" +ATTR_ROUTE_AUTHORIZED = "route.authorized" ATTR_ROUTE_IS_INVOKE = "route.is_invoke" ATTR_ROUTE_IS_AGENTIC = "route.is_agentic" +ATTR_ROUTE_MATCHED = "route.matched" ATTR_SERVICE_URL = "service_url" +ATTR_TOKEN_SERVICE_ENDPOINT = "agents.token_service.endpoint" + # VALUES UNKNOWN = "unknown" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py new file mode 100644 index 00000000..c10cede3 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py @@ -0,0 +1,34 @@ +from abc import ABC +from collections.abc import Iterator +from contextlib import contextmanager + +from opentelemetry.trace import Span + +from ._agents_telemetry import agents_telemetry +from .base_span_wrapper import BaseSpanWrapper +from .utils import AttributeMap + +class SimpleSpanWrapper(BaseSpanWrapper, ABC): + """Simple implementation of the BaseSpanWrapper that can be used when no additional attributes or functionality are needed on the span beyond what is provided by the base BaseSpanWrapper class. This can be used as a simple wrapper around an OTEL span for cases where no SDK-specific telemetry is needed, while still providing the benefits of the BaseSpanWrapper abstraction and lifecycle management.""" + + def __init__(self, span_name: str): + super().__init__() + self._span_name = span_name + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This can be overridden by subclasses to provide custom attributes for the span based on the context in which it is being used.""" + return {} + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This can be overridden by subclasses to provide custom logic for recording metrics or handling errors based on the outcome of the span.""" + pass + + @contextmanager + def _start_span(self) -> Iterator[Span]: + """Starts a basic OTEL span with the given name and no additional attributes.""" + with agents_telemetry.start_as_current_span(self._span_name, callback=self._callback) as span: + if span is not None: + attributes = self._get_attributes() + if attributes: + span.set_attributes(attributes) + yield span \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/utils.py new file mode 100644 index 00000000..697ae83f --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/utils.py @@ -0,0 +1,25 @@ +from typing import Mapping, TypeVar + +from opentelemetry.util.types import AttributeValue +from microsoft_agents.activity import Activity, DeliveryModes + +from . import constants + +AttributeMap = Mapping[str, AttributeValue] + +def format_scopes(scopes: list[str] | None) -> str: + if not scopes: + return constants.UNKNOWN + return ",".join(scopes) + +def get_conversation_id(activity: Activity) -> str: + return activity.conversation.id if activity.conversation else constants.UNKNOWN + + +def get_delivery_mode(activity: Activity) -> str: + if activity.delivery_mode: + if isinstance(activity.delivery_mode, DeliveryModes): + return activity.delivery_mode.value + else: + return activity.delivery_mode + return constants.UNKNOWN \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py index d0a6849f..91445b4b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py @@ -1,50 +1,42 @@ from collections.abc import Iterator from contextlib import contextmanager +from typing import Protocol +from . import _metrics from opentelemetry.trace import Span from microsoft_agents.activity import Activity, TurnContextProtocol, DeliveryModes -from . import _metrics, constants -from ._agents_telemetry import agents_telemetry -from .utils import _format_scopes +from . import core +from .core._agents_telemetry import agents_telemetry +from .utils import ( + _format_scopes, + _get_conversation_id, + _get_delivery_mode, +) # # Adapter # -def _get_conversation_id(activity: Activity) -> str: - return activity.conversation.id if activity.conversation else constants.UNKNOWN - - -def _get_delivery_mode(activity: Activity) -> str: - if activity.delivery_mode: - if isinstance(activity.delivery_mode, DeliveryModes): - return activity.delivery_mode.value - else: - return activity.delivery_mode - return constants.UNKNOWN - - @contextmanager def start_span_adapter_process(activity: Activity) -> Iterator[None]: """Context manager for reording adapter process call""" - def callback(span: Span, duration: float, error: Exception | None): + def _callback(span: Span, duration: float, error: Exception | None): _metrics.adapter_process_duration.record(duration) with agents_telemetry.start_timed_span( - constants.SPAN_ADAPTER_PROCESS, callback=callback + core.SPAN_ADAPTER_PROCESS, callback=_callback ) as span: span.set_attributes( { - constants.ATTR_ACTIVITY_TYPE: activity.type, - constants.ATTR_ACTIVITY_CHANNEL_ID: activity.channel_id - or constants.UNKNOWN, - constants.ATTR_ACTIVITY_DELIVERY_MODE: _get_delivery_mode(activity), - constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), - constants.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), + core.ATTR_ACTIVITY_TYPE: activity.type, + core.ATTR_ACTIVITY_CHANNEL_ID: activity.channel_id or core.UNKNOWN, + core.ATTR_ACTIVITY_DELIVERY_MODE: _get_delivery_mode(activity), + core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), + core.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), } ) yield @@ -54,18 +46,19 @@ def callback(span: Span, duration: float, error: Exception | None): def start_span_adapter_send_activities(activities: list[Activity]) -> Iterator[None]: """Context manager for recording adapter send_activities call""" with agents_telemetry.start_as_current_span( - constants.SPAN_ADAPTER_SEND_ACTIVITIES + core.SPAN_ADAPTER_SEND_ACTIVITIES ) as span: - span.set_attributes( - { - constants.ATTR_ACTIVITY_COUNT: len(activities), - constants.ATTR_CONVERSATION_ID: ( - _get_conversation_id(activities[0]) - if activities - else constants.UNKNOWN - ), - } - ) + count = len(activities) + if count > 0: + span.set_attributes({ + core.ATTR_ACTIVITY_COUNT: count, + core.ATTR_CONVERSATION_ID: _get_conversation_id(activities[0]), + core.ATTR_ACTIVITY_TYPE: activities[0].type, + core.ATTR_ACTIVITY_ID: activities[0].id or core.UNKNOWN, + }) + else: + span.set_attribute(core.ATTR_ACTIVITY_COUNT, 0) + yield @@ -73,12 +66,12 @@ def start_span_adapter_send_activities(activities: list[Activity]) -> Iterator[N def start_span_adapter_update_activity(activity: Activity) -> Iterator[None]: """Context manager for recording adapter update_activity call""" with agents_telemetry.start_as_current_span( - constants.SPAN_ADAPTER_UPDATE_ACTIVITY + core.SPAN_ADAPTER_UPDATE_ACTIVITY ) as span: span.set_attributes( { - constants.ATTR_ACTIVITY_ID: activity.id or constants.UNKNOWN, - constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), + core.ATTR_ACTIVITY_ID: activity.id or core.UNKNOWN, + core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), } ) yield @@ -88,12 +81,12 @@ def start_span_adapter_update_activity(activity: Activity) -> Iterator[None]: def start_span_adapter_delete_activity(activity: Activity) -> Iterator[None]: """Context manager for recording adapter delete_activity call""" with agents_telemetry.start_as_current_span( - constants.SPAN_ADAPTER_DELETE_ACTIVITY + core.SPAN_ADAPTER_DELETE_ACTIVITY ) as span: span.set_attributes( { - constants.ATTR_ACTIVITY_ID: activity.id or constants.UNKNOWN, - constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), + core.ATTR_ACTIVITY_ID: activity.id or core.UNKNOWN, + core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), } ) yield @@ -103,16 +96,33 @@ def start_span_adapter_delete_activity(activity: Activity) -> Iterator[None]: def start_span_adapter_continue_conversation(activity: Activity) -> Iterator[None]: """Context manager for recording adapter continue_conversation call""" with agents_telemetry.start_as_current_span( - constants.SPAN_ADAPTER_CONTINUE_CONVERSATION + core.SPAN_ADAPTER_CONTINUE_CONVERSATION ) as span: span.set_attributes( { - constants.ATTR_CONVERSATION_ID: _get_conversation_id(activity), - constants.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), + core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), + core.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), } ) yield +@contextmanager +def start_span_adapter_create_user_token_client( + *, + token_service_endpoint: str, + scopes: list[str] | None, +) -> Iterator[None]: + """Context manager for recording adapter create_user_token_client call""" + with agents_telemetry.start_as_current_span( + core.SPAN_ADAPTER_CREATE_USER_TOKEN_CLIENT + ) as span: + span.set_attributes( + { + core.ATTR_TOKEN_SERVICE_ENDPOINT: token_service_endpoint, + core.ATTR_AUTH_SCOPES: _format_scopes(scopes), + } + ) + yield @contextmanager def start_span_adapter_create_connector_client( @@ -123,13 +133,13 @@ def start_span_adapter_create_connector_client( ) -> Iterator[None]: """Context manager for recording adapter create_connector_client call""" with agents_telemetry.start_as_current_span( - constants.SPAN_ADAPTER_CREATE_CONNECTOR_CLIENT + core.SPAN_ADAPTER_CREATE_CONNECTOR_CLIENT ) as span: span.set_attributes( { - constants.ATTR_SERVICE_URL: service_url, - constants.ATTR_AUTH_SCOPES: _format_scopes(scopes), - constants.ATTR_IS_AGENTIC_REQUEST: is_agentic_request, + core.ATTR_SERVICE_URL: service_url, + core.ATTR_AUTH_SCOPES: _format_scopes(scopes), + core.ATTR_IS_AGENTIC_REQUEST: is_agentic_request, } ) yield @@ -139,14 +149,17 @@ def start_span_adapter_create_connector_client( # AgentApplication # +class ShareWithSpanAppOnTurn(Protocol): + """Client callable protocol for sharing data with the app.on_turn span""" + def __call__(self, authorized: bool, matched: bool) -> None: ... @contextmanager -def start_span_app_on_turn(turn_context: TurnContextProtocol) -> Iterator[None]: +def start_span_app_on_turn(turn_context: TurnContextProtocol) -> Iterator[ShareWithSpanAppOnTurn]: """Context manager for recording an app on_turn call, including success/failure and duration""" activity = turn_context.activity - def callback(span: Span, duration: float, error: Exception | None): + def _callback(span: Span, duration: float, error: Exception | None): if error is None: _metrics.turn_total.add(1) _metrics.turn_duration.record( @@ -162,33 +175,47 @@ def callback(span: Span, duration: float, error: Exception | None): _metrics.turn_errors.add(1) with agents_telemetry.start_timed_span( - constants.SPAN_APP_ON_TURN, + core.SPAN_APP_ON_TURN, turn_context=turn_context, - callback=callback, + callback=_callback, ) as span: + + def _share(authorized: bool, matched: bool): + span.set_attribute(core.ATTR_ROUTE_AUTHORIZED, authorized) + span.set_attribute(core.ATTR_ROUTE_MATCHED, matched) + span.set_attributes( { - constants.ATTR_ACTIVITY_TYPE: activity.type, - constants.ATTR_ACTIVITY_ID: activity.id or constants.UNKNOWN, + core.ATTR_ACTIVITY_TYPE: activity.type, + core.ATTR_ACTIVITY_ID: activity.id or core.UNKNOWN, } ) - yield + yield _share +class ShareWithSpanAppRouteHandler(Protocol): + """Client callable protocol for sharing data with the app.route_handler span""" + def __call__(self, is_invoke: bool, is_agentic: bool) -> None: ... @contextmanager -def start_span_app_route_handler(turn_context: TurnContextProtocol) -> Iterator[None]: +def start_span_app_route_handler(turn_context: TurnContextProtocol) -> Iterator[ShareWithSpanAppRouteHandler]: """Context manager for recording the app route handler span""" + with agents_telemetry.start_as_current_span( - constants.SPAN_APP_ROUTE_HANDLER, turn_context - ): - yield + core.SPAN_APP_ROUTE_HANDLER, turn_context + ) as span: + + def _share(is_invoke: bool, is_agentic: bool): + span.set_attribute(core.ATTR_ROUTE_IS_INVOKE, is_invoke) + span.set_attribute(core.ATTR_ROUTE_IS_AGENTIC, is_agentic) + + yield _share @contextmanager def start_span_app_before_turn(turn_context: TurnContextProtocol) -> Iterator[None]: """Context manager for recording the app before turn span""" with agents_telemetry.start_as_current_span( - constants.SPAN_APP_BEFORE_TURN, turn_context + core.SPAN_APP_BEFORE_TURN, turn_context ): yield @@ -197,7 +224,7 @@ def start_span_app_before_turn(turn_context: TurnContextProtocol) -> Iterator[No def start_span_app_after_turn(turn_context: TurnContextProtocol) -> Iterator[None]: """Context manager for recording the app after turn span""" with agents_telemetry.start_as_current_span( - constants.SPAN_APP_AFTER_TURN, turn_context + core.SPAN_APP_AFTER_TURN, turn_context ): yield @@ -206,7 +233,7 @@ def start_span_app_after_turn(turn_context: TurnContextProtocol) -> Iterator[Non def start_span_app_download_files(turn_context: TurnContextProtocol) -> Iterator[None]: """Context manager for recording the app download files span""" with agents_telemetry.start_as_current_span( - constants.SPAN_APP_DOWNLOAD_FILES, turn_context + core.SPAN_APP_DOWNLOAD_FILES, turn_context ): yield @@ -224,15 +251,15 @@ def _start_span_connector_op( activity_id: str | None = None, ) -> Iterator[Span]: - def callback(span: Span, duration: float, error: Exception | None): + def _callback(span: Span, duration: float, error: Exception | None): _metrics.connector_request_total.add(1) _metrics.connector_request_duration.record(duration) - with agents_telemetry.start_timed_span(span_name, callback=callback) as span: + with agents_telemetry.start_timed_span(span_name, callback=_callback) as span: if activity_id: - span.set_attribute(constants.ATTR_ACTIVITY_ID, activity_id) + span.set_attribute(core.ATTR_ACTIVITY_ID, activity_id) if conversation_id: - span.set_attribute(constants.ATTR_CONVERSATION_ID, conversation_id) + span.set_attribute(core.ATTR_CONVERSATION_ID, conversation_id) yield span @@ -241,7 +268,7 @@ def start_span_connector_reply_to_activity( conversation_id: str, activity_id: str ) -> Iterator[None]: with _start_span_connector_op( - constants.SPAN_CONNECTOR_REPLY_TO_ACTIVITY, + core.SPAN_CONNECTOR_REPLY_TO_ACTIVITY, conversation_id=conversation_id, activity_id=activity_id, ): @@ -253,7 +280,7 @@ def start_span_connector_send_to_conversation( conversation_id: str, activity_id: str | None ) -> Iterator[None]: with _start_span_connector_op( - constants.SPAN_CONNECTOR_SEND_TO_CONVERSATION, + core.SPAN_CONNECTOR_SEND_TO_CONVERSATION, conversation_id=conversation_id, activity_id=activity_id, ): @@ -265,7 +292,7 @@ def start_span_connector_update_activity( conversation_id: str, activity_id: str ) -> Iterator[None]: with _start_span_connector_op( - constants.SPAN_CONNECTOR_UPDATE_ACTIVITY, + core.SPAN_CONNECTOR_UPDATE_ACTIVITY, conversation_id=conversation_id, activity_id=activity_id, ): @@ -277,7 +304,7 @@ def start_span_connector_delete_activity( conversation_id: str, activity_id: str ) -> Iterator[None]: with _start_span_connector_op( - constants.SPAN_CONNECTOR_DELETE_ACTIVITY, + core.SPAN_CONNECTOR_DELETE_ACTIVITY, conversation_id=conversation_id, activity_id=activity_id, ): @@ -286,34 +313,34 @@ def start_span_connector_delete_activity( @contextmanager def start_span_connector_create_conversation() -> Iterator[None]: - with _start_span_connector_op(constants.SPAN_CONNECTOR_CREATE_CONVERSATION): + with _start_span_connector_op(core.SPAN_CONNECTOR_CREATE_CONVERSATION): yield @contextmanager def start_span_connector_get_conversations() -> Iterator[None]: - with _start_span_connector_op(constants.SPAN_CONNECTOR_GET_CONVERSATIONS): + with _start_span_connector_op(core.SPAN_CONNECTOR_GET_CONVERSATIONS): yield @contextmanager def start_span_connector_get_conversation_members() -> Iterator[None]: - with _start_span_connector_op(constants.SPAN_CONNECTOR_GET_CONVERSATION_MEMBERS): + with _start_span_connector_op(core.SPAN_CONNECTOR_GET_CONVERSATION_MEMBERS): yield @contextmanager def start_span_connector_upload_attachment(conversation_id: str) -> Iterator[None]: with _start_span_connector_op( - constants.SPAN_CONNECTOR_UPDLOAD_ATTACHMENT, conversation_id=conversation_id + core.SPAN_CONNECTOR_UPDLOAD_ATTACHMENT, conversation_id=conversation_id ): yield @contextmanager def start_span_connector_get_attachment(attachment_id: str) -> Iterator[None]: - with _start_span_connector_op(constants.SPAN_CONNECTOR_GET_ATTACHMENT) as span: - span.set_attribute(constants.ATTR_ATTACHMENT_ID, attachment_id) + with _start_span_connector_op(core.SPAN_CONNECTOR_GET_ATTACHMENT) as span: + span.set_attribute(core.ATTR_ATTACHMENT_ID, attachment_id) yield @@ -325,30 +352,30 @@ def start_span_connector_get_attachment(attachment_id: str) -> Iterator[None]: @contextmanager def _start_span_storage_op(span_name: str, num_keys: int) -> Iterator[None]: - def callback(span: Span, duration: int, error: Exception | None): + def _callback(span: Span, duration: int, error: Exception | None): _metrics.storage_operation_total.add(1) _metrics.storage_operation_duration.record(duration) - with agents_telemetry.start_timed_span(span_name, callback=callback) as span: - span.set_attribute(constants.ATTR_NUM_KEYS, num_keys) + with agents_telemetry.start_timed_span(span_name, callback=_callback) as span: + span.set_attribute(core.ATTR_NUM_KEYS, num_keys) yield @contextmanager def start_span_storage_read(num_keys: int) -> Iterator[None]: - with _start_span_storage_op(constants.SPAN_STORAGE_READ, num_keys): + with _start_span_storage_op(core.SPAN_STORAGE_READ, num_keys): yield @contextmanager def start_span_storage_write(num_keys: int) -> Iterator[None]: - with _start_span_storage_op(constants.SPAN_STORAGE_WRITE, num_keys): + with _start_span_storage_op(core.SPAN_STORAGE_WRITE, num_keys): yield @contextmanager def start_span_storage_delete(num_keys: int) -> Iterator[None]: - with _start_span_storage_op(constants.SPAN_STORAGE_DELETE, num_keys): + with _start_span_storage_op(core.SPAN_STORAGE_DELETE, num_keys): yield @@ -362,7 +389,7 @@ def start_span_turn_context_send_activity( turn_context: TurnContextProtocol, ) -> Iterator[None]: with agents_telemetry.start_as_current_span( - constants.SPAN_TURN_SEND_ACTIVITY, turn_context + core.SPAN_TURN_SEND_ACTIVITY, turn_context ): yield @@ -372,7 +399,7 @@ def start_span_turn_context_update_activity( turn_context: TurnContextProtocol, ) -> Iterator[None]: with agents_telemetry.start_as_current_span( - constants.SPAN_TURN_UPDATE_ACTIVITY, turn_context + core.SPAN_TURN_UPDATE_ACTIVITY, turn_context ): yield @@ -382,6 +409,6 @@ def start_span_turn_context_delete_activity( turn_context: TurnContextProtocol, ) -> Iterator[None]: with agents_telemetry.start_as_current_span( - constants.SPAN_TURN_DELETE_ACTIVITY, turn_context + core.SPAN_TURN_DELETE_ACTIVITY, turn_context ): yield diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/adapter.py new file mode 100644 index 00000000..1c28db63 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/adapter.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from ..core import constants, AttributeMap +from opentelemetry.trace import Span +from microsoft_agents.activity import Activity + +from ..core import ( + constants, + AttributeMap, + SimpleSpanWrapper, +) +from ..core.utils import get_conversation_id, get_delivery_mode, format_scopes +from .. import _metrics + +class AdapterProcess(SimpleSpanWrapper): + """Span for processing an incoming activity in the adapter.""" + + def __init__(self, activity: Activity): + """Initializes the AdapterProcess SpanWrapper.""" + super().__init__(constants.SPAN_ADAPTER_PROCESS) + self._activity = activity + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This is used to record metrics for the adapter processing based on the outcome of the span.""" + _metrics.adapter_process_duration.record(duration) + + def _get_attributes(self) -> AttributeMap: + return { + constants.ATTR_ACTIVITY_TYPE: self._activity.type, + constants.ATTR_ACTIVITY_CHANNEL_ID: self._activity.channel_id or constants.UNKNOWN, + constants.ATTR_ACTIVITY_DELIVERY_MODE: get_delivery_mode(self._activity), + constants.ATTR_CONVERSATION_ID: get_conversation_id(self._activity), + constants.ATTR_IS_AGENTIC_REQUEST: self._activity.is_agentic_request(), + } + +class AdapterSendActivities(SimpleSpanWrapper): + """Span for sending activities in the adapter.""" + + def __init__(self, activities: list[Activity]): + """Initializes the AdapterSendActivities span.""" + super().__init__(constants.SPAN_ADAPTER_SEND_ACTIVITIES) + self._activities = activities + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activities being sent.""" + return { + constants.ATTR_ACTIVITY_COUNT: len(self._activities), + constants.ATTR_CONVERSATION_ID: ( + get_conversation_id(self._activities[0]) + if self._activities else constants.UNKNOWN + ), + } + +class AdapterUpdateActivity(SimpleSpanWrapper): + """Span for updating an activity in the adapter.""" + + def __init__(self, activity: Activity): + """Initializes the AdapterUpdateActivity span.""" + super().__init__(constants.SPAN_ADAPTER_UPDATE_ACTIVITY) + self._activity = activity + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activity being updated.""" + return { + constants.ATTR_ACTIVITY_ID: self._activity.id or constants.UNKNOWN, + constants.ATTR_CONVERSATION_ID: get_conversation_id(self._activity), + } + +class AdapterDeleteActivity(SimpleSpanWrapper): + """Span for deleting an activity in the adapter.""" + + def __init__(self, activity: Activity): + """Initializes the AdapterDeleteActivity span.""" + super().__init__(constants.SPAN_ADAPTER_DELETE_ACTIVITY) + self._activity = activity + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activity being deleted.""" + return { + constants.ATTR_ACTIVITY_ID: self._activity.id or constants.UNKNOWN, + constants.ATTR_CONVERSATION_ID: get_conversation_id(self._activity), + } + +class AdapterContinueConversation(SimpleSpanWrapper): + """Span for continuing a conversation in the adapter.""" + + def __init__(self, activity: Activity): + """Initializes the AdapterContinueConversation span.""" + super().__init__(constants.SPAN_ADAPTER_CONTINUE_CONVERSATION) + self._activity = activity + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the conversation being continued.""" + return { + constants.ATTR_APP_ID: self._activity.recipient.id if self._activity.recipient else constants.UNKNOWN, + constants.ATTR_CONVERSATION_ID: get_conversation_id(self._activity), + constants.ATTR_IS_AGENTIC_REQUEST: self._activity.is_agentic_request(), + } + +class AdapterCreateUserTokenClient(SimpleSpanWrapper): + """Span for creating a user token in the adapter.""" + + def __init__(self, token_service_endpoint: str, scopes: list[str] | None): + """Initializes the AdapterCreateUserToken span.""" + super().__init__(constants.SPAN_ADAPTER_CREATE_USER_TOKEN_CLIENT) + self._token_service_endpoint = token_service_endpoint + self._scopes = scopes + + def _get_attributes(self) -> AttributeMap: + """Starts the AdapterCreateUserToken span, and sets attributes related to the user token being created.""" + return { + constants.ATTR_TOKEN_SERVICE_ENDPOINT: self._token_service_endpoint, + constants.ATTR_AUTH_SCOPES: format_scopes(self._scopes), + } + +class AdapterCreateConnectorClient(SimpleSpanWrapper): + """Span for creating a connector client in the adapter.""" + + def __init__(self, service_url: str, scopes: list[str] | None, is_agentic_request: bool): + """Initializes the AdapterCreateConnectorClient span.""" + super().__init__(constants.SPAN_ADAPTER_CREATE_CONNECTOR_CLIENT) + self._service_url = service_url + self._scopes = scopes + self._is_agentic_request = is_agentic_request + + def _get_attributes(self) -> AttributeMap: + """Starts the AdapterCreateConnectorClient span, and sets attributes related to the connector client being created.""" + return { + constants.ATTR_SERVICE_URL: self._service_url, + constants.ATTR_AUTH_SCOPES: format_scopes(self._scopes), + constants.ATTR_IS_AGENTIC_REQUEST: self._is_agentic_request, + } \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/app.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/app.py new file mode 100644 index 00000000..ce924c26 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/app.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from opentelemetry.trace import Span +from microsoft_agents.activity import TurnContextProtocol +from ..core import ( + constants, + get_conversation_id, + AttributeMap, + SimpleSpanWrapper, +) +from .. import _metrics + +class AppOnTurn(SimpleSpanWrapper): + """Span for the entire app run, starting from when an activity is received in the adapter, until a response is sent back (if applicable). This span is meant to be a parent span for all other spans created during the processing of the activity, and can be used to correlate all telemetry for a given app run.""" + + def __init__(self, turn_context: TurnContextProtocol): + """Initializes the AppOnTurn SpanWrapper. + + :param turn_context: The TurnContext for the app run, used to extract attributes for the span + """ + super().__init__(constants.SPAN_APP_ON_TURN) + self._turn_context = turn_context + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This is used to record metrics for the app run based on the outcome of the span.""" + if error is None: + _metrics.turn_total.add(1) + _metrics.turn_duration.record( + duration, + { + constants.ATTR_CONVERSATION_ID: ( + get_conversation_id(self._turn_context.activity) + ), + constants.ATTR_ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or constants.UNKNOWN, + }, + ) + else: + _metrics.turn_errors.add(1) + + def _get_attributes(self) -> AttributeMap: + return { + constants.ATTR_CONVERSATION_ID: get_conversation_id(self._turn_context.activity), + constants.ATTR_ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or constants.UNKNOWN, + constants.ATTR_SERVICE_URL: self._turn_context.activity.service_url, + } + + def share(self, route_authorized: bool, route_matched: bool) -> None: + """Shares the span context for this app run with downstream spans, and adds attributes related to routing decisions + + :param route_authorized: Whether the route for this app run was authorized + :param route_matched: Whether the route for this app run was matched + """ + if self._span is not None: + self._span.set_attribute("app_run.route.authorized", route_authorized) + self._span.set_attribute("app_run.route.matched", route_matched) + +class AppRouteHandler(SimpleSpanWrapper): + """Span for handling the routing logic. From selection, through authorization, and through the invocation of the route handler.""" + + def __init__(self, turn_context: TurnContextProtocol): + """Initializes the AppRouteHandler SpanWrapper.""" + super().__init__(constants.SPAN_APP_ROUTE_HANDLER) + self._turn_context = turn_context + + def _get_attributes(self) -> AttributeMap: + """Gets attributes for the AppRouteHandler span, based on the activity being processed.""" + return { + constants.ATTR_CONVERSATION_ID: get_conversation_id(self._turn_context.activity), + constants.ATTR_ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or constants.UNKNOWN, + constants.ATTR_SERVICE_URL: self._turn_context.activity.service_url, + } + +class AppBeforeTurn(SimpleSpanWrapper): + """Span for the logic that happens before the main turn processing. This is meant to capture telemetry for the pre-processing logic of the app run, and can be used to identify issues in the early stages of the app run before the main processing logic is invoked.""" + + def __init__(self): + """Initializes the AppBeforeTurn SpanWrapper.""" + super().__init__(constants.SPAN_APP_BEFORE_TURN) + +class AppAfterTurn(SimpleSpanWrapper): + """Span for the logic that happens after the main turn processing. This is meant to capture telemetry for the post-processing logic of the app run, and can be used to identify issues in the later stages of the app run after the main processing logic is invoked.""" + + def __init__(self): + """Initializes the AppAfterTurn SpanWrapper.""" + super().__init__(constants.SPAN_APP_AFTER_TURN) + +class AppDownloadFiles(SimpleSpanWrapper): + """Span for the logic related to downloading files in the app. This can be used to capture telemetry for file download operations, and to identify issues related to file downloads in the app.""" + + def __init__(self, turn_context: TurnContextProtocol): + """Initializes the AppDownloadFiles SpanWrapper.""" + super().__init__(constants.SPAN_APP_DOWNLOAD_FILES) + self._turn_context = turn_context + + def _get_attributes(self) -> AttributeMap: + return { + constants.ATTR_ATTACHMENT_COUNT: len(self._turn_context.activity.attachments or []), + } \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/connector.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/connector.py new file mode 100644 index 00000000..1da51ebc --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/connector.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from ..core import constants, AttributeMap +from opentelemetry.trace import Span +from microsoft_agents.activity import Activity + +from ..core import ( + constants, + AttributeMap, + SimpleSpanWrapper, +) +from ..core.utils import get_conversation_id, get_delivery_mode, format_scopes +from .. import _metrics + +class _ConnectorSpanWrapper(SimpleSpanWrapper): + """Base SpanWrapper for spans related to connector operations in the adapter. This is meant to be a base class for spans related to connector operations, such as creating a connector client or creating a user token, and can be used to share common functionality and attributes related to connector operations.""" + + def __init__(self, span_name: str, *, conversation_id: str | None = None, activity_id: str | None = None): + """Initializes the _ConnectorSpanWrapper span.""" + super().__init__(span_name) + self._conversation_id = conversation_id + self._activity_id = activity_id + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This is used to record metrics for the connector operation based on the outcome of the span.""" + _metrics.connector_request_duration.record(duration) + _metrics.connector_request_total.add(1) + + def _get_attributes(self) -> dict[str, str]: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the connector operation being performed.""" + attributes = {} + if self._conversation_id is not None: + attributes[constants.ATTR_CONVERSATION_ID] = self._conversation_id + if self._activity_id is not None: + attributes[constants.ATTR_ACTIVITY_ID] = self._activity_id + return attributes + +class ConnectorReplyToActivity(_ConnectorSpanWrapper): + """Span for replying to an activity using the connector client in the adapter.""" + + def __init__(self, conversation_id: str, activity_id: str): + """Initializes the ConnectorReplyToActivity span.""" + super().__init__(constants.SPAN_CONNECTOR_REPLY_TO_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id) + +class ConnectorSendToConversation(_ConnectorSpanWrapper): + """Span for sending to a conversation using the connector client in the adapter.""" + + def __init__(self, conversation_id: str, activity_id: str): + """Initializes the ConnectorSendToConversation span.""" + super().__init__(constants.SPAN_CONNECTOR_SEND_TO_CONVERSATION, + conversation_id=conversation_id, + activity_id=activity_id) + +class ConnectorUpdateActivity(_ConnectorSpanWrapper): + """Span for updating an activity using the connector client in the adapter.""" + + def __init__(self, conversation_id: str, activity_id: str): + """Initializes the ConnectorUpdateActivity span.""" + super().__init__(constants.SPAN_CONNECTOR_UPDATE_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id) + +class ConnectorDeleteActivity(_ConnectorSpanWrapper): + """Span for deleting an activity using the connector client in the adapter.""" + + def __init__(self, conversation_id: str, activity_id: str): + """Initializes the ConnectorDeleteActivity span.""" + super().__init__(constants.SPAN_CONNECTOR_DELETE_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id) + +class ConnectorCreateConversation(_ConnectorSpanWrapper): + """Span for creating a conversation using the connector client in the adapter.""" + + def __init__(self): + """Initializes the ConnectorCreateConversation span.""" + super().__init__(constants.SPAN_CONNECTOR_CREATE_CONVERSATION) + +class ConnectorGetConversations(_ConnectorSpanWrapper): + """Span for getting conversations using the connector client in the adapter.""" + + def __init__(self): + """Initializes the ConnectorGetConversations span.""" + super().__init__(constants.SPAN_CONNECTOR_GET_CONVERSATIONS) + +class ConnectorGetConversationMembers(_ConnectorSpanWrapper): + """Span for getting conversation members using the connector client in the adapter.""" + + def __init__(self): + """Initializes the ConnectorGetConversationMembers span.""" + super().__init__(constants.SPAN_CONNECTOR_GET_CONVERSATION_MEMBERS) + +class ConnectorUploadAttachment(_ConnectorSpanWrapper): + """Span for uploading an attachment using the connector client in the adapter.""" + + def __init__(self, conversation_id: str): + """Initializes the ConnectorUploadAttachment span.""" + super().__init__(constants.SPAN_CONNECTOR_UPLOAD_ATTACHMENT, + conversation_id=conversation_id) + +class ConnectorGetAttachment(_ConnectorSpanWrapper): + """Span for getting an attachment using the connector client in the adapter.""" + + def __init__(self, attachment_id: str): + """Initializes the ConnectorGetAttachment span.""" + super().__init__(constants.SPAN_CONNECTOR_GET_ATTACHMENT) + self._attachment_id = attachment_id + + def _get_attributes(self) -> AttributeMap: + attributes = super()._get_attributes() + attributes[constants.ATTR_ATTACHMENT_ID] = self._attachment_id + return attributes diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py index e32a5a71..4c1ac2cc 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py @@ -1,7 +1,21 @@ -from . import constants +from microsoft_agents.activity import Activity, DeliveryModes + +from . import core def _format_scopes(scopes: list[str] | None) -> str: if not scopes: - return constants.UNKNOWN + return core.UNKNOWN return ",".join(scopes) + +def _get_conversation_id(activity: Activity) -> str: + return activity.conversation.id if activity.conversation else core.UNKNOWN + + +def _get_delivery_mode(activity: Activity) -> str: + if activity.delivery_mode: + if isinstance(activity.delivery_mode, DeliveryModes): + return activity.delivery_mode.value + else: + return activity.delivery_mode + return core.UNKNOWN \ No newline at end of file From 29bf82fdc8e01ac20b881110001009183b341437 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 23 Mar 2026 15:40:25 -0700 Subject: [PATCH 33/55] Revised all existing span cms into SpanWrapper implementations --- .../hosting/core/telemetry/__init__.py | 13 +- .../hosting/core/telemetry/_metrics.py | 22 +- .../hosting/core/telemetry/core/__init__.py | 20 +- .../core/telemetry/core/_agents_telemetry.py | 8 +- .../core/telemetry/core/base_span_wrapper.py | 3 + .../hosting/core/telemetry/core/constants.py | 5 +- .../telemetry/core/simple_span_wrapper.py | 5 +- .../hosting/core/telemetry/core/type_defs.py | 7 + .../hosting/core/telemetry/core/utils.py | 25 - .../hosting/core/telemetry/spans.py | 770 +++++++++--------- .../hosting/core/telemetry/spans/adapter.py | 5 +- .../hosting/core/telemetry/spans/app.py | 5 +- .../hosting/core/telemetry/spans/connector.py | 13 +- .../hosting/core/telemetry/spans/storage.py | 55 ++ .../core/telemetry/spans/turn_context.py | 44 + .../hosting/core/telemetry/utils.py | 21 +- 16 files changed, 568 insertions(+), 453 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/utils.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/storage.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/turn_context.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py index 95cb63cc..d0e101a8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py @@ -11,12 +11,19 @@ from .core._agents_telemetry import ( agents_telemetry, ) -from .core import SERVICE_NAME, SERVICE_VERSION, RESOURCE -from .utils import _format_scopes +from .core import SERVICE_NAME, SERVICE_VERSION, RESOURCE, AttributeMap +from .utils import ( + format_scopes, + get_conversation_id, + get_delivery_mode, +) __all__ = [ "agents_telemetry", - "_format_scopes", + "format_scopes", + "get_conversation_id", + "get_delivery_mode", + "AttributeMap", "SERVICE_NAME", "SERVICE_VERSION", "RESOURCE", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py index c8b02779..d968ffc8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py @@ -1,13 +1,15 @@ -from . import core -from .core._agents_telemetry import agents_telemetry +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .core import agents_telemetry, constants storage_operation_total = agents_telemetry.meter.create_counter( - core.METRIC_STORAGE_OPERATION_TOTAL, + constants.METRIC_STORAGE_OPERATION_TOTAL, "operation", description="Number of storage operations performed by the agent", ) storage_operation_duration = agents_telemetry.meter.create_histogram( - core.METRIC_STORAGE_OPERATION_DURATION, + constants.METRIC_STORAGE_OPERATION_DURATION, "ms", description="Duration of storage operations in milliseconds", ) @@ -15,19 +17,19 @@ # AgentApplication turn_total = agents_telemetry.meter.create_counter( - core.METRIC_TURN_TOTAL, + constants.METRIC_TURN_TOTAL, "turn", description="Total number of turns processed by the agent", ) turn_errors = agents_telemetry.meter.create_counter( - core.METRIC_TURN_ERRORS, + constants.METRIC_TURN_ERRORS, "turn", description="Number of turns that resulted in an error", ) turn_duration = agents_telemetry.meter.create_histogram( - core.METRIC_TURN_DURATION, + constants.METRIC_TURN_DURATION, "ms", description="Duration of agent turns in milliseconds", ) @@ -35,7 +37,7 @@ # Adapters adapter_process_duration = agents_telemetry.meter.create_histogram( - core.METRIC_ADAPTER_PROCESS_DURATION, + constants.METRIC_ADAPTER_PROCESS_DURATION, "ms", description="Duration of adapter processing in milliseconds", ) @@ -43,13 +45,13 @@ # Connectors connector_request_total = agents_telemetry.meter.create_counter( - core.METRIC_CONNECTOR_REQUESTS_TOTAL, + constants.METRIC_CONNECTOR_REQUESTS_TOTAL, "request", description="Total number of connector requests made by the agent", ) connector_request_duration = agents_telemetry.meter.create_histogram( - core.METRIC_CONNECTOR_REQUEST_DURATION, + constants.METRIC_CONNECTOR_REQUEST_DURATION, "ms", description="Duration of connector requests in milliseconds", ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py index 5695acec..7f8c219d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py @@ -1,21 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from . import constants from ._agents_telemetry import agents_telemetry +from .type_defs import AttributeMap, SpanCallback from .simple_span_wrapper import SimpleSpanWrapper from .base_span_wrapper import BaseSpanWrapper -from .utils import ( - AttributeMap, - format_scopes, - get_conversation_id, - get_delivery_mode, -) +from .constants import SERVICE_NAME, SERVICE_VERSION, RESOURCE __all__ = [ "agents_telemetry", "constants", + "AttributeMap", + "SpanCallback", "SimpleSpanWrapper", "BaseSpanWrapper", - "AttributeMap", - "format_scopes", - "get_conversation_id", - "get_delivery_mode", + "SERVICE_NAME", + "SERVICE_VERSION", + "RESOURCE", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py index 0b741ebc..d03b602a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py @@ -1,6 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import time import logging -from typing import Callable from collections.abc import Iterator from contextlib import contextmanager @@ -12,9 +14,9 @@ from microsoft_agents.activity import TurnContextProtocol from .. import core -logger = logging.getLogger(__name__) +from .type_defs import SpanCallback -SpanCallback = Callable[[Span, float, Exception | None], None] +logger = logging.getLogger(__name__) class _AgentsTelemetry: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py index bfe4c344..5fddf139 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from __future__ import annotations import logging diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/constants.py index 1a396fc5..2d39398c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/constants.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import os from opentelemetry.sdk.resources import Resource @@ -99,7 +102,7 @@ # TODO -> rename to ATTR_IS_AGENTIC ATTR_IS_AGENTIC_REQUEST = "is_agentic_request" -ATTR_NUM_KEYS = "keys.num" +ATTR_KEY_COUNT = "storage.keys.count" ATTR_ROUTE_AUTHORIZED = "route.authorized" ATTR_ROUTE_IS_INVOKE = "route.is_invoke" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py index c10cede3..6eea4ad1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from abc import ABC from collections.abc import Iterator from contextlib import contextmanager @@ -6,7 +9,7 @@ from ._agents_telemetry import agents_telemetry from .base_span_wrapper import BaseSpanWrapper -from .utils import AttributeMap +from .type_defs import AttributeMap class SimpleSpanWrapper(BaseSpanWrapper, ABC): """Simple implementation of the BaseSpanWrapper that can be used when no additional attributes or functionality are needed on the span beyond what is provided by the base BaseSpanWrapper class. This can be used as a simple wrapper around an OTEL span for cases where no SDK-specific telemetry is needed, while still providing the benefits of the BaseSpanWrapper abstraction and lifecycle management.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py new file mode 100644 index 00000000..d532f88d --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py @@ -0,0 +1,7 @@ +from typing import Mapping, Callable + +from opentelemetry.util.types import AttributeValue +from opentelemetry.trace import Span + +AttributeMap = Mapping[str, AttributeValue] +SpanCallback = Callable[[Span, float, Exception | None], None] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/utils.py deleted file mode 100644 index 697ae83f..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/utils.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Mapping, TypeVar - -from opentelemetry.util.types import AttributeValue -from microsoft_agents.activity import Activity, DeliveryModes - -from . import constants - -AttributeMap = Mapping[str, AttributeValue] - -def format_scopes(scopes: list[str] | None) -> str: - if not scopes: - return constants.UNKNOWN - return ",".join(scopes) - -def get_conversation_id(activity: Activity) -> str: - return activity.conversation.id if activity.conversation else constants.UNKNOWN - - -def get_delivery_mode(activity: Activity) -> str: - if activity.delivery_mode: - if isinstance(activity.delivery_mode, DeliveryModes): - return activity.delivery_mode.value - else: - return activity.delivery_mode - return constants.UNKNOWN \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py index 91445b4b..b3d4becd 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py @@ -1,414 +1,414 @@ -from collections.abc import Iterator -from contextlib import contextmanager -from typing import Protocol - -from . import _metrics -from opentelemetry.trace import Span - -from microsoft_agents.activity import Activity, TurnContextProtocol, DeliveryModes - -from . import core -from .core._agents_telemetry import agents_telemetry -from .utils import ( - _format_scopes, - _get_conversation_id, - _get_delivery_mode, -) - -# -# Adapter -# - - -@contextmanager -def start_span_adapter_process(activity: Activity) -> Iterator[None]: - """Context manager for reording adapter process call""" - - def _callback(span: Span, duration: float, error: Exception | None): - _metrics.adapter_process_duration.record(duration) - - with agents_telemetry.start_timed_span( - core.SPAN_ADAPTER_PROCESS, callback=_callback - ) as span: - span.set_attributes( - { - core.ATTR_ACTIVITY_TYPE: activity.type, - core.ATTR_ACTIVITY_CHANNEL_ID: activity.channel_id or core.UNKNOWN, - core.ATTR_ACTIVITY_DELIVERY_MODE: _get_delivery_mode(activity), - core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), - core.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), - } - ) - yield - - -@contextmanager -def start_span_adapter_send_activities(activities: list[Activity]) -> Iterator[None]: - """Context manager for recording adapter send_activities call""" - with agents_telemetry.start_as_current_span( - core.SPAN_ADAPTER_SEND_ACTIVITIES - ) as span: - count = len(activities) - if count > 0: - span.set_attributes({ - core.ATTR_ACTIVITY_COUNT: count, - core.ATTR_CONVERSATION_ID: _get_conversation_id(activities[0]), - core.ATTR_ACTIVITY_TYPE: activities[0].type, - core.ATTR_ACTIVITY_ID: activities[0].id or core.UNKNOWN, - }) - else: - span.set_attribute(core.ATTR_ACTIVITY_COUNT, 0) +# from collections.abc import Iterator +# from contextlib import contextmanager +# from typing import Protocol + +# from . import _metrics +# from opentelemetry.trace import Span + +# from microsoft_agents.activity import Activity, TurnContextProtocol, DeliveryModes + +# from . import core +# from .core._agents_telemetry import agents_telemetry +# from .utils import ( +# _format_scopes, +# _get_conversation_id, +# _get_delivery_mode, +# ) + +# # +# # Adapter +# # + + +# @contextmanager +# def start_span_adapter_process(activity: Activity) -> Iterator[None]: +# """Context manager for reording adapter process call""" + +# def _callback(span: Span, duration: float, error: Exception | None): +# _metrics.adapter_process_duration.record(duration) + +# with agents_telemetry.start_timed_span( +# core.SPAN_ADAPTER_PROCESS, callback=_callback +# ) as span: +# span.set_attributes( +# { +# core.ATTR_ACTIVITY_TYPE: activity.type, +# core.ATTR_ACTIVITY_CHANNEL_ID: activity.channel_id or core.UNKNOWN, +# core.ATTR_ACTIVITY_DELIVERY_MODE: _get_delivery_mode(activity), +# core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), +# core.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), +# } +# ) +# yield + + +# @contextmanager +# def start_span_adapter_send_activities(activities: list[Activity]) -> Iterator[None]: +# """Context manager for recording adapter send_activities call""" +# with agents_telemetry.start_as_current_span( +# core.SPAN_ADAPTER_SEND_ACTIVITIES +# ) as span: +# count = len(activities) +# if count > 0: +# span.set_attributes({ +# core.ATTR_ACTIVITY_COUNT: count, +# core.ATTR_CONVERSATION_ID: _get_conversation_id(activities[0]), +# core.ATTR_ACTIVITY_TYPE: activities[0].type, +# core.ATTR_ACTIVITY_ID: activities[0].id or core.UNKNOWN, +# }) +# else: +# span.set_attribute(core.ATTR_ACTIVITY_COUNT, 0) - yield - - -@contextmanager -def start_span_adapter_update_activity(activity: Activity) -> Iterator[None]: - """Context manager for recording adapter update_activity call""" - with agents_telemetry.start_as_current_span( - core.SPAN_ADAPTER_UPDATE_ACTIVITY - ) as span: - span.set_attributes( - { - core.ATTR_ACTIVITY_ID: activity.id or core.UNKNOWN, - core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), - } - ) - yield - - -@contextmanager -def start_span_adapter_delete_activity(activity: Activity) -> Iterator[None]: - """Context manager for recording adapter delete_activity call""" - with agents_telemetry.start_as_current_span( - core.SPAN_ADAPTER_DELETE_ACTIVITY - ) as span: - span.set_attributes( - { - core.ATTR_ACTIVITY_ID: activity.id or core.UNKNOWN, - core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), - } - ) - yield - - -@contextmanager -def start_span_adapter_continue_conversation(activity: Activity) -> Iterator[None]: - """Context manager for recording adapter continue_conversation call""" - with agents_telemetry.start_as_current_span( - core.SPAN_ADAPTER_CONTINUE_CONVERSATION - ) as span: - span.set_attributes( - { - core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), - core.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), - } - ) - yield - -@contextmanager -def start_span_adapter_create_user_token_client( - *, - token_service_endpoint: str, - scopes: list[str] | None, -) -> Iterator[None]: - """Context manager for recording adapter create_user_token_client call""" - with agents_telemetry.start_as_current_span( - core.SPAN_ADAPTER_CREATE_USER_TOKEN_CLIENT - ) as span: - span.set_attributes( - { - core.ATTR_TOKEN_SERVICE_ENDPOINT: token_service_endpoint, - core.ATTR_AUTH_SCOPES: _format_scopes(scopes), - } - ) - yield - -@contextmanager -def start_span_adapter_create_connector_client( - *, - service_url: str, - scopes: list[str] | None, - is_agentic_request: bool, -) -> Iterator[None]: - """Context manager for recording adapter create_connector_client call""" - with agents_telemetry.start_as_current_span( - core.SPAN_ADAPTER_CREATE_CONNECTOR_CLIENT - ) as span: - span.set_attributes( - { - core.ATTR_SERVICE_URL: service_url, - core.ATTR_AUTH_SCOPES: _format_scopes(scopes), - core.ATTR_IS_AGENTIC_REQUEST: is_agentic_request, - } - ) - yield - - -# -# AgentApplication -# - -class ShareWithSpanAppOnTurn(Protocol): - """Client callable protocol for sharing data with the app.on_turn span""" - def __call__(self, authorized: bool, matched: bool) -> None: ... - -@contextmanager -def start_span_app_on_turn(turn_context: TurnContextProtocol) -> Iterator[ShareWithSpanAppOnTurn]: - """Context manager for recording an app on_turn call, including success/failure and duration""" - - activity = turn_context.activity - - def _callback(span: Span, duration: float, error: Exception | None): - if error is None: - _metrics.turn_total.add(1) - _metrics.turn_duration.record( - duration, - { - "conversation.id": ( - activity.conversation.id if activity.conversation else "unknown" - ), - "channel.id": str(activity.channel_id), - }, - ) - else: - _metrics.turn_errors.add(1) - - with agents_telemetry.start_timed_span( - core.SPAN_APP_ON_TURN, - turn_context=turn_context, - callback=_callback, - ) as span: +# yield + + +# @contextmanager +# def start_span_adapter_update_activity(activity: Activity) -> Iterator[None]: +# """Context manager for recording adapter update_activity call""" +# with agents_telemetry.start_as_current_span( +# core.SPAN_ADAPTER_UPDATE_ACTIVITY +# ) as span: +# span.set_attributes( +# { +# core.ATTR_ACTIVITY_ID: activity.id or core.UNKNOWN, +# core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), +# } +# ) +# yield + + +# @contextmanager +# def start_span_adapter_delete_activity(activity: Activity) -> Iterator[None]: +# """Context manager for recording adapter delete_activity call""" +# with agents_telemetry.start_as_current_span( +# core.SPAN_ADAPTER_DELETE_ACTIVITY +# ) as span: +# span.set_attributes( +# { +# core.ATTR_ACTIVITY_ID: activity.id or core.UNKNOWN, +# core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), +# } +# ) +# yield + + +# @contextmanager +# def start_span_adapter_continue_conversation(activity: Activity) -> Iterator[None]: +# """Context manager for recording adapter continue_conversation call""" +# with agents_telemetry.start_as_current_span( +# core.SPAN_ADAPTER_CONTINUE_CONVERSATION +# ) as span: +# span.set_attributes( +# { +# core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), +# core.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), +# } +# ) +# yield + +# @contextmanager +# def start_span_adapter_create_user_token_client( +# *, +# token_service_endpoint: str, +# scopes: list[str] | None, +# ) -> Iterator[None]: +# """Context manager for recording adapter create_user_token_client call""" +# with agents_telemetry.start_as_current_span( +# core.SPAN_ADAPTER_CREATE_USER_TOKEN_CLIENT +# ) as span: +# span.set_attributes( +# { +# core.ATTR_TOKEN_SERVICE_ENDPOINT: token_service_endpoint, +# core.ATTR_AUTH_SCOPES: _format_scopes(scopes), +# } +# ) +# yield + +# @contextmanager +# def start_span_adapter_create_connector_client( +# *, +# service_url: str, +# scopes: list[str] | None, +# is_agentic_request: bool, +# ) -> Iterator[None]: +# """Context manager for recording adapter create_connector_client call""" +# with agents_telemetry.start_as_current_span( +# core.SPAN_ADAPTER_CREATE_CONNECTOR_CLIENT +# ) as span: +# span.set_attributes( +# { +# core.ATTR_SERVICE_URL: service_url, +# core.ATTR_AUTH_SCOPES: _format_scopes(scopes), +# core.ATTR_IS_AGENTIC_REQUEST: is_agentic_request, +# } +# ) +# yield + + +# # +# # AgentApplication +# # + +# class ShareWithSpanAppOnTurn(Protocol): +# """Client callable protocol for sharing data with the app.on_turn span""" +# def __call__(self, authorized: bool, matched: bool) -> None: ... + +# @contextmanager +# def start_span_app_on_turn(turn_context: TurnContextProtocol) -> Iterator[ShareWithSpanAppOnTurn]: +# """Context manager for recording an app on_turn call, including success/failure and duration""" + +# activity = turn_context.activity + +# def _callback(span: Span, duration: float, error: Exception | None): +# if error is None: +# _metrics.turn_total.add(1) +# _metrics.turn_duration.record( +# duration, +# { +# "conversation.id": ( +# activity.conversation.id if activity.conversation else "unknown" +# ), +# "channel.id": str(activity.channel_id), +# }, +# ) +# else: +# _metrics.turn_errors.add(1) + +# with agents_telemetry.start_timed_span( +# core.SPAN_APP_ON_TURN, +# turn_context=turn_context, +# callback=_callback, +# ) as span: - def _share(authorized: bool, matched: bool): - span.set_attribute(core.ATTR_ROUTE_AUTHORIZED, authorized) - span.set_attribute(core.ATTR_ROUTE_MATCHED, matched) - - span.set_attributes( - { - core.ATTR_ACTIVITY_TYPE: activity.type, - core.ATTR_ACTIVITY_ID: activity.id or core.UNKNOWN, - } - ) - yield _share - -class ShareWithSpanAppRouteHandler(Protocol): - """Client callable protocol for sharing data with the app.route_handler span""" - def __call__(self, is_invoke: bool, is_agentic: bool) -> None: ... - -@contextmanager -def start_span_app_route_handler(turn_context: TurnContextProtocol) -> Iterator[ShareWithSpanAppRouteHandler]: - """Context manager for recording the app route handler span""" - - with agents_telemetry.start_as_current_span( - core.SPAN_APP_ROUTE_HANDLER, turn_context - ) as span: +# def _share(authorized: bool, matched: bool): +# span.set_attribute(core.ATTR_ROUTE_AUTHORIZED, authorized) +# span.set_attribute(core.ATTR_ROUTE_MATCHED, matched) + +# span.set_attributes( +# { +# core.ATTR_ACTIVITY_TYPE: activity.type, +# core.ATTR_ACTIVITY_ID: activity.id or core.UNKNOWN, +# } +# ) +# yield _share + +# class ShareWithSpanAppRouteHandler(Protocol): +# """Client callable protocol for sharing data with the app.route_handler span""" +# def __call__(self, is_invoke: bool, is_agentic: bool) -> None: ... + +# @contextmanager +# def start_span_app_route_handler(turn_context: TurnContextProtocol) -> Iterator[ShareWithSpanAppRouteHandler]: +# """Context manager for recording the app route handler span""" + +# with agents_telemetry.start_as_current_span( +# core.SPAN_APP_ROUTE_HANDLER, turn_context +# ) as span: - def _share(is_invoke: bool, is_agentic: bool): - span.set_attribute(core.ATTR_ROUTE_IS_INVOKE, is_invoke) - span.set_attribute(core.ATTR_ROUTE_IS_AGENTIC, is_agentic) - - yield _share - - -@contextmanager -def start_span_app_before_turn(turn_context: TurnContextProtocol) -> Iterator[None]: - """Context manager for recording the app before turn span""" - with agents_telemetry.start_as_current_span( - core.SPAN_APP_BEFORE_TURN, turn_context - ): - yield - - -@contextmanager -def start_span_app_after_turn(turn_context: TurnContextProtocol) -> Iterator[None]: - """Context manager for recording the app after turn span""" - with agents_telemetry.start_as_current_span( - core.SPAN_APP_AFTER_TURN, turn_context - ): - yield - - -@contextmanager -def start_span_app_download_files(turn_context: TurnContextProtocol) -> Iterator[None]: - """Context manager for recording the app download files span""" - with agents_telemetry.start_as_current_span( - core.SPAN_APP_DOWNLOAD_FILES, turn_context - ): - yield - - -# -# ConnectorClient -# - - -@contextmanager -def _start_span_connector_op( - span_name: str, - *, - conversation_id: str | None = None, - activity_id: str | None = None, -) -> Iterator[Span]: - - def _callback(span: Span, duration: float, error: Exception | None): - _metrics.connector_request_total.add(1) - _metrics.connector_request_duration.record(duration) - - with agents_telemetry.start_timed_span(span_name, callback=_callback) as span: - if activity_id: - span.set_attribute(core.ATTR_ACTIVITY_ID, activity_id) - if conversation_id: - span.set_attribute(core.ATTR_CONVERSATION_ID, conversation_id) - yield span +# def _share(is_invoke: bool, is_agentic: bool): +# span.set_attribute(core.ATTR_ROUTE_IS_INVOKE, is_invoke) +# span.set_attribute(core.ATTR_ROUTE_IS_AGENTIC, is_agentic) + +# yield _share + + +# @contextmanager +# def start_span_app_before_turn(turn_context: TurnContextProtocol) -> Iterator[None]: +# """Context manager for recording the app before turn span""" +# with agents_telemetry.start_as_current_span( +# core.SPAN_APP_BEFORE_TURN, turn_context +# ): +# yield + + +# @contextmanager +# def start_span_app_after_turn(turn_context: TurnContextProtocol) -> Iterator[None]: +# """Context manager for recording the app after turn span""" +# with agents_telemetry.start_as_current_span( +# core.SPAN_APP_AFTER_TURN, turn_context +# ): +# yield + + +# @contextmanager +# def start_span_app_download_files(turn_context: TurnContextProtocol) -> Iterator[None]: +# """Context manager for recording the app download files span""" +# with agents_telemetry.start_as_current_span( +# core.SPAN_APP_DOWNLOAD_FILES, turn_context +# ): +# yield + + +# # +# # ConnectorClient +# # + + +# @contextmanager +# def _start_span_connector_op( +# span_name: str, +# *, +# conversation_id: str | None = None, +# activity_id: str | None = None, +# ) -> Iterator[Span]: + +# def _callback(span: Span, duration: float, error: Exception | None): +# _metrics.connector_request_total.add(1) +# _metrics.connector_request_duration.record(duration) + +# with agents_telemetry.start_timed_span(span_name, callback=_callback) as span: +# if activity_id: +# span.set_attribute(core.ATTR_ACTIVITY_ID, activity_id) +# if conversation_id: +# span.set_attribute(core.ATTR_CONVERSATION_ID, conversation_id) +# yield span -@contextmanager -def start_span_connector_reply_to_activity( - conversation_id: str, activity_id: str -) -> Iterator[None]: - with _start_span_connector_op( - core.SPAN_CONNECTOR_REPLY_TO_ACTIVITY, - conversation_id=conversation_id, - activity_id=activity_id, - ): - yield +# @contextmanager +# def start_span_connector_reply_to_activity( +# conversation_id: str, activity_id: str +# ) -> Iterator[None]: +# with _start_span_connector_op( +# core.SPAN_CONNECTOR_REPLY_TO_ACTIVITY, +# conversation_id=conversation_id, +# activity_id=activity_id, +# ): +# yield -@contextmanager -def start_span_connector_send_to_conversation( - conversation_id: str, activity_id: str | None -) -> Iterator[None]: - with _start_span_connector_op( - core.SPAN_CONNECTOR_SEND_TO_CONVERSATION, - conversation_id=conversation_id, - activity_id=activity_id, - ): - yield +# @contextmanager +# def start_span_connector_send_to_conversation( +# conversation_id: str, activity_id: str | None +# ) -> Iterator[None]: +# with _start_span_connector_op( +# core.SPAN_CONNECTOR_SEND_TO_CONVERSATION, +# conversation_id=conversation_id, +# activity_id=activity_id, +# ): +# yield -@contextmanager -def start_span_connector_update_activity( - conversation_id: str, activity_id: str -) -> Iterator[None]: - with _start_span_connector_op( - core.SPAN_CONNECTOR_UPDATE_ACTIVITY, - conversation_id=conversation_id, - activity_id=activity_id, - ): - yield +# @contextmanager +# def start_span_connector_update_activity( +# conversation_id: str, activity_id: str +# ) -> Iterator[None]: +# with _start_span_connector_op( +# core.SPAN_CONNECTOR_UPDATE_ACTIVITY, +# conversation_id=conversation_id, +# activity_id=activity_id, +# ): +# yield -@contextmanager -def start_span_connector_delete_activity( - conversation_id: str, activity_id: str -) -> Iterator[None]: - with _start_span_connector_op( - core.SPAN_CONNECTOR_DELETE_ACTIVITY, - conversation_id=conversation_id, - activity_id=activity_id, - ): - yield +# @contextmanager +# def start_span_connector_delete_activity( +# conversation_id: str, activity_id: str +# ) -> Iterator[None]: +# with _start_span_connector_op( +# core.SPAN_CONNECTOR_DELETE_ACTIVITY, +# conversation_id=conversation_id, +# activity_id=activity_id, +# ): +# yield -@contextmanager -def start_span_connector_create_conversation() -> Iterator[None]: - with _start_span_connector_op(core.SPAN_CONNECTOR_CREATE_CONVERSATION): - yield - - -@contextmanager -def start_span_connector_get_conversations() -> Iterator[None]: - with _start_span_connector_op(core.SPAN_CONNECTOR_GET_CONVERSATIONS): - yield - - -@contextmanager -def start_span_connector_get_conversation_members() -> Iterator[None]: - with _start_span_connector_op(core.SPAN_CONNECTOR_GET_CONVERSATION_MEMBERS): - yield - - -@contextmanager -def start_span_connector_upload_attachment(conversation_id: str) -> Iterator[None]: - with _start_span_connector_op( - core.SPAN_CONNECTOR_UPDLOAD_ATTACHMENT, conversation_id=conversation_id - ): - yield +# @contextmanager +# def start_span_connector_create_conversation() -> Iterator[None]: +# with _start_span_connector_op(core.SPAN_CONNECTOR_CREATE_CONVERSATION): +# yield + + +# @contextmanager +# def start_span_connector_get_conversations() -> Iterator[None]: +# with _start_span_connector_op(core.SPAN_CONNECTOR_GET_CONVERSATIONS): +# yield + + +# @contextmanager +# def start_span_connector_get_conversation_members() -> Iterator[None]: +# with _start_span_connector_op(core.SPAN_CONNECTOR_GET_CONVERSATION_MEMBERS): +# yield + + +# @contextmanager +# def start_span_connector_upload_attachment(conversation_id: str) -> Iterator[None]: +# with _start_span_connector_op( +# core.SPAN_CONNECTOR_UPDLOAD_ATTACHMENT, conversation_id=conversation_id +# ): +# yield -@contextmanager -def start_span_connector_get_attachment(attachment_id: str) -> Iterator[None]: - with _start_span_connector_op(core.SPAN_CONNECTOR_GET_ATTACHMENT) as span: - span.set_attribute(core.ATTR_ATTACHMENT_ID, attachment_id) - yield - - -# -# Storage -# - - -@contextmanager -def _start_span_storage_op(span_name: str, num_keys: int) -> Iterator[None]: - - def _callback(span: Span, duration: int, error: Exception | None): - _metrics.storage_operation_total.add(1) - _metrics.storage_operation_duration.record(duration) - - with agents_telemetry.start_timed_span(span_name, callback=_callback) as span: - span.set_attribute(core.ATTR_NUM_KEYS, num_keys) - yield +# @contextmanager +# def start_span_connector_get_attachment(attachment_id: str) -> Iterator[None]: +# with _start_span_connector_op(core.SPAN_CONNECTOR_GET_ATTACHMENT) as span: +# span.set_attribute(core.ATTR_ATTACHMENT_ID, attachment_id) +# yield + + +# # +# # Storage +# # + + +# @contextmanager +# def _start_span_storage_op(span_name: str, num_keys: int) -> Iterator[None]: + +# def _callback(span: Span, duration: int, error: Exception | None): +# _metrics.storage_operation_total.add(1) +# _metrics.storage_operation_duration.record(duration) + +# with agents_telemetry.start_timed_span(span_name, callback=_callback) as span: +# span.set_attribute(core.ATTR_NUM_KEYS, num_keys) +# yield -@contextmanager -def start_span_storage_read(num_keys: int) -> Iterator[None]: - with _start_span_storage_op(core.SPAN_STORAGE_READ, num_keys): - yield +# @contextmanager +# def start_span_storage_read(num_keys: int) -> Iterator[None]: +# with _start_span_storage_op(core.SPAN_STORAGE_READ, num_keys): +# yield -@contextmanager -def start_span_storage_write(num_keys: int) -> Iterator[None]: - with _start_span_storage_op(core.SPAN_STORAGE_WRITE, num_keys): - yield +# @contextmanager +# def start_span_storage_write(num_keys: int) -> Iterator[None]: +# with _start_span_storage_op(core.SPAN_STORAGE_WRITE, num_keys): +# yield -@contextmanager -def start_span_storage_delete(num_keys: int) -> Iterator[None]: - with _start_span_storage_op(core.SPAN_STORAGE_DELETE, num_keys): - yield +# @contextmanager +# def start_span_storage_delete(num_keys: int) -> Iterator[None]: +# with _start_span_storage_op(core.SPAN_STORAGE_DELETE, num_keys): +# yield -# -# TurnContext -# +# # +# # TurnContext +# # -@contextmanager -def start_span_turn_context_send_activity( - turn_context: TurnContextProtocol, -) -> Iterator[None]: - with agents_telemetry.start_as_current_span( - core.SPAN_TURN_SEND_ACTIVITY, turn_context - ): - yield +# @contextmanager +# def start_span_turn_context_send_activity( +# turn_context: TurnContextProtocol, +# ) -> Iterator[None]: +# with agents_telemetry.start_as_current_span( +# core.SPAN_TURN_SEND_ACTIVITY, turn_context +# ): +# yield -@contextmanager -def start_span_turn_context_update_activity( - turn_context: TurnContextProtocol, -) -> Iterator[None]: - with agents_telemetry.start_as_current_span( - core.SPAN_TURN_UPDATE_ACTIVITY, turn_context - ): - yield +# @contextmanager +# def start_span_turn_context_update_activity( +# turn_context: TurnContextProtocol, +# ) -> Iterator[None]: +# with agents_telemetry.start_as_current_span( +# core.SPAN_TURN_UPDATE_ACTIVITY, turn_context +# ): +# yield -@contextmanager -def start_span_turn_context_delete_activity( - turn_context: TurnContextProtocol, -) -> Iterator[None]: - with agents_telemetry.start_as_current_span( - core.SPAN_TURN_DELETE_ACTIVITY, turn_context - ): - yield +# @contextmanager +# def start_span_turn_context_delete_activity( +# turn_context: TurnContextProtocol, +# ) -> Iterator[None]: +# with agents_telemetry.start_as_current_span( +# core.SPAN_TURN_DELETE_ACTIVITY, turn_context +# ): +# yield diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/adapter.py index 1c28db63..c8f1abe8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/adapter.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from __future__ import annotations from ..core import constants, AttributeMap @@ -9,7 +12,7 @@ AttributeMap, SimpleSpanWrapper, ) -from ..core.utils import get_conversation_id, get_delivery_mode, format_scopes +from ..utils import get_conversation_id, get_delivery_mode, format_scopes from .. import _metrics class AdapterProcess(SimpleSpanWrapper): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/app.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/app.py index ce924c26..5896c32f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/app.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/app.py @@ -1,14 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from __future__ import annotations from opentelemetry.trace import Span from microsoft_agents.activity import TurnContextProtocol from ..core import ( constants, - get_conversation_id, AttributeMap, SimpleSpanWrapper, ) from .. import _metrics +from ..utils import get_conversation_id class AppOnTurn(SimpleSpanWrapper): """Span for the entire app run, starting from when an activity is received in the adapter, until a response is sent back (if applicable). This span is meant to be a parent span for all other spans created during the processing of the activity, and can be used to correlate all telemetry for a given app run.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/connector.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/connector.py index 1da51ebc..daf101d3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/connector.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/connector.py @@ -1,15 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from __future__ import annotations -from ..core import constants, AttributeMap from opentelemetry.trace import Span -from microsoft_agents.activity import Activity from ..core import ( constants, - AttributeMap, SimpleSpanWrapper, + AttributeMap, ) -from ..core.utils import get_conversation_id, get_delivery_mode, format_scopes from .. import _metrics class _ConnectorSpanWrapper(SimpleSpanWrapper): @@ -27,7 +27,10 @@ def _callback(self, span: Span, duration: float, error: Exception | None) -> Non _metrics.connector_request_total.add(1) def _get_attributes(self) -> dict[str, str]: - """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the connector operation being performed.""" + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the connector operation being performed. + + NOTE: a dict is the annotated return type to allow child classes to add additional attributes. + """ attributes = {} if self._conversation_id is not None: attributes[constants.ATTR_CONVERSATION_ID] = self._conversation_id diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/storage.py new file mode 100644 index 00000000..042ec0b6 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/storage.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span + +from ..core import ( + constants, + SimpleSpanWrapper, +) +from .. import _metrics + +class _StorageSpanWrapper(SimpleSpanWrapper): + """Base SpanWrapper for spans related to storage operations. This is meant to be a base class for spans related to storage operations, such as retrieving or saving state, and can be used to share common functionality and attributes related to storage operations.""" + + def __init__(self, span_name: str, *, key_count: int): + """Initializes the _StorageSpanWrapper span.""" + super().__init__(span_name) + self._key_count = key_count + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This is used to record metrics for the storage operation based on the outcome of the span.""" + _metrics.storage_operation_duration.record(duration) + _metrics.storage_operation_total.add(1) + + def _get_attributes(self) -> dict[str, str]: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the storage operation being performed. + + NOTE: a dict is the annotated return type to allow child classes to add additional attributes. + """ + return { + constants.ATTR_KEY_COUNT: self._key_count, + } + +class StorageRead(_StorageSpanWrapper): + """Span for reading from storage.""" + + def __init__(self, key_count: int): + """Initializes the StorageRead span.""" + super().__init__(constants.SPAN_STORAGE_READ, key_count=key_count) + +class StorageWrite(_StorageSpanWrapper): + """Span for writing to storage.""" + + def __init__(self, key_count: int): + """Initializes the StorageWrite span.""" + super().__init__(constants.SPAN_STORAGE_WRITE, key_count=key_count) + +class StorageDelete(_StorageSpanWrapper): + """Span for deleting from storage.""" + + def __init__(self, key_count: int): + """Initializes the StorageDelete span.""" + super().__init__(constants.SPAN_STORAGE_DELETE, key_count=key_count) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/turn_context.py new file mode 100644 index 00000000..22b1847d --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/turn_context.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from microsoft_agents.activity import TurnContextProtocol +from ..core import ( + constants, + AttributeMap, + SimpleSpanWrapper, +) +from ..utils import get_conversation_id + +class _TurnContextSpanWrapper(SimpleSpanWrapper): + """Base span wrapper for TurnContext operations""" + + def __init__(self, span_name: str, turn_context: TurnContextProtocol): + """Initializes the span wrapper with the given span name and turn context.""" + super().__init__(span_name) + self._turn_context = turn_context + + def _get_attributes(self) -> AttributeMap: + activity = self._turn_context.activity + return { + constants.ATTR_CONVERSATION_ID: get_conversation_id(activity), + } + +class TurnContextSendActivity(_TurnContextSpanWrapper): + """Span wrapper for sending an activity within a turn context.""" + + def __init__(self, turn_context: TurnContextProtocol): + super().__init__(constants.SPAN_TURN_SEND_ACTIVITY, turn_context) + +class TurnContextUpdateActivity(_TurnContextSpanWrapper): + """Span wrapper for updating an activity within a turn context.""" + + def __init__(self, turn_context: TurnContextProtocol): + super().__init__(constants.SPAN_TURN_UPDATE_ACTIVITY, turn_context) + +class TurnContextDeleteActivity(_TurnContextSpanWrapper): + """Span wrapper for deleting an activity within a turn context.""" + + def __init__(self, turn_context: TurnContextProtocol): + super().__init__(constants.SPAN_TURN_DELETE_ACTIVITY, turn_context) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py index 4c1ac2cc..426fc24f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py @@ -1,21 +1,26 @@ -from microsoft_agents.activity import Activity, DeliveryModes +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. -from . import core +from microsoft_agents.activity import Activity, DeliveryModes +from .core import constants -def _format_scopes(scopes: list[str] | None) -> str: +def format_scopes(scopes: list[str] | None) -> str: + """Formats a list of scopes into a string for telemetry recording. If the list is None or empty, returns a constant value indicating unknown scopes.""" if not scopes: - return core.UNKNOWN + return constants.UNKNOWN return ",".join(scopes) -def _get_conversation_id(activity: Activity) -> str: - return activity.conversation.id if activity.conversation else core.UNKNOWN +def get_conversation_id(activity: Activity) -> str: + """Extracts the conversation ID from the given activity. If the conversation ID cannot be found, returns a constant value indicating unknown conversation ID.""" + return activity.conversation.id if activity.conversation else constants.UNKNOWN -def _get_delivery_mode(activity: Activity) -> str: +def get_delivery_mode(activity: Activity) -> str: + """Extracts the delivery mode from the given activity. If the delivery mode cannot be found, returns a constant value indicating unknown delivery mode.""" if activity.delivery_mode: if isinstance(activity.delivery_mode, DeliveryModes): return activity.delivery_mode.value else: return activity.delivery_mode - return core.UNKNOWN \ No newline at end of file + return constants.UNKNOWN \ No newline at end of file From a6a24bc9393dd37ebf4587d0c9195b36ca54aa20 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 23 Mar 2026 17:08:25 -0700 Subject: [PATCH 34/55] Telemetry submodules for turn context and adapter --- .../hosting/core/app/agent_application.py | 12 +- .../_telemetry => app/telemetry}/__init__.py | 0 .../hosting/core/app/telemetry/constants.py | 12 + .../hosting/core/app/telemetry/metrics.py | 23 + .../spans/app.py => app/telemetry/spans.py} | 46 +- .../hosting/core/channel_service_adapter.py | 14 +- .../core/connector/client/connector_client.py | 17 +- .../telemetry}/__init__.py | 0 .../core/connector/telemetry/constants.py | 15 + .../core/connector/telemetry/metrics.py | 17 + .../telemetry/spans.py} | 50 +-- .../rest_channel_service_client_factory.py | 49 ++- .../hosting/core/storage/memory_storage.py | 7 +- .../hosting/core/storage/storage.py | 12 +- .../spans => storage/telemetry}/__init__.py | 0 .../core/storage/telemetry/constants.py | 9 + .../hosting/core/storage/telemetry/metrics.py | 16 + .../storage.py => storage/telemetry/spans.py} | 14 +- .../hosting/core/telemetry/__init__.py | 14 +- .../hosting/core/telemetry/_metrics.py | 57 --- .../core/telemetry/adapter/__init__.py | 0 .../core/telemetry/adapter/constants.py | 12 + .../hosting/core/telemetry/adapter/metrics.py | 11 + .../{spans/adapter.py => adapter/spans.py} | 67 +-- .../hosting/core/telemetry/attributes.py | 37 ++ .../hosting/core/telemetry/core/__init__.py | 6 +- .../core/telemetry/core/base_span_wrapper.py | 1 - .../hosting/core/telemetry/core/constants.py | 118 ----- .../hosting/core/telemetry/core/resource.py | 18 + .../hosting/core/telemetry/spans.py | 414 ------------------ .../core/telemetry/turn_context/__init__.py | 0 .../core/telemetry/turn_context/constants.py | 11 + .../core/telemetry/turn_context/metrics.py | 0 .../turn_context.py => turn_context/spans.py} | 9 +- .../hosting/core/telemetry/utils.py | 8 +- .../hosting/core/turn_context.py | 8 +- 36 files changed, 356 insertions(+), 748 deletions(-) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/{connector/client/_telemetry => app/telemetry}/__init__.py (100%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/constants.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/metrics.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/{telemetry/spans/app.py => app/telemetry/spans.py} (70%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/{storage/_telemetry => connector/telemetry}/__init__.py (100%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/{telemetry/spans/connector.py => connector/telemetry/spans.py} (74%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/{telemetry/spans => storage/telemetry}/__init__.py (100%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/constants.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/metrics.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/{telemetry/spans/storage.py => storage/telemetry/spans.py} (85%) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/{spans/adapter.py => adapter/spans.py} (63%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/constants.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/resource.py delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/constants.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/metrics.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/{spans/turn_context.py => turn_context/spans.py} (88%) 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 760547c7..9b18339c 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,7 +30,6 @@ InvokeResponse, ) -from microsoft_agents.hosting.core.telemetry import spans from microsoft_agents.hosting.core.turn_context import TurnContext from ..agent import Agent @@ -42,6 +41,7 @@ from ..channel_service_adapter import ChannelServiceAdapter from .oauth import Authorization from .typing_indicator import TypingIndicator +from .telemetry import spans from ._type_defs import RouteHandler, RouteSelector from ._routes import _RouteList, _Route, RouteRank, _agentic_selector @@ -671,7 +671,7 @@ async def on_turn(self, context: TurnContext): async def _on_turn(self, context: TurnContext): typing = None try: - with spans.start_span_app_on_turn(context): + with spans.AppOnTurn(context) as on_turn_span: if context.activity.type != ActivityTypes.typing: if self._options.start_typing_timer: typing = TypingIndicator(context) @@ -780,7 +780,7 @@ async def _initialize_state(self, context: TurnContext) -> StateT: return turn_state async def _run_before_turn_middleware(self, context: TurnContext, state: StateT): - with spans.start_span_app_before_turn(context): + with spans.AppBeforeTurn(context): for before_turn in self._internal_before_turn: is_ok = await before_turn(context, state) if not is_ok: @@ -789,7 +789,7 @@ async def _run_before_turn_middleware(self, context: TurnContext, state: StateT) return True async def _handle_file_downloads(self, context: TurnContext, state: StateT): - with spans.start_span_app_download_files(context): + with spans.AppDownloadFiles(context): if ( self._options.file_downloaders and len(self._options.file_downloaders) > 0 @@ -811,7 +811,7 @@ def _contains_non_text_attachments(self, context: TurnContext): return len(list(non_text_attachments)) > 0 async def _run_after_turn_middleware(self, context: TurnContext, state: StateT): - with spans.start_span_app_after_turn(context): + with spans.AppAfterTurn(context): for after_turn in self._internal_after_turn: is_ok = await after_turn(context, state) if not is_ok: @@ -820,7 +820,7 @@ async def _run_after_turn_middleware(self, context: TurnContext, state: StateT): return True async def _on_activity(self, context: TurnContext, state: StateT): - with spans.start_span_app_route_handler(context): + with spans.AppRouteHandler(context): for route in self._route_list: if route.selector(context): if not route.auth_handlers: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/_telemetry/__init__.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/__init__.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/constants.py new file mode 100644 index 00000000..c0545330 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/constants.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +SPAN_ON_TURN = "agents.app.run" +SPAN_ROUTE_HANDLER = "agents.app.routeHandler" +SPAN_BEFORE_TURN = "agents.app.beforeTurn" +SPAN_AFTER_TURN = "agents.app.afterTurn" +SPAN_DOWNLOAD_FILES = "agents.app.downloadFiles" + +METRIC_TURN_TOTAL = "agents.turn.total" +METRIC_TURN_ERRORS = "agents.turn.errors" +METRIC_TURN_DURATION = "agents.turn.duration" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/metrics.py new file mode 100644 index 00000000..9562ba43 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/metrics.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import agents_telemetry +from . import constants + +turn_total = agents_telemetry.meter.create_counter( + constants.METRIC_TURN_TOTAL, + "turn", + description="Total number of turns processed by the agent", +) + +turn_errors = agents_telemetry.meter.create_counter( + constants.METRIC_TURN_ERRORS, + "turn", + description="Number of turns that resulted in an error", +) + +turn_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_TURN_DURATION, + "ms", + description="Duration of agent turns in milliseconds", +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/app.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py similarity index 70% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/app.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py index 5896c32f..0cc1836e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/app.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py @@ -5,13 +5,13 @@ from opentelemetry.trace import Span from microsoft_agents.activity import TurnContextProtocol -from ..core import ( - constants, +from microsoft_agents.hosting.core.telemetry import ( AttributeMap, + attributes, SimpleSpanWrapper, + get_conversation_id, ) -from .. import _metrics -from ..utils import get_conversation_id +from . import constants, metrics class AppOnTurn(SimpleSpanWrapper): """Span for the entire app run, starting from when an activity is received in the adapter, until a response is sent back (if applicable). This span is meant to be a parent span for all other spans created during the processing of the activity, and can be used to correlate all telemetry for a given app run.""" @@ -21,30 +21,30 @@ def __init__(self, turn_context: TurnContextProtocol): :param turn_context: The TurnContext for the app run, used to extract attributes for the span """ - super().__init__(constants.SPAN_APP_ON_TURN) + super().__init__(constants.SPAN_ON_TURN) self._turn_context = turn_context def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span is ended. This is used to record metrics for the app run based on the outcome of the span.""" if error is None: - _metrics.turn_total.add(1) - _metrics.turn_duration.record( + metrics.turn_total.add(1) + metrics.turn_duration.record( duration, { - constants.ATTR_CONVERSATION_ID: ( + attributes.CONVERSATION_ID: ( get_conversation_id(self._turn_context.activity) ), - constants.ATTR_ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or constants.UNKNOWN, + attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or attributes.UNKNOWN, }, ) else: - _metrics.turn_errors.add(1) + metrics.turn_errors.add(1) def _get_attributes(self) -> AttributeMap: return { - constants.ATTR_CONVERSATION_ID: get_conversation_id(self._turn_context.activity), - constants.ATTR_ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or constants.UNKNOWN, - constants.ATTR_SERVICE_URL: self._turn_context.activity.service_url, + attributes.CONVERSATION_ID: get_conversation_id(self._turn_context.activity), + attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or attributes.UNKNOWN, + attributes.SERVICE_URL: self._turn_context.activity.service_url, } def share(self, route_authorized: bool, route_matched: bool) -> None: @@ -54,23 +54,23 @@ def share(self, route_authorized: bool, route_matched: bool) -> None: :param route_matched: Whether the route for this app run was matched """ if self._span is not None: - self._span.set_attribute("app_run.route.authorized", route_authorized) - self._span.set_attribute("app_run.route.matched", route_matched) + self._span.set_attribute(attributes.ROUTE_AUTHORIZED, route_authorized) + self._span.set_attribute(attributes.ROUTE_MATCHED, route_matched) class AppRouteHandler(SimpleSpanWrapper): """Span for handling the routing logic. From selection, through authorization, and through the invocation of the route handler.""" def __init__(self, turn_context: TurnContextProtocol): """Initializes the AppRouteHandler SpanWrapper.""" - super().__init__(constants.SPAN_APP_ROUTE_HANDLER) + super().__init__(constants.SPAN_ROUTE_HANDLER) self._turn_context = turn_context def _get_attributes(self) -> AttributeMap: """Gets attributes for the AppRouteHandler span, based on the activity being processed.""" return { - constants.ATTR_CONVERSATION_ID: get_conversation_id(self._turn_context.activity), - constants.ATTR_ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or constants.UNKNOWN, - constants.ATTR_SERVICE_URL: self._turn_context.activity.service_url, + attributes.CONVERSATION_ID: get_conversation_id(self._turn_context.activity), + attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or attributes.UNKNOWN, + attributes.SERVICE_URL: self._turn_context.activity.service_url, } class AppBeforeTurn(SimpleSpanWrapper): @@ -78,24 +78,24 @@ class AppBeforeTurn(SimpleSpanWrapper): def __init__(self): """Initializes the AppBeforeTurn SpanWrapper.""" - super().__init__(constants.SPAN_APP_BEFORE_TURN) + super().__init__(constants.SPAN_BEFORE_TURN) class AppAfterTurn(SimpleSpanWrapper): """Span for the logic that happens after the main turn processing. This is meant to capture telemetry for the post-processing logic of the app run, and can be used to identify issues in the later stages of the app run after the main processing logic is invoked.""" def __init__(self): """Initializes the AppAfterTurn SpanWrapper.""" - super().__init__(constants.SPAN_APP_AFTER_TURN) + super().__init__(constants.SPAN_AFTER_TURN) class AppDownloadFiles(SimpleSpanWrapper): """Span for the logic related to downloading files in the app. This can be used to capture telemetry for file download operations, and to identify issues related to file downloads in the app.""" def __init__(self, turn_context: TurnContextProtocol): """Initializes the AppDownloadFiles SpanWrapper.""" - super().__init__(constants.SPAN_APP_DOWNLOAD_FILES) + super().__init__(constants.SPAN_DOWNLOAD_FILES) self._turn_context = turn_context def _get_attributes(self) -> AttributeMap: return { - constants.ATTR_ATTACHMENT_COUNT: len(self._turn_context.activity.attachments or []), + attributes.ATTACHMENT_COUNT: len(self._turn_context.activity.attachments or []), } \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index 2e700854..bad61ec5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -35,7 +35,7 @@ AuthenticationConstants, ClaimsIdentity, ) -from microsoft_agents.hosting.core.telemetry import spans +from microsoft_agents.hosting.core.telemetry.adapter import spans from .channel_service_client_factory_base import ChannelServiceClientFactoryBase from .channel_adapter import ChannelAdapter from .turn_context import TurnContext @@ -68,7 +68,7 @@ async def send_activities( :rtype: list[:class:`microsoft_agents.activity.ResourceResponse`] :raises TypeError: If context or activities are None/invalid. """ - with spans.start_span_adapter_send_activities(activities): + with spans.AdapterSendActivities(activities): if not context: raise TypeError("Expected TurnContext but got None instead") @@ -139,7 +139,7 @@ async def update_activity(self, context: TurnContext, activity: Activity): :rtype: :class:`microsoft_agents.activity.ResourceResponse` :raises TypeError: If context or activity are None/invalid. """ - with spans.start_span_adapter_update_activity(activity): + with spans.AdapterUpdateActivity(activity): if not context: raise TypeError("Expected TurnContext but got None instead") @@ -170,7 +170,7 @@ async def delete_activity( :type reference: :class:`microsoft_agents.activity.ConversationReference` :raises TypeError: If context or reference are None/invalid. """ - with spans.start_span_adapter_delete_activity(context.activity): + with spans.AdapterDeleteActivity(context.activity): if not context: raise TypeError("Expected TurnContext but got None instead") @@ -209,7 +209,7 @@ async def continue_conversation( # pylint: disable=arguments-differ :param callback: The method to call for the resulting agent turn. :type callback: Callable[[:class:`microsoft_agents.hosting.core.turn_context.TurnContext`], Awaitable] """ - with spans.start_span_adapter_continue_conversation(continuation_activity): + with spans.AdapterContinueConversation(continuation_activity): if not callable: raise TypeError( "Expected Callback (Callable[[TurnContext], Awaitable]) but got None instead" @@ -245,9 +245,7 @@ async def continue_conversation_with_claims( :param audience: The audience for the conversation. :type audience: Optional[str] """ - with spans.start_span_adapter_continue_continue_conversation( - continuation_activity - ): + with spans.AdapterContinueConversation(continuation_activity): return await self.process_proactive( claims_identity, continuation_activity, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index f7299601..8ea5c206 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -22,6 +22,7 @@ from ..attachments_base import AttachmentsBase from ..conversations_base import ConversationsBase from ..get_product_info import get_product_info +from ..telemetry import spans logger = logging.getLogger(__name__) @@ -95,7 +96,7 @@ async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: :param view_id: The ID of the view. :return: The attachment as a readable stream. """ - with spans.start_span_connector_get_attachment(attachment_id=attachment_id): + with spans.ConnectorGetAttachment(attachment_id): if attachment_id is None: logger.error( "AttachmentsOperations.get_attachment(): attachmentId is required", @@ -195,7 +196,7 @@ async def reply_to_activity( :param body: The activity object. :return: The resource response. """ - with spans.start_span_connector_reply_to_activity(conversation_id, activity_id): + with spans.ConnectorReplyToActivity(conversation_id, activity_id): if not conversation_id or not activity_id: logger.error( "ConversationsOperations.reply_to_activity(): conversationId and activityId are required", @@ -247,7 +248,7 @@ async def send_to_conversation( :param body: The activity object. :return: The resource response. """ - with spans.start_span_connector_send_to_conversation(conversation_id, body.id): + with spans.ConnectorSendToConversation(conversation_id, body.id): if not conversation_id: logger.error( "ConversationsOperations.sent_to_conversation(): conversationId is required", @@ -289,7 +290,7 @@ async def update_activity( :param body: The activity object. :return: The resource response. """ - with spans.start_span_connector_update_activity(conversation_id, activity_id): + with spans.ConnectorUpdateActivity(conversation_id, activity_id): if not conversation_id or not activity_id: logger.error( "ConversationsOperations.update_activity(): conversationId and activityId are required", @@ -326,7 +327,7 @@ async def delete_activity(self, conversation_id: str, activity_id: str) -> None: :param conversation_id: The ID of the conversation. :param activity_id: The ID of the activity. """ - with spans.start_span_connector_delete_activity(conversation_id, activity_id): + with spans.ConnectorDeleteActivity(conversation_id, activity_id): if not conversation_id or not activity_id: logger.error( "ConversationsOperations.delete_activity(): conversationId and activityId are required", @@ -359,7 +360,7 @@ async def upload_attachment( :param body: The attachment data. :return: The resource response. """ - with spans.start_span_connector_upload_attachment(conversation_id): + with spans.ConnectorUploadAttachment(conversation_id): if conversation_id is None: logger.error( "ConversationsOperations.upload_attachment(): conversationId is required", @@ -404,7 +405,7 @@ async def get_conversation_members( :param conversation_id: The ID of the conversation. :return: A list of members. """ - with spans.start_span_connector_get_conversation_members(): + with spans.ConnectorGetConversationMembers(): if not conversation_id: logger.error( "ConversationsOperations.get_conversation_members(): conversationId is required", @@ -440,7 +441,7 @@ async def get_conversation_member( :param member_id: The ID of the member. :return: The member. """ - with spans.start_span_connector_get_conversation_members(): + with spans.ConnectorGetConversationMembers(): if not conversation_id or not member_id: logger.error( "ConversationsOperations.get_conversation_member(): conversationId and memberId are required", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_telemetry/__init__.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/__init__.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py new file mode 100644 index 00000000..69590fdb --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +SPAN_REPLY_TO_ACTIVITY = "agents.connector.replyToActivity" +SPAN_SEND_TO_CONVERSATION = "agents.connector.sendToConversation" +SPAN_UPDATE_ACTIVITY = "agents.connector.updateActivity" +SPAN_DELETE_ACTIVITY = "agents.connector.deleteActivity" +SPAN_CREATE_CONVERSATION = "agents.connector.createConversation" +SPAN_GET_CONVERSATIONS = "agents.connector.getConversations" +SPAN_GET_CONVERSATION_MEMBERS = "agents.connector.getConversationMembers" +SPAN_UPLOAD_ATTACHMENT = "agents.connector.uploadAttachment" +SPAN_GET_ATTACHMENT = "agents.connector.getAttachment" + +METRIC_REQUESTS_TOTAL = "agents.connector.requests" +METRIC_REQUEST_DURATION = "agents.connector.request.duration" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py new file mode 100644 index 00000000..56230642 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import agents_telemetry +from . import constants + +connector_request_total = agents_telemetry.meter.create_counter( + constants.METRIC_REQUESTS_TOTAL, + "request", + description="Total number of connector requests made by the agent", +) + +connector_request_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_REQUEST_DURATION, + "ms", + description="Duration of connector requests in milliseconds", +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/connector.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/spans.py similarity index 74% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/connector.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/spans.py index daf101d3..84123411 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/connector.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/spans.py @@ -5,12 +5,12 @@ from opentelemetry.trace import Span -from ..core import ( - constants, +from microsoft_agents.hosting.core.telemetry import ( + attributes, SimpleSpanWrapper, AttributeMap, ) -from .. import _metrics +from . import metrics, constants class _ConnectorSpanWrapper(SimpleSpanWrapper): """Base SpanWrapper for spans related to connector operations in the adapter. This is meant to be a base class for spans related to connector operations, such as creating a connector client or creating a user token, and can be used to share common functionality and attributes related to connector operations.""" @@ -23,54 +23,54 @@ def __init__(self, span_name: str, *, conversation_id: str | None = None, activi def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span is ended. This is used to record metrics for the connector operation based on the outcome of the span.""" - _metrics.connector_request_duration.record(duration) - _metrics.connector_request_total.add(1) + metrics.connector_request_duration.record(duration) + metrics.connector_request_total.add(1) def _get_attributes(self) -> dict[str, str]: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the connector operation being performed. NOTE: a dict is the annotated return type to allow child classes to add additional attributes. """ - attributes = {} + attr_dict = {} if self._conversation_id is not None: - attributes[constants.ATTR_CONVERSATION_ID] = self._conversation_id + attr_dict[attributes.CONVERSATION_ID] = self._conversation_id if self._activity_id is not None: - attributes[constants.ATTR_ACTIVITY_ID] = self._activity_id - return attributes + attr_dict[attributes.ACTIVITY_ID] = self._activity_id + return attr_dict class ConnectorReplyToActivity(_ConnectorSpanWrapper): """Span for replying to an activity using the connector client in the adapter.""" - def __init__(self, conversation_id: str, activity_id: str): + def __init__(self, conversation_id: str, activity_id: str | None): """Initializes the ConnectorReplyToActivity span.""" - super().__init__(constants.SPAN_CONNECTOR_REPLY_TO_ACTIVITY, + super().__init__(constants.SPAN_REPLY_TO_ACTIVITY, conversation_id=conversation_id, activity_id=activity_id) class ConnectorSendToConversation(_ConnectorSpanWrapper): """Span for sending to a conversation using the connector client in the adapter.""" - def __init__(self, conversation_id: str, activity_id: str): + def __init__(self, conversation_id: str, activity_id: str | None): """Initializes the ConnectorSendToConversation span.""" - super().__init__(constants.SPAN_CONNECTOR_SEND_TO_CONVERSATION, + super().__init__(constants.SPAN_SEND_TO_CONVERSATION, conversation_id=conversation_id, activity_id=activity_id) class ConnectorUpdateActivity(_ConnectorSpanWrapper): """Span for updating an activity using the connector client in the adapter.""" - def __init__(self, conversation_id: str, activity_id: str): + def __init__(self, conversation_id: str, activity_id: str | None): """Initializes the ConnectorUpdateActivity span.""" - super().__init__(constants.SPAN_CONNECTOR_UPDATE_ACTIVITY, + super().__init__(constants.SPAN_UPDATE_ACTIVITY, conversation_id=conversation_id, activity_id=activity_id) class ConnectorDeleteActivity(_ConnectorSpanWrapper): """Span for deleting an activity using the connector client in the adapter.""" - def __init__(self, conversation_id: str, activity_id: str): + def __init__(self, conversation_id: str, activity_id: str | None): """Initializes the ConnectorDeleteActivity span.""" - super().__init__(constants.SPAN_CONNECTOR_DELETE_ACTIVITY, + super().__init__(constants.SPAN_DELETE_ACTIVITY, conversation_id=conversation_id, activity_id=activity_id) @@ -79,28 +79,28 @@ class ConnectorCreateConversation(_ConnectorSpanWrapper): def __init__(self): """Initializes the ConnectorCreateConversation span.""" - super().__init__(constants.SPAN_CONNECTOR_CREATE_CONVERSATION) + super().__init__(constants.SPAN_CREATE_CONVERSATION) class ConnectorGetConversations(_ConnectorSpanWrapper): """Span for getting conversations using the connector client in the adapter.""" def __init__(self): """Initializes the ConnectorGetConversations span.""" - super().__init__(constants.SPAN_CONNECTOR_GET_CONVERSATIONS) + super().__init__(constants.SPAN_GET_CONVERSATIONS) class ConnectorGetConversationMembers(_ConnectorSpanWrapper): """Span for getting conversation members using the connector client in the adapter.""" def __init__(self): """Initializes the ConnectorGetConversationMembers span.""" - super().__init__(constants.SPAN_CONNECTOR_GET_CONVERSATION_MEMBERS) + super().__init__(constants.SPAN_GET_CONVERSATION_MEMBERS) class ConnectorUploadAttachment(_ConnectorSpanWrapper): """Span for uploading an attachment using the connector client in the adapter.""" def __init__(self, conversation_id: str): """Initializes the ConnectorUploadAttachment span.""" - super().__init__(constants.SPAN_CONNECTOR_UPLOAD_ATTACHMENT, + super().__init__(constants.SPAN_UPLOAD_ATTACHMENT, conversation_id=conversation_id) class ConnectorGetAttachment(_ConnectorSpanWrapper): @@ -108,10 +108,10 @@ class ConnectorGetAttachment(_ConnectorSpanWrapper): def __init__(self, attachment_id: str): """Initializes the ConnectorGetAttachment span.""" - super().__init__(constants.SPAN_CONNECTOR_GET_ATTACHMENT) + super().__init__(constants.SPAN_GET_ATTACHMENT) self._attachment_id = attachment_id def _get_attributes(self) -> AttributeMap: - attributes = super()._get_attributes() - attributes[constants.ATTR_ATTACHMENT_ID] = self._attachment_id - return attributes + attr_dict = super()._get_attributes() + attr_dict[attributes.ATTACHMENT_ID] = self._attachment_id + return attr_dict diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py index c9f9a786..4abb11b5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py @@ -16,7 +16,7 @@ from microsoft_agents.hosting.core.connector.client import UserTokenClient from microsoft_agents.hosting.core.connector.teams import TeamsConnectorClient from microsoft_agents.hosting.core.connector.mcs import MCSConnectorClient -from microsoft_agents.hosting.core.telemetry import spans +from microsoft_agents.hosting.core.telemetry.adapter import spans from .channel_service_client_factory_base import ChannelServiceClientFactoryBase from .turn_context import TurnContext @@ -108,7 +108,7 @@ async def create_connector_client( is_agentic_request = context.activity.is_agentic_request() if context else False - with spans.start_span_adapter_create_connector_client( + with spans.AdapterCreateConnectorClient( service_url=service_url, scopes=scopes, is_agentic_request=is_agentic_request, @@ -158,28 +158,33 @@ async def create_user_token_client( """ if not context or not claims_identity: raise ValueError("context and claims_identity are required") + + scopes = claims_identity.get_token_scope() if claims_identity else None + + with spans.AdapterCreateUserTokenClient( + token_service_endpoint=self._token_service_endpoint, + scopes=scopes, + ): - if use_anonymous: - return UserTokenClient(endpoint=self._token_service_endpoint, token="") - - if context.activity.is_agentic_request(): - token = await self._get_agentic_token(context, self._token_service_endpoint) - else: - scopes = claims_identity.get_token_scope() + if use_anonymous: + return UserTokenClient(endpoint=self._token_service_endpoint, token="") - token_provider = self._connection_manager.get_token_provider( - claims_identity, self._token_service_endpoint - ) + if context.activity.is_agentic_request(): + token = await self._get_agentic_token(context, self._token_service_endpoint) + else: + token_provider = self._connection_manager.get_token_provider( + claims_identity, self._token_service_endpoint + ) - token = await token_provider.get_access_token( - self._token_service_audience, scopes - ) + token = await token_provider.get_access_token( + self._token_service_audience, scopes + ) - if not token: - logger.error("Failed to obtain token for user token client") - raise ValueError("Failed to obtain token for user token client") + if not token: + logger.error("Failed to obtain token for user token client") + raise ValueError("Failed to obtain token for user token client") - return UserTokenClient( - endpoint=self._token_service_endpoint, - token=token, - ) + return UserTokenClient( + endpoint=self._token_service_endpoint, + token=token, + ) 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 8e6ad37c..39d6f0e1 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 @@ -9,6 +9,7 @@ from ._type_aliases import JSON from .storage import Storage from .store_item import StoreItem +from .telemetry import spans StoreItemT = TypeVar("StoreItemT", bound=StoreItem) @@ -27,7 +28,7 @@ async def read( if not target_cls: raise ValueError("Storage.read(): target_cls cannot be None.") - with spans.start_span_storage_read(len(keys)): + with spans.StorageRead(len(keys)): result: dict[str, StoreItem] = {} with self._lock: for key in keys: @@ -51,7 +52,7 @@ async def write(self, changes: dict[str, StoreItem]): if not changes: raise ValueError("MemoryStorage.write(): changes cannot be None") - with spans.start_span_storage_write(len(changes)): + with spans.StorageWrite(len(changes)): with self._lock: for key in changes: if key == "": @@ -62,7 +63,7 @@ async def delete(self, keys: list[str]): if not keys: raise ValueError("Storage.delete(): Keys are required when deleting.") - with spans.start_span_storage_delete(len(keys)): + with spans.StorageDelete(len(keys)): with self._lock: for key in keys: if 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 f2de6b9a..1e9ddd86 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 @@ -2,13 +2,11 @@ # Licensed under the MIT License. from typing import Protocol, TypeVar, Type, Union -from abc import ABC, abstractmethod +from abc import abstractmethod from asyncio import gather -from microsoft_agents.hosting.core.telemetry import spans - -from ._type_aliases import JSON from .store_item import StoreItem +from .telemetry import spans StoreItemT = TypeVar("StoreItemT", bound=StoreItem) @@ -71,7 +69,7 @@ async def read( if not target_cls: raise ValueError("Storage.read(): target_cls cannot be None.") - with spans.start_span_storage_read(len(keys)): + with spans.StorageRead(len(keys)): await self.initialize() items: list[tuple[Union[str, None], Union[StoreItemT, None]]] = ( @@ -93,7 +91,7 @@ async def write(self, changes: dict[str, StoreItemT]) -> None: if not changes: raise ValueError("Storage.write(): Changes are required when writing.") - with spans.start_span_storage_write(len(changes)): + with spans.StorageWrite(len(changes)): await self.initialize() await gather( @@ -109,7 +107,7 @@ async def delete(self, keys: list[str]) -> None: if not keys: raise ValueError("Storage.delete(): Keys are required when deleting.") - with spans.start_span_storage_delete(len(keys)): + with spans.StorageDelete(len(keys)): await self.initialize() await gather(*[self._delete_item(key) for key in keys]) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/__init__.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/__init__.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/constants.py new file mode 100644 index 00000000..c80cee50 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/constants.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +SPAN_STORAGE_READ = "agents.storage.read" +SPAN_STORAGE_WRITE = "agents.storage.write" +SPAN_STORAGE_DELETE = "agents.storage.delete" + +METRIC_STORAGE_OPERATION_TOTAL = "agents.storage.operation.total" +METRIC_STORAGE_OPERATION_DURATION = "agents.storage.operation.duration" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/metrics.py new file mode 100644 index 00000000..47aa8322 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/metrics.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import agents_telemetry +from . import constants + +storage_operation_total = agents_telemetry.meter.create_counter( + constants.METRIC_STORAGE_OPERATION_TOTAL, + "operation", + description="Number of storage operations performed by the agent", +) +storage_operation_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_STORAGE_OPERATION_DURATION, + "ms", + description="Duration of storage operations in milliseconds", +) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py similarity index 85% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/storage.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py index 042ec0b6..36462e9e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/storage.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py @@ -5,11 +5,11 @@ from opentelemetry.trace import Span -from ..core import ( - constants, +from microsoft_agents.hosting.core.telemetry import ( + resource as common_constants, SimpleSpanWrapper, ) -from .. import _metrics +from . import metrics, constants class _StorageSpanWrapper(SimpleSpanWrapper): """Base SpanWrapper for spans related to storage operations. This is meant to be a base class for spans related to storage operations, such as retrieving or saving state, and can be used to share common functionality and attributes related to storage operations.""" @@ -21,16 +21,16 @@ def __init__(self, span_name: str, *, key_count: int): def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span is ended. This is used to record metrics for the storage operation based on the outcome of the span.""" - _metrics.storage_operation_duration.record(duration) - _metrics.storage_operation_total.add(1) + metrics.storage_operation_duration.record(duration) + metrics.storage_operation_total.add(1) - def _get_attributes(self) -> dict[str, str]: + def _get_attributes(self) -> dict[str, str | int]: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the storage operation being performed. NOTE: a dict is the annotated return type to allow child classes to add additional attributes. """ return { - constants.ATTR_KEY_COUNT: self._key_count, + common_constants.ATTR_KEY_COUNT: self._key_count, } class StorageRead(_StorageSpanWrapper): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py index d0e101a8..7d068aba 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py @@ -8,10 +8,19 @@ # # NOTE: this module should not be auto-loaded from __init__.py in order to avoid +from . import attributes from .core._agents_telemetry import ( agents_telemetry, ) -from .core import SERVICE_NAME, SERVICE_VERSION, RESOURCE, AttributeMap +from .core import ( + SERVICE_NAME, + SERVICE_VERSION, + RESOURCE, + AttributeMap, + BaseSpanWrapper, + SimpleSpanWrapper, +) + from .utils import ( format_scopes, get_conversation_id, @@ -19,11 +28,14 @@ ) __all__ = [ + "attributes", "agents_telemetry", "format_scopes", "get_conversation_id", "get_delivery_mode", "AttributeMap", + "BaseSpanWrapper", + "SimpleSpanWrapper", "SERVICE_NAME", "SERVICE_VERSION", "RESOURCE", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py deleted file mode 100644 index d968ffc8..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/_metrics.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .core import agents_telemetry, constants - -storage_operation_total = agents_telemetry.meter.create_counter( - constants.METRIC_STORAGE_OPERATION_TOTAL, - "operation", - description="Number of storage operations performed by the agent", -) -storage_operation_duration = agents_telemetry.meter.create_histogram( - constants.METRIC_STORAGE_OPERATION_DURATION, - "ms", - description="Duration of storage operations in milliseconds", -) - -# AgentApplication - -turn_total = agents_telemetry.meter.create_counter( - constants.METRIC_TURN_TOTAL, - "turn", - description="Total number of turns processed by the agent", -) - -turn_errors = agents_telemetry.meter.create_counter( - constants.METRIC_TURN_ERRORS, - "turn", - description="Number of turns that resulted in an error", -) - -turn_duration = agents_telemetry.meter.create_histogram( - constants.METRIC_TURN_DURATION, - "ms", - description="Duration of agent turns in milliseconds", -) - -# Adapters - -adapter_process_duration = agents_telemetry.meter.create_histogram( - constants.METRIC_ADAPTER_PROCESS_DURATION, - "ms", - description="Duration of adapter processing in milliseconds", -) - -# Connectors - -connector_request_total = agents_telemetry.meter.create_counter( - constants.METRIC_CONNECTOR_REQUESTS_TOTAL, - "request", - description="Total number of connector requests made by the agent", -) - -connector_request_duration = agents_telemetry.meter.create_histogram( - constants.METRIC_CONNECTOR_REQUEST_DURATION, - "ms", - description="Duration of connector requests in milliseconds", -) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py new file mode 100644 index 00000000..c606d4d0 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +SPAN_PROCESS = "agents.adapter.process" +SPAN_SEND_ACTIVITIES = "agents.adapter.sendActivities" +SPAN_UPDATE_ACTIVITY = "agents.adapter.updateActivity" +SPAN_DELETE_ACTIVITY = "agents.adapter.deleteActivity" +SPAN_CONTINUE_CONVERSATION = "agents.adapter.continueConversation" +SPAN_CREATE_CONNECTOR_CLIENT = "agents.adapter.createConnectorClient" +SPAN_CREATE_USER_TOKEN_CLIENT = "agents.adapter.createUserTokenClient" + +METRIC_ADAPTER_PROCESS_DURATION = "agents.adapter.process.duration" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py new file mode 100644 index 00000000..18101f10 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import agents_telemetry +from . import constants + +adapter_process_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_ADAPTER_PROCESS_DURATION, + "ms", + description="Duration of adapter processing in milliseconds", +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py similarity index 63% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/adapter.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py index c8f1abe8..89bf755c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py @@ -3,37 +3,38 @@ from __future__ import annotations -from ..core import constants, AttributeMap from opentelemetry.trace import Span from microsoft_agents.activity import Activity -from ..core import ( - constants, +from microsoft_agents.hosting.core.telemetry import ( AttributeMap, SimpleSpanWrapper, + get_conversation_id, + get_delivery_mode, + format_scopes, + attributes, ) -from ..utils import get_conversation_id, get_delivery_mode, format_scopes -from .. import _metrics +from . import constants, metrics class AdapterProcess(SimpleSpanWrapper): """Span for processing an incoming activity in the adapter.""" def __init__(self, activity: Activity): """Initializes the AdapterProcess SpanWrapper.""" - super().__init__(constants.SPAN_ADAPTER_PROCESS) + super().__init__(constants.SPAN_PROCESS) self._activity = activity def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span is ended. This is used to record metrics for the adapter processing based on the outcome of the span.""" - _metrics.adapter_process_duration.record(duration) + metrics.adapter_process_duration.record(duration) def _get_attributes(self) -> AttributeMap: return { - constants.ATTR_ACTIVITY_TYPE: self._activity.type, - constants.ATTR_ACTIVITY_CHANNEL_ID: self._activity.channel_id or constants.UNKNOWN, - constants.ATTR_ACTIVITY_DELIVERY_MODE: get_delivery_mode(self._activity), - constants.ATTR_CONVERSATION_ID: get_conversation_id(self._activity), - constants.ATTR_IS_AGENTIC_REQUEST: self._activity.is_agentic_request(), + attributes.ACTIVITY_TYPE: self._activity.type, + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id or attributes.UNKNOWN, + attributes.ACTIVITY_DELIVERY_MODE: get_delivery_mode(self._activity), + attributes.CONVERSATION_ID: get_conversation_id(self._activity), + attributes.IS_AGENTIC: self._activity.is_agentic_request(), } class AdapterSendActivities(SimpleSpanWrapper): @@ -41,16 +42,16 @@ class AdapterSendActivities(SimpleSpanWrapper): def __init__(self, activities: list[Activity]): """Initializes the AdapterSendActivities span.""" - super().__init__(constants.SPAN_ADAPTER_SEND_ACTIVITIES) + super().__init__(constants.SPAN_SEND_ACTIVITIES) self._activities = activities def _get_attributes(self) -> AttributeMap: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activities being sent.""" return { - constants.ATTR_ACTIVITY_COUNT: len(self._activities), - constants.ATTR_CONVERSATION_ID: ( + attributes.ACTIVITY_COUNT: len(self._activities), + attributes.CONVERSATION_ID: ( get_conversation_id(self._activities[0]) - if self._activities else constants.UNKNOWN + if self._activities else attributes.UNKNOWN ), } @@ -59,14 +60,14 @@ class AdapterUpdateActivity(SimpleSpanWrapper): def __init__(self, activity: Activity): """Initializes the AdapterUpdateActivity span.""" - super().__init__(constants.SPAN_ADAPTER_UPDATE_ACTIVITY) + super().__init__(constants.SPAN_UPDATE_ACTIVITY) self._activity = activity def _get_attributes(self) -> AttributeMap: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activity being updated.""" return { - constants.ATTR_ACTIVITY_ID: self._activity.id or constants.UNKNOWN, - constants.ATTR_CONVERSATION_ID: get_conversation_id(self._activity), + attributes.ACTIVITY_ID: self._activity.id or attributes.UNKNOWN, + attributes.CONVERSATION_ID: get_conversation_id(self._activity), } class AdapterDeleteActivity(SimpleSpanWrapper): @@ -74,14 +75,14 @@ class AdapterDeleteActivity(SimpleSpanWrapper): def __init__(self, activity: Activity): """Initializes the AdapterDeleteActivity span.""" - super().__init__(constants.SPAN_ADAPTER_DELETE_ACTIVITY) + super().__init__(constants.SPAN_DELETE_ACTIVITY) self._activity = activity def _get_attributes(self) -> AttributeMap: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activity being deleted.""" return { - constants.ATTR_ACTIVITY_ID: self._activity.id or constants.UNKNOWN, - constants.ATTR_CONVERSATION_ID: get_conversation_id(self._activity), + attributes.ACTIVITY_ID: self._activity.id or attributes.UNKNOWN, + attributes.CONVERSATION_ID: get_conversation_id(self._activity), } class AdapterContinueConversation(SimpleSpanWrapper): @@ -89,15 +90,15 @@ class AdapterContinueConversation(SimpleSpanWrapper): def __init__(self, activity: Activity): """Initializes the AdapterContinueConversation span.""" - super().__init__(constants.SPAN_ADAPTER_CONTINUE_CONVERSATION) + super().__init__(constants.SPAN_CONTINUE_CONVERSATION) self._activity = activity def _get_attributes(self) -> AttributeMap: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the conversation being continued.""" return { - constants.ATTR_APP_ID: self._activity.recipient.id if self._activity.recipient else constants.UNKNOWN, - constants.ATTR_CONVERSATION_ID: get_conversation_id(self._activity), - constants.ATTR_IS_AGENTIC_REQUEST: self._activity.is_agentic_request(), + attributes.APP_ID: self._activity.recipient.id if self._activity.recipient else attributes.UNKNOWN, + attributes.CONVERSATION_ID: get_conversation_id(self._activity), + attributes.IS_AGENTIC_REQUEST: self._activity.is_agentic_request(), } class AdapterCreateUserTokenClient(SimpleSpanWrapper): @@ -105,15 +106,15 @@ class AdapterCreateUserTokenClient(SimpleSpanWrapper): def __init__(self, token_service_endpoint: str, scopes: list[str] | None): """Initializes the AdapterCreateUserToken span.""" - super().__init__(constants.SPAN_ADAPTER_CREATE_USER_TOKEN_CLIENT) + super().__init__(constants.SPAN_CREATE_USER_TOKEN_CLIENT) self._token_service_endpoint = token_service_endpoint self._scopes = scopes def _get_attributes(self) -> AttributeMap: """Starts the AdapterCreateUserToken span, and sets attributes related to the user token being created.""" return { - constants.ATTR_TOKEN_SERVICE_ENDPOINT: self._token_service_endpoint, - constants.ATTR_AUTH_SCOPES: format_scopes(self._scopes), + attributes.TOKEN_SERVICE_ENDPOINT: self._token_service_endpoint, + attributes.AUTH_SCOPES: format_scopes(self._scopes), } class AdapterCreateConnectorClient(SimpleSpanWrapper): @@ -121,7 +122,7 @@ class AdapterCreateConnectorClient(SimpleSpanWrapper): def __init__(self, service_url: str, scopes: list[str] | None, is_agentic_request: bool): """Initializes the AdapterCreateConnectorClient span.""" - super().__init__(constants.SPAN_ADAPTER_CREATE_CONNECTOR_CLIENT) + super().__init__(constants.SPAN_CREATE_CONNECTOR_CLIENT) self._service_url = service_url self._scopes = scopes self._is_agentic_request = is_agentic_request @@ -129,7 +130,7 @@ def __init__(self, service_url: str, scopes: list[str] | None, is_agentic_reques def _get_attributes(self) -> AttributeMap: """Starts the AdapterCreateConnectorClient span, and sets attributes related to the connector client being created.""" return { - constants.ATTR_SERVICE_URL: self._service_url, - constants.ATTR_AUTH_SCOPES: format_scopes(self._scopes), - constants.ATTR_IS_AGENTIC_REQUEST: self._is_agentic_request, + attributes.SERVICE_URL: self._service_url, + attributes.AUTH_SCOPES: format_scopes(self._scopes), + attributes.IS_AGENTIC_REQUEST: self._is_agentic_request, } \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py new file mode 100644 index 00000000..80f163a6 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +ACTIVITY_DELIVERY_MODE = "activity.delivery_mode" +ACTIVITY_CHANNEL_ID = "activity.channel_id" +ACTIVITY_ID = "activity.id" +ACTIVITY_COUNT = "activities.count" +ACTIVITY_TYPE = "activity.type" + +AGENTIC_USER_ID = "agentic.user_id" +AGENTIC_INSTANCE_ID = "agentic.instance_id" + +APP_ID = "agent.app_id" + +ATTACHMENT_ID = "activity.attachment.id" +ATTACHMENT_COUNT = "activity.attachments.count" + +AUTH_SCOPES = "auth.scopes" +AUTH_TYPE = "auth.method" + +CONVERSATION_ID = "activity.conversation.id" + +IS_AGENTIC = "is_agentic_request" + +KEY_COUNT = "storage.keys.count" + +ROUTE_AUTHORIZED = "route.authorized" +ROUTE_IS_INVOKE = "route.is_invoke" +ROUTE_IS_AGENTIC = "route.is_agentic" +ROUTE_MATCHED = "route.matched" + +SERVICE_URL = "service_url" + +TOKEN_SERVICE_ENDPOINT = "agents.token_service.endpoint" + +# for missing values +UNKNOWN = "unknown" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py index 7f8c219d..71b29dcd 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py @@ -1,16 +1,16 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from . import constants +from . import resource from ._agents_telemetry import agents_telemetry from .type_defs import AttributeMap, SpanCallback from .simple_span_wrapper import SimpleSpanWrapper from .base_span_wrapper import BaseSpanWrapper -from .constants import SERVICE_NAME, SERVICE_VERSION, RESOURCE +from .resource import SERVICE_NAME, SERVICE_VERSION, RESOURCE __all__ = [ "agents_telemetry", - "constants", + "resource", "AttributeMap", "SpanCallback", "SimpleSpanWrapper", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py index 5fddf139..caf41fe8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py @@ -46,7 +46,6 @@ def _log_lifespan_error(desc: str) -> None: logger.warning("Attempting to perform an operation on an inactive BaseSpanWrapper. This may indicate a bug in the telemetry implementation or misuse of the BaseSpanWrapper lifecycle.") logger.warning("Description: %s", desc) - @abstractmethod def __enter__(self) -> BaseSpanWrapper: """Starts the BaseSpanWrapper and returns the BaseSpanWrapper instance for chaining. This method should check if the BaseSpanWrapper is already active and log a warning if an attempt is made to start an already active BaseSpanWrapper, to help identify potential issues with BaseSpanWrapper lifecycle management.""" if self.active: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/constants.py deleted file mode 100644 index 2d39398c..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/constants.py +++ /dev/null @@ -1,118 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os - -from opentelemetry.sdk.resources import Resource - -# Telemetry resource information - -SERVICE_NAME = "microsoft_agents" -SERVICE_VERSION = "1.0.0" - -RESOURCE = Resource.create( - { - "service.name": SERVICE_NAME, - "service.version": SERVICE_VERSION, - "service.instance.id": os.getenv("HOSTNAME", "unknown"), - "telemetry.sdk.language": "python", - } -) - -# Span operation names - -SPAN_ADAPTER_PROCESS = "agents.adapter.process" -SPAN_ADAPTER_SEND_ACTIVITIES = "agents.adapter.sendActivities" -SPAN_ADAPTER_UPDATE_ACTIVITY = "agents.adapter.updateActivity" -SPAN_ADAPTER_DELETE_ACTIVITY = "agents.adapter.deleteActivity" -SPAN_ADAPTER_CONTINUE_CONVERSATION = "agents.adapter.continueConversation" -SPAN_ADAPTER_CREATE_CONNECTOR_CLIENT = "agents.adapter.createConnectorClient" -SPAN_ADAPTER_CREATE_USER_TOKEN_CLIENT = "agents.adapter.createUserTokenClient" - -SPAN_APP_ON_TURN = "agents.app.run" -SPAN_APP_ROUTE_HANDLER = "agents.app.routeHandler" -SPAN_APP_BEFORE_TURN = "agents.app.beforeTurn" -SPAN_APP_AFTER_TURN = "agents.app.afterTurn" -SPAN_APP_DOWNLOAD_FILES = "agents.app.downloadFiles" - -SPAN_CONNECTOR_REPLY_TO_ACTIVITY = "agents.connector.replyToActivity" -SPAN_CONNECTOR_SEND_TO_CONVERSATION = "agents.connector.sendToConversation" -SPAN_CONNECTOR_UPDATE_ACTIVITY = "agents.connector.updateActivity" -SPAN_CONNECTOR_DELETE_ACTIVITY = "agents.connector.deleteActivity" -SPAN_CONNECTOR_CREATE_CONVERSATION = "agents.connector.createConversation" -SPAN_CONNECTOR_GET_CONVERSATIONS = "agents.connector.getConversations" -SPAN_CONNECTOR_GET_CONVERSATION_MEMBERS = "agents.connector.getConversationMembers" -SPAN_CONNECTOR_UPLOAD_ATTACHMENT = "agents.connector.uploadAttachment" -SPAN_CONNECTOR_GET_ATTACHMENT = "agents.connector.getAttachment" - -SPAN_STORAGE_READ = "agents.storage.read" -SPAN_STORAGE_WRITE = "agents.storage.write" -SPAN_STORAGE_DELETE = "agents.storage.delete" - -SPAN_TURN_SEND_ACTIVITY = "agents.turn.sendActivity" -SPAN_TURN_UPDATE_ACTIVITY = "agents.turn.updateActivity" -SPAN_TURN_DELETE_ACTIVITY = "agents.turn.deleteActivity" - -# Metrics - -# counters - -METRIC_ACTIVITIES_RECEIVED = "agents.activities.received" -METRIC_ACTIVITIES_SENT = "agents.activities.sent" -METRIC_ACTIVITIES_UPDATED = "agents.activities.updated" -METRIC_ACTIVITIES_DELETED = "agents.activities.deleted" - -METRIC_TURN_TOTAL = "agents.turn.total" -METRIC_TURN_ERRORS = "agents.turn.errors" - -METRIC_CONNECTOR_REQUESTS_TOTAL = "agents.connector.requests" -METRIC_STORAGE_OPERATION_TOTAL = "agents.storage.operation.total" - -# histograms - -METRIC_TURN_DURATION = "agents.turn.duration" -METRIC_ADAPTER_PROCESS_DURATION = "agents.adapter.process.duration" -METRIC_CONNECTOR_REQUEST_DURATION = "agents.connector.request.duration" -METRIC_STORAGE_OPERATION_DURATION = "agents.storage.operation.duration" - - -# Attributes -# -# This section represents a mapping of internal attribute names to standardized telemetry attribute names. -# There are two major reasons for this: -# 1. Consistency with the other SDKs and the docs. Each language/SDK has different conventions for variable naming. -# 2. Flexibility: This mapping allows us to change the internal attribute names without affecting the telemetry data. -# 3. Efficiency: avoid snake case to camel case conversions (or any other convention) - -ATTR_ACTIVITY_DELIVERY_MODE = "activity.delivery_mode" -ATTR_ACTIVITY_CHANNEL_ID = "activity.channel_id" -ATTR_ACTIVITY_ID = "activity.id" -ATTR_ACTIVITY_COUNT = "activities.count" -ATTR_ACTIVITY_TYPE = "activity.type" -ATTR_AGENTIC_USER_ID = "agentic.user_id" -ATTR_AGENTIC_INSTANCE_ID = "agentic.instance_id" -ATTR_APP_ID = "agent.app_id" -ATTR_ATTACHMENT_ID = "activity.attachment.id" -ATTR_ATTACHMENT_COUNT = "activity.attachments.count" -ATTR_AUTH_SCOPES = "auth.scopes" -ATTR_AUTH_TYPE = "auth.method" - -ATTR_CONVERSATION_ID = "activity.conversation.id" - -# TODO -> rename to ATTR_IS_AGENTIC -ATTR_IS_AGENTIC_REQUEST = "is_agentic_request" - -ATTR_KEY_COUNT = "storage.keys.count" - -ATTR_ROUTE_AUTHORIZED = "route.authorized" -ATTR_ROUTE_IS_INVOKE = "route.is_invoke" -ATTR_ROUTE_IS_AGENTIC = "route.is_agentic" -ATTR_ROUTE_MATCHED = "route.matched" - -ATTR_SERVICE_URL = "service_url" - -ATTR_TOKEN_SERVICE_ENDPOINT = "agents.token_service.endpoint" - -# VALUES - -UNKNOWN = "unknown" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/resource.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/resource.py new file mode 100644 index 00000000..a95449c9 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/resource.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +from opentelemetry.sdk.resources import Resource + +SERVICE_NAME = "microsoft_agents" +SERVICE_VERSION = "1.0.0" + +RESOURCE = Resource.create( + { + "service.name": SERVICE_NAME, + "service.version": SERVICE_VERSION, + "service.instance.id": os.getenv("HOSTNAME", "unknown"), + "telemetry.sdk.language": "python", + } +) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py deleted file mode 100644 index b3d4becd..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans.py +++ /dev/null @@ -1,414 +0,0 @@ -# from collections.abc import Iterator -# from contextlib import contextmanager -# from typing import Protocol - -# from . import _metrics -# from opentelemetry.trace import Span - -# from microsoft_agents.activity import Activity, TurnContextProtocol, DeliveryModes - -# from . import core -# from .core._agents_telemetry import agents_telemetry -# from .utils import ( -# _format_scopes, -# _get_conversation_id, -# _get_delivery_mode, -# ) - -# # -# # Adapter -# # - - -# @contextmanager -# def start_span_adapter_process(activity: Activity) -> Iterator[None]: -# """Context manager for reording adapter process call""" - -# def _callback(span: Span, duration: float, error: Exception | None): -# _metrics.adapter_process_duration.record(duration) - -# with agents_telemetry.start_timed_span( -# core.SPAN_ADAPTER_PROCESS, callback=_callback -# ) as span: -# span.set_attributes( -# { -# core.ATTR_ACTIVITY_TYPE: activity.type, -# core.ATTR_ACTIVITY_CHANNEL_ID: activity.channel_id or core.UNKNOWN, -# core.ATTR_ACTIVITY_DELIVERY_MODE: _get_delivery_mode(activity), -# core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), -# core.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), -# } -# ) -# yield - - -# @contextmanager -# def start_span_adapter_send_activities(activities: list[Activity]) -> Iterator[None]: -# """Context manager for recording adapter send_activities call""" -# with agents_telemetry.start_as_current_span( -# core.SPAN_ADAPTER_SEND_ACTIVITIES -# ) as span: -# count = len(activities) -# if count > 0: -# span.set_attributes({ -# core.ATTR_ACTIVITY_COUNT: count, -# core.ATTR_CONVERSATION_ID: _get_conversation_id(activities[0]), -# core.ATTR_ACTIVITY_TYPE: activities[0].type, -# core.ATTR_ACTIVITY_ID: activities[0].id or core.UNKNOWN, -# }) -# else: -# span.set_attribute(core.ATTR_ACTIVITY_COUNT, 0) - -# yield - - -# @contextmanager -# def start_span_adapter_update_activity(activity: Activity) -> Iterator[None]: -# """Context manager for recording adapter update_activity call""" -# with agents_telemetry.start_as_current_span( -# core.SPAN_ADAPTER_UPDATE_ACTIVITY -# ) as span: -# span.set_attributes( -# { -# core.ATTR_ACTIVITY_ID: activity.id or core.UNKNOWN, -# core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), -# } -# ) -# yield - - -# @contextmanager -# def start_span_adapter_delete_activity(activity: Activity) -> Iterator[None]: -# """Context manager for recording adapter delete_activity call""" -# with agents_telemetry.start_as_current_span( -# core.SPAN_ADAPTER_DELETE_ACTIVITY -# ) as span: -# span.set_attributes( -# { -# core.ATTR_ACTIVITY_ID: activity.id or core.UNKNOWN, -# core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), -# } -# ) -# yield - - -# @contextmanager -# def start_span_adapter_continue_conversation(activity: Activity) -> Iterator[None]: -# """Context manager for recording adapter continue_conversation call""" -# with agents_telemetry.start_as_current_span( -# core.SPAN_ADAPTER_CONTINUE_CONVERSATION -# ) as span: -# span.set_attributes( -# { -# core.ATTR_CONVERSATION_ID: _get_conversation_id(activity), -# core.ATTR_IS_AGENTIC_REQUEST: activity.is_agentic_request(), -# } -# ) -# yield - -# @contextmanager -# def start_span_adapter_create_user_token_client( -# *, -# token_service_endpoint: str, -# scopes: list[str] | None, -# ) -> Iterator[None]: -# """Context manager for recording adapter create_user_token_client call""" -# with agents_telemetry.start_as_current_span( -# core.SPAN_ADAPTER_CREATE_USER_TOKEN_CLIENT -# ) as span: -# span.set_attributes( -# { -# core.ATTR_TOKEN_SERVICE_ENDPOINT: token_service_endpoint, -# core.ATTR_AUTH_SCOPES: _format_scopes(scopes), -# } -# ) -# yield - -# @contextmanager -# def start_span_adapter_create_connector_client( -# *, -# service_url: str, -# scopes: list[str] | None, -# is_agentic_request: bool, -# ) -> Iterator[None]: -# """Context manager for recording adapter create_connector_client call""" -# with agents_telemetry.start_as_current_span( -# core.SPAN_ADAPTER_CREATE_CONNECTOR_CLIENT -# ) as span: -# span.set_attributes( -# { -# core.ATTR_SERVICE_URL: service_url, -# core.ATTR_AUTH_SCOPES: _format_scopes(scopes), -# core.ATTR_IS_AGENTIC_REQUEST: is_agentic_request, -# } -# ) -# yield - - -# # -# # AgentApplication -# # - -# class ShareWithSpanAppOnTurn(Protocol): -# """Client callable protocol for sharing data with the app.on_turn span""" -# def __call__(self, authorized: bool, matched: bool) -> None: ... - -# @contextmanager -# def start_span_app_on_turn(turn_context: TurnContextProtocol) -> Iterator[ShareWithSpanAppOnTurn]: -# """Context manager for recording an app on_turn call, including success/failure and duration""" - -# activity = turn_context.activity - -# def _callback(span: Span, duration: float, error: Exception | None): -# if error is None: -# _metrics.turn_total.add(1) -# _metrics.turn_duration.record( -# duration, -# { -# "conversation.id": ( -# activity.conversation.id if activity.conversation else "unknown" -# ), -# "channel.id": str(activity.channel_id), -# }, -# ) -# else: -# _metrics.turn_errors.add(1) - -# with agents_telemetry.start_timed_span( -# core.SPAN_APP_ON_TURN, -# turn_context=turn_context, -# callback=_callback, -# ) as span: - -# def _share(authorized: bool, matched: bool): -# span.set_attribute(core.ATTR_ROUTE_AUTHORIZED, authorized) -# span.set_attribute(core.ATTR_ROUTE_MATCHED, matched) - -# span.set_attributes( -# { -# core.ATTR_ACTIVITY_TYPE: activity.type, -# core.ATTR_ACTIVITY_ID: activity.id or core.UNKNOWN, -# } -# ) -# yield _share - -# class ShareWithSpanAppRouteHandler(Protocol): -# """Client callable protocol for sharing data with the app.route_handler span""" -# def __call__(self, is_invoke: bool, is_agentic: bool) -> None: ... - -# @contextmanager -# def start_span_app_route_handler(turn_context: TurnContextProtocol) -> Iterator[ShareWithSpanAppRouteHandler]: -# """Context manager for recording the app route handler span""" - -# with agents_telemetry.start_as_current_span( -# core.SPAN_APP_ROUTE_HANDLER, turn_context -# ) as span: - -# def _share(is_invoke: bool, is_agentic: bool): -# span.set_attribute(core.ATTR_ROUTE_IS_INVOKE, is_invoke) -# span.set_attribute(core.ATTR_ROUTE_IS_AGENTIC, is_agentic) - -# yield _share - - -# @contextmanager -# def start_span_app_before_turn(turn_context: TurnContextProtocol) -> Iterator[None]: -# """Context manager for recording the app before turn span""" -# with agents_telemetry.start_as_current_span( -# core.SPAN_APP_BEFORE_TURN, turn_context -# ): -# yield - - -# @contextmanager -# def start_span_app_after_turn(turn_context: TurnContextProtocol) -> Iterator[None]: -# """Context manager for recording the app after turn span""" -# with agents_telemetry.start_as_current_span( -# core.SPAN_APP_AFTER_TURN, turn_context -# ): -# yield - - -# @contextmanager -# def start_span_app_download_files(turn_context: TurnContextProtocol) -> Iterator[None]: -# """Context manager for recording the app download files span""" -# with agents_telemetry.start_as_current_span( -# core.SPAN_APP_DOWNLOAD_FILES, turn_context -# ): -# yield - - -# # -# # ConnectorClient -# # - - -# @contextmanager -# def _start_span_connector_op( -# span_name: str, -# *, -# conversation_id: str | None = None, -# activity_id: str | None = None, -# ) -> Iterator[Span]: - -# def _callback(span: Span, duration: float, error: Exception | None): -# _metrics.connector_request_total.add(1) -# _metrics.connector_request_duration.record(duration) - -# with agents_telemetry.start_timed_span(span_name, callback=_callback) as span: -# if activity_id: -# span.set_attribute(core.ATTR_ACTIVITY_ID, activity_id) -# if conversation_id: -# span.set_attribute(core.ATTR_CONVERSATION_ID, conversation_id) -# yield span - - -# @contextmanager -# def start_span_connector_reply_to_activity( -# conversation_id: str, activity_id: str -# ) -> Iterator[None]: -# with _start_span_connector_op( -# core.SPAN_CONNECTOR_REPLY_TO_ACTIVITY, -# conversation_id=conversation_id, -# activity_id=activity_id, -# ): -# yield - - -# @contextmanager -# def start_span_connector_send_to_conversation( -# conversation_id: str, activity_id: str | None -# ) -> Iterator[None]: -# with _start_span_connector_op( -# core.SPAN_CONNECTOR_SEND_TO_CONVERSATION, -# conversation_id=conversation_id, -# activity_id=activity_id, -# ): -# yield - - -# @contextmanager -# def start_span_connector_update_activity( -# conversation_id: str, activity_id: str -# ) -> Iterator[None]: -# with _start_span_connector_op( -# core.SPAN_CONNECTOR_UPDATE_ACTIVITY, -# conversation_id=conversation_id, -# activity_id=activity_id, -# ): -# yield - - -# @contextmanager -# def start_span_connector_delete_activity( -# conversation_id: str, activity_id: str -# ) -> Iterator[None]: -# with _start_span_connector_op( -# core.SPAN_CONNECTOR_DELETE_ACTIVITY, -# conversation_id=conversation_id, -# activity_id=activity_id, -# ): -# yield - - -# @contextmanager -# def start_span_connector_create_conversation() -> Iterator[None]: -# with _start_span_connector_op(core.SPAN_CONNECTOR_CREATE_CONVERSATION): -# yield - - -# @contextmanager -# def start_span_connector_get_conversations() -> Iterator[None]: -# with _start_span_connector_op(core.SPAN_CONNECTOR_GET_CONVERSATIONS): -# yield - - -# @contextmanager -# def start_span_connector_get_conversation_members() -> Iterator[None]: -# with _start_span_connector_op(core.SPAN_CONNECTOR_GET_CONVERSATION_MEMBERS): -# yield - - -# @contextmanager -# def start_span_connector_upload_attachment(conversation_id: str) -> Iterator[None]: -# with _start_span_connector_op( -# core.SPAN_CONNECTOR_UPDLOAD_ATTACHMENT, conversation_id=conversation_id -# ): -# yield - - -# @contextmanager -# def start_span_connector_get_attachment(attachment_id: str) -> Iterator[None]: -# with _start_span_connector_op(core.SPAN_CONNECTOR_GET_ATTACHMENT) as span: -# span.set_attribute(core.ATTR_ATTACHMENT_ID, attachment_id) -# yield - - -# # -# # Storage -# # - - -# @contextmanager -# def _start_span_storage_op(span_name: str, num_keys: int) -> Iterator[None]: - -# def _callback(span: Span, duration: int, error: Exception | None): -# _metrics.storage_operation_total.add(1) -# _metrics.storage_operation_duration.record(duration) - -# with agents_telemetry.start_timed_span(span_name, callback=_callback) as span: -# span.set_attribute(core.ATTR_NUM_KEYS, num_keys) -# yield - - -# @contextmanager -# def start_span_storage_read(num_keys: int) -> Iterator[None]: -# with _start_span_storage_op(core.SPAN_STORAGE_READ, num_keys): -# yield - - -# @contextmanager -# def start_span_storage_write(num_keys: int) -> Iterator[None]: -# with _start_span_storage_op(core.SPAN_STORAGE_WRITE, num_keys): -# yield - - -# @contextmanager -# def start_span_storage_delete(num_keys: int) -> Iterator[None]: -# with _start_span_storage_op(core.SPAN_STORAGE_DELETE, num_keys): -# yield - - -# # -# # TurnContext -# # - - -# @contextmanager -# def start_span_turn_context_send_activity( -# turn_context: TurnContextProtocol, -# ) -> Iterator[None]: -# with agents_telemetry.start_as_current_span( -# core.SPAN_TURN_SEND_ACTIVITY, turn_context -# ): -# yield - - -# @contextmanager -# def start_span_turn_context_update_activity( -# turn_context: TurnContextProtocol, -# ) -> Iterator[None]: -# with agents_telemetry.start_as_current_span( -# core.SPAN_TURN_UPDATE_ACTIVITY, turn_context -# ): -# yield - - -# @contextmanager -# def start_span_turn_context_delete_activity( -# turn_context: TurnContextProtocol, -# ) -> Iterator[None]: -# with agents_telemetry.start_as_current_span( -# core.SPAN_TURN_DELETE_ACTIVITY, turn_context -# ): -# yield diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/constants.py new file mode 100644 index 00000000..c94dee7c --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/constants.py @@ -0,0 +1,11 @@ +# Span operation names + +SPAN_TURN_SEND_ACTIVITY = "agents.turn.sendActivity" +SPAN_TURN_UPDATE_ACTIVITY = "agents.turn.updateActivity" +SPAN_TURN_DELETE_ACTIVITY = "agents.turn.deleteActivity" + + +METRIC_ACTIVITIES_RECEIVED = "agents.activities.received" +METRIC_ACTIVITIES_SENT = "agents.activities.sent" +METRIC_ACTIVITIES_UPDATED = "agents.activities.updated" +METRIC_ACTIVITIES_DELETED = "agents.activities.deleted" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/metrics.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py similarity index 88% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/turn_context.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py index 22b1847d..a9fd0aa6 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/spans/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py @@ -4,12 +4,13 @@ from __future__ import annotations from microsoft_agents.activity import TurnContextProtocol -from ..core import ( - constants, +from microsoft_agents.hosting.core.telemetry import ( AttributeMap, SimpleSpanWrapper, + attributes, + get_conversation_id ) -from ..utils import get_conversation_id +from . import constants class _TurnContextSpanWrapper(SimpleSpanWrapper): """Base span wrapper for TurnContext operations""" @@ -22,7 +23,7 @@ def __init__(self, span_name: str, turn_context: TurnContextProtocol): def _get_attributes(self) -> AttributeMap: activity = self._turn_context.activity return { - constants.ATTR_CONVERSATION_ID: get_conversation_id(activity), + attributes.CONVERSATION_ID: get_conversation_id(activity), } class TurnContextSendActivity(_TurnContextSpanWrapper): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py index 426fc24f..d76dd77d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py @@ -3,17 +3,17 @@ from microsoft_agents.activity import Activity, DeliveryModes -from .core import constants +from .attributes import UNKNOWN def format_scopes(scopes: list[str] | None) -> str: """Formats a list of scopes into a string for telemetry recording. If the list is None or empty, returns a constant value indicating unknown scopes.""" if not scopes: - return constants.UNKNOWN + return UNKNOWN return ",".join(scopes) def get_conversation_id(activity: Activity) -> str: """Extracts the conversation ID from the given activity. If the conversation ID cannot be found, returns a constant value indicating unknown conversation ID.""" - return activity.conversation.id if activity.conversation else constants.UNKNOWN + return activity.conversation.id if activity.conversation else UNKNOWN def get_delivery_mode(activity: Activity) -> str: @@ -23,4 +23,4 @@ def get_delivery_mode(activity: Activity) -> str: return activity.delivery_mode.value else: return activity.delivery_mode - return constants.UNKNOWN \ No newline at end of file + return UNKNOWN \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index 6c7cb10c..daf6231b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -20,7 +20,7 @@ ) from microsoft_agents.activity.entity.entity_types import EntityTypes from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity -from microsoft_agents.hosting.core.telemetry import spans +from microsoft_agents.hosting.core.telemetry.turn_context import spans class TurnContext(TurnContextProtocol): @@ -208,7 +208,7 @@ async def send_activity( if speak: activity_or_text.speak = speak - with spans.start_span_turn_context_send_activity(self): + with spans.TurnContextSendActivity(self): result = await self.send_activities([activity_or_text]) return result[0] if result else None @@ -271,7 +271,7 @@ async def update_activity(self, activity: Activity): :param activity: :return: """ - with spans.start_span_turn_context_update_activity(self): + with spans.TurnContextUpdateActivity(self): reference = self.activity.get_conversation_reference() return await self._emit( @@ -286,7 +286,7 @@ async def delete_activity(self, id_or_reference: str | ConversationReference): :param id_or_reference: :return: """ - with spans.start_span_turn_context_delete_activity(self): + with spans.TurnContextDeleteActivity(self): if isinstance(id_or_reference, str): reference = self.activity.get_conversation_reference() reference.activity_id = id_or_reference From dcad8ad98b688194b6205fe2f55dfeeec4d7a922 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 23 Mar 2026 17:19:27 -0700 Subject: [PATCH 35/55] active state of SpanWrapper bug fixed --- .../hosting/core/telemetry/core/base_span_wrapper.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py index caf41fe8..4b7f7dd0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py @@ -33,7 +33,7 @@ def otel_span(self) -> Span | None: @property def active(self) -> bool: """Indicates whether the BaseSpanWrapper is currently active. This can be used to prevent operations on an inactive BaseSpanWrapper, and to check the BaseSpanWrapper's lifecycle state.""" - return self._span is not None + return self._active @abstractmethod def _start_span(self) -> ContextManager[Span]: @@ -48,7 +48,7 @@ def _log_lifespan_error(desc: str) -> None: def __enter__(self) -> BaseSpanWrapper: """Starts the BaseSpanWrapper and returns the BaseSpanWrapper instance for chaining. This method should check if the BaseSpanWrapper is already active and log a warning if an attempt is made to start an already active BaseSpanWrapper, to help identify potential issues with BaseSpanWrapper lifecycle management.""" - if self.active: + if self._active: BaseSpanWrapper._log_lifespan_error("Attempting to start a BaseSpanWrapper that is already active.") self._span = self._exit_stack.enter_context(self._start_span()) @@ -62,9 +62,10 @@ def start(self) -> BaseSpanWrapper: def __exit__(self, exc_type, exc_val, exc_tb): """Stops the BaseSpanWrapper if it is active, and logs a warning if an attempt is made to stop a BaseSpanWrapper that is not active. This ensures that BaseSpanWrappers are properly cleaned up and that potential issues with BaseSpanWrapper lifecycle management are logged for debugging purposes.""" - if self.active: + if self._active: self._exit_stack.__exit__(exc_type, exc_val, exc_tb) self._span = None + self._active = False else: BaseSpanWrapper._log_lifespan_error("BaseSpanWrapper is not active and cannot be exited") From b884333c9eaf51f0c7df8497d2cf254209449b5c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 24 Mar 2026 12:24:00 -0700 Subject: [PATCH 36/55] Adding authorization spans --- .../authentication/msal/msal_auth.py | 10 +- .../msal/telemetry/constants.py | 11 - .../authentication/msal/telemetry/spans.py | 65 ----- .../oauth/_handlers/_user_authorization.py | 88 ++++--- .../_handlers/agentic_user_authorization.py | 12 +- .../core/app/oauth}/telemetry/__init__.py | 0 .../core/app/oauth/telemetry/constants.py | 7 + .../hosting/core/app/oauth/telemetry/spans.py | 109 ++++++++ .../core/authorization/telemetry/__init__.py} | 0 .../core/authorization/telemetry/constants.py | 15 ++ .../core/authorization/telemetry/metrics.py | 6 + .../core/authorization/telemetry/spans.py | 91 +++++++ .../core/connector/client/connector_client.py | 3 +- .../connector/client/user_token_client.py | 235 ++++++++++-------- .../{spans.py => connector_client_spans.py} | 0 .../core/connector/telemetry/constants.py | 8 + .../telemetry/user_token_client_spans.py | 111 +++++++++ .../hosting/core/http/_http_adapter_base.py | 4 +- .../hosting/core/telemetry/attributes.py | 4 + 19 files changed, 545 insertions(+), 234 deletions(-) delete mode 100644 libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py delete mode 100644 libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py rename libraries/{microsoft-agents-authentication-msal/microsoft_agents/authentication/msal => microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth}/telemetry/__init__.py (100%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/constants.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/spans.py rename libraries/{microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/_metrics.py => microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/__init__.py} (100%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/{spans.py => connector_client_spans.py} (100%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index b509ed0a..01ead68a 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -28,8 +28,8 @@ AccessTokenProviderBase, AgentAuthConfiguration, ) +from microsoft_agents.hosting.core.authorization.telemetry import spans from microsoft_agents.authentication.msal.errors import authentication_errors -from .telemetry import spans logger = logging.getLogger(__name__) @@ -69,7 +69,7 @@ def __init__(self, msal_configuration: AgentAuthConfiguration): async def get_access_token( self, resource_url: str, scopes: list[str], force_refresh: bool = False ) -> str: - with spans.start_span_auth_get_access_token( + with spans.GetAccessToken( scopes, self._msal_configuration.AUTH_TYPE, ): @@ -121,7 +121,7 @@ async def acquire_token_on_behalf_of( :param user_assertion: The user assertion token. :return: The access token as a string. """ - with spans.start_span_auth_acquire_token_on_behalf_of(scopes): + with spans.AcquireTokenOnBehalfOf(scopes): msal_auth_client = self._get_client() if isinstance(msal_auth_client, ManagedIdentityClient): logger.error( @@ -348,7 +348,7 @@ async def get_agentic_instance_token( :return: A tuple containing the agentic instance token and the agent application token. :rtype: tuple[str, str] """ - with spans.start_span_auth_get_agentic_instance_token(agent_app_instance_id): + with spans.GetAgenticInstanceToken(agent_app_instance_id): if not agent_app_instance_id: raise ValueError( @@ -441,7 +441,7 @@ async def get_agentic_user_token( :return: The agentic user token, or None if not found. :rtype: Optional[str] """ - with spans.start_span_get_agentic_user_token( + with spans.GetAgenticUserToken( agent_app_instance_id, agentic_user_id, scopes ): if not agent_app_instance_id or not agentic_user_id: diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py deleted file mode 100644 index 1437308b..00000000 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/constants.py +++ /dev/null @@ -1,11 +0,0 @@ -# Spans - -SPAN_AUTH_GET_ACCESS_TOKEN = "agents.auth.getAccessToken" -SPAN_AUTH_ACQUIRE_TOKEN_ON_BEHALF_OF = "agents.auth.acquireTokenOnBehalfOf" -SPAN_AUTH_GET_AGENTIC_INSTANCE_TOKEN = "agents.auth.getAgenticInstanceToken" -SPAN_AUTH_GET_AGENTIC_USER_TOKEN = "agents.auth.getAgenticUserToken" - -# Metrics - -METRIC_AUTH_TOKEN_REQUEST_DURATION = "agents.auth.token.request.duration" -METRIC_AUTH_TOKEN_REQUESTS = "agents.auth.token.requests" diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py deleted file mode 100644 index c9106d38..00000000 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/spans.py +++ /dev/null @@ -1,65 +0,0 @@ -from contextlib import contextmanager -from collections.abc import Iterator - -from microsoft_agents.hosting.core.telemetry import ( - agents_telemetry, - core as common_constants, - _format_scopes, -) - -from . import constants, _metrics - - -@contextmanager -def start_span_auth_get_access_token( - scopes: list[str], auth_type: str -) -> Iterator[None]: - with agents_telemetry.start_as_current_span( - constants.SPAN_AUTH_GET_ACCESS_TOKEN - ) as span: - span.set_attributes( - { - common_constants.ATTR_AUTH_SCOPES: _format_scopes(scopes), - common_constants.ATTR_AUTH_TYPE: auth_type, - } - ) - yield - - -@contextmanager -def start_span_auth_acquire_token_on_behalf_of(scopes: list[str]) -> Iterator[None]: - with agents_telemetry.start_as_current_span( - constants.SPAN_AUTH_ACQUIRE_TOKEN_ON_BEHALF_OF - ) as span: - span.set_attribute(common_constants.ATTR_AUTH_SCOPES, _format_scopes(scopes)) - yield - - -@contextmanager -def start_span_auth_get_agentic_instance_token( - agentic_instance_id: str, -) -> Iterator[None]: - with agents_telemetry.start_as_current_span( - constants.SPAN_AUTH_GET_AGENTIC_INSTANCE_TOKEN - ) as span: - span.set_attribute( - common_constants.ATTR_AGENTIC_INSTANCE_ID, agentic_instance_id - ) - yield - - -@contextmanager -def start_span_auth_get_agentic_user_token( - agentic_instance_id: str, agentic_user_id: str, scopes: list[str] -) -> Iterator[None]: - with agents_telemetry.start_as_current_span( - constants.SPAN_AUTH_GET_AGENTIC_USER_TOKEN - ): - span.set_attributes( - { - common_constants.ATTR_AGENTIC_INSTANCE_ID: agentic_instance_id, - common_constants.ATTR_AGENTIC_USER_ID: agentic_user_id, - common_constants.ATTR_AUTH_SCOPES: scopes, - } - ) - yield diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py index 902b0dd4..efdae668 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py @@ -29,6 +29,7 @@ ) from .._sign_in_response import _SignInResponse from ._authorization_handler import _AuthorizationHandler +from ..telemetry import spans logger = logging.getLogger(__name__) @@ -119,21 +120,26 @@ async def _handle_obo( connection_name = exchange_connection or self._handler.obo_connection_name exchange_scopes = exchange_scopes or self._handler.scopes - if not connection_name or not exchange_scopes: - return input_token_response + with spans.AzureBotToken( + auth_handler_id=self._id, + connection_name=connection_name, + scopes=exchange_scopes, + ): + if not connection_name or not exchange_scopes: + return input_token_response - if not input_token_response.is_exchangeable(): - return input_token_response + if not input_token_response.is_exchangeable(): + return input_token_response - token_provider = self._connection_manager.get_connection(connection_name) - if not token_provider: - raise ValueError(f"Connection '{connection_name}' not found") + token_provider = self._connection_manager.get_connection(connection_name) + if not token_provider: + raise ValueError(f"Connection '{connection_name}' not found") - token = await token_provider.acquire_token_on_behalf_of( - scopes=exchange_scopes, - user_assertion=input_token_response.token, - ) - return TokenResponse(token=token) if token else TokenResponse() + token = await token_provider.acquire_token_on_behalf_of( + scopes=exchange_scopes, + user_assertion=input_token_response.token, + ) + return TokenResponse(token=token) if token else TokenResponse() async def _sign_out( self, @@ -147,10 +153,11 @@ async def _sign_out( :param auth_handler_id: Optional ID of the auth handler to use for sign out. If None, signs out from all the handlers. """ - flow, flow_storage_client = await self._load_flow(context) - logger.info("Signing out from handler: %s", self._id) - await flow.sign_out() - await flow_storage_client.delete(self._id) + with spans.AzureBotSignOut(auth_handler_id=self._id): + flow, flow_storage_client = await self._load_flow(context) + logger.info("Signing out from handler: %s", self._id) + await flow.sign_out() + await flow_storage_client.delete(self._id) async def _handle_flow_response( self, context: TurnContext, flow_response: _FlowResponse @@ -212,31 +219,36 @@ async def _sign_in( :return: The _SignInResponse containing the token response and flow state tag. :rtype: _SignInResponse """ - flow, flow_storage_client = await self._load_flow(context) - flow_response: _FlowResponse = await flow.begin_or_continue_flow( - context.activity - ) - - logger.info("Saving OAuth flow state to storage") - await flow_storage_client.write(flow_response.flow_state) - await self._handle_flow_response(context, flow_response) - - if flow_response.token_response: - # attempt exchange if needed - # if not needed, returns the same token - token_response = await self._handle_obo( - context, - flow_response.token_response, - exchange_connection, - exchange_scopes, + with spans.AzureBotSignIn( + auth_handler_id=self._id, + connection_name=exchange_connection, + scopes=exchange_scopes, + ): + flow, flow_storage_client = await self._load_flow(context) + flow_response: _FlowResponse = await flow.begin_or_continue_flow( + context.activity ) - return _SignInResponse( - token_response=token_response, - tag=_FlowStateTag.COMPLETE if token_response else _FlowStateTag.FAILURE, - ) + logger.info("Saving OAuth flow state to storage") + await flow_storage_client.write(flow_response.flow_state) + await self._handle_flow_response(context, flow_response) + + if flow_response.token_response: + # attempt exchange if needed + # if not needed, returns the same token + token_response = await self._handle_obo( + context, + flow_response.token_response, + exchange_connection, + exchange_scopes, + ) + + return _SignInResponse( + token_response=token_response, + tag=_FlowStateTag.COMPLETE if token_response else _FlowStateTag.FAILURE, + ) - return _SignInResponse(tag=flow_response.flow_state.tag) + return _SignInResponse(tag=flow_response.flow_state.tag) async def get_refreshed_token( self, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py index 85fdc9a4..548edaab 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py @@ -16,6 +16,7 @@ from ....storage import Storage from ....authorization import Connections from ..auth_handler import AuthHandler +from ..telemetry import spans logger = logging.getLogger(__name__) @@ -179,9 +180,14 @@ async def get_refreshed_token( :param exchange_scopes: Optional list of scopes to request during token exchange. If None, default scopes will be used. :type exchange_scopes: Optional[list[str]], Optional """ - if not exchange_scopes: - exchange_scopes = self._handler.scopes or [] - return await self.get_agentic_user_token(context, exchange_scopes) + with spans.AgenticToken( + auth_handler_id=self._id, + connection_name=exchange_connection, + scopes=exchange_scopes, + ): + if not exchange_scopes: + exchange_scopes = self._handler.scopes or [] + return await self.get_agentic_user_token(context, exchange_scopes) async def sign_out( self, context: TurnContext, auth_handler_id: Optional[str] = None diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/__init__.py similarity index 100% rename from libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/__init__.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/__init__.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/constants.py new file mode 100644 index 00000000..3172652c --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/constants.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +AGENTIC_TOKEN = "agents.authorization.agentic_token" +AZURE_BOT_TOKEN = "agents.authorization.azure_bot_token" +AZURE_BOT_SIGN_OUT = "agents.authorization.azure_bot_sign_out" +AZURE_BOT_SIGN_IN = "agents.authorization.azure_bot_sign_in" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/spans.py new file mode 100644 index 00000000..cbffded7 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/spans.py @@ -0,0 +1,109 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span + +from microsoft_agents.hosting.core.telemetry import ( + attributes, + AttributeMap, + SimpleSpanWrapper, + format_scopes, +) +from . import constants + +class _AuthorizationSpanWrapper(SimpleSpanWrapper): + """Base SpanWrapper for spans related to authorization operations. + + This is meant to be a base class for spans related to authorization operations, + and can be used to share common functionality and attributes + """ + + def __init__( + self, + span_name: str, + auth_handler_id: str, + connection_name: str | None = None, + scopes: list[str] | None = None, + ): + """Initializes the _StorageSpanWrapper span.""" + super().__init__(span_name) + self._auth_handler_id = auth_handler_id + self._connection_name = connection_name + self._scopes = scopes + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span ends.""" + + def _get_attributes(self) -> dict[str, str]: + """Gets the attributes to be added to the span.""" + attr_dict = { + attributes.AUTH_HANDLER_ID: self._auth_handler_id, + attributes.CONNECTION_NAME: self._connection_name or attributes.UNKNOWN, + } + if self._scopes is not None: + attr_dict[attributes.AUTH_SCOPES] = format_scopes(self._scopes) + return attr_dict + + +class AgenticToken(_AuthorizationSpanWrapper): + """Span wrapper for agentic token operations.""" + + def __init__( + self, + auth_handler_id: str, + connection_name: str | None, + scopes: list[str] | None, + ): + """Initializes the AgenticToken span.""" + super().__init__( + constants.AGENTIC_TOKEN, + auth_handler_id, + connection_name, + scopes, + ) + +class AzureBotToken(_AuthorizationSpanWrapper): + """Span wrapper for azure bot token operations.""" + + def __init__( + self, + auth_handler_id: str, + connection_name: str | None, + scopes: list[str] | None, + ): + """Initializes the AzureBotToken span.""" + super().__init__( + constants.AZURE_BOT_TOKEN, + auth_handler_id, + connection_name, + scopes, + ) + +class AzureBotSignIn(_AuthorizationSpanWrapper): + """Span wrapper for azure bot sign in operations.""" + + def __init__( + self, + auth_handler_id: str, + connection_name: str | None, + scopes: list[str] | None, + ): + """Initializes the AzureBotSignIn span.""" + super().__init__( + constants.AZURE_BOT_SIGN_IN, + auth_handler_id, + connection_name, + scopes, + ) + +class AzureBotSignOut(_AuthorizationSpanWrapper): + """Span wrapper for azure bot sign out operations.""" + + def __init__(self, auth_handler_id: str): + """Initializes the AzureBotSignOut span.""" + super().__init__( + constants.AZURE_BOT_SIGN_OUT, + auth_handler_id, + ) \ No newline at end of file diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/_metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/__init__.py similarity index 100% rename from libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/telemetry/_metrics.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/__init__.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py new file mode 100644 index 00000000..efd9873a --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License.# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# Spans + +SPAN_GET_ACCESS_TOKEN = "agents.auth.getAccessToken" +SPAN_ACQUIRE_TOKEN_ON_BEHALF_OF = "agents.auth.acquireTokenOnBehalfOf" +SPAN_GET_AGENTIC_INSTANCE_TOKEN = "agents.auth.getAgenticInstanceToken" +SPAN_GET_AGENTIC_USER_TOKEN = "agents.auth.getAgenticUserToken" + +# Metrics + +METRIC_AUTH_TOKEN_REQUEST_DURATION = "agents.auth.token.request.duration" +METRIC_AUTH_TOKEN_REQUESTS = "agents.auth.token.requests" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py new file mode 100644 index 00000000..fa5d1b8e --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import agents_telemetry +from . import constants + diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py new file mode 100644 index 00000000..d5ed09b4 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py @@ -0,0 +1,91 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span + +from microsoft_agents.hosting.core.telemetry import ( + attributes, + AttributeMap, + SimpleSpanWrapper, + format_scopes, +) +from . import constants, metrics + +class _AuthenticationSpanWrapper(SimpleSpanWrapper): + """Base SpanWrapper for spans related to authentication operations. + + This is meant to be a base class for spans related to authentication operations, such as retrieving or validating tokens, + and can be used to share common functionality and attributes + """ + + def __init__(self, span_name: str): + """Initializes the _StorageSpanWrapper span.""" + super().__init__(span_name) + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span ends. This function can be used to set additional attributes or record exceptions based on the outcome of the operation being traced.""" + + +class GetAccessToken(_AuthenticationSpanWrapper): + """Span wrapper for the operation of retrieving an access token.""" + + def __init__(self, scopes: list[str], auth_type: str): + """Initializes the GetAccessToken span with the specified authentication scope and type.""" + super().__init__(constants.SPAN_GET_ACCESS_TOKEN) + self._scopes = scopes + self._auth_type = auth_type + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to be set on the span. This includes the authentication scope and type.""" + return { + attributes.AUTH_SCOPES: format_scopes(self._scopes), + attributes.AUTH_TYPE: self._auth_type, + } + +class AcquireTokenOnBehalfOf(_AuthenticationSpanWrapper): + """Span wrapper for the operation of acquiring a token on behalf of a user.""" + + def __init__(self, scopes: list[str]): + """Initializes the AcquireTokenOnBehalfOf span with the specified authentication scope.""" + super().__init__(constants.SPAN_ACQUIRE_TOKEN_ON_BEHALF_OF) + self._scopes = scopes + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to be set on the span. This includes the authentication scope.""" + return { + attributes.AUTH_SCOPES: format_scopes(self._scopes), + } + +class GetAgenticInstanceToken(_AuthenticationSpanWrapper): + """Span wrapper for the operation of retrieving an agentic instance token.""" + + def __init__(self, agentic_instance_id: str): + """Initializes the GetAgenticInstanceToken span with the specified agentic instance ID.""" + super().__init__(constants.SPAN_GET_AGENTIC_INSTANCE_TOKEN) + self._agentic_instance_id = agentic_instance_id + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to be set on the span. This includes the agentic instance ID.""" + return { + attributes.AGENTIC_INSTANCE_ID: self._agentic_instance_id, + } + +class GetAgenticUserToken(_AuthenticationSpanWrapper): + """Span wrapper for the operation of retrieving an agentic user token.""" + + def __init__(self, agentic_instance_id: str, agentic_user_id: str, scopes: list[str]): + """Initializes the GetAgenticUserToken span with the specified agentic instance ID, user ID, and authentication scopes.""" + super().__init__(constants.SPAN_GET_AGENTIC_USER_TOKEN) + self._agentic_instance_id = agentic_instance_id + self._agentic_user_id = agentic_user_id + self._scopes = scopes + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to be set on the span. This includes the agentic instance ID, user ID, and authentication scopes.""" + return { + attributes.AGENTIC_INSTANCE_ID: self._agentic_instance_id, + attributes.AGENTIC_USER_ID: self._agentic_user_id, + attributes.AUTH_SCOPES: format_scopes(self._scopes), + } \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 8ea5c206..cd1c2449 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -18,11 +18,10 @@ PagedMembersResult, ) from microsoft_agents.hosting.core.connector import ConnectorClientBase -from microsoft_agents.hosting.core.telemetry import spans from ..attachments_base import AttachmentsBase from ..conversations_base import ConversationsBase from ..get_product_info import get_product_info -from ..telemetry import spans +from ..telemetry import connector_client_spans as spans logger = logging.getLogger(__name__) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py index 2a380acf..6867af36 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py @@ -15,6 +15,7 @@ SignInResource, ) from ..get_product_info import get_product_info +from ..telemetry import user_token_client_spans as spans from ..user_token_base import UserTokenBase from ..agent_sign_in_base import AgentSignInBase @@ -80,27 +81,28 @@ async def get_sign_in_resource( :param final_redirect: Final redirect URL. :return: The sign-in resource. """ - params = {"state": state} - if code_challenge: - params["codeChallenge"] = code_challenge - if emulator_url: - params["emulatorUrl"] = emulator_url - if final_redirect: - params["finalRedirect"] = final_redirect - - logger.info( - "AgentSignIn.get_sign_in_resource(): Getting sign-in resource with params: %s", - params, - ) - async with self.client.get( - "api/botsignin/getSignInResource", params=params - ) as response: - if response.status >= 300: - logger.error("Error getting sign-in resource: %s", response.status) - response.raise_for_status() - - data = await response.json() - return SignInResource.model_validate(data) + with spans.GetSignInResource(): + params = {"state": state} + if code_challenge: + params["codeChallenge"] = code_challenge + if emulator_url: + params["emulatorUrl"] = emulator_url + if final_redirect: + params["finalRedirect"] = final_redirect + + logger.info( + "AgentSignIn.get_sign_in_resource(): Getting sign-in resource with params: %s", + params, + ) + async with self.client.get( + "api/botsignin/getSignInResource", params=params + ) as response: + if response.status >= 300: + logger.error("Error getting sign-in resource: %s", response.status) + response.raise_for_status() + + data = await response.json() + return SignInResource.model_validate(data) class UserToken(UserTokenBase): @@ -116,21 +118,23 @@ async def get_token( channel_id: Optional[str] = None, code: Optional[str] = None, ) -> TokenResponse: - params = {"userId": user_id, "connectionName": connection_name} + + with spans.GetUserToken(connection_name=connection_name, user_id=user_id, channel_id=channel_id): + params = {"userId": user_id, "connectionName": connection_name} - if channel_id: - params["channelId"] = channel_id - if code: - params["code"] = code + if channel_id: + params["channelId"] = channel_id + if code: + params["code"] = code - logger.info("User_token.get_token(): Getting token with params: %s", params) - async with self.client.get("api/usertoken/GetToken", params=params) as response: - if response.status >= 300: - logger.error("Error getting token: %s", response.status) - response.raise_for_status() + logger.info("User_token.get_token(): Getting token with params: %s", params) + async with self.client.get("api/usertoken/GetToken", params=params) as response: + if response.status >= 300: + logger.error("Error getting token: %s", response.status) + response.raise_for_status() - data = await response.json() - return TokenResponse.model_validate(data) + data = await response.json() + return TokenResponse.model_validate(data) async def _get_token_or_sign_in_resource( self, @@ -142,29 +146,31 @@ async def _get_token_or_sign_in_resource( final_redirect: str = "", fwd_url: str = "", ) -> TokenOrSignInResourceResponse: - - params = { - "userId": user_id, - "connectionName": connection_name, - "channelId": channel_id, - "state": state, - "code": code, - "finalRedirect": final_redirect, - "fwdUrl": fwd_url, - } - - logger.info("Getting token or sign-in resource with params: %s", params) - async with self.client.get( - "/api/usertoken/GetTokenOrSignInResource", params=params - ) as response: - if response.status != 200: - logger.error( - "Error getting token or sign-in resource: %s", response.status - ) - response.raise_for_status() - - data = await response.json() - return TokenOrSignInResourceResponse.model_validate(data) + """Get token or sign-in resource for a user.""" + + with spans.GetTokenOrSignInResource(connection_name=connection_name, user_id=user_id, channel_id=channel_id): + params = { + "userId": user_id, + "connectionName": connection_name, + "channelId": channel_id, + "state": state, + "code": code, + "finalRedirect": final_redirect, + "fwdUrl": fwd_url, + } + + logger.info("Getting token or sign-in resource with params: %s", params) + async with self.client.get( + "/api/usertoken/GetTokenOrSignInResource", params=params + ) as response: + if response.status != 200: + logger.error( + "Error getting token or sign-in resource: %s", response.status + ) + response.raise_for_status() + + data = await response.json() + return TokenOrSignInResourceResponse.model_validate(data) async def get_aad_tokens( self, @@ -173,21 +179,24 @@ async def get_aad_tokens( channel_id: Optional[str] = None, body: Optional[dict] = None, ) -> dict[str, TokenResponse]: - params = {"userId": user_id, "connectionName": connection_name} + """Get AAD tokens for a user.""" - if channel_id: - params["channelId"] = channel_id + with spans.GetAadTokens(connection_name=connection_name, user_id=user_id, channel_id=channel_id): + params = {"userId": user_id, "connectionName": connection_name} - logger.info("Getting AAD tokens with params: %s and body: %s", params, body) - async with self.client.post( - "api/usertoken/GetAadTokens", params=params, json=body - ) as response: - if response.status >= 300: - logger.error("Error getting AAD tokens: %s", response.status) - response.raise_for_status() + if channel_id: + params["channelId"] = channel_id - data = await response.json() - return {k: TokenResponse.model_validate(v) for k, v in data.items()} + logger.info("Getting AAD tokens with params: %s and body: %s", params, body) + async with self.client.post( + "api/usertoken/GetAadTokens", params=params, json=body + ) as response: + if response.status >= 300: + logger.error("Error getting AAD tokens: %s", response.status) + response.raise_for_status() + + data = await response.json() + return {k: TokenResponse.model_validate(v) for k, v in data.items()} async def sign_out( self, @@ -195,20 +204,23 @@ async def sign_out( connection_name: Optional[str] = None, channel_id: Optional[str] = None, ) -> None: - params = {"userId": user_id} + """Sign out user from a connection.""" - if connection_name: - params["connectionName"] = connection_name - if channel_id: - params["channelId"] = channel_id + with spans.SignOut(user_id=user_id, connection_name=connection_name, channel_id=channel_id): + params = {"userId": user_id} - logger.info("Signing out user %s with params: %s", user_id, params) - async with self.client.delete( - "api/usertoken/SignOut", params=params - ) as response: - if response.status >= 300: - logger.error("Error signing out: %s", response.status) - response.raise_for_status() + if connection_name: + params["connectionName"] = connection_name + if channel_id: + params["channelId"] = channel_id + + logger.info("Signing out user %s with params: %s", user_id, params) + async with self.client.delete( + "api/usertoken/SignOut", params=params + ) as response: + if response.status >= 300: + logger.error("Error signing out: %s", response.status) + response.raise_for_status() async def get_token_status( self, @@ -216,23 +228,26 @@ async def get_token_status( channel_id: Optional[str] = None, include: Optional[str] = None, ) -> list[TokenStatus]: - params = {"userId": user_id} + """Get token status for a user.""" - if channel_id: - params["channelId"] = channel_id - if include: - params["include"] = include + with spans.GetTokenStatus(user_id=user_id, channel_id=channel_id): + params = {"userId": user_id} - logger.info("Getting token status for user %s with params: %s", user_id, params) - async with self.client.get( - "api/usertoken/GetTokenStatus", params=params - ) as response: - if response.status >= 300: - logger.error("Error getting token status: %s", response.status) - response.raise_for_status() + if channel_id: + params["channelId"] = channel_id + if include: + params["include"] = include + + logger.info("Getting token status for user %s with params: %s", user_id, params) + async with self.client.get( + "api/usertoken/GetTokenStatus", params=params + ) as response: + if response.status >= 300: + logger.error("Error getting token status: %s", response.status) + response.raise_for_status() - data = await response.json() - return [TokenStatus.model_validate(status) for status in data] + data = await response.json() + return [TokenStatus.model_validate(status) for status in data] async def exchange_token( self, @@ -241,25 +256,29 @@ async def exchange_token( channel_id: str, body: Optional[dict] = None, ) -> TokenResponse: - params = { - "userId": user_id, - "connectionName": connection_name, - "channelId": channel_id, - } + """Exchange token for a user.""" - logger.info("Exchanging token with params: %s and body: %s", params, body) - async with self.client.post( - "api/usertoken/exchange", params=params, json=body - ) as response: - if response.status >= 300: - logger.error("Error exchanging token: %s", response.status) - response.raise_for_status() + with spans.ExchangeToken(connection_name=connection_name, user_id=user_id, channel_id=channel_id): + params = { + "userId": user_id, + "connectionName": connection_name, + "channelId": channel_id, + } + + logger.info("Exchanging token with params: %s and body: %s", params, body) + async with self.client.post( + "api/usertoken/exchange", params=params, json=body + ) as response: + if response.status >= 300: + logger.error("Error exchanging token: %s", response.status) + response.raise_for_status() - data = await response.json() - return TokenResponse.model_validate(data) + data = await response.json() + return TokenResponse.model_validate(data) class UserTokenClient(UserTokenClientBase): + """ UserTokenClient is a client for interacting with the Microsoft M365 Agents SDK User Token API. """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/spans.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py index 69590fdb..7b6fe502 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py @@ -11,5 +11,13 @@ SPAN_UPLOAD_ATTACHMENT = "agents.connector.uploadAttachment" SPAN_GET_ATTACHMENT = "agents.connector.getAttachment" +SPAN_GET_USER_TOKEN = "agents.user_token_client.get_user_token" +SPAN_SIGN_OUT = "agents.user_token_client.sign_out" +SPAN_GET_SIGN_IN_RESOURCE = "agents.user_token_client.get_sign_in_resource" +SPAN_EXCHANGE_TOKEN = "agents.user_token_client.exchange_token" +SPAN_GET_TOKEN_OR_SIGN_IN_RESOURCE = "agents.user_token_client.get_token_or_sign_in_resource" +SPAN_GET_TOKEN_STATUS = "agents.user_token_client.get_token_status" +SPAN_GET_AAD_TOKENS = "agents.user_token_client.get_aad_tokens" + METRIC_REQUESTS_TOTAL = "agents.connector.requests" METRIC_REQUEST_DURATION = "agents.connector.request.duration" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py new file mode 100644 index 00000000..686d7121 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py @@ -0,0 +1,111 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span + +from microsoft_agents.hosting.core.telemetry import ( + attributes, + SimpleSpanWrapper, + AttributeMap, +) +from . import metrics, constants + +class _UserTokenClientSpanWrapper(SimpleSpanWrapper): + """Base SpanWrapper for spans related to user token client operations in the adapter. This is meant to be a base class for spans related to user token client operations, such as creating a user token, and can be used to share common functionality and attributes related to user token client operations.""" + + def __init__( + self, + span_name: str, + *, + connection_name: str | None = None, + user_id: str | None = None, + channel_id: str | None = None + ): + """Initializes the _UserTokenClientSpanWrapper span.""" + super().__init__(span_name) + self._connection_name = connection_name or attributes.UNKNOWN + self._user_id = user_id or attributes.UNKNOWN + self._channel_id = channel_id or attributes + + def _get_attributes(self) -> dict[str, str]: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the user token client operation being performed. + + NOTE: a dict is the annotated return type to allow child classes to add additional attributes. + """ + attr_dict = {} + if self._connection_name is not None: + attr_dict[attributes.CONNECTION_NAME] = self._connection_name + if self._user_id is not None: + attr_dict[attributes.USER_ID] = self._user_id + if self._channel_id is not None: + attr_dict[attributes.ACTIVITY_CHANNEL_ID] = self._channel_id + return attr_dict + +class GetUserToken(_UserTokenClientSpanWrapper): + """Span for getting a user token using the user token client in the adapter.""" + + def __init__(self, connection_name: str, user_id: str, channel_id: str | None = None): + """Initializes the GetUserToken span.""" + super().__init__(constants.SPAN_GET_USER_TOKEN, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id) + +class SignOut(_UserTokenClientSpanWrapper): + """Span for signing out a user using the user token client in the adapter.""" + + def __init__(self, connection_name: str, user_id: str, channel_id: str | None = None): + """Initializes the SignOut span.""" + super().__init__(constants.SPAN_SIGN_OUT, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id) + +class GetSignInResource(_UserTokenClientSpanWrapper): + """Span for getting a sign-in resource using the user token client in the adapter.""" + + def __init__(self): + """Initializes the GetSignInResource span.""" + super().__init__(constants.SPAN_GET_SIGN_IN_RESOURCE) + +class ExchangeToken(_UserTokenClientSpanWrapper): + """Span for exchanging a token using the user token client in the adapter.""" + + def __init__(self, connection_name: str, user_id: str, channel_id: str | None = None): + """Initializes the ExchangeToken span.""" + super().__init__(constants.SPAN_EXCHANGE_TOKEN, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id) + +class GetTokenOrSignInResource(_UserTokenClientSpanWrapper): + """Span for getting a token or sign-in resource using the user token client in the adapter.""" + + def __init__(self, connection_name: str, user_id: str, channel_id: str | None = None): + """Initializes the GetTokenOrSignInResource span.""" + super().__init__(constants.SPAN_GET_TOKEN_OR_SIGN_IN_RESOURCE, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id) + +class GetTokenStatus(_UserTokenClientSpanWrapper): + """Span for getting token status using the user token client in the adapter.""" + + def __init__(self, user_id: str, channel_id: str | None = None): + """Initializes the GetTokenStatus span.""" + super().__init__(constants.SPAN_GET_TOKEN_STATUS, + user_id=user_id, + channel_id=channel_id) + +class GetAadTokens(_UserTokenClientSpanWrapper): + """Span for getting AAD tokens using the user token client in the adapter.""" + + def __init__(self, connection_name: str, user_id: str, channel_id: str | None = None): + """Initializes the GetAadTokens span.""" + super().__init__(constants.SPAN_GET_AAD_TOKENS, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id) + \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py index 4ad4e1f1..b83caf74 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py @@ -16,7 +16,7 @@ RestChannelServiceClientFactory, TurnContext, ) -from microsoft_agents.hosting.core.telemetry import spans +from microsoft_agents.hosting.core.telemetry.adapter import spans from ._http_request_protocol import HttpRequestProtocol from ._http_response import HttpResponse, HttpResponseFactory @@ -97,7 +97,7 @@ async def process_request( activity: Activity = Activity.model_validate(body) - with spans.start_span_adapter_process(activity): + with spans.AdapterProcess(activity): # Get claims identity (default to anonymous if not set by middleware) claims_identity: ClaimsIdentity = ( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py index 80f163a6..99ca3eef 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py @@ -15,9 +15,11 @@ ATTACHMENT_ID = "activity.attachment.id" ATTACHMENT_COUNT = "activity.attachments.count" +AUTH_HANDLER_ID = "auth.handler.id" AUTH_SCOPES = "auth.scopes" AUTH_TYPE = "auth.method" +CONNECTION_NAME = "auth.connection.name" CONVERSATION_ID = "activity.conversation.id" IS_AGENTIC = "is_agentic_request" @@ -33,5 +35,7 @@ TOKEN_SERVICE_ENDPOINT = "agents.token_service.endpoint" +USER_ID = "user.id" + # for missing values UNKNOWN = "unknown" From 22b1a76fe5edce8db96097df2dde2fca1a3b404f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 24 Mar 2026 13:04:46 -0700 Subject: [PATCH 37/55] Fixing small bugs and formatting --- .../authentication/msal/msal_auth.py | 4 +- .../oauth/_handlers/_user_authorization.py | 6 +- .../core/app/oauth/telemetry/constants.py | 2 +- .../hosting/core/app/oauth/telemetry/spans.py | 10 +- .../hosting/core/app/telemetry/spans.py | 32 +++-- .../core/authorization/telemetry/metrics.py | 1 - .../core/authorization/telemetry/spans.py | 20 +-- .../connector/client/user_token_client.py | 31 +++-- .../telemetry/connector_client_spans.py | 61 ++++++--- .../core/connector/telemetry/constants.py | 4 +- .../telemetry/user_token_client_spans.py | 117 +++++++++++------- .../rest_channel_service_client_factory.py | 8 +- .../hosting/core/storage/memory_storage.py | 4 +- .../core/storage/telemetry/constants.py | 2 +- .../hosting/core/storage/telemetry/metrics.py | 2 +- .../hosting/core/storage/telemetry/spans.py | 12 +- .../hosting/core/telemetry/__init__.py | 4 +- .../core/telemetry/adapter/constants.py | 2 +- .../hosting/core/telemetry/adapter/spans.py | 27 +++- .../core/telemetry/core/_agents_telemetry.py | 19 ++- .../core/telemetry/core/base_span_wrapper.py | 27 ++-- .../hosting/core/telemetry/core/resource.py | 2 +- .../telemetry/core/simple_span_wrapper.py | 13 +- .../hosting/core/telemetry/core/type_defs.py | 2 +- .../core/telemetry/turn_context/spans.py | 10 +- .../hosting/core/telemetry/utils.py | 4 +- 26 files changed, 276 insertions(+), 150 deletions(-) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 01ead68a..433861f2 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -441,9 +441,7 @@ async def get_agentic_user_token( :return: The agentic user token, or None if not found. :rtype: Optional[str] """ - with spans.GetAgenticUserToken( - agent_app_instance_id, agentic_user_id, scopes - ): + with spans.GetAgenticUserToken(agent_app_instance_id, agentic_user_id, scopes): if not agent_app_instance_id or not agentic_user_id: raise ValueError( str( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py index efdae668..81a02b83 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py @@ -245,7 +245,11 @@ async def _sign_in( return _SignInResponse( token_response=token_response, - tag=_FlowStateTag.COMPLETE if token_response else _FlowStateTag.FAILURE, + tag=( + _FlowStateTag.COMPLETE + if token_response + else _FlowStateTag.FAILURE + ), ) return _SignInResponse(tag=flow_response.flow_state.tag) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/constants.py index 3172652c..0897443b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/constants.py @@ -4,4 +4,4 @@ AGENTIC_TOKEN = "agents.authorization.agentic_token" AZURE_BOT_TOKEN = "agents.authorization.azure_bot_token" AZURE_BOT_SIGN_OUT = "agents.authorization.azure_bot_sign_out" -AZURE_BOT_SIGN_IN = "agents.authorization.azure_bot_sign_in" \ No newline at end of file +AZURE_BOT_SIGN_IN = "agents.authorization.azure_bot_sign_in" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/spans.py index cbffded7..b7f3df5e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/spans.py @@ -13,9 +13,10 @@ ) from . import constants + class _AuthorizationSpanWrapper(SimpleSpanWrapper): """Base SpanWrapper for spans related to authorization operations. - + This is meant to be a base class for spans related to authorization operations, and can be used to share common functionality and attributes """ @@ -64,6 +65,7 @@ def __init__( scopes, ) + class AzureBotToken(_AuthorizationSpanWrapper): """Span wrapper for azure bot token operations.""" @@ -81,6 +83,7 @@ def __init__( scopes, ) + class AzureBotSignIn(_AuthorizationSpanWrapper): """Span wrapper for azure bot sign in operations.""" @@ -98,12 +101,13 @@ def __init__( scopes, ) + class AzureBotSignOut(_AuthorizationSpanWrapper): """Span wrapper for azure bot sign out operations.""" - def __init__(self, auth_handler_id: str): + def __init__(self, auth_handler_id: str): """Initializes the AzureBotSignOut span.""" super().__init__( constants.AZURE_BOT_SIGN_OUT, auth_handler_id, - ) \ No newline at end of file + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py index 0cc1836e..90e55a5f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py @@ -13,12 +13,13 @@ ) from . import constants, metrics + class AppOnTurn(SimpleSpanWrapper): """Span for the entire app run, starting from when an activity is received in the adapter, until a response is sent back (if applicable). This span is meant to be a parent span for all other spans created during the processing of the activity, and can be used to correlate all telemetry for a given app run.""" def __init__(self, turn_context: TurnContextProtocol): """Initializes the AppOnTurn SpanWrapper. - + :param turn_context: The TurnContext for the app run, used to extract attributes for the span """ super().__init__(constants.SPAN_ON_TURN) @@ -34,7 +35,8 @@ def _callback(self, span: Span, duration: float, error: Exception | None) -> Non attributes.CONVERSATION_ID: ( get_conversation_id(self._turn_context.activity) ), - attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or attributes.UNKNOWN, + attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id + or attributes.UNKNOWN, }, ) else: @@ -42,11 +44,14 @@ def _callback(self, span: Span, duration: float, error: Exception | None) -> Non def _get_attributes(self) -> AttributeMap: return { - attributes.CONVERSATION_ID: get_conversation_id(self._turn_context.activity), - attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or attributes.UNKNOWN, + attributes.CONVERSATION_ID: get_conversation_id( + self._turn_context.activity + ), + attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id + or attributes.UNKNOWN, attributes.SERVICE_URL: self._turn_context.activity.service_url, } - + def share(self, route_authorized: bool, route_matched: bool) -> None: """Shares the span context for this app run with downstream spans, and adds attributes related to routing decisions @@ -57,6 +62,7 @@ def share(self, route_authorized: bool, route_matched: bool) -> None: self._span.set_attribute(attributes.ROUTE_AUTHORIZED, route_authorized) self._span.set_attribute(attributes.ROUTE_MATCHED, route_matched) + class AppRouteHandler(SimpleSpanWrapper): """Span for handling the routing logic. From selection, through authorization, and through the invocation of the route handler.""" @@ -68,11 +74,15 @@ def __init__(self, turn_context: TurnContextProtocol): def _get_attributes(self) -> AttributeMap: """Gets attributes for the AppRouteHandler span, based on the activity being processed.""" return { - attributes.CONVERSATION_ID: get_conversation_id(self._turn_context.activity), - attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or attributes.UNKNOWN, + attributes.CONVERSATION_ID: get_conversation_id( + self._turn_context.activity + ), + attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id + or attributes.UNKNOWN, attributes.SERVICE_URL: self._turn_context.activity.service_url, } + class AppBeforeTurn(SimpleSpanWrapper): """Span for the logic that happens before the main turn processing. This is meant to capture telemetry for the pre-processing logic of the app run, and can be used to identify issues in the early stages of the app run before the main processing logic is invoked.""" @@ -80,6 +90,7 @@ def __init__(self): """Initializes the AppBeforeTurn SpanWrapper.""" super().__init__(constants.SPAN_BEFORE_TURN) + class AppAfterTurn(SimpleSpanWrapper): """Span for the logic that happens after the main turn processing. This is meant to capture telemetry for the post-processing logic of the app run, and can be used to identify issues in the later stages of the app run after the main processing logic is invoked.""" @@ -87,6 +98,7 @@ def __init__(self): """Initializes the AppAfterTurn SpanWrapper.""" super().__init__(constants.SPAN_AFTER_TURN) + class AppDownloadFiles(SimpleSpanWrapper): """Span for the logic related to downloading files in the app. This can be used to capture telemetry for file download operations, and to identify issues related to file downloads in the app.""" @@ -97,5 +109,7 @@ def __init__(self, turn_context: TurnContextProtocol): def _get_attributes(self) -> AttributeMap: return { - attributes.ATTACHMENT_COUNT: len(self._turn_context.activity.attachments or []), - } \ No newline at end of file + attributes.ATTACHMENT_COUNT: len( + self._turn_context.activity.attachments or [] + ), + } diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py index fa5d1b8e..c69d7473 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py @@ -3,4 +3,3 @@ from microsoft_agents.hosting.core.telemetry import agents_telemetry from . import constants - diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py index d5ed09b4..7f6ad5c9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py @@ -13,9 +13,10 @@ ) from . import constants, metrics + class _AuthenticationSpanWrapper(SimpleSpanWrapper): """Base SpanWrapper for spans related to authentication operations. - + This is meant to be a base class for spans related to authentication operations, such as retrieving or validating tokens, and can be used to share common functionality and attributes """ @@ -26,7 +27,7 @@ def __init__(self, span_name: str): def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span ends. This function can be used to set additional attributes or record exceptions based on the outcome of the operation being traced.""" - + class GetAccessToken(_AuthenticationSpanWrapper): """Span wrapper for the operation of retrieving an access token.""" @@ -43,7 +44,8 @@ def _get_attributes(self) -> AttributeMap: attributes.AUTH_SCOPES: format_scopes(self._scopes), attributes.AUTH_TYPE: self._auth_type, } - + + class AcquireTokenOnBehalfOf(_AuthenticationSpanWrapper): """Span wrapper for the operation of acquiring a token on behalf of a user.""" @@ -57,7 +59,8 @@ def _get_attributes(self) -> AttributeMap: return { attributes.AUTH_SCOPES: format_scopes(self._scopes), } - + + class GetAgenticInstanceToken(_AuthenticationSpanWrapper): """Span wrapper for the operation of retrieving an agentic instance token.""" @@ -71,11 +74,14 @@ def _get_attributes(self) -> AttributeMap: return { attributes.AGENTIC_INSTANCE_ID: self._agentic_instance_id, } - + + class GetAgenticUserToken(_AuthenticationSpanWrapper): """Span wrapper for the operation of retrieving an agentic user token.""" - def __init__(self, agentic_instance_id: str, agentic_user_id: str, scopes: list[str]): + def __init__( + self, agentic_instance_id: str, agentic_user_id: str, scopes: list[str] + ): """Initializes the GetAgenticUserToken span with the specified agentic instance ID, user ID, and authentication scopes.""" super().__init__(constants.SPAN_GET_AGENTIC_USER_TOKEN) self._agentic_instance_id = agentic_instance_id @@ -88,4 +94,4 @@ def _get_attributes(self) -> AttributeMap: attributes.AGENTIC_INSTANCE_ID: self._agentic_instance_id, attributes.AGENTIC_USER_ID: self._agentic_user_id, attributes.AUTH_SCOPES: format_scopes(self._scopes), - } \ No newline at end of file + } diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py index 6867af36..5b11bde4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py @@ -118,8 +118,10 @@ async def get_token( channel_id: Optional[str] = None, code: Optional[str] = None, ) -> TokenResponse: - - with spans.GetUserToken(connection_name=connection_name, user_id=user_id, channel_id=channel_id): + + with spans.GetUserToken( + connection_name=connection_name, user_id=user_id, channel_id=channel_id + ): params = {"userId": user_id, "connectionName": connection_name} if channel_id: @@ -128,7 +130,9 @@ async def get_token( params["code"] = code logger.info("User_token.get_token(): Getting token with params: %s", params) - async with self.client.get("api/usertoken/GetToken", params=params) as response: + async with self.client.get( + "api/usertoken/GetToken", params=params + ) as response: if response.status >= 300: logger.error("Error getting token: %s", response.status) response.raise_for_status() @@ -148,7 +152,9 @@ async def _get_token_or_sign_in_resource( ) -> TokenOrSignInResourceResponse: """Get token or sign-in resource for a user.""" - with spans.GetTokenOrSignInResource(connection_name=connection_name, user_id=user_id, channel_id=channel_id): + with spans.GetTokenOrSignInResource( + connection_name=connection_name, user_id=user_id, channel_id=channel_id + ): params = { "userId": user_id, "connectionName": connection_name, @@ -181,7 +187,9 @@ async def get_aad_tokens( ) -> dict[str, TokenResponse]: """Get AAD tokens for a user.""" - with spans.GetAadTokens(connection_name=connection_name, user_id=user_id, channel_id=channel_id): + with spans.GetAadTokens( + connection_name=connection_name, user_id=user_id, channel_id=channel_id + ): params = {"userId": user_id, "connectionName": connection_name} if channel_id: @@ -206,7 +214,9 @@ async def sign_out( ) -> None: """Sign out user from a connection.""" - with spans.SignOut(user_id=user_id, connection_name=connection_name, channel_id=channel_id): + with spans.SignOut( + user_id=user_id, connection_name=connection_name, channel_id=channel_id + ): params = {"userId": user_id} if connection_name: @@ -238,7 +248,9 @@ async def get_token_status( if include: params["include"] = include - logger.info("Getting token status for user %s with params: %s", user_id, params) + logger.info( + "Getting token status for user %s with params: %s", user_id, params + ) async with self.client.get( "api/usertoken/GetTokenStatus", params=params ) as response: @@ -258,7 +270,9 @@ async def exchange_token( ) -> TokenResponse: """Exchange token for a user.""" - with spans.ExchangeToken(connection_name=connection_name, user_id=user_id, channel_id=channel_id): + with spans.ExchangeToken( + connection_name=connection_name, user_id=user_id, channel_id=channel_id + ): params = { "userId": user_id, "connectionName": connection_name, @@ -278,7 +292,6 @@ async def exchange_token( class UserTokenClient(UserTokenClientBase): - """ UserTokenClient is a client for interacting with the Microsoft M365 Agents SDK User Token API. """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py index 84123411..dfc723b4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py @@ -12,10 +12,17 @@ ) from . import metrics, constants + class _ConnectorSpanWrapper(SimpleSpanWrapper): """Base SpanWrapper for spans related to connector operations in the adapter. This is meant to be a base class for spans related to connector operations, such as creating a connector client or creating a user token, and can be used to share common functionality and attributes related to connector operations.""" - def __init__(self, span_name: str, *, conversation_id: str | None = None, activity_id: str | None = None): + def __init__( + self, + span_name: str, + *, + conversation_id: str | None = None, + activity_id: str | None = None, + ): """Initializes the _ConnectorSpanWrapper span.""" super().__init__(span_name) self._conversation_id = conversation_id @@ -28,7 +35,7 @@ def _callback(self, span: Span, duration: float, error: Exception | None) -> Non def _get_attributes(self) -> dict[str, str]: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the connector operation being performed. - + NOTE: a dict is the annotated return type to allow child classes to add additional attributes. """ attr_dict = {} @@ -37,42 +44,55 @@ def _get_attributes(self) -> dict[str, str]: if self._activity_id is not None: attr_dict[attributes.ACTIVITY_ID] = self._activity_id return attr_dict - + + class ConnectorReplyToActivity(_ConnectorSpanWrapper): """Span for replying to an activity using the connector client in the adapter.""" def __init__(self, conversation_id: str, activity_id: str | None): """Initializes the ConnectorReplyToActivity span.""" - super().__init__(constants.SPAN_REPLY_TO_ACTIVITY, - conversation_id=conversation_id, - activity_id=activity_id) + super().__init__( + constants.SPAN_REPLY_TO_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id, + ) + class ConnectorSendToConversation(_ConnectorSpanWrapper): """Span for sending to a conversation using the connector client in the adapter.""" def __init__(self, conversation_id: str, activity_id: str | None): """Initializes the ConnectorSendToConversation span.""" - super().__init__(constants.SPAN_SEND_TO_CONVERSATION, - conversation_id=conversation_id, - activity_id=activity_id) + super().__init__( + constants.SPAN_SEND_TO_CONVERSATION, + conversation_id=conversation_id, + activity_id=activity_id, + ) + class ConnectorUpdateActivity(_ConnectorSpanWrapper): """Span for updating an activity using the connector client in the adapter.""" def __init__(self, conversation_id: str, activity_id: str | None): """Initializes the ConnectorUpdateActivity span.""" - super().__init__(constants.SPAN_UPDATE_ACTIVITY, - conversation_id=conversation_id, - activity_id=activity_id) + super().__init__( + constants.SPAN_UPDATE_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id, + ) + class ConnectorDeleteActivity(_ConnectorSpanWrapper): """Span for deleting an activity using the connector client in the adapter.""" def __init__(self, conversation_id: str, activity_id: str | None): """Initializes the ConnectorDeleteActivity span.""" - super().__init__(constants.SPAN_DELETE_ACTIVITY, - conversation_id=conversation_id, - activity_id=activity_id) + super().__init__( + constants.SPAN_DELETE_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id, + ) + class ConnectorCreateConversation(_ConnectorSpanWrapper): """Span for creating a conversation using the connector client in the adapter.""" @@ -81,6 +101,7 @@ def __init__(self): """Initializes the ConnectorCreateConversation span.""" super().__init__(constants.SPAN_CREATE_CONVERSATION) + class ConnectorGetConversations(_ConnectorSpanWrapper): """Span for getting conversations using the connector client in the adapter.""" @@ -88,6 +109,7 @@ def __init__(self): """Initializes the ConnectorGetConversations span.""" super().__init__(constants.SPAN_GET_CONVERSATIONS) + class ConnectorGetConversationMembers(_ConnectorSpanWrapper): """Span for getting conversation members using the connector client in the adapter.""" @@ -95,14 +117,17 @@ def __init__(self): """Initializes the ConnectorGetConversationMembers span.""" super().__init__(constants.SPAN_GET_CONVERSATION_MEMBERS) + class ConnectorUploadAttachment(_ConnectorSpanWrapper): """Span for uploading an attachment using the connector client in the adapter.""" def __init__(self, conversation_id: str): """Initializes the ConnectorUploadAttachment span.""" - super().__init__(constants.SPAN_UPLOAD_ATTACHMENT, - conversation_id=conversation_id) - + super().__init__( + constants.SPAN_UPLOAD_ATTACHMENT, conversation_id=conversation_id + ) + + class ConnectorGetAttachment(_ConnectorSpanWrapper): """Span for getting an attachment using the connector client in the adapter.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py index 7b6fe502..a6102ab3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py @@ -15,7 +15,9 @@ SPAN_SIGN_OUT = "agents.user_token_client.sign_out" SPAN_GET_SIGN_IN_RESOURCE = "agents.user_token_client.get_sign_in_resource" SPAN_EXCHANGE_TOKEN = "agents.user_token_client.exchange_token" -SPAN_GET_TOKEN_OR_SIGN_IN_RESOURCE = "agents.user_token_client.get_token_or_sign_in_resource" +SPAN_GET_TOKEN_OR_SIGN_IN_RESOURCE = ( + "agents.user_token_client.get_token_or_sign_in_resource" +) SPAN_GET_TOKEN_STATUS = "agents.user_token_client.get_token_status" SPAN_GET_AAD_TOKENS = "agents.user_token_client.get_aad_tokens" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py index 686d7121..bcfaadfb 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py @@ -12,17 +12,18 @@ ) from . import metrics, constants + class _UserTokenClientSpanWrapper(SimpleSpanWrapper): """Base SpanWrapper for spans related to user token client operations in the adapter. This is meant to be a base class for spans related to user token client operations, such as creating a user token, and can be used to share common functionality and attributes related to user token client operations.""" def __init__( - self, - span_name: str, - *, - connection_name: str | None = None, - user_id: str | None = None, - channel_id: str | None = None - ): + self, + span_name: str, + *, + connection_name: str | None = None, + user_id: str | None = None, + channel_id: str | None = None, + ): """Initializes the _UserTokenClientSpanWrapper span.""" super().__init__(span_name) self._connection_name = connection_name or attributes.UNKNOWN @@ -31,37 +32,48 @@ def __init__( def _get_attributes(self) -> dict[str, str]: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the user token client operation being performed. - + NOTE: a dict is the annotated return type to allow child classes to add additional attributes. """ attr_dict = {} if self._connection_name is not None: - attr_dict[attributes.CONNECTION_NAME] = self._connection_name + attr_dict[attributes.CONNECTION_NAME] = self._connection_name if self._user_id is not None: - attr_dict[attributes.USER_ID] = self._user_id + attr_dict[attributes.USER_ID] = self._user_id if self._channel_id is not None: - attr_dict[attributes.ACTIVITY_CHANNEL_ID] = self._channel_id + attr_dict[attributes.ACTIVITY_CHANNEL_ID] = self._channel_id return attr_dict - + + class GetUserToken(_UserTokenClientSpanWrapper): """Span for getting a user token using the user token client in the adapter.""" - def __init__(self, connection_name: str, user_id: str, channel_id: str | None = None): + def __init__( + self, connection_name: str, user_id: str, channel_id: str | None = None + ): """Initializes the GetUserToken span.""" - super().__init__(constants.SPAN_GET_USER_TOKEN, - connection_name=connection_name, - user_id=user_id, - channel_id=channel_id) - + super().__init__( + constants.SPAN_GET_USER_TOKEN, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id, + ) + + class SignOut(_UserTokenClientSpanWrapper): """Span for signing out a user using the user token client in the adapter.""" - def __init__(self, connection_name: str, user_id: str, channel_id: str | None = None): + def __init__( + self, connection_name: str, user_id: str, channel_id: str | None = None + ): """Initializes the SignOut span.""" - super().__init__(constants.SPAN_SIGN_OUT, - connection_name=connection_name, - user_id=user_id, - channel_id=channel_id) + super().__init__( + constants.SPAN_SIGN_OUT, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id, + ) + class GetSignInResource(_UserTokenClientSpanWrapper): """Span for getting a sign-in resource using the user token client in the adapter.""" @@ -70,42 +82,57 @@ def __init__(self): """Initializes the GetSignInResource span.""" super().__init__(constants.SPAN_GET_SIGN_IN_RESOURCE) + class ExchangeToken(_UserTokenClientSpanWrapper): """Span for exchanging a token using the user token client in the adapter.""" - def __init__(self, connection_name: str, user_id: str, channel_id: str | None = None): + def __init__( + self, connection_name: str, user_id: str, channel_id: str | None = None + ): """Initializes the ExchangeToken span.""" - super().__init__(constants.SPAN_EXCHANGE_TOKEN, - connection_name=connection_name, - user_id=user_id, - channel_id=channel_id) - + super().__init__( + constants.SPAN_EXCHANGE_TOKEN, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id, + ) + + class GetTokenOrSignInResource(_UserTokenClientSpanWrapper): """Span for getting a token or sign-in resource using the user token client in the adapter.""" - def __init__(self, connection_name: str, user_id: str, channel_id: str | None = None): + def __init__( + self, connection_name: str, user_id: str, channel_id: str | None = None + ): """Initializes the GetTokenOrSignInResource span.""" - super().__init__(constants.SPAN_GET_TOKEN_OR_SIGN_IN_RESOURCE, - connection_name=connection_name, - user_id=user_id, - channel_id=channel_id) - + super().__init__( + constants.SPAN_GET_TOKEN_OR_SIGN_IN_RESOURCE, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id, + ) + + class GetTokenStatus(_UserTokenClientSpanWrapper): """Span for getting token status using the user token client in the adapter.""" def __init__(self, user_id: str, channel_id: str | None = None): """Initializes the GetTokenStatus span.""" - super().__init__(constants.SPAN_GET_TOKEN_STATUS, - user_id=user_id, - channel_id=channel_id) - + super().__init__( + constants.SPAN_GET_TOKEN_STATUS, user_id=user_id, channel_id=channel_id + ) + + class GetAadTokens(_UserTokenClientSpanWrapper): """Span for getting AAD tokens using the user token client in the adapter.""" - def __init__(self, connection_name: str, user_id: str, channel_id: str | None = None): + def __init__( + self, connection_name: str, user_id: str, channel_id: str | None = None + ): """Initializes the GetAadTokens span.""" - super().__init__(constants.SPAN_GET_AAD_TOKENS, - connection_name=connection_name, - user_id=user_id, - channel_id=channel_id) - \ No newline at end of file + super().__init__( + constants.SPAN_GET_AAD_TOKENS, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id, + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py index 4abb11b5..3497ef47 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py @@ -158,9 +158,9 @@ async def create_user_token_client( """ if not context or not claims_identity: raise ValueError("context and claims_identity are required") - + scopes = claims_identity.get_token_scope() if claims_identity else None - + with spans.AdapterCreateUserTokenClient( token_service_endpoint=self._token_service_endpoint, scopes=scopes, @@ -170,7 +170,9 @@ async def create_user_token_client( return UserTokenClient(endpoint=self._token_service_endpoint, token="") if context.activity.is_agentic_request(): - token = await self._get_agentic_token(context, self._token_service_endpoint) + token = await self._get_agentic_token( + context, self._token_service_endpoint + ) else: token_provider = self._connection_manager.get_token_provider( claims_identity, self._token_service_endpoint 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 39d6f0e1..bf75d2aa 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,7 +4,7 @@ from threading import Lock from typing import TypeVar -from microsoft_agents.hosting.core.telemetry import spans +from .telemetry import spans from ._type_aliases import JSON from .storage import Storage @@ -27,7 +27,7 @@ async def read( raise ValueError("Storage.read(): Keys are required when reading.") if not target_cls: raise ValueError("Storage.read(): target_cls cannot be None.") - + with spans.StorageRead(len(keys)): result: dict[str, StoreItem] = {} with self._lock: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/constants.py index c80cee50..d8e3af85 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/constants.py @@ -6,4 +6,4 @@ SPAN_STORAGE_DELETE = "agents.storage.delete" METRIC_STORAGE_OPERATION_TOTAL = "agents.storage.operation.total" -METRIC_STORAGE_OPERATION_DURATION = "agents.storage.operation.duration" \ No newline at end of file +METRIC_STORAGE_OPERATION_DURATION = "agents.storage.operation.duration" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/metrics.py index 47aa8322..0e9c2e68 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/metrics.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/metrics.py @@ -13,4 +13,4 @@ constants.METRIC_STORAGE_OPERATION_DURATION, "ms", description="Duration of storage operations in milliseconds", -) \ No newline at end of file +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py index 36462e9e..7ce92274 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py @@ -6,11 +6,12 @@ from opentelemetry.trace import Span from microsoft_agents.hosting.core.telemetry import ( - resource as common_constants, + attributes, SimpleSpanWrapper, ) from . import metrics, constants + class _StorageSpanWrapper(SimpleSpanWrapper): """Base SpanWrapper for spans related to storage operations. This is meant to be a base class for spans related to storage operations, such as retrieving or saving state, and can be used to share common functionality and attributes related to storage operations.""" @@ -30,9 +31,10 @@ def _get_attributes(self) -> dict[str, str | int]: NOTE: a dict is the annotated return type to allow child classes to add additional attributes. """ return { - common_constants.ATTR_KEY_COUNT: self._key_count, + attributes.KEY_COUNT: self._key_count, } - + + class StorageRead(_StorageSpanWrapper): """Span for reading from storage.""" @@ -40,6 +42,7 @@ def __init__(self, key_count: int): """Initializes the StorageRead span.""" super().__init__(constants.SPAN_STORAGE_READ, key_count=key_count) + class StorageWrite(_StorageSpanWrapper): """Span for writing to storage.""" @@ -47,9 +50,10 @@ def __init__(self, key_count: int): """Initializes the StorageWrite span.""" super().__init__(constants.SPAN_STORAGE_WRITE, key_count=key_count) + class StorageDelete(_StorageSpanWrapper): """Span for deleting from storage.""" def __init__(self, key_count: int): """Initializes the StorageDelete span.""" - super().__init__(constants.SPAN_STORAGE_DELETE, key_count=key_count) \ No newline at end of file + super().__init__(constants.SPAN_STORAGE_DELETE, key_count=key_count) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py index 7d068aba..b054f57c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py @@ -9,10 +9,8 @@ # NOTE: this module should not be auto-loaded from __init__.py in order to avoid from . import attributes -from .core._agents_telemetry import ( - agents_telemetry, -) from .core import ( + agents_telemetry, SERVICE_NAME, SERVICE_VERSION, RESOURCE, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py index c606d4d0..108e2d63 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py @@ -9,4 +9,4 @@ SPAN_CREATE_CONNECTOR_CLIENT = "agents.adapter.createConnectorClient" SPAN_CREATE_USER_TOKEN_CLIENT = "agents.adapter.createUserTokenClient" -METRIC_ADAPTER_PROCESS_DURATION = "agents.adapter.process.duration" \ No newline at end of file +METRIC_ADAPTER_PROCESS_DURATION = "agents.adapter.process.duration" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py index 89bf755c..71924336 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py @@ -16,6 +16,7 @@ ) from . import constants, metrics + class AdapterProcess(SimpleSpanWrapper): """Span for processing an incoming activity in the adapter.""" @@ -31,12 +32,14 @@ def _callback(self, span: Span, duration: float, error: Exception | None) -> Non def _get_attributes(self) -> AttributeMap: return { attributes.ACTIVITY_TYPE: self._activity.type, - attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id or attributes.UNKNOWN, + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id + or attributes.UNKNOWN, attributes.ACTIVITY_DELIVERY_MODE: get_delivery_mode(self._activity), attributes.CONVERSATION_ID: get_conversation_id(self._activity), attributes.IS_AGENTIC: self._activity.is_agentic_request(), } + class AdapterSendActivities(SimpleSpanWrapper): """Span for sending activities in the adapter.""" @@ -51,10 +54,12 @@ def _get_attributes(self) -> AttributeMap: attributes.ACTIVITY_COUNT: len(self._activities), attributes.CONVERSATION_ID: ( get_conversation_id(self._activities[0]) - if self._activities else attributes.UNKNOWN + if self._activities + else attributes.UNKNOWN ), } + class AdapterUpdateActivity(SimpleSpanWrapper): """Span for updating an activity in the adapter.""" @@ -70,6 +75,7 @@ def _get_attributes(self) -> AttributeMap: attributes.CONVERSATION_ID: get_conversation_id(self._activity), } + class AdapterDeleteActivity(SimpleSpanWrapper): """Span for deleting an activity in the adapter.""" @@ -85,6 +91,7 @@ def _get_attributes(self) -> AttributeMap: attributes.CONVERSATION_ID: get_conversation_id(self._activity), } + class AdapterContinueConversation(SimpleSpanWrapper): """Span for continuing a conversation in the adapter.""" @@ -96,11 +103,16 @@ def __init__(self, activity: Activity): def _get_attributes(self) -> AttributeMap: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the conversation being continued.""" return { - attributes.APP_ID: self._activity.recipient.id if self._activity.recipient else attributes.UNKNOWN, + attributes.APP_ID: ( + self._activity.recipient.id + if self._activity.recipient + else attributes.UNKNOWN + ), attributes.CONVERSATION_ID: get_conversation_id(self._activity), attributes.IS_AGENTIC_REQUEST: self._activity.is_agentic_request(), } + class AdapterCreateUserTokenClient(SimpleSpanWrapper): """Span for creating a user token in the adapter.""" @@ -117,10 +129,13 @@ def _get_attributes(self) -> AttributeMap: attributes.AUTH_SCOPES: format_scopes(self._scopes), } + class AdapterCreateConnectorClient(SimpleSpanWrapper): """Span for creating a connector client in the adapter.""" - def __init__(self, service_url: str, scopes: list[str] | None, is_agentic_request: bool): + def __init__( + self, service_url: str, scopes: list[str] | None, is_agentic_request: bool + ): """Initializes the AdapterCreateConnectorClient span.""" super().__init__(constants.SPAN_CREATE_CONNECTOR_CLIENT) self._service_url = service_url @@ -132,5 +147,5 @@ def _get_attributes(self) -> AttributeMap: return { attributes.SERVICE_URL: self._service_url, attributes.AUTH_SCOPES: format_scopes(self._scopes), - attributes.IS_AGENTIC_REQUEST: self._is_agentic_request, - } \ No newline at end of file + attributes.IS_AGENTIC: self._is_agentic_request, + } diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py index d03b602a..acfee61d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py @@ -13,11 +13,12 @@ from microsoft_agents.activity import TurnContextProtocol -from .. import core +from .resource import SERVICE_NAME, SERVICE_VERSION from .type_defs import SpanCallback logger = logging.getLogger(__name__) + class _AgentsTelemetry: def __init__(self): @@ -26,12 +27,8 @@ def __init__(self): :param tracer: Optional OpenTelemetry Tracer instance to use for creating spans. If not provided, a new tracer will be created with the service name and version from constants. :param meter: Optional OpenTelemetry Meter instance to use for recording metrics. If not provided, a new meter will be created with the service name and version from constants. """ - self._tracer = trace.get_tracer( - core.SERVICE_NAME, core.SERVICE_VERSION - ) - self._meter = metrics.get_meter( - core.SERVICE_NAME, core.SERVICE_VERSION - ) + self._tracer = trace.get_tracer(SERVICE_NAME, SERVICE_VERSION) + self._meter = metrics.get_meter(SERVICE_NAME, SERVICE_VERSION) @property def tracer(self) -> Tracer: @@ -63,15 +60,17 @@ def _extract_attributes_from_context( len(turn_context.activity.text) if turn_context.activity.text else 0 ) return attributes - - def set_attributes_from_context(self, span: Span, turn_context: TurnContextProtocol) -> None: + + def set_attributes_from_context( + self, span: Span, turn_context: TurnContextProtocol + ) -> None: """Extracts attributes from the TurnContext and sets them on the given span :param span: The OpenTelemetry span to set attributes on :param turn_context: The TurnContext to extract attributes from """ span.set_attributes(self._extract_attributes_from_context(turn_context)) - + @contextmanager def start_as_current_span( self, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py index 4b7f7dd0..acda2bc0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py @@ -14,6 +14,7 @@ logger = logging.getLogger(__name__) + class BaseSpanWrapper(ABC): """Wrapper around OTEL spans for SDK-specific telemetry""" @@ -29,37 +30,41 @@ def otel_span(self) -> Span | None: if self._span is None: raise RuntimeError("BaseSpanWrapper has not been started yet") return self._span - + @property def active(self) -> bool: """Indicates whether the BaseSpanWrapper is currently active. This can be used to prevent operations on an inactive BaseSpanWrapper, and to check the BaseSpanWrapper's lifecycle state.""" return self._active - + @abstractmethod def _start_span(self) -> ContextManager[Span]: """Abstract method that must be implemented by subclasses to define how the BaseSpanWrapper is started and what attributes are set on the BaseSpanWrapper. This method should return a context manager that yields the started BaseSpanWrapper, allowing the base BaseSpanWrapper class to manage the BaseSpanWrapper's lifecycle and ensure proper cleanup when the BaseSpanWrapper is ended.""" raise NotImplementedError - + @staticmethod def _log_lifespan_error(desc: str) -> None: """Helper method to log a warning when an operation is attempted on an inactive BaseSpanWrapper. This can be used in methods that require an active BaseSpanWrapper to indicate potential misuse of the BaseSpanWrapper lifecycle.""" - logger.warning("Attempting to perform an operation on an inactive BaseSpanWrapper. This may indicate a bug in the telemetry implementation or misuse of the BaseSpanWrapper lifecycle.") + logger.warning( + "Attempting to perform an operation on an inactive BaseSpanWrapper. This may indicate a bug in the telemetry implementation or misuse of the BaseSpanWrapper lifecycle." + ) logger.warning("Description: %s", desc) def __enter__(self) -> BaseSpanWrapper: """Starts the BaseSpanWrapper and returns the BaseSpanWrapper instance for chaining. This method should check if the BaseSpanWrapper is already active and log a warning if an attempt is made to start an already active BaseSpanWrapper, to help identify potential issues with BaseSpanWrapper lifecycle management.""" if self._active: - BaseSpanWrapper._log_lifespan_error("Attempting to start a BaseSpanWrapper that is already active.") + BaseSpanWrapper._log_lifespan_error( + "Attempting to start a BaseSpanWrapper that is already active." + ) self._span = self._exit_stack.enter_context(self._start_span()) self._active = True return self - + def start(self) -> BaseSpanWrapper: """Starts the BaseSpanWrapper and returns the BaseSpanWrapper instance for chaining""" return self.__enter__() - + def __exit__(self, exc_type, exc_val, exc_tb): """Stops the BaseSpanWrapper if it is active, and logs a warning if an attempt is made to stop a BaseSpanWrapper that is not active. This ensures that BaseSpanWrappers are properly cleaned up and that potential issues with BaseSpanWrapper lifecycle management are logged for debugging purposes.""" if self._active: @@ -67,8 +72,10 @@ def __exit__(self, exc_type, exc_val, exc_tb): self._span = None self._active = False else: - BaseSpanWrapper._log_lifespan_error("BaseSpanWrapper is not active and cannot be exited") - + BaseSpanWrapper._log_lifespan_error( + "BaseSpanWrapper is not active and cannot be exited" + ) + def end(self) -> None: """Stops the BaseSpanWrapper if it is active""" - self.__exit__(None, None, None) \ No newline at end of file + self.__exit__(None, None, None) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/resource.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/resource.py index a95449c9..e93fd464 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/resource.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/resource.py @@ -15,4 +15,4 @@ "service.instance.id": os.getenv("HOSTNAME", "unknown"), "telemetry.sdk.language": "python", } -) \ No newline at end of file +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py index 6eea4ad1..1531fb10 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py @@ -11,9 +11,10 @@ from .base_span_wrapper import BaseSpanWrapper from .type_defs import AttributeMap + class SimpleSpanWrapper(BaseSpanWrapper, ABC): """Simple implementation of the BaseSpanWrapper that can be used when no additional attributes or functionality are needed on the span beyond what is provided by the base BaseSpanWrapper class. This can be used as a simple wrapper around an OTEL span for cases where no SDK-specific telemetry is needed, while still providing the benefits of the BaseSpanWrapper abstraction and lifecycle management.""" - + def __init__(self, span_name: str): super().__init__() self._span_name = span_name @@ -21,17 +22,19 @@ def __init__(self, span_name: str): def _get_attributes(self) -> AttributeMap: """Returns a dictionary of attributes to set on the span when it is started. This can be overridden by subclasses to provide custom attributes for the span based on the context in which it is being used.""" return {} - + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span is ended. This can be overridden by subclasses to provide custom logic for recording metrics or handling errors based on the outcome of the span.""" pass - + @contextmanager def _start_span(self) -> Iterator[Span]: """Starts a basic OTEL span with the given name and no additional attributes.""" - with agents_telemetry.start_as_current_span(self._span_name, callback=self._callback) as span: + with agents_telemetry.start_as_current_span( + self._span_name, callback=self._callback + ) as span: if span is not None: attributes = self._get_attributes() if attributes: span.set_attributes(attributes) - yield span \ No newline at end of file + yield span diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py index d532f88d..0169e73f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py @@ -4,4 +4,4 @@ from opentelemetry.trace import Span AttributeMap = Mapping[str, AttributeValue] -SpanCallback = Callable[[Span, float, Exception | None], None] \ No newline at end of file +SpanCallback = Callable[[Span, float, Exception | None], None] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py index a9fd0aa6..03fe2db0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py @@ -8,10 +8,11 @@ AttributeMap, SimpleSpanWrapper, attributes, - get_conversation_id + get_conversation_id, ) from . import constants + class _TurnContextSpanWrapper(SimpleSpanWrapper): """Base span wrapper for TurnContext operations""" @@ -25,21 +26,24 @@ def _get_attributes(self) -> AttributeMap: return { attributes.CONVERSATION_ID: get_conversation_id(activity), } - + + class TurnContextSendActivity(_TurnContextSpanWrapper): """Span wrapper for sending an activity within a turn context.""" def __init__(self, turn_context: TurnContextProtocol): super().__init__(constants.SPAN_TURN_SEND_ACTIVITY, turn_context) + class TurnContextUpdateActivity(_TurnContextSpanWrapper): """Span wrapper for updating an activity within a turn context.""" def __init__(self, turn_context: TurnContextProtocol): super().__init__(constants.SPAN_TURN_UPDATE_ACTIVITY, turn_context) + class TurnContextDeleteActivity(_TurnContextSpanWrapper): """Span wrapper for deleting an activity within a turn context.""" def __init__(self, turn_context: TurnContextProtocol): - super().__init__(constants.SPAN_TURN_DELETE_ACTIVITY, turn_context) \ No newline at end of file + super().__init__(constants.SPAN_TURN_DELETE_ACTIVITY, turn_context) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py index d76dd77d..693cb7bc 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py @@ -5,12 +5,14 @@ from .attributes import UNKNOWN + def format_scopes(scopes: list[str] | None) -> str: """Formats a list of scopes into a string for telemetry recording. If the list is None or empty, returns a constant value indicating unknown scopes.""" if not scopes: return UNKNOWN return ",".join(scopes) + def get_conversation_id(activity: Activity) -> str: """Extracts the conversation ID from the given activity. If the conversation ID cannot be found, returns a constant value indicating unknown conversation ID.""" return activity.conversation.id if activity.conversation else UNKNOWN @@ -23,4 +25,4 @@ def get_delivery_mode(activity: Activity) -> str: return activity.delivery_mode.value else: return activity.delivery_mode - return UNKNOWN \ No newline at end of file + return UNKNOWN From 9413d31236da46c548d23b7afceae74204ea5279 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 24 Mar 2026 13:14:50 -0700 Subject: [PATCH 38/55] Adding call to share route handling info to AppOnTurn span --- .../hosting/core/app/agent_application.py | 16 +++++++--- .../core/connector/client/connector_client.py | 29 ++++++++++--------- .../telemetry/connector_client_spans.py | 12 ++++++++ .../core/connector/telemetry/constants.py | 1 + 4 files changed, 40 insertions(+), 18 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 9b18339c..8bdd3221 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 @@ -710,7 +710,7 @@ async def _on_turn(self, context: TurnContext): await self._handle_file_downloads(context, turn_state) logger.debug("Running activity handlers") - await self._on_activity(context, turn_state) + await self._on_activity(context, turn_state, on_turn_span) logger.debug("Running after turn middleware") if await self._run_after_turn_middleware(context, turn_state): @@ -780,7 +780,7 @@ async def _initialize_state(self, context: TurnContext) -> StateT: return turn_state async def _run_before_turn_middleware(self, context: TurnContext, state: StateT): - with spans.AppBeforeTurn(context): + with spans.AppBeforeTurn(): for before_turn in self._internal_before_turn: is_ok = await before_turn(context, state) if not is_ok: @@ -811,7 +811,7 @@ def _contains_non_text_attachments(self, context: TurnContext): return len(list(non_text_attachments)) > 0 async def _run_after_turn_middleware(self, context: TurnContext, state: StateT): - with spans.AppAfterTurn(context): + with spans.AppAfterTurn(): for after_turn in self._internal_after_turn: is_ok = await after_turn(context, state) if not is_ok: @@ -819,11 +819,17 @@ async def _run_after_turn_middleware(self, context: TurnContext, state: StateT): return False return True - async def _on_activity(self, context: TurnContext, state: StateT): + async def _on_activity(self, context: TurnContext, state: StateT, on_turn_span: spans.AppOnTurn | None = None): with spans.AppRouteHandler(context): + + route_matched: bool = False + route_authorized: bool = False + for route in self._route_list: if route.selector(context): + route_matched = True if not route.auth_handlers: + route_authorized = True await route.handler(context, state) else: sign_in_complete = True @@ -837,11 +843,13 @@ async def _on_activity(self, context: TurnContext, state: StateT): break if sign_in_complete: + route_authorized = True await route.handler(context, state) return logger.warning( f"No route found for activity type: {context.activity.type} with text: {context.activity.text}" ) + on_turn_span.share(route_authorized=route_authorized, route_matched=route_matched) async def _start_long_running_call( self, context: TurnContext, func: Callable[[TurnContext], Awaitable] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index cd1c2449..a3a52b54 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -69,23 +69,24 @@ async def get_attachment_info(self, attachment_id: str) -> AttachmentInfo: :param attachment_id: The ID of the attachment. :return: The attachment information. """ - if attachment_id is None: - raise ValueError("attachmentId is required") + with spans.ConnectorGetAttachmentInfo(attachment_id=attachment_id): + if attachment_id is None: + raise ValueError("attachmentId is required") - url = f"v3/attachments/{attachment_id}" + url = f"v3/attachments/{attachment_id}" - logger.info("Getting attachment info for ID: %s", attachment_id) - async with self.client.get(url) as response: - if response.status >= 300: - logger.error( - "Error getting attachment info: %s", - response.status, - stack_info=True, - ) - response.raise_for_status() + logger.info("Getting attachment info for ID: %s", attachment_id) + async with self.client.get(url) as response: + if response.status >= 300: + logger.error( + "Error getting attachment info: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() - data = await response.json() - return AttachmentInfo(**data) + data = await response.json() + return AttachmentInfo(**data) async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py index dfc723b4..1b4b04c8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py @@ -127,6 +127,18 @@ def __init__(self, conversation_id: str): constants.SPAN_UPLOAD_ATTACHMENT, conversation_id=conversation_id ) +class ConnectorGetAttachmentInfo(_ConnectorSpanWrapper): + """Span for getting attachment info using the connector client in the adapter.""" + + def __init__(self, attachment_id: str): + """Initializes the ConnectorGetAttachmentInfo span.""" + super().__init__(constants.SPAN_GET_ATTACHMENT_INFO) + self._attachment_id = attachment_id + + def _get_attributes(self) -> AttributeMap: + attr_dict = super()._get_attributes() + attr_dict[attributes.ATTACHMENT_ID] = self._attachment_id + return attr_dict class ConnectorGetAttachment(_ConnectorSpanWrapper): """Span for getting an attachment using the connector client in the adapter.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py index a6102ab3..29d88a8f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py @@ -10,6 +10,7 @@ SPAN_GET_CONVERSATION_MEMBERS = "agents.connector.getConversationMembers" SPAN_UPLOAD_ATTACHMENT = "agents.connector.uploadAttachment" SPAN_GET_ATTACHMENT = "agents.connector.getAttachment" +SPAN_GET_ATTACHMENT_INFO = "agents.connector.getAttachmentInfo" SPAN_GET_USER_TOKEN = "agents.user_token_client.get_user_token" SPAN_SIGN_OUT = "agents.user_token_client.sign_out" From d3cec646dac75e5b9ad097410dd4e49efc308385 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 24 Mar 2026 15:08:41 -0700 Subject: [PATCH 39/55] Mapping attributes for metric readings --- .../hosting/core/app/agent_application.py | 52 ++++++++-------- .../hosting/core/app/telemetry/constants.py | 12 ++-- .../hosting/core/app/telemetry/metrics.py | 8 +-- .../hosting/core/app/telemetry/spans.py | 35 +++++------ .../core/authorization/telemetry/constants.py | 6 +- .../core/authorization/telemetry/metrics.py | 12 ++++ .../core/authorization/telemetry/spans.py | 24 +++++--- .../core/connector/client/connector_client.py | 2 +- .../telemetry/_request_span_wrapper.py | 31 ++++++++++ .../telemetry/connector_client_spans.py | 16 +++-- .../core/connector/telemetry/constants.py | 8 ++- .../core/connector/telemetry/metrics.py | 20 ++++-- .../telemetry/user_token_client_spans.py | 16 ++--- .../hosting/core/storage/memory_storage.py | 61 +++++++++---------- .../hosting/core/storage/telemetry/spans.py | 4 +- .../core/telemetry/adapter/constants.py | 17 ++++-- .../hosting/core/telemetry/adapter/metrics.py | 20 ++++++ .../hosting/core/telemetry/adapter/spans.py | 24 +++++++- .../hosting/core/telemetry/attributes.py | 11 +++- .../core/telemetry/core/base_span_wrapper.py | 3 +- .../core/telemetry/turn_context/spans.py | 33 ++-------- .../hosting/core/turn_context.py | 34 +++++------ 22 files changed, 274 insertions(+), 175 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.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 8bdd3221..1e24e7a6 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 @@ -820,35 +820,37 @@ async def _run_after_turn_middleware(self, context: TurnContext, state: StateT): return True async def _on_activity(self, context: TurnContext, state: StateT, on_turn_span: spans.AppOnTurn | None = None): - with spans.AppRouteHandler(context): - route_matched: bool = False - route_authorized: bool = False + route_matched: bool = False + route_authorized: bool = False + + for route in self._route_list: + if route.selector(context): + route_matched = True + if not route.auth_handlers: + route_authorized = True + with spans.AppRouteHandler(route.is_invoke, route.is_agentic): + await route.handler(context, state) + else: + sign_in_complete = True + for auth_handler_id in route.auth_handlers: + if not ( + await self._auth._start_or_continue_sign_in( + context, state, auth_handler_id + ) + ).sign_in_complete(): + sign_in_complete = False + break - for route in self._route_list: - if route.selector(context): - route_matched = True - if not route.auth_handlers: + if sign_in_complete: route_authorized = True - await route.handler(context, state) - else: - sign_in_complete = True - for auth_handler_id in route.auth_handlers: - if not ( - await self._auth._start_or_continue_sign_in( - context, state, auth_handler_id - ) - ).sign_in_complete(): - sign_in_complete = False - break - - if sign_in_complete: - route_authorized = True + with spans.AppRouteHandler(route.is_invoke, route.is_agentic): await route.handler(context, state) - return - logger.warning( - f"No route found for activity type: {context.activity.type} with text: {context.activity.text}" - ) + return + logger.warning( + f"No route found for activity type: {context.activity.type} with text: {context.activity.text}" + ) + if on_turn_span is not None: on_turn_span.share(route_authorized=route_authorized, route_matched=route_matched) async def _start_long_running_call( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/constants.py index c0545330..a1dbfccf 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/constants.py @@ -2,11 +2,11 @@ # Licensed under the MIT License. SPAN_ON_TURN = "agents.app.run" -SPAN_ROUTE_HANDLER = "agents.app.routeHandler" -SPAN_BEFORE_TURN = "agents.app.beforeTurn" -SPAN_AFTER_TURN = "agents.app.afterTurn" -SPAN_DOWNLOAD_FILES = "agents.app.downloadFiles" +SPAN_ROUTE_HANDLER = "agents.app.route_handler" +SPAN_BEFORE_TURN = "agents.app.before_turn" +SPAN_AFTER_TURN = "agents.app.after_turn" +SPAN_DOWNLOAD_FILES = "agents.app.download_files" -METRIC_TURN_TOTAL = "agents.turn.total" -METRIC_TURN_ERRORS = "agents.turn.errors" +METRIC_TURN_COUNT = "agents.turn.count" +METRIC_TURN_ERROR_COUNT = "agents.turn.error.count" METRIC_TURN_DURATION = "agents.turn.duration" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/metrics.py index 9562ba43..8681358f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/metrics.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/metrics.py @@ -4,14 +4,14 @@ from microsoft_agents.hosting.core.telemetry import agents_telemetry from . import constants -turn_total = agents_telemetry.meter.create_counter( - constants.METRIC_TURN_TOTAL, +turn_count = agents_telemetry.meter.create_counter( + constants.METRIC_TURN_COUNT, "turn", description="Total number of turns processed by the agent", ) -turn_errors = agents_telemetry.meter.create_counter( - constants.METRIC_TURN_ERRORS, +turn_error_count = agents_telemetry.meter.create_counter( + constants.METRIC_TURN_ERROR_COUNT, "turn", description="Number of turns that resulted in an error", ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py index 90e55a5f..8ef4dcec 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py @@ -11,6 +11,7 @@ SimpleSpanWrapper, get_conversation_id, ) +from microsoft_agents.hosting.core._routes import _Route from . import constants, metrics @@ -27,20 +28,16 @@ def __init__(self, turn_context: TurnContextProtocol): def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span is ended. This is used to record metrics for the app run based on the outcome of the span.""" + attrs = { + attributes.ACTIVITY_TYPE: self._turn_context.activity.type, + attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or attributes.UNKNOWN, + attributes.CONVERSATION_ID: get_conversation_id(self._turn_context.activity), + } if error is None: - metrics.turn_total.add(1) - metrics.turn_duration.record( - duration, - { - attributes.CONVERSATION_ID: ( - get_conversation_id(self._turn_context.activity) - ), - attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id - or attributes.UNKNOWN, - }, - ) + metrics.turn_count.add(1, attributes=attrs) + metrics.turn_duration.record(duration, attributes=attrs) else: - metrics.turn_errors.add(1) + metrics.turn_error_count.add(1, attributes=attrs) def _get_attributes(self) -> AttributeMap: return { @@ -66,23 +63,19 @@ def share(self, route_authorized: bool, route_matched: bool) -> None: class AppRouteHandler(SimpleSpanWrapper): """Span for handling the routing logic. From selection, through authorization, and through the invocation of the route handler.""" - def __init__(self, turn_context: TurnContextProtocol): + def __init__(self, is_invoke: bool, is_agentic: bool): """Initializes the AppRouteHandler SpanWrapper.""" super().__init__(constants.SPAN_ROUTE_HANDLER) - self._turn_context = turn_context + self._is_invoke = is_invoke + self._is_agentic = is_agentic def _get_attributes(self) -> AttributeMap: """Gets attributes for the AppRouteHandler span, based on the activity being processed.""" return { - attributes.CONVERSATION_ID: get_conversation_id( - self._turn_context.activity - ), - attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id - or attributes.UNKNOWN, - attributes.SERVICE_URL: self._turn_context.activity.service_url, + attributes.ROUTE_IS_INVOKE: self._is_invoke, + attributes.ROUTE_IS_AGENTIC: self._is_agentic, } - class AppBeforeTurn(SimpleSpanWrapper): """Span for the logic that happens before the main turn processing. This is meant to capture telemetry for the pre-processing logic of the app run, and can be used to identify issues in the early stages of the app run before the main processing logic is invoked.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py index efd9873a..12a5e2ff 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py @@ -12,4 +12,8 @@ # Metrics METRIC_AUTH_TOKEN_REQUEST_DURATION = "agents.auth.token.request.duration" -METRIC_AUTH_TOKEN_REQUESTS = "agents.auth.token.requests" +METRIC_AUTH_TOKEN_REQUEST_COUNT = "agents.auth.token.request.count" + +AUTH_METHOD_OBO = "obo" +AUTH_METHOD_AGENTIC_INSTANCE = "agentic_instance" +AUTH_METHOD_AGENTIC_USER = "agentic_user" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py index c69d7473..5b961a19 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py @@ -3,3 +3,15 @@ from microsoft_agents.hosting.core.telemetry import agents_telemetry from . import constants + +auth_token_request_count = agents_telemetry.meter.create_counter( + constants.METRIC_AUTH_TOKEN_REQUEST_COUNT, + "request", + description="Total number of auth token requests made by the AuthTokenClient", +) + +auth_token_request_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_AUTH_TOKEN_REQUEST_DURATION, + "ms", + description="Duration of auth token requests in milliseconds", +) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py index 7f6ad5c9..1e397592 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py @@ -13,7 +13,6 @@ ) from . import constants, metrics - class _AuthenticationSpanWrapper(SimpleSpanWrapper): """Base SpanWrapper for spans related to authentication operations. @@ -21,28 +20,34 @@ class _AuthenticationSpanWrapper(SimpleSpanWrapper): and can be used to share common functionality and attributes """ - def __init__(self, span_name: str): + def __init__(self, span_name: str, auth_method: str): """Initializes the _StorageSpanWrapper span.""" super().__init__(span_name) + self._auth_method = auth_method def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span ends. This function can be used to set additional attributes or record exceptions based on the outcome of the operation being traced.""" + attrs = { + attributes.AUTH_METHOD: self._auth_method, + attributes.AUTH_SUCCESS: error is None, + } + metrics.auth_token_request_count.add(1, attributes=attrs) + metrics.auth_token_request_duration.record(duration, attributes=attrs) class GetAccessToken(_AuthenticationSpanWrapper): """Span wrapper for the operation of retrieving an access token.""" - def __init__(self, scopes: list[str], auth_type: str): + def __init__(self, scopes: list[str], auth_method: str): """Initializes the GetAccessToken span with the specified authentication scope and type.""" - super().__init__(constants.SPAN_GET_ACCESS_TOKEN) + super().__init__(constants.SPAN_GET_ACCESS_TOKEN, auth_method) self._scopes = scopes - self._auth_type = auth_type def _get_attributes(self) -> AttributeMap: """Returns a dictionary of attributes to be set on the span. This includes the authentication scope and type.""" return { attributes.AUTH_SCOPES: format_scopes(self._scopes), - attributes.AUTH_TYPE: self._auth_type, + attributes.AUTH_METHOD: self._auth_method, } @@ -51,7 +56,7 @@ class AcquireTokenOnBehalfOf(_AuthenticationSpanWrapper): def __init__(self, scopes: list[str]): """Initializes the AcquireTokenOnBehalfOf span with the specified authentication scope.""" - super().__init__(constants.SPAN_ACQUIRE_TOKEN_ON_BEHALF_OF) + super().__init__(constants.SPAN_ACQUIRE_TOKEN_ON_BEHALF_OF, constants.AUTH_METHOD_OBO) self._scopes = scopes def _get_attributes(self) -> AttributeMap: @@ -60,13 +65,12 @@ def _get_attributes(self) -> AttributeMap: attributes.AUTH_SCOPES: format_scopes(self._scopes), } - class GetAgenticInstanceToken(_AuthenticationSpanWrapper): """Span wrapper for the operation of retrieving an agentic instance token.""" def __init__(self, agentic_instance_id: str): """Initializes the GetAgenticInstanceToken span with the specified agentic instance ID.""" - super().__init__(constants.SPAN_GET_AGENTIC_INSTANCE_TOKEN) + super().__init__(constants.SPAN_GET_AGENTIC_INSTANCE_TOKEN, constants.AUTH_METHOD_AGENTIC_INSTANCE) self._agentic_instance_id = agentic_instance_id def _get_attributes(self) -> AttributeMap: @@ -83,7 +87,7 @@ def __init__( self, agentic_instance_id: str, agentic_user_id: str, scopes: list[str] ): """Initializes the GetAgenticUserToken span with the specified agentic instance ID, user ID, and authentication scopes.""" - super().__init__(constants.SPAN_GET_AGENTIC_USER_TOKEN) + super().__init__(constants.SPAN_GET_AGENTIC_USER_TOKEN, constants.AUTH_METHOD_AGENTIC_USER) self._agentic_instance_id = agentic_instance_id self._agentic_user_id = agentic_user_id self._scopes = scopes diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index a3a52b54..3ea7be1e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -96,7 +96,7 @@ async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: :param view_id: The ID of the view. :return: The attachment as a readable stream. """ - with spans.ConnectorGetAttachment(attachment_id): + with spans.ConnectorGetAttachment(attachment_id, view_id): if attachment_id is None: logger.error( "AttachmentsOperations.get_attachment(): attachmentId is required", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.py new file mode 100644 index 00000000..e1607a46 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.py @@ -0,0 +1,31 @@ +from aiohttp.web import Request, Response + +from microsoft_agents.hosting.core.telemetry import ( + attributes, + SimpleSpanWrapper, +) + +class _RequestSpanWrapper(SimpleSpanWrapper): + + def __init__(self, span_name: str): + """Initializes the RequestSpanWrapper.""" + super().__init__(span_name) + self._request: Request | None = None + self._response: Response | None = None + + def _get_request_attributes(self) -> dict[str, str]: + """Returns a dictionary of attributes related to the request to set on the span.""" + attr_dict = {} + if self._request is not None: + attr_dict[attributes.HTTP_METHOD] = self._request.method + if self._response is not None: + attr_dict[attributes.HTTP_STATUS_CODE] = self._response.status + attr_dict[attributes.OPERATION] = self._span_name + return attr_dict + + def share(self, request: Request | None = None, response: Response | None = None) -> None: + """Shares the span by setting the request and response attributes and ending the span. This should be called when the client operation is complete and a response is being sent back to the caller.""" + if request is not None: + self._request = request + if response is not None: + self._response = response diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py index 1b4b04c8..47110640 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py @@ -5,15 +5,17 @@ from opentelemetry.trace import Span +from aiohttp.web import Request, Response + from microsoft_agents.hosting.core.telemetry import ( attributes, - SimpleSpanWrapper, AttributeMap, ) +from ._request_span_wrapper import _RequestSpanWrapper from . import metrics, constants -class _ConnectorSpanWrapper(SimpleSpanWrapper): +class _ConnectorSpanWrapper(_RequestSpanWrapper): """Base SpanWrapper for spans related to connector operations in the adapter. This is meant to be a base class for spans related to connector operations, such as creating a connector client or creating a user token, and can be used to share common functionality and attributes related to connector operations.""" def __init__( @@ -30,8 +32,9 @@ def __init__( def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span is ended. This is used to record metrics for the connector operation based on the outcome of the span.""" - metrics.connector_request_duration.record(duration) - metrics.connector_request_total.add(1) + attrs = self._get_request_attributes() + metrics.connector_request_duration.record(duration, attributes=attrs) + metrics.connector_request_count.add(1, attributes=attrs) def _get_attributes(self) -> dict[str, str]: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the connector operation being performed. @@ -45,7 +48,6 @@ def _get_attributes(self) -> dict[str, str]: attr_dict[attributes.ACTIVITY_ID] = self._activity_id return attr_dict - class ConnectorReplyToActivity(_ConnectorSpanWrapper): """Span for replying to an activity using the connector client in the adapter.""" @@ -143,12 +145,14 @@ def _get_attributes(self) -> AttributeMap: class ConnectorGetAttachment(_ConnectorSpanWrapper): """Span for getting an attachment using the connector client in the adapter.""" - def __init__(self, attachment_id: str): + def __init__(self, attachment_id: str, view_id: str): """Initializes the ConnectorGetAttachment span.""" super().__init__(constants.SPAN_GET_ATTACHMENT) self._attachment_id = attachment_id + self._view_id = view_id def _get_attributes(self) -> AttributeMap: attr_dict = super()._get_attributes() attr_dict[attributes.ATTACHMENT_ID] = self._attachment_id + attr_dict[attributes.VIEW_ID] = self._view_id return attr_dict diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py index 29d88a8f..5623511f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py @@ -22,5 +22,9 @@ SPAN_GET_TOKEN_STATUS = "agents.user_token_client.get_token_status" SPAN_GET_AAD_TOKENS = "agents.user_token_client.get_aad_tokens" -METRIC_REQUESTS_TOTAL = "agents.connector.requests" -METRIC_REQUEST_DURATION = "agents.connector.request.duration" +METRIC_CONNECTOR_REQUEST_COUNT = "agents.connector.request.count" +METRIC_CONNECTOR_REQUEST_DURATION = "agents.connector.request.duration" + +METRIC_USER_TOKEN_CLIENT_REQUEST_COUNT = "agents.user_token_client.request.count" +METRIC_USER_TOKEN_CLIENT_REQUEST_DURATION = "agents.user_token_client.request.duration" + diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py index 56230642..0f17e60a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py @@ -4,14 +4,26 @@ from microsoft_agents.hosting.core.telemetry import agents_telemetry from . import constants -connector_request_total = agents_telemetry.meter.create_counter( - constants.METRIC_REQUESTS_TOTAL, +connector_request_count = agents_telemetry.meter.create_counter( + constants.METRIC_CONNECTOR_REQUEST_COUNT, "request", - description="Total number of connector requests made by the agent", + description="Total number of connector requests made by the ConnectorClient", ) connector_request_duration = agents_telemetry.meter.create_histogram( - constants.METRIC_REQUEST_DURATION, + constants.METRIC_CONNECTOR_REQUEST_DURATION, "ms", description="Duration of connector requests in milliseconds", ) + +user_token_client_request_count = agents_telemetry.meter.create_counter( + constants.METRIC_USER_TOKEN_CLIENT_REQUEST_COUNT, + "request", + description="Total number of user token client requests made by the UserTokenClient", +) + +user_token_client_request_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_USER_TOKEN_CLIENT_REQUEST_DURATION, + "ms", + description="Duration of user token client requests in milliseconds", +) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py index bcfaadfb..f62dbac8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py @@ -5,15 +5,11 @@ from opentelemetry.trace import Span -from microsoft_agents.hosting.core.telemetry import ( - attributes, - SimpleSpanWrapper, - AttributeMap, -) +from microsoft_agents.hosting.core.telemetry import attributes +from ._request_span_wrapper import _RequestSpanWrapper from . import metrics, constants - -class _UserTokenClientSpanWrapper(SimpleSpanWrapper): +class _UserTokenClientSpanWrapper(_RequestSpanWrapper): """Base SpanWrapper for spans related to user token client operations in the adapter. This is meant to be a base class for spans related to user token client operations, such as creating a user token, and can be used to share common functionality and attributes related to user token client operations.""" def __init__( @@ -30,6 +26,12 @@ def __init__( self._user_id = user_id or attributes.UNKNOWN self._channel_id = channel_id or attributes + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This is used to record metrics for the user token client operation based on the outcome of the span.""" + attrs = self._get_request_attributes() + metrics.user_token_client_request_duration.record(duration, attributes=attrs) + metrics.user_token_client_request_count.add(1, attributes=attrs) + def _get_attributes(self) -> dict[str, str]: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the user token client operation being performed. 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 bf75d2aa..e685a1ce 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 @@ -28,45 +28,42 @@ async def read( if not target_cls: raise ValueError("Storage.read(): target_cls cannot be None.") - with spans.StorageRead(len(keys)): - 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 + 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 async def write(self, changes: dict[str, StoreItem]): if not changes: raise ValueError("MemoryStorage.write(): changes cannot be None") - with spans.StorageWrite(len(changes)): - 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 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() async def delete(self, keys: list[str]): if not keys: raise ValueError("Storage.delete(): Keys are required when deleting.") - with spans.StorageDelete(len(keys)): - 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 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] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py index 7ce92274..fea0ccd9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py @@ -22,7 +22,9 @@ def __init__(self, span_name: str, *, key_count: int): def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span is ended. This is used to record metrics for the storage operation based on the outcome of the span.""" - metrics.storage_operation_duration.record(duration) + metrics.storage_operation_duration.record(duration, attributes={ + attributes.STORAGE_OPERATION: self._span_name, + }) metrics.storage_operation_total.add(1) def _get_attributes(self) -> dict[str, str | int]: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py index 108e2d63..4a502b89 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py @@ -2,11 +2,16 @@ # Licensed under the MIT License. SPAN_PROCESS = "agents.adapter.process" -SPAN_SEND_ACTIVITIES = "agents.adapter.sendActivities" -SPAN_UPDATE_ACTIVITY = "agents.adapter.updateActivity" -SPAN_DELETE_ACTIVITY = "agents.adapter.deleteActivity" -SPAN_CONTINUE_CONVERSATION = "agents.adapter.continueConversation" -SPAN_CREATE_CONNECTOR_CLIENT = "agents.adapter.createConnectorClient" -SPAN_CREATE_USER_TOKEN_CLIENT = "agents.adapter.createUserTokenClient" +SPAN_SEND_ACTIVITIES = "agents.adapter.send_activities" +SPAN_UPDATE_ACTIVITY = "agents.adapter.update_activity" +SPAN_DELETE_ACTIVITY = "agents.adapter.delete_activity" +SPAN_CONTINUE_CONVERSATION = "agents.adapter.continue_conversation" +SPAN_CREATE_CONNECTOR_CLIENT = "agents.adapter.create_connector_client" +SPAN_CREATE_USER_TOKEN_CLIENT = "agents.adapter.create_user_token_client" METRIC_ADAPTER_PROCESS_DURATION = "agents.adapter.process.duration" + +METRIC_ACTIVITIES_RECEIVED = "agents.activities.received" +METRIC_ACTIVITIES_SENT = "agents.activities.sent" +METRIC_ACTIVITIES_UPDATED = "agents.activities.updated" +METRIC_ACTIVITIES_DELETED = "agents.activities.deleted" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py index 18101f10..ac44974d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py @@ -9,3 +9,23 @@ "ms", description="Duration of adapter processing in milliseconds", ) + +activities_received = agents_telemetry.meter.create_counter( + constants.METRIC_ACTIVITIES_RECEIVED, + description="Number of activities received by the adapter", +) + +activities_sent = agents_telemetry.meter.create_counter( + constants.METRIC_ACTIVITIES_SENT, + description="Number of activities sent by the adapter", +) + +activities_updated = agents_telemetry.meter.create_counter( + constants.METRIC_ACTIVITIES_UPDATED, + description="Number of activities updated by the adapter", +) + +activities_deleted = agents_telemetry.meter.create_counter( + constants.METRIC_ACTIVITIES_DELETED, + description="Number of activities deleted by the adapter", +) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py index 71924336..4139b4e4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py @@ -27,7 +27,12 @@ def __init__(self, activity: Activity): def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span is ended. This is used to record metrics for the adapter processing based on the outcome of the span.""" - metrics.adapter_process_duration.record(duration) + attrs = { + attributes.ACTIVITY_TYPE: self._activity.type, + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id or attributes.UNKNOWN, + } + metrics.adapter_process_duration.record(duration, attributes=attrs) + metrics.activities_received.add(1, attributes=attrs) def _get_attributes(self) -> AttributeMap: return { @@ -48,6 +53,13 @@ def __init__(self, activities: list[Activity]): super().__init__(constants.SPAN_SEND_ACTIVITIES) self._activities = activities + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + for act in self._activities: + metrics.activities_sent.add(1, attributes={ + attributes.ACTIVITY_TYPE: act.type, + attributes.ACTIVITY_CHANNEL_ID: act.channel_id or attributes.UNKNOWN, + }) + def _get_attributes(self) -> AttributeMap: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activities being sent.""" return { @@ -68,6 +80,11 @@ def __init__(self, activity: Activity): super().__init__(constants.SPAN_UPDATE_ACTIVITY) self._activity = activity + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + metrics.activities_updated.add(1, attributes={ + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id or attributes.UNKNOWN, + }) + def _get_attributes(self) -> AttributeMap: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activity being updated.""" return { @@ -84,6 +101,11 @@ def __init__(self, activity: Activity): super().__init__(constants.SPAN_DELETE_ACTIVITY) self._activity = activity + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + metrics.activities_deleted.add(1, attributes={ + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id or attributes.UNKNOWN, + }) + def _get_attributes(self) -> AttributeMap: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activity being deleted.""" return { diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py index 99ca3eef..379a443a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py @@ -16,26 +16,35 @@ ATTACHMENT_COUNT = "activity.attachments.count" AUTH_HANDLER_ID = "auth.handler.id" +AUTH_METHOD = "auth.method" AUTH_SCOPES = "auth.scopes" -AUTH_TYPE = "auth.method" +AUTH_SUCCESS = "auth.success" CONNECTION_NAME = "auth.connection.name" CONVERSATION_ID = "activity.conversation.id" +HTTP_METHOD = "http.method" +HTTP_STATUS_CODE = "http.status_code" + IS_AGENTIC = "is_agentic_request" KEY_COUNT = "storage.keys.count" +OPERATION = "operation" + ROUTE_AUTHORIZED = "route.authorized" ROUTE_IS_INVOKE = "route.is_invoke" ROUTE_IS_AGENTIC = "route.is_agentic" ROUTE_MATCHED = "route.matched" SERVICE_URL = "service_url" +STORAGE_OPERATION = "storage.operation" TOKEN_SERVICE_ENDPOINT = "agents.token_service.endpoint" USER_ID = "user.id" +VIEW_ID = "view.id" + # for missing values UNKNOWN = "unknown" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py index acda2bc0..9f752d24 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py @@ -49,7 +49,8 @@ def _log_lifespan_error(desc: str) -> None: ) logger.warning("Description: %s", desc) - def __enter__(self) -> BaseSpanWrapper: + # TODO -> Add Self annotation once 3.11 is the minimum supported version + def __enter__(self): """Starts the BaseSpanWrapper and returns the BaseSpanWrapper instance for chaining. This method should check if the BaseSpanWrapper is already active and log a warning if an attempt is made to start an already active BaseSpanWrapper, to help identify potential issues with BaseSpanWrapper lifecycle management.""" if self._active: BaseSpanWrapper._log_lifespan_error( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py index 03fe2db0..d9e0e76d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py @@ -12,38 +12,15 @@ ) from . import constants +class TurnContextSendActivity(SimpleSpanWrapper): + """Span wrapper for sending an activity within a turn context.""" -class _TurnContextSpanWrapper(SimpleSpanWrapper): - """Base span wrapper for TurnContext operations""" - - def __init__(self, span_name: str, turn_context: TurnContextProtocol): - """Initializes the span wrapper with the given span name and turn context.""" - super().__init__(span_name) + def __init__(self, turn_context: TurnContextProtocol): + super().__init__(constants.SPAN_TURN_SEND_ACTIVITY) self._turn_context = turn_context def _get_attributes(self) -> AttributeMap: activity = self._turn_context.activity return { attributes.CONVERSATION_ID: get_conversation_id(activity), - } - - -class TurnContextSendActivity(_TurnContextSpanWrapper): - """Span wrapper for sending an activity within a turn context.""" - - def __init__(self, turn_context: TurnContextProtocol): - super().__init__(constants.SPAN_TURN_SEND_ACTIVITY, turn_context) - - -class TurnContextUpdateActivity(_TurnContextSpanWrapper): - """Span wrapper for updating an activity within a turn context.""" - - def __init__(self, turn_context: TurnContextProtocol): - super().__init__(constants.SPAN_TURN_UPDATE_ACTIVITY, turn_context) - - -class TurnContextDeleteActivity(_TurnContextSpanWrapper): - """Span wrapper for deleting an activity within a turn context.""" - - def __init__(self, turn_context: TurnContextProtocol): - super().__init__(constants.SPAN_TURN_DELETE_ACTIVITY, turn_context) + } \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index daf6231b..a91c70a5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -271,14 +271,13 @@ async def update_activity(self, activity: Activity): :param activity: :return: """ - with spans.TurnContextUpdateActivity(self): - reference = self.activity.get_conversation_reference() + reference = self.activity.get_conversation_reference() - return await self._emit( - self._on_update_activity, - TurnContext.apply_conversation_reference(activity, reference), - self.adapter.update_activity(self, activity), - ) + return await self._emit( + self._on_update_activity, + TurnContext.apply_conversation_reference(activity, reference), + self.adapter.update_activity(self, activity), + ) async def delete_activity(self, id_or_reference: str | ConversationReference): """ @@ -286,17 +285,16 @@ async def delete_activity(self, id_or_reference: str | ConversationReference): :param id_or_reference: :return: """ - with spans.TurnContextDeleteActivity(self): - if isinstance(id_or_reference, str): - reference = self.activity.get_conversation_reference() - reference.activity_id = id_or_reference - else: - reference = id_or_reference - return await self._emit( - self._on_delete_activity, - reference, - self.adapter.delete_activity(self, reference), - ) + if isinstance(id_or_reference, str): + reference = self.activity.get_conversation_reference() + reference.activity_id = id_or_reference + else: + reference = id_or_reference + return await self._emit( + self._on_delete_activity, + reference, + self.adapter.delete_activity(self, reference), + ) def on_send_activities(self, handler) -> "TurnContext": """ From 866f885699c7118166b7c07e64fa40337461112f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 24 Mar 2026 15:43:58 -0700 Subject: [PATCH 40/55] Improved test coverage of OTEL layer --- .../hosting/core/app/agent_application.py | 13 +- .../hosting/core/app/telemetry/spans.py | 10 +- .../core/authorization/telemetry/constants.py | 2 +- .../core/authorization/telemetry/metrics.py | 2 +- .../core/authorization/telemetry/spans.py | 15 +- .../core/connector/client/connector_client.py | 2 +- .../telemetry/_request_span_wrapper.py | 5 +- ...tor_client_spans.py => connector_spans.py} | 3 + .../core/connector/telemetry/constants.py | 1 - .../core/connector/telemetry/metrics.py | 2 +- .../telemetry/user_token_client_spans.py | 1 + .../hosting/core/storage/telemetry/spans.py | 9 +- .../core/telemetry/adapter/constants.py | 2 +- .../hosting/core/telemetry/adapter/metrics.py | 2 +- .../hosting/core/telemetry/adapter/spans.py | 35 ++- .../core/telemetry/core/_agents_telemetry.py | 10 - .../core/telemetry/turn_context/constants.py | 13 +- .../core/telemetry/turn_context/metrics.py | 0 .../core/telemetry/turn_context/spans.py | 3 +- tests/_common/fixtures/telemetry.py | 46 ++++ tests/_common/telemetry_utils.py | 33 +++ .../telemetry/test_agents_telemetry.py | 191 ++++--------- .../telemetry/test_simple_span_wrapper.py | 254 ++++++++++++++++++ tests/hosting_core/telemetry/test_spans.py | 105 ++++++++ 24 files changed, 562 insertions(+), 197 deletions(-) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/{connector_client_spans.py => connector_spans.py} (99%) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/metrics.py create mode 100644 tests/_common/fixtures/telemetry.py create mode 100644 tests/_common/telemetry_utils.py create mode 100644 tests/hosting_core/telemetry/test_simple_span_wrapper.py create mode 100644 tests/hosting_core/telemetry/test_spans.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 1e24e7a6..94265144 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 @@ -819,8 +819,13 @@ async def _run_after_turn_middleware(self, context: TurnContext, state: StateT): return False return True - async def _on_activity(self, context: TurnContext, state: StateT, on_turn_span: spans.AppOnTurn | None = None): - + async def _on_activity( + self, + context: TurnContext, + state: StateT, + on_turn_span: spans.AppOnTurn | None = None, + ): + route_matched: bool = False route_authorized: bool = False @@ -851,7 +856,9 @@ async def _on_activity(self, context: TurnContext, state: StateT, on_turn_span: f"No route found for activity type: {context.activity.type} with text: {context.activity.text}" ) if on_turn_span is not None: - on_turn_span.share(route_authorized=route_authorized, route_matched=route_matched) + on_turn_span.share( + route_authorized=route_authorized, route_matched=route_matched + ) async def _start_long_running_call( self, context: TurnContext, func: Callable[[TurnContext], Awaitable] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py index 8ef4dcec..618a8964 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py @@ -11,7 +11,7 @@ SimpleSpanWrapper, get_conversation_id, ) -from microsoft_agents.hosting.core._routes import _Route +from microsoft_agents.hosting.core.app._routes import _Route from . import constants, metrics @@ -30,8 +30,11 @@ def _callback(self, span: Span, duration: float, error: Exception | None) -> Non """Callback function that is called when the span is ended. This is used to record metrics for the app run based on the outcome of the span.""" attrs = { attributes.ACTIVITY_TYPE: self._turn_context.activity.type, - attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id or attributes.UNKNOWN, - attributes.CONVERSATION_ID: get_conversation_id(self._turn_context.activity), + attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id + or attributes.UNKNOWN, + attributes.CONVERSATION_ID: get_conversation_id( + self._turn_context.activity + ), } if error is None: metrics.turn_count.add(1, attributes=attrs) @@ -76,6 +79,7 @@ def _get_attributes(self) -> AttributeMap: attributes.ROUTE_IS_AGENTIC: self._is_agentic, } + class AppBeforeTurn(SimpleSpanWrapper): """Span for the logic that happens before the main turn processing. This is meant to capture telemetry for the pre-processing logic of the app run, and can be used to identify issues in the early stages of the app run before the main processing logic is invoked.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py index 12a5e2ff..277148c4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py @@ -16,4 +16,4 @@ AUTH_METHOD_OBO = "obo" AUTH_METHOD_AGENTIC_INSTANCE = "agentic_instance" -AUTH_METHOD_AGENTIC_USER = "agentic_user" \ No newline at end of file +AUTH_METHOD_AGENTIC_USER = "agentic_user" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py index 5b961a19..fd5d0156 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py @@ -14,4 +14,4 @@ constants.METRIC_AUTH_TOKEN_REQUEST_DURATION, "ms", description="Duration of auth token requests in milliseconds", -) \ No newline at end of file +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py index 1e397592..f0abfd4a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py @@ -13,6 +13,7 @@ ) from . import constants, metrics + class _AuthenticationSpanWrapper(SimpleSpanWrapper): """Base SpanWrapper for spans related to authentication operations. @@ -56,7 +57,9 @@ class AcquireTokenOnBehalfOf(_AuthenticationSpanWrapper): def __init__(self, scopes: list[str]): """Initializes the AcquireTokenOnBehalfOf span with the specified authentication scope.""" - super().__init__(constants.SPAN_ACQUIRE_TOKEN_ON_BEHALF_OF, constants.AUTH_METHOD_OBO) + super().__init__( + constants.SPAN_ACQUIRE_TOKEN_ON_BEHALF_OF, constants.AUTH_METHOD_OBO + ) self._scopes = scopes def _get_attributes(self) -> AttributeMap: @@ -65,12 +68,16 @@ def _get_attributes(self) -> AttributeMap: attributes.AUTH_SCOPES: format_scopes(self._scopes), } + class GetAgenticInstanceToken(_AuthenticationSpanWrapper): """Span wrapper for the operation of retrieving an agentic instance token.""" def __init__(self, agentic_instance_id: str): """Initializes the GetAgenticInstanceToken span with the specified agentic instance ID.""" - super().__init__(constants.SPAN_GET_AGENTIC_INSTANCE_TOKEN, constants.AUTH_METHOD_AGENTIC_INSTANCE) + super().__init__( + constants.SPAN_GET_AGENTIC_INSTANCE_TOKEN, + constants.AUTH_METHOD_AGENTIC_INSTANCE, + ) self._agentic_instance_id = agentic_instance_id def _get_attributes(self) -> AttributeMap: @@ -87,7 +94,9 @@ def __init__( self, agentic_instance_id: str, agentic_user_id: str, scopes: list[str] ): """Initializes the GetAgenticUserToken span with the specified agentic instance ID, user ID, and authentication scopes.""" - super().__init__(constants.SPAN_GET_AGENTIC_USER_TOKEN, constants.AUTH_METHOD_AGENTIC_USER) + super().__init__( + constants.SPAN_GET_AGENTIC_USER_TOKEN, constants.AUTH_METHOD_AGENTIC_USER + ) self._agentic_instance_id = agentic_instance_id self._agentic_user_id = agentic_user_id self._scopes = scopes diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 3ea7be1e..1d12ebb4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -21,7 +21,7 @@ from ..attachments_base import AttachmentsBase from ..conversations_base import ConversationsBase from ..get_product_info import get_product_info -from ..telemetry import connector_client_spans as spans +from ..telemetry import connector_spans as spans logger = logging.getLogger(__name__) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.py index e1607a46..056251f5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.py @@ -5,6 +5,7 @@ SimpleSpanWrapper, ) + class _RequestSpanWrapper(SimpleSpanWrapper): def __init__(self, span_name: str): @@ -23,7 +24,9 @@ def _get_request_attributes(self) -> dict[str, str]: attr_dict[attributes.OPERATION] = self._span_name return attr_dict - def share(self, request: Request | None = None, response: Response | None = None) -> None: + def share( + self, request: Request | None = None, response: Response | None = None + ) -> None: """Shares the span by setting the request and response attributes and ending the span. This should be called when the client operation is complete and a response is being sent back to the caller.""" if request is not None: self._request = request diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_spans.py similarity index 99% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_spans.py index 47110640..5353ae9f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_client_spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_spans.py @@ -48,6 +48,7 @@ def _get_attributes(self) -> dict[str, str]: attr_dict[attributes.ACTIVITY_ID] = self._activity_id return attr_dict + class ConnectorReplyToActivity(_ConnectorSpanWrapper): """Span for replying to an activity using the connector client in the adapter.""" @@ -129,6 +130,7 @@ def __init__(self, conversation_id: str): constants.SPAN_UPLOAD_ATTACHMENT, conversation_id=conversation_id ) + class ConnectorGetAttachmentInfo(_ConnectorSpanWrapper): """Span for getting attachment info using the connector client in the adapter.""" @@ -142,6 +144,7 @@ def _get_attributes(self) -> AttributeMap: attr_dict[attributes.ATTACHMENT_ID] = self._attachment_id return attr_dict + class ConnectorGetAttachment(_ConnectorSpanWrapper): """Span for getting an attachment using the connector client in the adapter.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py index 5623511f..3b3a9027 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py @@ -27,4 +27,3 @@ METRIC_USER_TOKEN_CLIENT_REQUEST_COUNT = "agents.user_token_client.request.count" METRIC_USER_TOKEN_CLIENT_REQUEST_DURATION = "agents.user_token_client.request.duration" - diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py index 0f17e60a..47d61881 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py @@ -26,4 +26,4 @@ constants.METRIC_USER_TOKEN_CLIENT_REQUEST_DURATION, "ms", description="Duration of user token client requests in milliseconds", -) \ No newline at end of file +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py index f62dbac8..8ee420a1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py @@ -9,6 +9,7 @@ from ._request_span_wrapper import _RequestSpanWrapper from . import metrics, constants + class _UserTokenClientSpanWrapper(_RequestSpanWrapper): """Base SpanWrapper for spans related to user token client operations in the adapter. This is meant to be a base class for spans related to user token client operations, such as creating a user token, and can be used to share common functionality and attributes related to user token client operations.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py index fea0ccd9..49baaf9d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py @@ -22,9 +22,12 @@ def __init__(self, span_name: str, *, key_count: int): def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span is ended. This is used to record metrics for the storage operation based on the outcome of the span.""" - metrics.storage_operation_duration.record(duration, attributes={ - attributes.STORAGE_OPERATION: self._span_name, - }) + metrics.storage_operation_duration.record( + duration, + attributes={ + attributes.STORAGE_OPERATION: self._span_name, + }, + ) metrics.storage_operation_total.add(1) def _get_attributes(self) -> dict[str, str | int]: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py index 4a502b89..7ff5cfcd 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py @@ -14,4 +14,4 @@ METRIC_ACTIVITIES_RECEIVED = "agents.activities.received" METRIC_ACTIVITIES_SENT = "agents.activities.sent" METRIC_ACTIVITIES_UPDATED = "agents.activities.updated" -METRIC_ACTIVITIES_DELETED = "agents.activities.deleted" \ No newline at end of file +METRIC_ACTIVITIES_DELETED = "agents.activities.deleted" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py index ac44974d..0659a8d5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py @@ -28,4 +28,4 @@ activities_deleted = agents_telemetry.meter.create_counter( constants.METRIC_ACTIVITIES_DELETED, description="Number of activities deleted by the adapter", -) \ No newline at end of file +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py index 4139b4e4..b3c14bf4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py @@ -29,7 +29,8 @@ def _callback(self, span: Span, duration: float, error: Exception | None) -> Non """Callback function that is called when the span is ended. This is used to record metrics for the adapter processing based on the outcome of the span.""" attrs = { attributes.ACTIVITY_TYPE: self._activity.type, - attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id or attributes.UNKNOWN, + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id + or attributes.UNKNOWN, } metrics.adapter_process_duration.record(duration, attributes=attrs) metrics.activities_received.add(1, attributes=attrs) @@ -55,10 +56,14 @@ def __init__(self, activities: list[Activity]): def _callback(self, span: Span, duration: float, error: Exception | None) -> None: for act in self._activities: - metrics.activities_sent.add(1, attributes={ - attributes.ACTIVITY_TYPE: act.type, - attributes.ACTIVITY_CHANNEL_ID: act.channel_id or attributes.UNKNOWN, - }) + metrics.activities_sent.add( + 1, + attributes={ + attributes.ACTIVITY_TYPE: act.type, + attributes.ACTIVITY_CHANNEL_ID: act.channel_id + or attributes.UNKNOWN, + }, + ) def _get_attributes(self) -> AttributeMap: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activities being sent.""" @@ -81,9 +86,13 @@ def __init__(self, activity: Activity): self._activity = activity def _callback(self, span: Span, duration: float, error: Exception | None) -> None: - metrics.activities_updated.add(1, attributes={ - attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id or attributes.UNKNOWN, - }) + metrics.activities_updated.add( + 1, + attributes={ + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id + or attributes.UNKNOWN, + }, + ) def _get_attributes(self) -> AttributeMap: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activity being updated.""" @@ -102,9 +111,13 @@ def __init__(self, activity: Activity): self._activity = activity def _callback(self, span: Span, duration: float, error: Exception | None) -> None: - metrics.activities_deleted.add(1, attributes={ - attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id or attributes.UNKNOWN, - }) + metrics.activities_deleted.add( + 1, + attributes={ + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id + or attributes.UNKNOWN, + }, + ) def _get_attributes(self) -> AttributeMap: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activity being deleted.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py index acfee61d..f92686a3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py @@ -61,16 +61,6 @@ def _extract_attributes_from_context( ) return attributes - def set_attributes_from_context( - self, span: Span, turn_context: TurnContextProtocol - ) -> None: - """Extracts attributes from the TurnContext and sets them on the given span - - :param span: The OpenTelemetry span to set attributes on - :param turn_context: The TurnContext to extract attributes from - """ - span.set_attributes(self._extract_attributes_from_context(turn_context)) - @contextmanager def start_as_current_span( self, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/constants.py index c94dee7c..b8155e78 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/constants.py @@ -1,11 +1,4 @@ -# Span operation names +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. -SPAN_TURN_SEND_ACTIVITY = "agents.turn.sendActivity" -SPAN_TURN_UPDATE_ACTIVITY = "agents.turn.updateActivity" -SPAN_TURN_DELETE_ACTIVITY = "agents.turn.deleteActivity" - - -METRIC_ACTIVITIES_RECEIVED = "agents.activities.received" -METRIC_ACTIVITIES_SENT = "agents.activities.sent" -METRIC_ACTIVITIES_UPDATED = "agents.activities.updated" -METRIC_ACTIVITIES_DELETED = "agents.activities.deleted" +SPAN_TURN_SEND_ACTIVITY = "agents.turn.send_activity" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/metrics.py deleted file mode 100644 index e69de29b..00000000 diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py index d9e0e76d..8655731e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py @@ -12,6 +12,7 @@ ) from . import constants + class TurnContextSendActivity(SimpleSpanWrapper): """Span wrapper for sending an activity within a turn context.""" @@ -23,4 +24,4 @@ def _get_attributes(self) -> AttributeMap: activity = self._turn_context.activity return { attributes.CONVERSATION_ID: get_conversation_id(activity), - } \ No newline at end of file + } diff --git a/tests/_common/fixtures/telemetry.py b/tests/_common/fixtures/telemetry.py new file mode 100644 index 00000000..310f7cc5 --- /dev/null +++ b/tests/_common/fixtures/telemetry.py @@ -0,0 +1,46 @@ +import pytest + +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 + +@pytest.fixture(scope="module") +def test_telemetry(): + """Set up fresh in-memory exporter for testing.""" + exporter = InMemorySpanExporter() + 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, metric_reader + + exporter.clear() + 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() \ No newline at end of file diff --git a/tests/_common/telemetry_utils.py b/tests/_common/telemetry_utils.py new file mode 100644 index 00000000..843c29d4 --- /dev/null +++ b/tests/_common/telemetry_utils.py @@ -0,0 +1,33 @@ +def _find_metric(metrics_data, metric_name): + for resource_metric in metrics_data.resource_metrics: + for scope_metric in resource_metric.scope_metrics: + for metric in scope_metric.metrics: + if metric.name == metric_name: + return metric + return None + + +def _sum_counter(metric, attribute_filter=None): + if metric is None: + return 0 + total = 0 + for point in metric.data.data_points: + if attribute_filter is None or all( + point.attributes.get(key) == value + for key, value in attribute_filter.items() + ): + total += point.value + return total + + +def _sum_hist_count(metric, attribute_filter=None): + if metric is None: + return 0 + total = 0 + for point in metric.data.data_points: + if attribute_filter is None or all( + point.attributes.get(key) == value + for key, value in attribute_filter.items() + ): + total += point.count + return total \ No newline at end of file diff --git a/tests/hosting_core/telemetry/test_agents_telemetry.py b/tests/hosting_core/telemetry/test_agents_telemetry.py index f21b5f92..70cd3ace 100644 --- a/tests/hosting_core/telemetry/test_agents_telemetry.py +++ b/tests/hosting_core/telemetry/test_agents_telemetry.py @@ -1,139 +1,36 @@ -import pytest -from types import SimpleNamespace +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 tests._common.fixtures.telemetry import ( # unused imports are needed for fixtures + test_telemetry, + test_exporter, + test_metric_reader, + clear +) -from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.core.telemetry import ( - agents_telemetry, - constants, - spans as _spans, + agents_telemetry, + SERVICE_NAME, + SERVICE_VERSION, ) -@pytest.fixture(scope="module") -def test_telemetry(): - """Set up fresh in-memory exporter for testing.""" - exporter = InMemorySpanExporter() - 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, metric_reader - - exporter.clear() - 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() - - -def _build_turn_context(mocker): - activity = SimpleNamespace( - type="message", - id="activity-1", - from_property=SimpleNamespace(id="user-1"), - recipient=SimpleNamespace(id="bot-1"), - conversation=SimpleNamespace(id="conversation-1"), - channel_id="msteams", - text="Hello!", - ) - activity.is_agentic_request = lambda: False - - context = mocker.Mock(spec=TurnContext) - context.activity = activity - return context - - -def _find_metric(metrics_data, metric_name): - for resource_metric in metrics_data.resource_metrics: - for scope_metric in resource_metric.scope_metrics: - for metric in scope_metric.metrics: - if metric.name == metric_name: - return metric - return None - - -def _sum_counter(metric, attribute_filter=None): - if metric is None: - return 0 - total = 0 - for point in metric.data.data_points: - if attribute_filter is None or all( - point.attributes.get(key) == value - for key, value in attribute_filter.items() - ): - total += point.value - return total - - -def _sum_hist_count(metric, attribute_filter=None): - if metric is None: - return 0 - total = 0 - for point in metric.data.data_points: - if attribute_filter is None or all( - point.attributes.get(key) == value - for key, value in attribute_filter.items() - ): - total += point.count - return total - - -def test_start_as_current_span(mocker, test_exporter): +def test_start_as_current_span(test_exporter): """Test start_as_current_span creates a span with context attributes.""" - context = _build_turn_context(mocker) - with agents_telemetry.start_as_current_span("test_span", context): + with agents_telemetry.start_as_current_span("test_span"): pass spans = test_exporter.get_finished_spans() assert len(spans) == 1 assert spans[0].name == "test_span" + assert spans[0].resource.attributes["service.name"] == SERVICE_NAME + assert spans[0].resource.attributes["service.version"] == SERVICE_VERSION - attributes = spans[0].attributes - assert attributes["activity.type"] == "message" - assert attributes["agent.is_agentic"] is False - assert attributes["from.id"] == "user-1" - assert attributes["recipient.id"] == "bot-1" - assert attributes["conversation.id"] == "conversation-1" - assert attributes["channel_id"] == "msteams" - assert attributes["message.text.length"] == 6 - -def test_start_timed_span(mocker, test_exporter): - """Test start_timed_span records success status and callback payload.""" - context = _build_turn_context(mocker) +def test_start_as_current_span_with_callback(mocker, test_exporter): + """Test start_as_current_span records success status and callback payload.""" callback = mocker.Mock() - with agents_telemetry.start_timed_span( - "test_timed_span", - context, + with agents_telemetry.start_as_current_span( + "test_span", callback=callback, ): pass @@ -142,38 +39,42 @@ def test_start_timed_span(mocker, test_exporter): assert len(finished_spans) == 1 finished_span = finished_spans[0] - assert finished_span.name == "test_timed_span" + assert finished_span.name == "test_span" assert finished_span.status.status_code == trace.StatusCode.OK - completion_events = [ - event for event in finished_span.events if event.name == "test_timed_span completed" - ] - assert len(completion_events) == 1 - assert completion_events[0].attributes["duration_ms"] >= 0 - callback.assert_called_once() callback_span, duration_ms, callback_exception = callback.call_args.args - assert callback_span.name == "test_timed_span" + assert callback_span.name == "test_span" assert duration_ms >= 0 assert callback_exception is None +def test_start_as_current_span_with_callback_with_failure(mocker, test_exporter): + """Test start_as_current_span records failure status and callback payload.""" + callback = mocker.Mock() -def test_start_span_app_on_turn(mocker, test_exporter, test_metric_reader): - """Test agent_turn_operation records span and turn metrics.""" - context = _build_turn_context(mocker) - - with _spans.start_span_app_on_turn(context): - pass + exception_raised = False + try: + with agents_telemetry.start_as_current_span( + "test_span", + callback=callback, + ): + raise ValueError("Test exception") + except ValueError as ex: + exception_raised = True + assert str(ex) == "Test exception" - spans = test_exporter.get_finished_spans() - assert len(spans) == 1 - assert spans[0].name == constants.SPAN_APP_ON_TURN + assert exception_raised + + finished_spans = test_exporter.get_finished_spans() + assert len(finished_spans) == 1 - metric_data = test_metric_reader.get_metrics_data() - turn_total = _sum_counter(_find_metric(metric_data, constants.METRIC_TURN_TOTAL)) - turn_duration_count = _sum_hist_count( - _find_metric(metric_data, constants.METRIC_TURN_DURATION) - ) + finished_span = finished_spans[0] + assert finished_span.name == "test_span" + assert finished_span.status.status_code == trace.StatusCode.ERROR - assert turn_total == 1 - assert turn_duration_count == 1 + callback.assert_called_once() + callback_span, duration_ms, callback_exception = callback.call_args.args + assert callback_span.name == "test_span" + assert duration_ms >= 0 + assert callback_exception is not None + assert str(callback_exception) == "Test exception" \ No newline at end of file diff --git a/tests/hosting_core/telemetry/test_simple_span_wrapper.py b/tests/hosting_core/telemetry/test_simple_span_wrapper.py new file mode 100644 index 00000000..db674648 --- /dev/null +++ b/tests/hosting_core/telemetry/test_simple_span_wrapper.py @@ -0,0 +1,254 @@ +import time + +import pytest +from types import SimpleNamespace + +from opentelemetry.trace import StatusCode + +from tests._common.fixtures.telemetry import ( # unused imports are needed for fixtures + test_telemetry, + test_exporter, + test_metric_reader, + clear +) +from tests._common.telemetry_utils import ( + _find_metric, + _sum_counter, + _sum_hist_count, +) + +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core.telemetry import ( + agents_telemetry, + SimpleSpanWrapper, +) + + +class MySpanWrapper(SimpleSpanWrapper): + """Subclass with custom attributes and a callback that records info on the span.""" + + def __init__(self, span_name): + super().__init__(span_name) + + def _callback(self, span, duration_ms, exception): + span.set_attribute("callback_called", True) + span.set_attribute("duration_ms", duration_ms) + if exception: + span.set_attribute("exception_message", str(exception)) + + def _get_attributes(self): + return {"custom_attribute": "custom_value"} + + +class MinimalSpanWrapper(SimpleSpanWrapper): + """Subclass that uses default (no-op) _callback and empty _get_attributes.""" + + def __init__(self, span_name): + super().__init__(span_name) + + +class TestSimpleSpanWrapper: + def test_simple_span_wrapper(self, test_exporter): + """Test that MySpanWrapper creates a span with the correct attributes and callback.""" + with MySpanWrapper("test_simple_span"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.name == "test_simple_span" + assert span.attributes["custom_attribute"] == "custom_value" + assert span.attributes["callback_called"] is True + assert span.attributes["duration_ms"] >= 0 + + def test_minimal_span_wrapper_creates_span(self, test_exporter): + """A subclass with no overrides still creates a valid span.""" + with MinimalSpanWrapper("minimal_span"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "minimal_span" + + def test_minimal_span_no_custom_attributes(self, test_exporter): + """Default _get_attributes returns empty dict, so no custom attributes are set.""" + with MinimalSpanWrapper("no_attrs"): + pass + + span = test_exporter.get_finished_spans()[0] + # The span should not have the custom_attribute key + assert "custom_attribute" not in (span.attributes or {}) + + def test_span_status_ok_on_success(self, test_exporter): + """Span status is OK when the body completes without error.""" + with MySpanWrapper("ok_span"): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.status.status_code == StatusCode.OK + + def test_span_status_error_on_exception(self, test_exporter): + """Span status is ERROR and exception is re-raised when body raises.""" + with pytest.raises(ValueError, match="boom"): + with MySpanWrapper("err_span"): + raise ValueError("boom") + + span = test_exporter.get_finished_spans()[0] + assert span.status.status_code == StatusCode.ERROR + + def test_callback_receives_exception(self, test_exporter): + """The callback receives the exception object when the body raises.""" + with pytest.raises(RuntimeError): + with MySpanWrapper("cb_err"): + raise RuntimeError("fail") + + span = test_exporter.get_finished_spans()[0] + assert span.attributes["callback_called"] is True + assert span.attributes["exception_message"] == "fail" + + def test_exception_is_recorded_on_span(self, test_exporter): + """record_exception is called, so the span events contain the exception.""" + with pytest.raises(TypeError): + with MySpanWrapper("rec_exc"): + raise TypeError("type error") + + span = test_exporter.get_finished_spans()[0] + exception_events = [e for e in span.events if e.name == "exception"] + assert len(exception_events) == 1 + assert "type error" in exception_events[0].attributes["exception.message"] + + def test_span_completion_event_on_success(self, test_exporter): + """A completion event is added on successful span execution.""" + with MinimalSpanWrapper("evt_span"): + pass + + span = test_exporter.get_finished_spans()[0] + completion_events = [e for e in span.events if "completed" in e.name] + assert len(completion_events) == 1 + assert completion_events[0].attributes["duration_ms"] >= 0 + + def test_no_completion_event_on_failure(self, test_exporter): + """No completion event is added when the span body raises.""" + with pytest.raises(Exception): + with MinimalSpanWrapper("no_evt"): + raise Exception("oops") + + span = test_exporter.get_finished_spans()[0] + completion_events = [e for e in span.events if "completed" in e.name] + assert len(completion_events) == 0 + + def test_duration_is_positive(self, test_exporter): + """The callback's duration_ms reflects actual elapsed time.""" + with MySpanWrapper("dur_span"): + time.sleep(0.05) + + span = test_exporter.get_finished_spans()[0] + assert span.attributes["duration_ms"] >= 40 # at least ~40ms + + def test_active_property_inside_context(self, test_exporter): + """The active property is True while the context manager is open.""" + wrapper = MySpanWrapper("active_test") + assert wrapper.active is False + + with wrapper: + assert wrapper.active is True + + assert wrapper.active is False + + def test_otel_span_accessible_inside_context(self, test_exporter): + """otel_span returns the underlying span while active.""" + wrapper = MinimalSpanWrapper("otel_access") + with wrapper: + otel_span = wrapper.otel_span + assert otel_span is not None + + def test_otel_span_raises_when_not_started(self): + """Accessing otel_span before start raises RuntimeError.""" + wrapper = MinimalSpanWrapper("not_started") + with pytest.raises(RuntimeError): + _ = wrapper.otel_span + + def test_start_end_manual_lifecycle(self, test_exporter): + """start() and end() can be used instead of the context manager.""" + wrapper = MySpanWrapper("manual_lifecycle") + wrapper.start() + assert wrapper.active is True + wrapper.end() + assert wrapper.active is False + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "manual_lifecycle" + + def test_multiple_sequential_spans(self, test_exporter): + """Multiple span wrappers used sequentially each create their own span.""" + with MySpanWrapper("seq_1"): + pass + with MySpanWrapper("seq_2"): + pass + with MinimalSpanWrapper("seq_3"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 3 + names = [s.name for s in spans] + assert "seq_1" in names + assert "seq_2" in names + assert "seq_3" in names + + def test_nested_span_wrappers(self, test_exporter): + """Nested span wrappers create parent-child span relationships.""" + with MySpanWrapper("parent"): + with MinimalSpanWrapper("child"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 2 + child_span = next(s for s in spans if s.name == "child") + parent_span = next(s for s in spans if s.name == "parent") + assert child_span.parent.span_id == parent_span.context.span_id + + def test_wrapper_reuse_after_end(self, test_exporter): + """A wrapper can be reused after it has been ended.""" + wrapper = MySpanWrapper("reuse") + + with wrapper: + pass + assert wrapper.active is False + + # Re-enter + with wrapper: + assert wrapper.active is True + assert wrapper.active is False + + spans = test_exporter.get_finished_spans() + assert len(spans) == 2 + assert all(s.name == "reuse" for s in spans) + + def test_custom_attributes_set_on_span(self, test_exporter): + """Custom attributes from _get_attributes appear on the finished span.""" + + class MultiAttrWrapper(SimpleSpanWrapper): + def __init__(self): + super().__init__("multi_attr") + + def _get_attributes(self): + return {"key_a": "val_a", "key_b": 42, "key_c": True} + + with MultiAttrWrapper(): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes["key_a"] == "val_a" + assert span.attributes["key_b"] == 42 + assert span.attributes["key_c"] is True + + def test_exception_propagates_unchanged(self, test_exporter): + """The original exception type and message are preserved after re-raise.""" + + class CustomError(Exception): + pass + + with pytest.raises(CustomError, match="custom msg"): + with MinimalSpanWrapper("propagate"): + raise CustomError("custom msg") \ No newline at end of file diff --git a/tests/hosting_core/telemetry/test_spans.py b/tests/hosting_core/telemetry/test_spans.py new file mode 100644 index 00000000..2108d402 --- /dev/null +++ b/tests/hosting_core/telemetry/test_spans.py @@ -0,0 +1,105 @@ +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core.telemetry import ( + agents_telemetry, + SimpleSpanWrapper, +) + +from tests._common.fixtures.telemetry import ( # unused imports are needed for fixtures + test_telemetry, + test_exporter, + test_metric_reader, + clear +) +from tests._common.telemetry_utils import ( + _find_metric, + _sum_counter, + _sum_hist_count, +) + +def _build_turn_context(mocker): + activity = SimpleNamespace( + type="message", + id="activity-1", + from_property=SimpleNamespace(id="user-1"), + recipient=SimpleNamespace(id="bot-1"), + conversation=SimpleNamespace(id="conversation-1"), + channel_id="msteams", + text="Hello!", + ) + activity.is_agentic_request = lambda: False + + context = mocker.Mock(spec=TurnContext) + context.activity = activity + return context + +def test_start_as_current_span(mocker, test_exporter): + """Test start_as_current_span creates a span with context attributes.""" + context = _build_turn_context(mocker) + + with agents_telemetry.start_as_current_span("test_span", context): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "test_span" + + attributes = spans[0].attributes + assert attributes["activity.type"] == "message" + assert attributes["agent.is_agentic"] is False + assert attributes["from.id"] == "user-1" + assert attributes["recipient.id"] == "bot-1" + assert attributes["conversation.id"] == "conversation-1" + assert attributes["channel_id"] == "msteams" + assert attributes["message.text.length"] == 6 + +def test_start_timed_span(mocker, test_exporter): + """Test start_timed_span records success status and callback payload.""" + context = _build_turn_context(mocker) + callback = mocker.Mock() + + with agents_telemetry.start_timed_span( + "test_timed_span", + context, + callback=callback, + ): + pass + + finished_spans = test_exporter.get_finished_spans() + assert len(finished_spans) == 1 + + finished_span = finished_spans[0] + assert finished_span.name == "test_timed_span" + assert finished_span.status.status_code == trace.StatusCode.OK + + completion_events = [ + event for event in finished_span.events if event.name == "test_timed_span completed" + ] + assert len(completion_events) == 1 + assert completion_events[0].attributes["duration_ms"] >= 0 + + callback.assert_called_once() + callback_span, duration_ms, callback_exception = callback.call_args.args + assert callback_span.name == "test_timed_span" + assert duration_ms >= 0 + assert callback_exception is None + + +def test_start_span_app_on_turn(mocker, test_exporter, test_metric_reader): + """Test agent_turn_operation records span and turn metrics.""" + context = _build_turn_context(mocker) + + with _spans.start_span_app_on_turn(context): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == core.SPAN_APP_ON_TURN + + metric_data = test_metric_reader.get_metrics_data() + turn_total = _sum_counter(_find_metric(metric_data, core.METRIC_TURN_TOTAL)) + turn_duration_count = _sum_hist_count( + _find_metric(metric_data, core.METRIC_TURN_DURATION) + ) + + assert turn_total == 1 + assert turn_duration_count == 1 From c0428d7638a50de531a6460cd12103d435a3fe17 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 24 Mar 2026 15:53:14 -0700 Subject: [PATCH 41/55] Adding share call in clients --- .../core/connector/client/connector_client.py | 96 ++++++++++++------- .../connector/client/user_token_client.py | 27 ++++-- .../telemetry/_request_span_wrapper.py | 23 +++-- 3 files changed, 91 insertions(+), 55 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 1d12ebb4..84ae14de 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -69,7 +69,7 @@ async def get_attachment_info(self, attachment_id: str) -> AttachmentInfo: :param attachment_id: The ID of the attachment. :return: The attachment information. """ - with spans.ConnectorGetAttachmentInfo(attachment_id=attachment_id): + with spans.ConnectorGetAttachmentInfo(attachment_id=attachment_id) as span: if attachment_id is None: raise ValueError("attachmentId is required") @@ -77,6 +77,8 @@ async def get_attachment_info(self, attachment_id: str) -> AttachmentInfo: logger.info("Getting attachment info for ID: %s", attachment_id) async with self.client.get(url) as response: + span.share(http_method="GET", status_code=response.status) + if response.status >= 300: logger.error( "Error getting attachment info: %s", @@ -87,6 +89,7 @@ async def get_attachment_info(self, attachment_id: str) -> AttachmentInfo: data = await response.json() return AttachmentInfo(**data) + async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: """ @@ -96,7 +99,7 @@ async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: :param view_id: The ID of the view. :return: The attachment as a readable stream. """ - with spans.ConnectorGetAttachment(attachment_id, view_id): + with spans.ConnectorGetAttachment(attachment_id, view_id) as span: if attachment_id is None: logger.error( "AttachmentsOperations.get_attachment(): attachmentId is required", @@ -116,6 +119,8 @@ async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: "Getting attachment for ID: %s, View ID: %s", attachment_id, view_id ) async with self.client.get(url) as response: + span.share(http_method="GET", status_code=response.status) + if response.status >= 300: logger.error( "Error getting attachment: %s", response.status, stack_info=True @@ -144,22 +149,25 @@ async def get_conversations( :param continuation_token: The continuation token for pagination. :return: A list of conversations. """ - params = ( - {"continuationToken": continuation_token} if continuation_token else None - ) + with spans.ConnectorGetConversations() as span: + params = ( + {"continuationToken": continuation_token} if continuation_token else None + ) - logger.info( - "Getting conversations with continuation token: %s", continuation_token - ) - async with self.client.get("v3/conversations", params=params) as response: - if response.status >= 300: - logger.error( - "Error getting conversations: %s", response.status, stack_info=True - ) - response.raise_for_status() + logger.info( + "Getting conversations with continuation token: %s", continuation_token + ) + async with self.client.get("v3/conversations", params=params) as response: + span.share(http_method="GET", status_code=response.status) - data = await response.json() - return ConversationsResult.model_validate(data) + if response.status >= 300: + logger.error( + "Error getting conversations: %s", response.status, stack_info=True + ) + response.raise_for_status() + + data = await response.json() + return ConversationsResult.model_validate(data) async def create_conversation( self, body: ConversationParameters @@ -170,20 +178,21 @@ async def create_conversation( :param body: The conversation parameters. :return: The conversation resource response. """ + with spans.ConnectorCreateConversation() as span: + logger.info("Creating a new conversation") + async with self.client.post( + "v3/conversations", + json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), + ) as response: + span.share(http_method="POST", status_code=response.status) + if response.status >= 300: + logger.error( + "Error creating conversation: %s", response.status, stack_info=True + ) + response.raise_for_status() - logger.info("Creating a new conversation") - async with self.client.post( - "v3/conversations", - json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), - ) as response: - if response.status >= 300: - logger.error( - "Error creating conversation: %s", response.status, stack_info=True - ) - response.raise_for_status() - - data = await response.json() - return ConversationResourceResponse.model_validate(data) + data = await response.json() + return ConversationResourceResponse.model_validate(data) async def reply_to_activity( self, conversation_id: str, activity_id: str, body: Activity @@ -196,7 +205,7 @@ async def reply_to_activity( :param body: The activity object. :return: The resource response. """ - with spans.ConnectorReplyToActivity(conversation_id, activity_id): + with spans.ConnectorReplyToActivity(conversation_id, activity_id) as span: if not conversation_id or not activity_id: logger.error( "ConversationsOperations.reply_to_activity(): conversationId and activityId are required", @@ -220,6 +229,8 @@ async def reply_to_activity( by_alias=True, exclude_unset=True, exclude_none=True, mode="json" ), ) as response: + span.share(http_method="POST", status_code=response.status) + result = await response.json() if response.content_length else {} if response.status >= 300: @@ -248,7 +259,7 @@ async def send_to_conversation( :param body: The activity object. :return: The resource response. """ - with spans.ConnectorSendToConversation(conversation_id, body.id): + with spans.ConnectorSendToConversation(conversation_id, body.id) as span: if not conversation_id: logger.error( "ConversationsOperations.sent_to_conversation(): conversationId is required", @@ -268,6 +279,8 @@ async def send_to_conversation( url, json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), ) as response: + span.share(http_method="POST", status_code=response.status) + if response.status >= 300: logger.error( "Error sending to conversation: %s", @@ -290,7 +303,7 @@ async def update_activity( :param body: The activity object. :return: The resource response. """ - with spans.ConnectorUpdateActivity(conversation_id, activity_id): + with spans.ConnectorUpdateActivity(conversation_id, activity_id) as span: if not conversation_id or not activity_id: logger.error( "ConversationsOperations.update_activity(): conversationId and activityId are required", @@ -311,6 +324,8 @@ async def update_activity( url, json=body.model_dump(by_alias=True, exclude_unset=True), ) as response: + span.share(http_method="PUT", status_code=response.status) + if response.status >= 300: logger.error( "Error updating activity: %s", response.status, stack_info=True @@ -327,7 +342,7 @@ async def delete_activity(self, conversation_id: str, activity_id: str) -> None: :param conversation_id: The ID of the conversation. :param activity_id: The ID of the activity. """ - with spans.ConnectorDeleteActivity(conversation_id, activity_id): + with spans.ConnectorDeleteActivity(conversation_id, activity_id) as span: if not conversation_id or not activity_id: logger.error( "ConversationsOperations.delete_activity(): conversationId and activityId are required", @@ -344,6 +359,8 @@ async def delete_activity(self, conversation_id: str, activity_id: str) -> None: conversation_id, ) async with self.client.delete(url) as response: + span.share(http_method="DELETE", status_code=response.status) + if response.status >= 300: logger.error( "Error deleting activity: %s", response.status, stack_info=True @@ -360,7 +377,7 @@ async def upload_attachment( :param body: The attachment data. :return: The resource response. """ - with spans.ConnectorUploadAttachment(conversation_id): + with spans.ConnectorUploadAttachment(conversation_id) as span: if conversation_id is None: logger.error( "ConversationsOperations.upload_attachment(): conversationId is required", @@ -385,6 +402,8 @@ async def upload_attachment( body.name, ) async with self.client.post(url, json=attachment_dict) as response: + span.share(http_method="POST", status_code=response.status) + if response.status >= 300: logger.error( "Error uploading attachment: %s", @@ -405,7 +424,8 @@ async def get_conversation_members( :param conversation_id: The ID of the conversation. :return: A list of members. """ - with spans.ConnectorGetConversationMembers(): + with spans.ConnectorGetConversationMembers() as span: + if not conversation_id: logger.error( "ConversationsOperations.get_conversation_members(): conversationId is required", @@ -420,6 +440,8 @@ async def get_conversation_members( "Getting conversation members for conversation: %s", conversation_id ) async with self.client.get(url) as response: + span.share(http_method="GET", status_code=response.status) + if response.status >= 300: logger.error( "Error getting conversation members: %s", @@ -441,7 +463,7 @@ async def get_conversation_member( :param member_id: The ID of the member. :return: The member. """ - with spans.ConnectorGetConversationMembers(): + with spans.ConnectorGetConversationMembers() as span: if not conversation_id or not member_id: logger.error( "ConversationsOperations.get_conversation_member(): conversationId and memberId are required", @@ -458,6 +480,8 @@ async def get_conversation_member( conversation_id, ) async with self.client.get(url) as response: + span.share(http_method="GET", status_code=response.status) + if response.status >= 300: logger.error( "Error getting conversation member: %s", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py index 5b11bde4..22759895 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py @@ -81,7 +81,7 @@ async def get_sign_in_resource( :param final_redirect: Final redirect URL. :return: The sign-in resource. """ - with spans.GetSignInResource(): + with spans.GetSignInResource() as span: params = {"state": state} if code_challenge: params["codeChallenge"] = code_challenge @@ -97,6 +97,7 @@ async def get_sign_in_resource( async with self.client.get( "api/botsignin/getSignInResource", params=params ) as response: + span.share(http_method="GET", status_code=response.status) if response.status >= 300: logger.error("Error getting sign-in resource: %s", response.status) response.raise_for_status() @@ -121,7 +122,7 @@ async def get_token( with spans.GetUserToken( connection_name=connection_name, user_id=user_id, channel_id=channel_id - ): + ) as span: params = {"userId": user_id, "connectionName": connection_name} if channel_id: @@ -133,6 +134,8 @@ async def get_token( async with self.client.get( "api/usertoken/GetToken", params=params ) as response: + span.share(http_method="GET", status_code=response.status) + if response.status >= 300: logger.error("Error getting token: %s", response.status) response.raise_for_status() @@ -154,7 +157,7 @@ async def _get_token_or_sign_in_resource( with spans.GetTokenOrSignInResource( connection_name=connection_name, user_id=user_id, channel_id=channel_id - ): + ) as span: params = { "userId": user_id, "connectionName": connection_name, @@ -169,6 +172,8 @@ async def _get_token_or_sign_in_resource( async with self.client.get( "/api/usertoken/GetTokenOrSignInResource", params=params ) as response: + span.share(http_method="GET", status_code=response.status) + if response.status != 200: logger.error( "Error getting token or sign-in resource: %s", response.status @@ -189,7 +194,7 @@ async def get_aad_tokens( with spans.GetAadTokens( connection_name=connection_name, user_id=user_id, channel_id=channel_id - ): + ) as span: params = {"userId": user_id, "connectionName": connection_name} if channel_id: @@ -199,6 +204,8 @@ async def get_aad_tokens( async with self.client.post( "api/usertoken/GetAadTokens", params=params, json=body ) as response: + span.share(http_method="POST", status_code=response.status) + if response.status >= 300: logger.error("Error getting AAD tokens: %s", response.status) response.raise_for_status() @@ -216,7 +223,7 @@ async def sign_out( with spans.SignOut( user_id=user_id, connection_name=connection_name, channel_id=channel_id - ): + ) as span: params = {"userId": user_id} if connection_name: @@ -228,6 +235,8 @@ async def sign_out( async with self.client.delete( "api/usertoken/SignOut", params=params ) as response: + span.share(http_method="DELETE", status_code=response.status) + if response.status >= 300: logger.error("Error signing out: %s", response.status) response.raise_for_status() @@ -240,7 +249,7 @@ async def get_token_status( ) -> list[TokenStatus]: """Get token status for a user.""" - with spans.GetTokenStatus(user_id=user_id, channel_id=channel_id): + with spans.GetTokenStatus(user_id=user_id, channel_id=channel_id) as span: params = {"userId": user_id} if channel_id: @@ -254,6 +263,8 @@ async def get_token_status( async with self.client.get( "api/usertoken/GetTokenStatus", params=params ) as response: + span.share(http_method="GET", status_code=response.status) + if response.status >= 300: logger.error("Error getting token status: %s", response.status) response.raise_for_status() @@ -272,7 +283,7 @@ async def exchange_token( with spans.ExchangeToken( connection_name=connection_name, user_id=user_id, channel_id=channel_id - ): + ) as span: params = { "userId": user_id, "connectionName": connection_name, @@ -283,6 +294,8 @@ async def exchange_token( async with self.client.post( "api/usertoken/exchange", params=params, json=body ) as response: + span.share(http_method="POST", status_code=response.status) + if response.status >= 300: logger.error("Error exchanging token: %s", response.status) response.raise_for_status() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.py index 056251f5..fa5e108b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.py @@ -1,4 +1,5 @@ -from aiohttp.web import Request, Response +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from microsoft_agents.hosting.core.telemetry import ( attributes, @@ -11,24 +12,22 @@ class _RequestSpanWrapper(SimpleSpanWrapper): def __init__(self, span_name: str): """Initializes the RequestSpanWrapper.""" super().__init__(span_name) - self._request: Request | None = None - self._response: Response | None = None + self._http_method: str | None = None + self._status_code: int | None = None def _get_request_attributes(self) -> dict[str, str]: """Returns a dictionary of attributes related to the request to set on the span.""" attr_dict = {} - if self._request is not None: - attr_dict[attributes.HTTP_METHOD] = self._request.method - if self._response is not None: - attr_dict[attributes.HTTP_STATUS_CODE] = self._response.status + if self._http_method is not None: + attr_dict[attributes.HTTP_METHOD] = self._http_method + if self._status_code is not None: + attr_dict[attributes.HTTP_STATUS_CODE] = self._status_code attr_dict[attributes.OPERATION] = self._span_name return attr_dict def share( - self, request: Request | None = None, response: Response | None = None + self, *, http_method: str | None = None, status_code: int | None = None ) -> None: """Shares the span by setting the request and response attributes and ending the span. This should be called when the client operation is complete and a response is being sent back to the caller.""" - if request is not None: - self._request = request - if response is not None: - self._response = response + self._http_method = http_method + self._status_code = status_code From 703cd0a526f96598bbe2678a63ee35853597204c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 25 Mar 2026 10:24:47 -0700 Subject: [PATCH 42/55] agents_telemetry tests --- tests/_common/telemetry_utils.py | 11 +++++-- .../telemetry/test_agents_telemetry.py | 29 +++++++++++++++++-- .../telemetry/test_simple_span_wrapper.py | 6 ++-- tests/hosting_core/telemetry/test_spans.py | 6 ++-- 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/tests/_common/telemetry_utils.py b/tests/_common/telemetry_utils.py index 843c29d4..70d3eb62 100644 --- a/tests/_common/telemetry_utils.py +++ b/tests/_common/telemetry_utils.py @@ -1,4 +1,9 @@ -def _find_metric(metrics_data, metric_name): +def find_metric(metrics_data, metric_name): + """Helper function to find a metric by name in the collected metrics data. + + Usage: + metric = find_metric(metrics_data, "my_metric_name") + """ for resource_metric in metrics_data.resource_metrics: for scope_metric in resource_metric.scope_metrics: for metric in scope_metric.metrics: @@ -7,7 +12,7 @@ def _find_metric(metrics_data, metric_name): return None -def _sum_counter(metric, attribute_filter=None): +def sum_counter(metric, attribute_filter=None): if metric is None: return 0 total = 0 @@ -20,7 +25,7 @@ def _sum_counter(metric, attribute_filter=None): return total -def _sum_hist_count(metric, attribute_filter=None): +def sum_hist_count(metric, attribute_filter=None): if metric is None: return 0 total = 0 diff --git a/tests/hosting_core/telemetry/test_agents_telemetry.py b/tests/hosting_core/telemetry/test_agents_telemetry.py index 70cd3ace..1c789123 100644 --- a/tests/hosting_core/telemetry/test_agents_telemetry.py +++ b/tests/hosting_core/telemetry/test_agents_telemetry.py @@ -7,12 +7,37 @@ clear ) +from tests._common.telemetry_utils import find_metric, sum_counter + from microsoft_agents.hosting.core.telemetry import ( agents_telemetry, SERVICE_NAME, SERVICE_VERSION, ) +def test_tracer(test_exporter): + """Test that the tracer is initialized with the correct service name and version.""" + + with agents_telemetry.tracer.start_as_current_span("test_span"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "test_span" + assert spans[0].instrumentation_scope.name == SERVICE_NAME + assert spans[0].instrumentation_scope.version == SERVICE_VERSION + +def test_meter(test_metric_reader): + """Test that the meter is initialized with the correct service name and version.""" + counter = agents_telemetry.meter.create_counter("test_counter") + counter.add(1) + + metrics_data = test_metric_reader.get_metrics_data() + metric = find_metric(metrics_data, "test_counter") + assert len(metric.data.data_points) == 1 + assert sum_counter(metric) == 1 + assert metric.name == "test_counter" + def test_start_as_current_span(test_exporter): """Test start_as_current_span creates a span with context attributes.""" @@ -22,8 +47,8 @@ def test_start_as_current_span(test_exporter): spans = test_exporter.get_finished_spans() assert len(spans) == 1 assert spans[0].name == "test_span" - assert spans[0].resource.attributes["service.name"] == SERVICE_NAME - assert spans[0].resource.attributes["service.version"] == SERVICE_VERSION + assert spans[0].instrumentation_scope.name == SERVICE_NAME + assert spans[0].instrumentation_scope.version == SERVICE_VERSION def test_start_as_current_span_with_callback(mocker, test_exporter): """Test start_as_current_span records success status and callback payload.""" diff --git a/tests/hosting_core/telemetry/test_simple_span_wrapper.py b/tests/hosting_core/telemetry/test_simple_span_wrapper.py index db674648..6b6f02a4 100644 --- a/tests/hosting_core/telemetry/test_simple_span_wrapper.py +++ b/tests/hosting_core/telemetry/test_simple_span_wrapper.py @@ -12,9 +12,9 @@ clear ) from tests._common.telemetry_utils import ( - _find_metric, - _sum_counter, - _sum_hist_count, + find_metric, + sum_counter, + sum_hist_count, ) from microsoft_agents.hosting.core import TurnContext diff --git a/tests/hosting_core/telemetry/test_spans.py b/tests/hosting_core/telemetry/test_spans.py index 2108d402..3d9f857c 100644 --- a/tests/hosting_core/telemetry/test_spans.py +++ b/tests/hosting_core/telemetry/test_spans.py @@ -11,9 +11,9 @@ clear ) from tests._common.telemetry_utils import ( - _find_metric, - _sum_counter, - _sum_hist_count, + find_metric, + sum_counter, + sum_hist_count, ) def _build_turn_context(mocker): From 54b050ae421127c66b95e356979a9ccee86bdfad Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 25 Mar 2026 11:16:11 -0700 Subject: [PATCH 43/55] Adding core telemetry tests --- .../hosting/core/telemetry/adapter/spans.py | 2 +- .../core/telemetry/core/_agents_telemetry.py | 3 +- .../_tests/test_delta_metric_reader.py | 212 ++++++++++++ tests/_common/fixtures/telemetry.py | 127 ++++++- .../telemetry/test_adapter_spans.py | 320 ++++++++++++++++++ .../telemetry/test_agents_telemetry.py | 1 - .../telemetry/test_simple_span_wrapper.py | 1 - tests/hosting_core/telemetry/test_spans.py | 105 ------ tests/hosting_core/telemetry/test_utils.py | 74 ++++ 9 files changed, 726 insertions(+), 119 deletions(-) create mode 100644 tests/_common/_tests/test_delta_metric_reader.py create mode 100644 tests/hosting_core/telemetry/test_adapter_spans.py delete mode 100644 tests/hosting_core/telemetry/test_spans.py create mode 100644 tests/hosting_core/telemetry/test_utils.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py index b3c14bf4..a344e58b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py @@ -144,7 +144,7 @@ def _get_attributes(self) -> AttributeMap: else attributes.UNKNOWN ), attributes.CONVERSATION_ID: get_conversation_id(self._activity), - attributes.IS_AGENTIC_REQUEST: self._activity.is_agentic_request(), + attributes.IS_AGENTIC: self._activity.is_agentic_request(), } diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py index f92686a3..34c13ec4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py @@ -82,8 +82,8 @@ def start_as_current_span( try: yield span # execute the operation in the with block except Exception as e: - span.record_exception(e) exception = e + raise finally: success = exception is None @@ -101,7 +101,6 @@ def start_as_current_span( callback(span, duration, exception) span.set_status(trace.Status(trace.StatusCode.ERROR)) - raise exception from None # re-raise to ensure it's not swallowed agents_telemetry = _AgentsTelemetry() diff --git a/tests/_common/_tests/test_delta_metric_reader.py b/tests/_common/_tests/test_delta_metric_reader.py new file mode 100644 index 00000000..67be5eed --- /dev/null +++ b/tests/_common/_tests/test_delta_metric_reader.py @@ -0,0 +1,212 @@ +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader + +from tests._common.fixtures.telemetry import DeltaMetricReader +from tests._common.telemetry_utils import find_metric, sum_counter, sum_hist_count + + +def _make_reader(): + """Create a standalone MeterProvider + InMemoryMetricReader pair.""" + inner = InMemoryMetricReader() + provider = MeterProvider([inner]) + meter = provider.get_meter("test") + return inner, provider, meter + + +# ---- basic counter delta ---- + + +def test_counter_delta_excludes_baseline(): + inner, provider, meter = _make_reader() + counter = meter.create_counter("my_counter") + + counter.add(5) + delta = DeltaMetricReader(inner) # baseline captures 5 + + counter.add(3) + data = delta.get_metrics_data() + + assert sum_counter(find_metric(data, "my_counter")) == 3 + provider.shutdown() + + +def test_counter_delta_is_zero_when_nothing_new(): + inner, provider, meter = _make_reader() + counter = meter.create_counter("my_counter") + + counter.add(10) + delta = DeltaMetricReader(inner) + + data = delta.get_metrics_data() + metric = find_metric(data, "my_counter") + # No new increments → metric either absent or zero + assert metric is None or sum_counter(metric) == 0 + provider.shutdown() + + +def test_counter_accumulates_across_calls(): + inner, provider, meter = _make_reader() + counter = meter.create_counter("my_counter") + + delta = DeltaMetricReader(inner) + + counter.add(2) + counter.add(3) + + data = delta.get_metrics_data() + assert sum_counter(find_metric(data, "my_counter")) == 5 + provider.shutdown() + + +# ---- reset ---- + + +def test_reset_clears_delta(): + inner, provider, meter = _make_reader() + counter = meter.create_counter("my_counter") + + delta = DeltaMetricReader(inner) + counter.add(7) + + delta.reset() # new baseline includes the 7 + + data = delta.get_metrics_data() + metric = find_metric(data, "my_counter") + assert metric is None or sum_counter(metric) == 0 + + counter.add(2) + data = delta.get_metrics_data() + assert sum_counter(find_metric(data, "my_counter")) == 2 + provider.shutdown() + + +# ---- histogram delta ---- + + +def test_histogram_delta_excludes_baseline(): + inner, provider, meter = _make_reader() + hist = meter.create_histogram("my_hist") + + hist.record(100) + hist.record(200) + delta = DeltaMetricReader(inner) # baseline count=2 + + hist.record(50) + data = delta.get_metrics_data() + + assert sum_hist_count(find_metric(data, "my_hist")) == 1 + provider.shutdown() + + +def test_histogram_delta_is_zero_when_nothing_new(): + inner, provider, meter = _make_reader() + hist = meter.create_histogram("my_hist") + + hist.record(42) + delta = DeltaMetricReader(inner) + + data = delta.get_metrics_data() + metric = find_metric(data, "my_hist") + assert metric is None or sum_hist_count(metric) == 0 + provider.shutdown() + + +# ---- attribute-keyed counters ---- + + +def test_counter_delta_respects_attributes(): + inner, provider, meter = _make_reader() + counter = meter.create_counter("tagged") + + counter.add(10, attributes={"ch": "teams"}) + counter.add(20, attributes={"ch": "webchat"}) + + delta = DeltaMetricReader(inner) + + counter.add(1, attributes={"ch": "teams"}) + counter.add(2, attributes={"ch": "webchat"}) + + data = delta.get_metrics_data() + metric = find_metric(data, "tagged") + + assert sum_counter(metric, {"ch": "teams"}) == 1 + assert sum_counter(metric, {"ch": "webchat"}) == 2 + provider.shutdown() + + +# ---- multiple metrics ---- + + +def test_multiple_metrics_tracked_independently(): + inner, provider, meter = _make_reader() + c1 = meter.create_counter("counter_a") + c2 = meter.create_counter("counter_b") + + c1.add(100) + delta = DeltaMetricReader(inner) + + c1.add(1) + c2.add(5) + + data = delta.get_metrics_data() + assert sum_counter(find_metric(data, "counter_a")) == 1 + assert sum_counter(find_metric(data, "counter_b")) == 5 + provider.shutdown() + + +# ---- new metric after baseline ---- + + +def test_new_metric_after_baseline(): + inner, provider, meter = _make_reader() + + delta = DeltaMetricReader(inner) + + counter = meter.create_counter("late_counter") + counter.add(3) + + data = delta.get_metrics_data() + assert sum_counter(find_metric(data, "late_counter")) == 3 + provider.shutdown() + + +# ---- force_flush delegates ---- + + +def test_force_flush_delegates(): + inner, provider, meter = _make_reader() + delta = DeltaMetricReader(inner) + + counter = meter.create_counter("flushed") + counter.add(1) + delta.force_flush() + + data = delta.get_metrics_data() + assert sum_counter(find_metric(data, "flushed")) == 1 + provider.shutdown() + + +# ---- output structure is compatible with find_metric ---- + + +def test_output_structure_compatible_with_helpers(): + inner, provider, meter = _make_reader() + delta = DeltaMetricReader(inner) + + counter = meter.create_counter("compat") + counter.add(1) + + data = delta.get_metrics_data() + + assert hasattr(data, "resource_metrics") + rm = data.resource_metrics[0] + assert hasattr(rm, "scope_metrics") + sm = rm.scope_metrics[0] + assert hasattr(sm, "metrics") + m = sm.metrics[0] + assert m.name == "compat" + assert hasattr(m.data, "data_points") + dp = m.data.data_points[0] + assert hasattr(dp, "value") + assert hasattr(dp, "attributes") + provider.shutdown() diff --git a/tests/_common/fixtures/telemetry.py b/tests/_common/fixtures/telemetry.py index 310f7cc5..526ce04c 100644 --- a/tests/_common/fixtures/telemetry.py +++ b/tests/_common/fixtures/telemetry.py @@ -1,4 +1,5 @@ import pytest +from types import SimpleNamespace from opentelemetry import trace, metrics from opentelemetry.sdk.trace import TracerProvider @@ -7,7 +8,117 @@ from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import InMemoryMetricReader -@pytest.fixture(scope="module") + +class DeltaMetricReader: + """Wraps an InMemoryMetricReader so each test only sees metrics + accrued *after* the wrapper was created (or last reset). + + InMemoryMetricReader uses cumulative aggregation by default and has + no ``clear()`` method, so counters and histograms accumulate across + the whole session. This wrapper snapshots the cumulative values at + construction time and subtracts them from every subsequent + ``get_metrics_data()`` call, producing a delta view that is + compatible with the ``find_metric`` / ``sum_counter`` / + ``sum_hist_count`` helpers. + """ + + def __init__(self, inner: InMemoryMetricReader): + self._inner = inner + self._baseline: dict[tuple, tuple] = {} + self.reset() + + def reset(self): + """Capture the current cumulative values as the new zero-line.""" + data = self._inner.get_metrics_data() + self._baseline = self._snapshot(data) + + def force_flush(self): + self._inner.force_flush() + + def get_metrics_data(self): + """Return a metrics-data object containing only the delta + since the last ``reset()``.""" + raw = self._inner.get_metrics_data() + return self._subtract(raw, self._baseline) + + # -- internals -------------------------------------------------- + + @staticmethod + def _dp_key(metric_name, dp): + attrs = dp.attributes or {} + return (metric_name, tuple(sorted(attrs.items()))) + + @staticmethod + def _snapshot(data): + snap: dict[tuple, tuple] = {} + if data is None: + return snap + for rm in data.resource_metrics: + for sm in rm.scope_metrics: + for m in sm.metrics: + for dp in m.data.data_points: + k = DeltaMetricReader._dp_key(m.name, dp) + if hasattr(dp, "bucket_counts"): + snap[k] = ("hist", dp.count) + else: + snap[k] = ("counter", dp.value) + return snap + + @staticmethod + def _empty_data(): + return SimpleNamespace( + resource_metrics=[ + SimpleNamespace( + scope_metrics=[SimpleNamespace(metrics=[])] + ) + ] + ) + + @staticmethod + def _subtract(data, baseline): + if data is None: + return DeltaMetricReader._empty_data() + all_metrics: list = [] + for rm in data.resource_metrics: + for sm in rm.scope_metrics: + for m in sm.metrics: + points: list = [] + for dp in m.data.data_points: + k = DeltaMetricReader._dp_key(m.name, dp) + base = baseline.get(k) + if hasattr(dp, "bucket_counts"): + base_count = base[1] if base else 0 + points.append( + SimpleNamespace( + attributes=dp.attributes, + count=dp.count - base_count, + ) + ) + else: + base_val = base[1] if base else 0 + points.append( + SimpleNamespace( + attributes=dp.attributes, + value=dp.value - base_val, + ) + ) + if points: + all_metrics.append( + SimpleNamespace( + name=m.name, + data=SimpleNamespace(data_points=points), + ) + ) + return SimpleNamespace( + resource_metrics=[ + SimpleNamespace( + scope_metrics=[SimpleNamespace(metrics=all_metrics)] + ) + ] + ) + + +@pytest.fixture(scope="session") def test_telemetry(): """Set up fresh in-memory exporter for testing.""" exporter = InMemorySpanExporter() @@ -24,6 +135,7 @@ def test_telemetry(): yield exporter, metric_reader exporter.clear() + meter_provider.force_flush() tracer_provider.shutdown() meter_provider.shutdown() @@ -31,16 +143,13 @@ def test_telemetry(): def test_exporter(test_telemetry): """Provide the in-memory span exporter for each test.""" exporter, _ = test_telemetry + exporter.clear() return exporter @pytest.fixture(scope="function") def test_metric_reader(test_telemetry): - """Provide the in-memory metric reader for each test.""" + """Provide a delta view of the metric reader for each test. + Only metrics recorded *during* the test are visible.""" _, 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() \ No newline at end of file + metric_reader.force_flush() + return DeltaMetricReader(metric_reader) \ No newline at end of file diff --git a/tests/hosting_core/telemetry/test_adapter_spans.py b/tests/hosting_core/telemetry/test_adapter_spans.py new file mode 100644 index 00000000..995d7fdc --- /dev/null +++ b/tests/hosting_core/telemetry/test_adapter_spans.py @@ -0,0 +1,320 @@ +from datetime import datetime +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core.telemetry import ( + agents_telemetry, + SimpleSpanWrapper, + attributes, +) +from microsoft_agents.hosting.core.telemetry.adapter.spans import ( + AdapterProcess, + AdapterSendActivities, + AdapterUpdateActivity, + AdapterDeleteActivity, + AdapterContinueConversation, + AdapterCreateUserTokenClient, + AdapterCreateConnectorClient, +) +from microsoft_agents.hosting.core.telemetry.adapter import constants +from microsoft_agents.activity import Activity, ConversationAccount, ChannelAccount + +from tests._common.fixtures.telemetry import ( # unused imports are needed for fixtures + test_telemetry, + test_exporter, + test_metric_reader, +) +from tests._common.telemetry_utils import ( + find_metric, + sum_counter, + sum_hist_count, +) + + +def _make_activity(**overrides) -> Activity: + defaults = dict( + type="message", + id="activity-1", + channel_id="msteams", + text="Hello!", + conversation=ConversationAccount(id="conversation-1"), + from_property=ChannelAccount(id="user-1", name="User"), + recipient=ChannelAccount(id="bot-1", name="Bot"), + ) + defaults.update(overrides) + return Activity(**defaults) + + +# ---- AdapterProcess ---- + + +def test_adapter_process_creates_span(test_exporter): + activity = _make_activity() + + with AdapterProcess(activity): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_PROCESS + + +def test_adapter_process_span_attributes(test_exporter): + activity = _make_activity(type="invoke", channel_id="webchat") + + with AdapterProcess(activity): + pass + + span = test_exporter.get_finished_spans()[0] + span_attrs = dict(span.attributes) + assert span_attrs[attributes.ACTIVITY_TYPE] == "invoke" + assert span_attrs[attributes.ACTIVITY_CHANNEL_ID] == "webchat" + assert attributes.CONVERSATION_ID in span_attrs + assert attributes.ACTIVITY_DELIVERY_MODE in span_attrs + assert attributes.IS_AGENTIC in span_attrs + + +def test_adapter_process_records_metrics(test_exporter, test_metric_reader): + activity = _make_activity() + + with AdapterProcess(activity): + pass + + metric_data = test_metric_reader.get_metrics_data() + + received = sum_counter(find_metric(metric_data, constants.METRIC_ACTIVITIES_RECEIVED)) + + assert received == 1 + + duration_count = sum_hist_count( + find_metric(metric_data, constants.METRIC_ADAPTER_PROCESS_DURATION) + ) + assert duration_count == 1 + + +# ---- AdapterSendActivities ---- + + +def test_adapter_send_activities_creates_span(test_exporter): + activities = [_make_activity(), _make_activity(type="typing")] + + with AdapterSendActivities(activities): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_SEND_ACTIVITIES + + +def test_adapter_send_activities_span_attributes(test_exporter): + activities = [_make_activity(), _make_activity()] + + with AdapterSendActivities(activities): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_COUNT] == 2 + assert span.attributes[attributes.CONVERSATION_ID] == "conversation-1" + + +def test_adapter_send_activities_empty_list(test_exporter): + with AdapterSendActivities([]): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_COUNT] == 0 + assert span.attributes[attributes.CONVERSATION_ID] == attributes.UNKNOWN + + +def test_adapter_send_activities_records_metrics(test_exporter, test_metric_reader): + activities = [ + _make_activity(channel_id="msteams"), + _make_activity(channel_id="webchat"), + ] + + with AdapterSendActivities(activities): + pass + + metric_data = test_metric_reader.get_metrics_data() + sent = sum_counter(find_metric(metric_data, constants.METRIC_ACTIVITIES_SENT)) + assert sent == 2 + + +# ---- AdapterUpdateActivity ---- + + +def test_adapter_update_activity_creates_span(test_exporter): + activity = _make_activity(id="act-42") + + with AdapterUpdateActivity(activity): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_UPDATE_ACTIVITY + + +def test_adapter_update_activity_span_attributes(test_exporter): + activity = _make_activity(id="act-42") + + with AdapterUpdateActivity(activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_ID] == "act-42" + assert span.attributes[attributes.CONVERSATION_ID] == "conversation-1" + + +def test_adapter_update_activity_records_metrics(test_exporter, test_metric_reader): + activity = _make_activity() + + with AdapterUpdateActivity(activity): + pass + + metric_data = test_metric_reader.get_metrics_data() + updated = sum_counter(find_metric(metric_data, constants.METRIC_ACTIVITIES_UPDATED)) + assert updated == 1 + + +def test_adapter_update_activity_missing_id(test_exporter): + activity = _make_activity(id=None) + + with AdapterUpdateActivity(activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_ID] == attributes.UNKNOWN + + +# ---- AdapterDeleteActivity ---- + + +def test_adapter_delete_activity_creates_span(test_exporter): + activity = _make_activity(id="act-99") + + with AdapterDeleteActivity(activity): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_DELETE_ACTIVITY + + +def test_adapter_delete_activity_span_attributes(test_exporter): + activity = _make_activity(id="act-99") + + with AdapterDeleteActivity(activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_ID] == "act-99" + assert span.attributes[attributes.CONVERSATION_ID] == "conversation-1" + + +def test_adapter_delete_activity_records_metrics(test_exporter, test_metric_reader): + activity = _make_activity() + + with AdapterDeleteActivity(activity): + pass + + metric_data = test_metric_reader.get_metrics_data() + deleted = sum_counter(find_metric(metric_data, constants.METRIC_ACTIVITIES_DELETED)) + assert deleted == 1 + + +# ---- AdapterContinueConversation ---- + + +def test_adapter_continue_conversation_creates_span(test_exporter): + activity = _make_activity() + + with AdapterContinueConversation(activity): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_CONTINUE_CONVERSATION + + +def test_adapter_continue_conversation_span_attributes(test_exporter): + activity = _make_activity() + + with AdapterContinueConversation(activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.APP_ID] == "bot-1" + assert span.attributes[attributes.CONVERSATION_ID] == "conversation-1" + + +def test_adapter_continue_conversation_no_recipient(test_exporter): + activity = _make_activity() + activity.recipient = None + + with AdapterContinueConversation(activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.APP_ID] == attributes.UNKNOWN + + +# ---- AdapterCreateUserTokenClient ---- + + +def test_adapter_create_user_token_client_creates_span(test_exporter): + with AdapterCreateUserTokenClient("https://token.example.com", ["scope1"]): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_CREATE_USER_TOKEN_CLIENT + + +def test_adapter_create_user_token_client_span_attributes(test_exporter): + with AdapterCreateUserTokenClient( + "https://token.example.com", ["User.Read", "Mail.Read"] + ): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.TOKEN_SERVICE_ENDPOINT] == "https://token.example.com" + assert span.attributes[attributes.AUTH_SCOPES] == "User.Read,Mail.Read" + + +def test_adapter_create_user_token_client_no_scopes(test_exporter): + with AdapterCreateUserTokenClient("https://token.example.com", None): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AUTH_SCOPES] == attributes.UNKNOWN + + +# ---- AdapterCreateConnectorClient ---- + + +def test_adapter_create_connector_client_creates_span(test_exporter): + with AdapterCreateConnectorClient("https://service.example.com", ["scope1"], False): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_CREATE_CONNECTOR_CLIENT + + +def test_adapter_create_connector_client_span_attributes(test_exporter): + with AdapterCreateConnectorClient( + "https://service.example.com", ["Bot.Read"], True + ): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.SERVICE_URL] == "https://service.example.com" + assert span.attributes[attributes.AUTH_SCOPES] == "Bot.Read" + assert span.attributes[attributes.IS_AGENTIC] is True + + +def test_adapter_create_connector_client_not_agentic(test_exporter): + with AdapterCreateConnectorClient("https://svc.example.com", None, False): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.IS_AGENTIC] is False + assert span.attributes[attributes.AUTH_SCOPES] == attributes.UNKNOWN diff --git a/tests/hosting_core/telemetry/test_agents_telemetry.py b/tests/hosting_core/telemetry/test_agents_telemetry.py index 1c789123..97df3097 100644 --- a/tests/hosting_core/telemetry/test_agents_telemetry.py +++ b/tests/hosting_core/telemetry/test_agents_telemetry.py @@ -4,7 +4,6 @@ test_telemetry, test_exporter, test_metric_reader, - clear ) from tests._common.telemetry_utils import find_metric, sum_counter diff --git a/tests/hosting_core/telemetry/test_simple_span_wrapper.py b/tests/hosting_core/telemetry/test_simple_span_wrapper.py index 6b6f02a4..caf36ba8 100644 --- a/tests/hosting_core/telemetry/test_simple_span_wrapper.py +++ b/tests/hosting_core/telemetry/test_simple_span_wrapper.py @@ -9,7 +9,6 @@ test_telemetry, test_exporter, test_metric_reader, - clear ) from tests._common.telemetry_utils import ( find_metric, diff --git a/tests/hosting_core/telemetry/test_spans.py b/tests/hosting_core/telemetry/test_spans.py deleted file mode 100644 index 3d9f857c..00000000 --- a/tests/hosting_core/telemetry/test_spans.py +++ /dev/null @@ -1,105 +0,0 @@ -from microsoft_agents.hosting.core import TurnContext -from microsoft_agents.hosting.core.telemetry import ( - agents_telemetry, - SimpleSpanWrapper, -) - -from tests._common.fixtures.telemetry import ( # unused imports are needed for fixtures - test_telemetry, - test_exporter, - test_metric_reader, - clear -) -from tests._common.telemetry_utils import ( - find_metric, - sum_counter, - sum_hist_count, -) - -def _build_turn_context(mocker): - activity = SimpleNamespace( - type="message", - id="activity-1", - from_property=SimpleNamespace(id="user-1"), - recipient=SimpleNamespace(id="bot-1"), - conversation=SimpleNamespace(id="conversation-1"), - channel_id="msteams", - text="Hello!", - ) - activity.is_agentic_request = lambda: False - - context = mocker.Mock(spec=TurnContext) - context.activity = activity - return context - -def test_start_as_current_span(mocker, test_exporter): - """Test start_as_current_span creates a span with context attributes.""" - context = _build_turn_context(mocker) - - with agents_telemetry.start_as_current_span("test_span", context): - pass - - spans = test_exporter.get_finished_spans() - assert len(spans) == 1 - assert spans[0].name == "test_span" - - attributes = spans[0].attributes - assert attributes["activity.type"] == "message" - assert attributes["agent.is_agentic"] is False - assert attributes["from.id"] == "user-1" - assert attributes["recipient.id"] == "bot-1" - assert attributes["conversation.id"] == "conversation-1" - assert attributes["channel_id"] == "msteams" - assert attributes["message.text.length"] == 6 - -def test_start_timed_span(mocker, test_exporter): - """Test start_timed_span records success status and callback payload.""" - context = _build_turn_context(mocker) - callback = mocker.Mock() - - with agents_telemetry.start_timed_span( - "test_timed_span", - context, - callback=callback, - ): - pass - - finished_spans = test_exporter.get_finished_spans() - assert len(finished_spans) == 1 - - finished_span = finished_spans[0] - assert finished_span.name == "test_timed_span" - assert finished_span.status.status_code == trace.StatusCode.OK - - completion_events = [ - event for event in finished_span.events if event.name == "test_timed_span completed" - ] - assert len(completion_events) == 1 - assert completion_events[0].attributes["duration_ms"] >= 0 - - callback.assert_called_once() - callback_span, duration_ms, callback_exception = callback.call_args.args - assert callback_span.name == "test_timed_span" - assert duration_ms >= 0 - assert callback_exception is None - - -def test_start_span_app_on_turn(mocker, test_exporter, test_metric_reader): - """Test agent_turn_operation records span and turn metrics.""" - context = _build_turn_context(mocker) - - with _spans.start_span_app_on_turn(context): - pass - - spans = test_exporter.get_finished_spans() - assert len(spans) == 1 - assert spans[0].name == core.SPAN_APP_ON_TURN - - metric_data = test_metric_reader.get_metrics_data() - turn_total = _sum_counter(_find_metric(metric_data, core.METRIC_TURN_TOTAL)) - turn_duration_count = _sum_hist_count( - _find_metric(metric_data, core.METRIC_TURN_DURATION) - ) - - assert turn_total == 1 - assert turn_duration_count == 1 diff --git a/tests/hosting_core/telemetry/test_utils.py b/tests/hosting_core/telemetry/test_utils.py new file mode 100644 index 00000000..ddfc746b --- /dev/null +++ b/tests/hosting_core/telemetry/test_utils.py @@ -0,0 +1,74 @@ +from microsoft_agents.activity import ( + Activity, + ConversationAccount, + DeliveryModes, +) +from microsoft_agents.hosting.core.telemetry.attributes import UNKNOWN +from microsoft_agents.hosting.core.telemetry.utils import ( + format_scopes, + get_conversation_id, + get_delivery_mode, +) + + +# ---- format_scopes ---- + + +def test_format_scopes_single(): + assert format_scopes(["User.Read"]) == "User.Read" + + +def test_format_scopes_multiple(): + assert format_scopes(["User.Read", "Mail.Read"]) == "User.Read,Mail.Read" + + +def test_format_scopes_none(): + assert format_scopes(None) == UNKNOWN + + +def test_format_scopes_empty_list(): + assert format_scopes([]) == UNKNOWN + + +# ---- get_conversation_id ---- + + +def test_get_conversation_id_present(): + activity = Activity( + type="message", + conversation=ConversationAccount(id="conv-123"), + ) + assert get_conversation_id(activity) == "conv-123" + + +def test_get_conversation_id_no_conversation(): + activity = Activity(type="message") + assert get_conversation_id(activity) == UNKNOWN + + +# ---- get_delivery_mode ---- + + +def test_get_delivery_mode_enum(): + activity = Activity( + type="message", + delivery_mode=DeliveryModes.expect_replies, + ) + assert get_delivery_mode(activity) == "expectReplies" + + +def test_get_delivery_mode_string(): + activity = Activity(type="message", delivery_mode="custom_mode") + assert get_delivery_mode(activity) == "custom_mode" + + +def test_get_delivery_mode_none(): + activity = Activity(type="message") + assert get_delivery_mode(activity) == UNKNOWN + + +def test_get_delivery_mode_all_enum_values(): + for mode in DeliveryModes: + activity = Activity(type="message", delivery_mode=mode) + assert get_delivery_mode(activity) == mode.value + From 7ba0a9638c5d573b9d61d4377b89bb2f57c3f986 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 25 Mar 2026 11:22:10 -0700 Subject: [PATCH 44/55] Adding span tests --- tests/_common/fixtures/telemetry.py | 12 +- tests/_common/telemetry_utils.py | 4 +- .../test_copilot_client.py | 1 + .../telemetry/test_adapter_spans.py | 13 +- .../telemetry/test_agents_telemetry.py | 13 +- .../hosting_core/telemetry/test_app_spans.py | 191 ++++++++++++++++++ .../hosting_core/telemetry/test_auth_spans.py | 147 ++++++++++++++ .../telemetry/test_oauth_spans.py | 125 ++++++++++++ .../telemetry/test_simple_span_wrapper.py | 6 +- .../telemetry/test_storage_spans.py | 105 ++++++++++ .../telemetry/test_turn_context_spans.py | 62 ++++++ tests/hosting_core/telemetry/test_utils.py | 2 - 12 files changed, 659 insertions(+), 22 deletions(-) create mode 100644 tests/hosting_core/telemetry/test_app_spans.py create mode 100644 tests/hosting_core/telemetry/test_auth_spans.py create mode 100644 tests/hosting_core/telemetry/test_oauth_spans.py create mode 100644 tests/hosting_core/telemetry/test_storage_spans.py create mode 100644 tests/hosting_core/telemetry/test_turn_context_spans.py diff --git a/tests/_common/fixtures/telemetry.py b/tests/_common/fixtures/telemetry.py index 526ce04c..52864de6 100644 --- a/tests/_common/fixtures/telemetry.py +++ b/tests/_common/fixtures/telemetry.py @@ -68,9 +68,7 @@ def _snapshot(data): def _empty_data(): return SimpleNamespace( resource_metrics=[ - SimpleNamespace( - scope_metrics=[SimpleNamespace(metrics=[])] - ) + SimpleNamespace(scope_metrics=[SimpleNamespace(metrics=[])]) ] ) @@ -111,9 +109,7 @@ def _subtract(data, baseline): ) return SimpleNamespace( resource_metrics=[ - SimpleNamespace( - scope_metrics=[SimpleNamespace(metrics=all_metrics)] - ) + SimpleNamespace(scope_metrics=[SimpleNamespace(metrics=all_metrics)]) ] ) @@ -139,6 +135,7 @@ def test_telemetry(): tracer_provider.shutdown() meter_provider.shutdown() + @pytest.fixture(scope="function") def test_exporter(test_telemetry): """Provide the in-memory span exporter for each test.""" @@ -146,10 +143,11 @@ def test_exporter(test_telemetry): exporter.clear() return exporter + @pytest.fixture(scope="function") def test_metric_reader(test_telemetry): """Provide a delta view of the metric reader for each test. Only metrics recorded *during* the test are visible.""" _, metric_reader = test_telemetry metric_reader.force_flush() - return DeltaMetricReader(metric_reader) \ No newline at end of file + return DeltaMetricReader(metric_reader) diff --git a/tests/_common/telemetry_utils.py b/tests/_common/telemetry_utils.py index 70d3eb62..3acb4008 100644 --- a/tests/_common/telemetry_utils.py +++ b/tests/_common/telemetry_utils.py @@ -1,6 +1,6 @@ def find_metric(metrics_data, metric_name): """Helper function to find a metric by name in the collected metrics data. - + Usage: metric = find_metric(metrics_data, "my_metric_name") """ @@ -35,4 +35,4 @@ def sum_hist_count(metric, attribute_filter=None): for key, value in attribute_filter.items() ): total += point.count - return total \ No newline at end of file + return total diff --git a/tests/copilotstudio_client/test_copilot_client.py b/tests/copilotstudio_client/test_copilot_client.py index e82e2869..cffc82f6 100644 --- a/tests/copilotstudio_client/test_copilot_client.py +++ b/tests/copilotstudio_client/test_copilot_client.py @@ -16,6 +16,7 @@ from aiohttp import ClientSession, ClientError from urllib.parse import urlparse + @pytest.mark.asyncio async def test_copilot_client_error(mocker): # Define the connection settings diff --git a/tests/hosting_core/telemetry/test_adapter_spans.py b/tests/hosting_core/telemetry/test_adapter_spans.py index 995d7fdc..6b1cd60c 100644 --- a/tests/hosting_core/telemetry/test_adapter_spans.py +++ b/tests/hosting_core/telemetry/test_adapter_spans.py @@ -80,15 +80,17 @@ def test_adapter_process_records_metrics(test_exporter, test_metric_reader): metric_data = test_metric_reader.get_metrics_data() - received = sum_counter(find_metric(metric_data, constants.METRIC_ACTIVITIES_RECEIVED)) - + received = sum_counter( + find_metric(metric_data, constants.METRIC_ACTIVITIES_RECEIVED) + ) + assert received == 1 duration_count = sum_hist_count( find_metric(metric_data, constants.METRIC_ADAPTER_PROCESS_DURATION) ) assert duration_count == 1 - + # ---- AdapterSendActivities ---- @@ -275,7 +277,10 @@ def test_adapter_create_user_token_client_span_attributes(test_exporter): pass span = test_exporter.get_finished_spans()[0] - assert span.attributes[attributes.TOKEN_SERVICE_ENDPOINT] == "https://token.example.com" + assert ( + span.attributes[attributes.TOKEN_SERVICE_ENDPOINT] + == "https://token.example.com" + ) assert span.attributes[attributes.AUTH_SCOPES] == "User.Read,Mail.Read" diff --git a/tests/hosting_core/telemetry/test_agents_telemetry.py b/tests/hosting_core/telemetry/test_agents_telemetry.py index 97df3097..954daa9a 100644 --- a/tests/hosting_core/telemetry/test_agents_telemetry.py +++ b/tests/hosting_core/telemetry/test_agents_telemetry.py @@ -1,6 +1,6 @@ from opentelemetry import trace -from tests._common.fixtures.telemetry import ( # unused imports are needed for fixtures +from tests._common.fixtures.telemetry import ( # unused imports are needed for fixtures test_telemetry, test_exporter, test_metric_reader, @@ -9,11 +9,12 @@ from tests._common.telemetry_utils import find_metric, sum_counter from microsoft_agents.hosting.core.telemetry import ( - agents_telemetry, + agents_telemetry, SERVICE_NAME, SERVICE_VERSION, ) + def test_tracer(test_exporter): """Test that the tracer is initialized with the correct service name and version.""" @@ -26,6 +27,7 @@ def test_tracer(test_exporter): assert spans[0].instrumentation_scope.name == SERVICE_NAME assert spans[0].instrumentation_scope.version == SERVICE_VERSION + def test_meter(test_metric_reader): """Test that the meter is initialized with the correct service name and version.""" counter = agents_telemetry.meter.create_counter("test_counter") @@ -37,6 +39,7 @@ def test_meter(test_metric_reader): assert sum_counter(metric) == 1 assert metric.name == "test_counter" + def test_start_as_current_span(test_exporter): """Test start_as_current_span creates a span with context attributes.""" @@ -49,6 +52,7 @@ def test_start_as_current_span(test_exporter): assert spans[0].instrumentation_scope.name == SERVICE_NAME assert spans[0].instrumentation_scope.version == SERVICE_VERSION + def test_start_as_current_span_with_callback(mocker, test_exporter): """Test start_as_current_span records success status and callback payload.""" callback = mocker.Mock() @@ -72,6 +76,7 @@ def test_start_as_current_span_with_callback(mocker, test_exporter): assert duration_ms >= 0 assert callback_exception is None + def test_start_as_current_span_with_callback_with_failure(mocker, test_exporter): """Test start_as_current_span records failure status and callback payload.""" callback = mocker.Mock() @@ -88,7 +93,7 @@ def test_start_as_current_span_with_callback_with_failure(mocker, test_exporter) assert str(ex) == "Test exception" assert exception_raised - + finished_spans = test_exporter.get_finished_spans() assert len(finished_spans) == 1 @@ -101,4 +106,4 @@ def test_start_as_current_span_with_callback_with_failure(mocker, test_exporter) assert callback_span.name == "test_span" assert duration_ms >= 0 assert callback_exception is not None - assert str(callback_exception) == "Test exception" \ No newline at end of file + assert str(callback_exception) == "Test exception" diff --git a/tests/hosting_core/telemetry/test_app_spans.py b/tests/hosting_core/telemetry/test_app_spans.py new file mode 100644 index 00000000..1d3805a4 --- /dev/null +++ b/tests/hosting_core/telemetry/test_app_spans.py @@ -0,0 +1,191 @@ +from types import SimpleNamespace + +from microsoft_agents.activity import Activity, ConversationAccount, ChannelAccount +from microsoft_agents.hosting.core.telemetry import attributes +from microsoft_agents.hosting.core.app.telemetry.spans import ( + AppOnTurn, + AppRouteHandler, + AppBeforeTurn, + AppAfterTurn, + AppDownloadFiles, +) +from microsoft_agents.hosting.core.app.telemetry import constants + +from tests._common.fixtures.telemetry import ( + test_telemetry, + test_exporter, + test_metric_reader, +) +from tests._common.telemetry_utils import find_metric, sum_counter, sum_hist_count + + +def _make_context(**activity_overrides): + defaults = dict( + type="message", + channel_id="msteams", + service_url="https://smba.trafficmanager.net/teams/", + conversation=ConversationAccount(id="conv-1"), + from_property=ChannelAccount(id="user-1"), + recipient=ChannelAccount(id="bot-1"), + ) + defaults.update(activity_overrides) + activity = Activity(**defaults) + return SimpleNamespace(activity=activity) + + +# ---- AppOnTurn ---- + + +def test_app_on_turn_creates_span(test_exporter): + ctx = _make_context() + + with AppOnTurn(ctx): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_ON_TURN + + +def test_app_on_turn_span_attributes(test_exporter): + ctx = _make_context() + + with AppOnTurn(ctx): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == "conv-1" + assert span.attributes[attributes.ACTIVITY_CHANNEL_ID] == "msteams" + assert span.attributes[attributes.SERVICE_URL] == "https://smba.trafficmanager.net/teams/" + + +def test_app_on_turn_records_turn_metrics(test_exporter, test_metric_reader): + ctx = _make_context() + + with AppOnTurn(ctx): + pass + + data = test_metric_reader.get_metrics_data() + count = sum_counter(find_metric(data, constants.METRIC_TURN_COUNT)) + assert count == 1 + duration = sum_hist_count(find_metric(data, constants.METRIC_TURN_DURATION)) + assert duration == 1 + + +def test_app_on_turn_records_error_metric_on_exception(test_exporter, test_metric_reader): + ctx = _make_context() + + try: + with AppOnTurn(ctx): + raise ValueError("boom") + except ValueError: + pass + + data = test_metric_reader.get_metrics_data() + error_count = sum_counter(find_metric(data, constants.METRIC_TURN_ERROR_COUNT)) + assert error_count == 1 + # success counter should NOT be incremented + success_count = sum_counter(find_metric(data, constants.METRIC_TURN_COUNT)) + assert success_count == 0 + + +def test_app_on_turn_share(test_exporter): + ctx = _make_context() + + with AppOnTurn(ctx) as wrapper: + wrapper.share(route_authorized=True, route_matched=False) + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ROUTE_AUTHORIZED] is True + assert span.attributes[attributes.ROUTE_MATCHED] is False + + +# ---- AppRouteHandler ---- + + +def test_app_route_handler_creates_span(test_exporter): + with AppRouteHandler(is_invoke=False, is_agentic=False): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_ROUTE_HANDLER + + +def test_app_route_handler_span_attributes(test_exporter): + with AppRouteHandler(is_invoke=True, is_agentic=True): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ROUTE_IS_INVOKE] is True + assert span.attributes[attributes.ROUTE_IS_AGENTIC] is True + + +def test_app_route_handler_not_invoke_not_agentic(test_exporter): + with AppRouteHandler(is_invoke=False, is_agentic=False): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ROUTE_IS_INVOKE] is False + assert span.attributes[attributes.ROUTE_IS_AGENTIC] is False + + +# ---- AppBeforeTurn ---- + + +def test_app_before_turn_creates_span(test_exporter): + with AppBeforeTurn(): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_BEFORE_TURN + + +# ---- AppAfterTurn ---- + + +def test_app_after_turn_creates_span(test_exporter): + with AppAfterTurn(): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_AFTER_TURN + + +# ---- AppDownloadFiles ---- + + +def test_app_download_files_creates_span(test_exporter): + ctx = _make_context() + ctx.activity.attachments = [] + + with AppDownloadFiles(ctx): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_DOWNLOAD_FILES + + +def test_app_download_files_attachment_count(test_exporter): + ctx = _make_context() + ctx.activity.attachments = [SimpleNamespace(), SimpleNamespace(), SimpleNamespace()] + + with AppDownloadFiles(ctx): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ATTACHMENT_COUNT] == 3 + + +def test_app_download_files_no_attachments(test_exporter): + ctx = _make_context() + ctx.activity.attachments = None + + with AppDownloadFiles(ctx): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ATTACHMENT_COUNT] == 0 diff --git a/tests/hosting_core/telemetry/test_auth_spans.py b/tests/hosting_core/telemetry/test_auth_spans.py new file mode 100644 index 00000000..7d75c90d --- /dev/null +++ b/tests/hosting_core/telemetry/test_auth_spans.py @@ -0,0 +1,147 @@ +from microsoft_agents.hosting.core.telemetry import attributes +from microsoft_agents.hosting.core.authorization.telemetry.spans import ( + GetAccessToken, + AcquireTokenOnBehalfOf, + GetAgenticInstanceToken, + GetAgenticUserToken, +) +from microsoft_agents.hosting.core.authorization.telemetry import constants + +from tests._common.fixtures.telemetry import ( + test_telemetry, + test_exporter, + test_metric_reader, +) +from tests._common.telemetry_utils import find_metric, sum_counter, sum_hist_count + + +# ---- GetAccessToken ---- + + +def test_get_access_token_creates_span(test_exporter): + with GetAccessToken(["User.Read"], "client_credentials"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_GET_ACCESS_TOKEN + + +def test_get_access_token_span_attributes(test_exporter): + with GetAccessToken(["User.Read", "Mail.Read"], "client_credentials"): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AUTH_SCOPES] == "User.Read,Mail.Read" + assert span.attributes[attributes.AUTH_METHOD] == "client_credentials" + + +def test_get_access_token_records_metrics(test_exporter, test_metric_reader): + with GetAccessToken(["scope"], "client_credentials"): + pass + + data = test_metric_reader.get_metrics_data() + count = sum_counter(find_metric(data, constants.METRIC_AUTH_TOKEN_REQUEST_COUNT)) + assert count == 1 + duration = sum_hist_count( + find_metric(data, constants.METRIC_AUTH_TOKEN_REQUEST_DURATION) + ) + assert duration == 1 + + +# ---- AcquireTokenOnBehalfOf ---- + + +def test_acquire_token_obo_creates_span(test_exporter): + with AcquireTokenOnBehalfOf(["User.Read"]): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_ACQUIRE_TOKEN_ON_BEHALF_OF + + +def test_acquire_token_obo_span_attributes(test_exporter): + with AcquireTokenOnBehalfOf(["User.Read"]): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AUTH_SCOPES] == "User.Read" + + +def test_acquire_token_obo_records_metrics(test_exporter, test_metric_reader): + with AcquireTokenOnBehalfOf(["scope"]): + pass + + data = test_metric_reader.get_metrics_data() + count = sum_counter( + find_metric(data, constants.METRIC_AUTH_TOKEN_REQUEST_COUNT), + {attributes.AUTH_METHOD: constants.AUTH_METHOD_OBO}, + ) + assert count == 1 + + +# ---- GetAgenticInstanceToken ---- + + +def test_get_agentic_instance_token_creates_span(test_exporter): + with GetAgenticInstanceToken("instance-42"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_GET_AGENTIC_INSTANCE_TOKEN + + +def test_get_agentic_instance_token_span_attributes(test_exporter): + with GetAgenticInstanceToken("instance-42"): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AGENTIC_INSTANCE_ID] == "instance-42" + + +def test_get_agentic_instance_token_records_metrics(test_exporter, test_metric_reader): + with GetAgenticInstanceToken("instance-42"): + pass + + data = test_metric_reader.get_metrics_data() + count = sum_counter( + find_metric(data, constants.METRIC_AUTH_TOKEN_REQUEST_COUNT), + {attributes.AUTH_METHOD: constants.AUTH_METHOD_AGENTIC_INSTANCE}, + ) + assert count == 1 + + +# ---- GetAgenticUserToken ---- + + +def test_get_agentic_user_token_creates_span(test_exporter): + with GetAgenticUserToken("instance-1", "user-1", ["scope"]): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_GET_AGENTIC_USER_TOKEN + + +def test_get_agentic_user_token_span_attributes(test_exporter): + with GetAgenticUserToken("instance-1", "user-1", ["Scope.A", "Scope.B"]): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AGENTIC_INSTANCE_ID] == "instance-1" + assert span.attributes[attributes.AGENTIC_USER_ID] == "user-1" + assert span.attributes[attributes.AUTH_SCOPES] == "Scope.A,Scope.B" + + +def test_get_agentic_user_token_records_metrics(test_exporter, test_metric_reader): + with GetAgenticUserToken("instance-1", "user-1", ["scope"]): + pass + + data = test_metric_reader.get_metrics_data() + count = sum_counter( + find_metric(data, constants.METRIC_AUTH_TOKEN_REQUEST_COUNT), + {attributes.AUTH_METHOD: constants.AUTH_METHOD_AGENTIC_USER}, + ) + assert count == 1 diff --git a/tests/hosting_core/telemetry/test_oauth_spans.py b/tests/hosting_core/telemetry/test_oauth_spans.py new file mode 100644 index 00000000..1b50a960 --- /dev/null +++ b/tests/hosting_core/telemetry/test_oauth_spans.py @@ -0,0 +1,125 @@ +from microsoft_agents.hosting.core.telemetry import attributes +from microsoft_agents.hosting.core.app.oauth.telemetry.spans import ( + AgenticToken, + AzureBotToken, + AzureBotSignIn, + AzureBotSignOut, +) +from microsoft_agents.hosting.core.app.oauth.telemetry import constants + +from tests._common.fixtures.telemetry import ( + test_telemetry, + test_exporter, + test_metric_reader, +) + + +# ---- AgenticToken ---- + + +def test_agentic_token_creates_span(test_exporter): + with AgenticToken("handler-1", "conn-1", ["scope"]): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.AGENTIC_TOKEN + + +def test_agentic_token_span_attributes(test_exporter): + with AgenticToken("handler-1", "conn-1", ["Scope.A", "Scope.B"]): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AUTH_HANDLER_ID] == "handler-1" + assert span.attributes[attributes.CONNECTION_NAME] == "conn-1" + assert span.attributes[attributes.AUTH_SCOPES] == "Scope.A,Scope.B" + + +def test_agentic_token_no_connection(test_exporter): + with AgenticToken("handler-1", None, ["scope"]): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONNECTION_NAME] == attributes.UNKNOWN + + +def test_agentic_token_no_scopes(test_exporter): + with AgenticToken("handler-1", "conn-1", None): + pass + + span = test_exporter.get_finished_spans()[0] + assert attributes.AUTH_SCOPES not in span.attributes + + +# ---- AzureBotToken ---- + + +def test_azure_bot_token_creates_span(test_exporter): + with AzureBotToken("handler-2", "conn-2", ["scope"]): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.AZURE_BOT_TOKEN + + +def test_azure_bot_token_span_attributes(test_exporter): + with AzureBotToken("handler-2", "conn-2", ["Mail.Read"]): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AUTH_HANDLER_ID] == "handler-2" + assert span.attributes[attributes.CONNECTION_NAME] == "conn-2" + assert span.attributes[attributes.AUTH_SCOPES] == "Mail.Read" + + +# ---- AzureBotSignIn ---- + + +def test_azure_bot_sign_in_creates_span(test_exporter): + with AzureBotSignIn("handler-3", "conn-3", ["scope"]): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.AZURE_BOT_SIGN_IN + + +def test_azure_bot_sign_in_span_attributes(test_exporter): + with AzureBotSignIn("handler-3", "conn-3", ["User.Read"]): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AUTH_HANDLER_ID] == "handler-3" + assert span.attributes[attributes.CONNECTION_NAME] == "conn-3" + assert span.attributes[attributes.AUTH_SCOPES] == "User.Read" + + +# ---- AzureBotSignOut ---- + + +def test_azure_bot_sign_out_creates_span(test_exporter): + with AzureBotSignOut("handler-4"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.AZURE_BOT_SIGN_OUT + + +def test_azure_bot_sign_out_span_attributes(test_exporter): + with AzureBotSignOut("handler-4"): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AUTH_HANDLER_ID] == "handler-4" + assert span.attributes[attributes.CONNECTION_NAME] == attributes.UNKNOWN + + +def test_azure_bot_sign_out_no_scopes_in_attributes(test_exporter): + with AzureBotSignOut("handler-4"): + pass + + span = test_exporter.get_finished_spans()[0] + assert attributes.AUTH_SCOPES not in span.attributes diff --git a/tests/hosting_core/telemetry/test_simple_span_wrapper.py b/tests/hosting_core/telemetry/test_simple_span_wrapper.py index caf36ba8..ff74ce1b 100644 --- a/tests/hosting_core/telemetry/test_simple_span_wrapper.py +++ b/tests/hosting_core/telemetry/test_simple_span_wrapper.py @@ -5,7 +5,7 @@ from opentelemetry.trace import StatusCode -from tests._common.fixtures.telemetry import ( # unused imports are needed for fixtures +from tests._common.fixtures.telemetry import ( # unused imports are needed for fixtures test_telemetry, test_exporter, test_metric_reader, @@ -34,7 +34,7 @@ def _callback(self, span, duration_ms, exception): span.set_attribute("duration_ms", duration_ms) if exception: span.set_attribute("exception_message", str(exception)) - + def _get_attributes(self): return {"custom_attribute": "custom_value"} @@ -250,4 +250,4 @@ class CustomError(Exception): with pytest.raises(CustomError, match="custom msg"): with MinimalSpanWrapper("propagate"): - raise CustomError("custom msg") \ No newline at end of file + raise CustomError("custom msg") diff --git a/tests/hosting_core/telemetry/test_storage_spans.py b/tests/hosting_core/telemetry/test_storage_spans.py new file mode 100644 index 00000000..8769e45b --- /dev/null +++ b/tests/hosting_core/telemetry/test_storage_spans.py @@ -0,0 +1,105 @@ +from microsoft_agents.hosting.core.telemetry import attributes +from microsoft_agents.hosting.core.storage.telemetry.spans import ( + StorageRead, + StorageWrite, + StorageDelete, +) +from microsoft_agents.hosting.core.storage.telemetry import constants + +from tests._common.fixtures.telemetry import ( + test_telemetry, + test_exporter, + test_metric_reader, +) +from tests._common.telemetry_utils import find_metric, sum_counter, sum_hist_count + + +# ---- StorageRead ---- + + +def test_storage_read_creates_span(test_exporter): + with StorageRead(key_count=3): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_STORAGE_READ + + +def test_storage_read_span_attributes(test_exporter): + with StorageRead(key_count=5): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.KEY_COUNT] == 5 + + +def test_storage_read_records_metrics(test_exporter, test_metric_reader): + with StorageRead(key_count=1): + pass + + data = test_metric_reader.get_metrics_data() + total = sum_counter(find_metric(data, constants.METRIC_STORAGE_OPERATION_TOTAL)) + assert total == 1 + duration = sum_hist_count( + find_metric(data, constants.METRIC_STORAGE_OPERATION_DURATION) + ) + assert duration == 1 + + +# ---- StorageWrite ---- + + +def test_storage_write_creates_span(test_exporter): + with StorageWrite(key_count=2): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_STORAGE_WRITE + + +def test_storage_write_span_attributes(test_exporter): + with StorageWrite(key_count=7): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.KEY_COUNT] == 7 + + +def test_storage_write_records_metrics(test_exporter, test_metric_reader): + with StorageWrite(key_count=1): + pass + + data = test_metric_reader.get_metrics_data() + total = sum_counter(find_metric(data, constants.METRIC_STORAGE_OPERATION_TOTAL)) + assert total == 1 + + +# ---- StorageDelete ---- + + +def test_storage_delete_creates_span(test_exporter): + with StorageDelete(key_count=1): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_STORAGE_DELETE + + +def test_storage_delete_span_attributes(test_exporter): + with StorageDelete(key_count=4): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.KEY_COUNT] == 4 + + +def test_storage_delete_records_metrics(test_exporter, test_metric_reader): + with StorageDelete(key_count=1): + pass + + data = test_metric_reader.get_metrics_data() + total = sum_counter(find_metric(data, constants.METRIC_STORAGE_OPERATION_TOTAL)) + assert total == 1 diff --git a/tests/hosting_core/telemetry/test_turn_context_spans.py b/tests/hosting_core/telemetry/test_turn_context_spans.py new file mode 100644 index 00000000..ca9c3db9 --- /dev/null +++ b/tests/hosting_core/telemetry/test_turn_context_spans.py @@ -0,0 +1,62 @@ +from types import SimpleNamespace + +from microsoft_agents.activity import Activity, ConversationAccount, ChannelAccount +from microsoft_agents.hosting.core.telemetry import attributes +from microsoft_agents.hosting.core.telemetry.turn_context.spans import ( + TurnContextSendActivity, +) +from microsoft_agents.hosting.core.telemetry.turn_context import constants + +from tests._common.fixtures.telemetry import ( + test_telemetry, + test_exporter, + test_metric_reader, +) + + +def _make_context(**activity_overrides): + defaults = dict( + type="message", + channel_id="msteams", + conversation=ConversationAccount(id="conv-1"), + from_property=ChannelAccount(id="user-1"), + recipient=ChannelAccount(id="bot-1"), + ) + defaults.update(activity_overrides) + activity = Activity(**defaults) + return SimpleNamespace(activity=activity) + + +# ---- TurnContextSendActivity ---- + + +def test_send_activity_creates_span(test_exporter): + ctx = _make_context() + + with TurnContextSendActivity(ctx): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_TURN_SEND_ACTIVITY + + +def test_send_activity_span_attributes(test_exporter): + ctx = _make_context() + + with TurnContextSendActivity(ctx): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == "conv-1" + + +def test_send_activity_no_conversation(test_exporter): + ctx = _make_context() + ctx.activity.conversation = None + + with TurnContextSendActivity(ctx): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == attributes.UNKNOWN diff --git a/tests/hosting_core/telemetry/test_utils.py b/tests/hosting_core/telemetry/test_utils.py index ddfc746b..8f4bd044 100644 --- a/tests/hosting_core/telemetry/test_utils.py +++ b/tests/hosting_core/telemetry/test_utils.py @@ -10,7 +10,6 @@ get_delivery_mode, ) - # ---- format_scopes ---- @@ -71,4 +70,3 @@ def test_get_delivery_mode_all_enum_values(): for mode in DeliveryModes: activity = Activity(type="message", delivery_mode=mode) assert get_delivery_mode(activity) == mode.value - From 7e8e21f37b03bb6515195383b08abaf374fa2d06 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 25 Mar 2026 11:27:03 -0700 Subject: [PATCH 45/55] Addressing fixture cleanup for test_telemetry fixture --- tests/_common/fixtures/telemetry.py | 34 ++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/tests/_common/fixtures/telemetry.py b/tests/_common/fixtures/telemetry.py index 52864de6..aeb9317c 100644 --- a/tests/_common/fixtures/telemetry.py +++ b/tests/_common/fixtures/telemetry.py @@ -113,27 +113,41 @@ def _subtract(data, baseline): ] ) +_metric_reader = None +_exporter = None @pytest.fixture(scope="session") def test_telemetry(): """Set up fresh in-memory exporter for testing.""" - exporter = InMemorySpanExporter() - metric_reader = InMemoryMetricReader() + global _exporter, _metric_reader - tracer_provider = TracerProvider() - tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) - trace.set_tracer_provider(tracer_provider) + if _exporter is None: + exporter = InMemorySpanExporter() + metric_reader = InMemoryMetricReader() - meter_provider = MeterProvider([metric_reader]) + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) + trace.set_tracer_provider(tracer_provider) - metrics.set_meter_provider(meter_provider) + meter_provider = MeterProvider([metric_reader]) - yield exporter, metric_reader + metrics.set_meter_provider(meter_provider) + + _exporter = exporter + _metric_reader = metric_reader + else: + meter_provider = metrics.get_meter_provider() + tracer_provider = trace.get_tracer_provider() + + exporter = _exporter + metric_reader = _metric_reader + + yield _exporter, metric_reader exporter.clear() meter_provider.force_flush() - tracer_provider.shutdown() - meter_provider.shutdown() + # tracer_provider.shutdown() + # meter_provider.shutdown() @pytest.fixture(scope="function") From 050c7ac908e1c9e4ff7adbffb3c74d3a9537f558 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 25 Mar 2026 11:59:09 -0700 Subject: [PATCH 46/55] Cleaning up span creation across files --- .../authentication/msal/msal_auth.py | 23 +-- .../core/authorization/telemetry/spans.py | 2 +- .../hosting/core/channel_service_adapter.py | 49 +++---- .../core/connector/client/connector_client.py | 135 ++++++++++-------- .../telemetry/user_token_client_spans.py | 2 +- .../hosting/core/http/_http_adapter_base.py | 23 +-- .../hosting/core/telemetry/adapter/spans.py | 24 +++- .../hosting/core/turn_context.py | 18 +-- .../telemetry/test_adapter_spans.py | 14 ++ 9 files changed, 165 insertions(+), 125 deletions(-) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 433861f2..1b528445 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -348,12 +348,12 @@ async def get_agentic_instance_token( :return: A tuple containing the agentic instance token and the agent application token. :rtype: tuple[str, str] """ - with spans.GetAgenticInstanceToken(agent_app_instance_id): + if not agent_app_instance_id: + raise ValueError( + str(authentication_errors.AgentApplicationInstanceIdRequired) + ) - if not agent_app_instance_id: - raise ValueError( - str(authentication_errors.AgentApplicationInstanceIdRequired) - ) + with spans.GetAgenticInstanceToken(agent_app_instance_id): logger.info( "Attempting to get agentic instance token from agent_app_instance_id %s", @@ -441,13 +441,14 @@ async def get_agentic_user_token( :return: The agentic user token, or None if not found. :rtype: Optional[str] """ - with spans.GetAgenticUserToken(agent_app_instance_id, agentic_user_id, scopes): - if not agent_app_instance_id or not agentic_user_id: - raise ValueError( - str( - authentication_errors.AgentApplicationInstanceIdAndUserIdRequired - ) + if not agent_app_instance_id or not agentic_user_id: + raise ValueError( + str( + authentication_errors.AgentApplicationInstanceIdAndUserIdRequired ) + ) + + with spans.GetAgenticUserToken(agent_app_instance_id, agentic_user_id, scopes): logger.info( "Attempting to get agentic user token from agent_app_instance_id %s and agentic_user_id %s", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py index f0abfd4a..afef95ec 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py @@ -39,7 +39,7 @@ def _callback(self, span: Span, duration: float, error: Exception | None) -> Non class GetAccessToken(_AuthenticationSpanWrapper): """Span wrapper for the operation of retrieving an access token.""" - def __init__(self, scopes: list[str], auth_method: str): + def __init__(self, scopes: list[str], auth_method: str | Enum): """Initializes the GetAccessToken span with the specified authentication scope and type.""" super().__init__(constants.SPAN_GET_ACCESS_TOKEN, auth_method) self._scopes = scopes diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index bad61ec5..29783bd8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -68,18 +68,18 @@ async def send_activities( :rtype: list[:class:`microsoft_agents.activity.ResourceResponse`] :raises TypeError: If context or activities are None/invalid. """ - with spans.AdapterSendActivities(activities): + if not context: + raise TypeError("Expected TurnContext but got None instead") - if not context: - raise TypeError("Expected TurnContext but got None instead") + if activities is None: + raise TypeError("Expected Activities list but got None instead") - if activities is None: - raise TypeError("Expected Activities list but got None instead") + if len(activities) == 0: + raise TypeError( + "Expecting one or more activities, but the list was empty." + ) - if len(activities) == 0: - raise TypeError( - "Expecting one or more activities, but the list was empty." - ) + with spans.AdapterSendActivities(activities): responses = [] @@ -139,13 +139,13 @@ async def update_activity(self, context: TurnContext, activity: Activity): :rtype: :class:`microsoft_agents.activity.ResourceResponse` :raises TypeError: If context or activity are None/invalid. """ - with spans.AdapterUpdateActivity(activity): + if not context: + raise TypeError("Expected TurnContext but got None instead") - if not context: - raise TypeError("Expected TurnContext but got None instead") + if activity is None: + raise TypeError("Expected Activity but got None instead") - if activity is None: - raise TypeError("Expected Activity but got None instead") + with spans.AdapterUpdateActivity(activity): connector_client = cast( ConnectorClientBase, @@ -170,13 +170,13 @@ async def delete_activity( :type reference: :class:`microsoft_agents.activity.ConversationReference` :raises TypeError: If context or reference are None/invalid. """ - with spans.AdapterDeleteActivity(context.activity): - - if not context: - raise TypeError("Expected TurnContext but got None instead") + if not context: + raise TypeError("Expected TurnContext but got None instead") - if not reference: - raise TypeError("Expected ConversationReference but got None instead") + if not reference: + raise TypeError("Expected ConversationReference but got None instead") + + with spans.AdapterDeleteActivity(context.activity): connector_client = cast( ConnectorClientBase, @@ -209,11 +209,12 @@ async def continue_conversation( # pylint: disable=arguments-differ :param callback: The method to call for the resulting agent turn. :type callback: Callable[[:class:`microsoft_agents.hosting.core.turn_context.TurnContext`], Awaitable] """ + if not callable(callback): + raise TypeError( + "Expected Callback (Callable[[TurnContext], Awaitable]) but got None instead" + ) + with spans.AdapterContinueConversation(continuation_activity): - if not callable: - raise TypeError( - "Expected Callback (Callable[[TurnContext], Awaitable]) but got None instead" - ) self._validate_continuation_activity(continuation_activity) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 84ae14de..23d7f0cd 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -69,9 +69,10 @@ async def get_attachment_info(self, attachment_id: str) -> AttachmentInfo: :param attachment_id: The ID of the attachment. :return: The attachment information. """ + if attachment_id is None: + raise ValueError("attachmentId is required") + with spans.ConnectorGetAttachmentInfo(attachment_id=attachment_id) as span: - if attachment_id is None: - raise ValueError("attachmentId is required") url = f"v3/attachments/{attachment_id}" @@ -89,7 +90,6 @@ async def get_attachment_info(self, attachment_id: str) -> AttachmentInfo: data = await response.json() return AttachmentInfo(**data) - async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: """ @@ -99,19 +99,20 @@ async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: :param view_id: The ID of the view. :return: The attachment as a readable stream. """ + if attachment_id is None: + logger.error( + "AttachmentsOperations.get_attachment(): attachmentId is required", + stack_info=True, + ) + raise ValueError("attachmentId is required") + if view_id is None: + logger.error( + "AttachmentsOperations.get_attachment(): viewId is required", + stack_info=True, + ) + raise ValueError("viewId is required") + with spans.ConnectorGetAttachment(attachment_id, view_id) as span: - if attachment_id is None: - logger.error( - "AttachmentsOperations.get_attachment(): attachmentId is required", - stack_info=True, - ) - raise ValueError("attachmentId is required") - if view_id is None: - logger.error( - "AttachmentsOperations.get_attachment(): viewId is required", - stack_info=True, - ) - raise ValueError("viewId is required") url = f"v3/attachments/{attachment_id}/views/{view_id}" @@ -151,7 +152,9 @@ async def get_conversations( """ with spans.ConnectorGetConversations() as span: params = ( - {"continuationToken": continuation_token} if continuation_token else None + {"continuationToken": continuation_token} + if continuation_token + else None ) logger.info( @@ -162,7 +165,9 @@ async def get_conversations( if response.status >= 300: logger.error( - "Error getting conversations: %s", response.status, stack_info=True + "Error getting conversations: %s", + response.status, + stack_info=True, ) response.raise_for_status() @@ -187,7 +192,9 @@ async def create_conversation( span.share(http_method="POST", status_code=response.status) if response.status >= 300: logger.error( - "Error creating conversation: %s", response.status, stack_info=True + "Error creating conversation: %s", + response.status, + stack_info=True, ) response.raise_for_status() @@ -205,13 +212,14 @@ async def reply_to_activity( :param body: The activity object. :return: The resource response. """ + if not conversation_id or not activity_id: + logger.error( + "ConversationsOperations.reply_to_activity(): conversationId and activityId are required", + stack_info=True, + ) + raise ValueError("conversationId and activityId are required") + with spans.ConnectorReplyToActivity(conversation_id, activity_id) as span: - if not conversation_id or not activity_id: - logger.error( - "ConversationsOperations.reply_to_activity(): conversationId and activityId are required", - stack_info=True, - ) - raise ValueError("conversationId and activityId are required") conversation_id = self._normalize_conversation_id(conversation_id) url = f"v3/conversations/{conversation_id}/activities/{activity_id}" @@ -259,13 +267,14 @@ async def send_to_conversation( :param body: The activity object. :return: The resource response. """ + if not conversation_id: + logger.error( + "ConversationsOperations.send_to_conversation(): conversationId is required", + stack_info=True, + ) + raise ValueError("conversationId is required") + with spans.ConnectorSendToConversation(conversation_id, body.id) as span: - if not conversation_id: - logger.error( - "ConversationsOperations.sent_to_conversation(): conversationId is required", - stack_info=True, - ) - raise ValueError("conversationId is required") conversation_id = self._normalize_conversation_id(conversation_id) url = f"v3/conversations/{conversation_id}/activities" @@ -303,13 +312,14 @@ async def update_activity( :param body: The activity object. :return: The resource response. """ + if not conversation_id or not activity_id: + logger.error( + "ConversationsOperations.update_activity(): conversationId and activityId are required", + stack_info=True, + ) + raise ValueError("conversationId and activityId are required") + with spans.ConnectorUpdateActivity(conversation_id, activity_id) as span: - if not conversation_id or not activity_id: - logger.error( - "ConversationsOperations.update_activity(): conversationId and activityId are required", - stack_info=True, - ) - raise ValueError("conversationId and activityId are required") conversation_id = self._normalize_conversation_id(conversation_id) url = f"v3/conversations/{conversation_id}/activities/{activity_id}" @@ -342,13 +352,14 @@ async def delete_activity(self, conversation_id: str, activity_id: str) -> None: :param conversation_id: The ID of the conversation. :param activity_id: The ID of the activity. """ + if not conversation_id or not activity_id: + logger.error( + "ConversationsOperations.delete_activity(): conversationId and activityId are required", + stack_info=True, + ) + raise ValueError("conversationId and activityId are required") + with spans.ConnectorDeleteActivity(conversation_id, activity_id) as span: - if not conversation_id or not activity_id: - logger.error( - "ConversationsOperations.delete_activity(): conversationId and activityId are required", - stack_info=True, - ) - raise ValueError("conversationId and activityId are required") conversation_id = self._normalize_conversation_id(conversation_id) url = f"v3/conversations/{conversation_id}/activities/{activity_id}" @@ -377,13 +388,14 @@ async def upload_attachment( :param body: The attachment data. :return: The resource response. """ + if conversation_id is None: + logger.error( + "ConversationsOperations.upload_attachment(): conversationId is required", + stack_info=True, + ) + raise ValueError("conversationId is required") + with spans.ConnectorUploadAttachment(conversation_id) as span: - if conversation_id is None: - logger.error( - "ConversationsOperations.upload_attachment(): conversationId is required", - stack_info=True, - ) - raise ValueError("conversationId is required") conversation_id = self._normalize_conversation_id(conversation_id) url = f"v3/conversations/{conversation_id}/attachments" @@ -424,14 +436,14 @@ async def get_conversation_members( :param conversation_id: The ID of the conversation. :return: A list of members. """ + if not conversation_id: + logger.error( + "ConversationsOperations.get_conversation_members(): conversationId is required", + stack_info=True, + ) + raise ValueError("conversationId is required") + with spans.ConnectorGetConversationMembers() as span: - - if not conversation_id: - logger.error( - "ConversationsOperations.get_conversation_members(): conversationId is required", - stack_info=True, - ) - raise ValueError("conversationId is required") conversation_id = self._normalize_conversation_id(conversation_id) url = f"v3/conversations/{conversation_id}/members" @@ -463,13 +475,14 @@ async def get_conversation_member( :param member_id: The ID of the member. :return: The member. """ + if not conversation_id or not member_id: + logger.error( + "ConversationsOperations.get_conversation_member(): conversationId and memberId are required", + stack_info=True, + ) + raise ValueError("conversationId and memberId are required") + with spans.ConnectorGetConversationMembers() as span: - if not conversation_id or not member_id: - logger.error( - "ConversationsOperations.get_conversation_member(): conversationId and memberId are required", - stack_info=True, - ) - raise ValueError("conversationId and memberId are required") conversation_id = self._normalize_conversation_id(conversation_id) url = f"v3/conversations/{conversation_id}/members/{member_id}" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py index 8ee420a1..bba0848f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py @@ -67,7 +67,7 @@ class SignOut(_UserTokenClientSpanWrapper): """Span for signing out a user using the user token client in the adapter.""" def __init__( - self, connection_name: str, user_id: str, channel_id: str | None = None + self, connection_name: str | None, user_id: str, channel_id: str | None = None ): """Initializes the SignOut span.""" super().__init__( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py index b83caf74..48d3cab7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py @@ -84,20 +84,21 @@ async def process_request( raise TypeError("HttpAdapterBase.process_request: request can't be None") if not agent: raise TypeError("HttpAdapterBase.process_request: agent can't be None") + + with spans.AdapterProcess() as span: - if request.method != "POST": - return HttpResponseFactory.method_not_allowed() + if request.method != "POST": + return HttpResponseFactory.method_not_allowed() - try: - body = await request.json() - except Exception: - return HttpResponseFactory.bad_request( - "Invalid JSON or unsupported Content-Type" - ) - - activity: Activity = Activity.model_validate(body) + try: + body = await request.json() + except Exception: + return HttpResponseFactory.bad_request( + "Invalid JSON or unsupported Content-Type" + ) - with spans.AdapterProcess(activity): + activity: Activity = Activity.model_validate(body) + span.share(activity=activity) # Get claims identity (default to anonymous if not set by middleware) claims_identity: ClaimsIdentity = ( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py index a344e58b..28a83e67 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py @@ -20,18 +20,24 @@ class AdapterProcess(SimpleSpanWrapper): """Span for processing an incoming activity in the adapter.""" - def __init__(self, activity: Activity): + def __init__(self, activity: Activity | None = None): """Initializes the AdapterProcess SpanWrapper.""" super().__init__(constants.SPAN_PROCESS) - self._activity = activity + self._activity: Activity | None = None def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span is ended. This is used to record metrics for the adapter processing based on the outcome of the span.""" - attrs = { - attributes.ACTIVITY_TYPE: self._activity.type, - attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id - or attributes.UNKNOWN, - } + if self._active is None: + attrs = { + attributes.ACTIVITY_TYPE: attributes.UNKNOWN, + attributes.ACTIVITY_CHANNEL_ID: attributes.UNKNOWN, + } + else: + attrs = { + attributes.ACTIVITY_TYPE: self._activity.type, + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id + or attributes.UNKNOWN, + } metrics.adapter_process_duration.record(duration, attributes=attrs) metrics.activities_received.add(1, attributes=attrs) @@ -45,6 +51,10 @@ def _get_attributes(self) -> AttributeMap: attributes.IS_AGENTIC: self._activity.is_agentic_request(), } + def share(self, activity: Activity) -> None: + """Shares the activity being processed with the span, so that it can be used in the callback to record metrics.""" + self._activity = activity + class AdapterSendActivities(SimpleSpanWrapper): """Span for sending activities in the adapter.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index a91c70a5..b2f247a8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -199,16 +199,16 @@ async def send_activity( :param activity_or_text: :return: """ - if isinstance(activity_or_text, str): - activity_or_text = Activity( - type=ActivityTypes.message, - text=activity_or_text, - input_hint=input_hint or InputHints.accepting_input, - ) - if speak: - activity_or_text.speak = speak - with spans.TurnContextSendActivity(self): + + if isinstance(activity_or_text, str): + activity_or_text = Activity( + type=ActivityTypes.message, + text=activity_or_text, + input_hint=input_hint or InputHints.accepting_input, + ) + if speak: + activity_or_text.speak = speak result = await self.send_activities([activity_or_text]) return result[0] if result else None diff --git a/tests/hosting_core/telemetry/test_adapter_spans.py b/tests/hosting_core/telemetry/test_adapter_spans.py index 6b1cd60c..b82dd825 100644 --- a/tests/hosting_core/telemetry/test_adapter_spans.py +++ b/tests/hosting_core/telemetry/test_adapter_spans.py @@ -71,6 +71,20 @@ def test_adapter_process_span_attributes(test_exporter): assert attributes.ACTIVITY_DELIVERY_MODE in span_attrs assert attributes.IS_AGENTIC in span_attrs +def test_adapter_process_span_attributes_shared_activity(test_exporter): + activity = _make_activity(type="invoke", channel_id="webchat") + + with AdapterProcess() as span: + span.share(activity) + + span = test_exporter.get_finished_spans()[0] + span_attrs = dict(span.attributes) + assert span_attrs[attributes.ACTIVITY_TYPE] == "invoke" + assert span_attrs[attributes.ACTIVITY_CHANNEL_ID] == "webchat" + assert attributes.CONVERSATION_ID in span_attrs + assert attributes.ACTIVITY_DELIVERY_MODE in span_attrs + assert attributes.IS_AGENTIC in span_attrs + def test_adapter_process_records_metrics(test_exporter, test_metric_reader): activity = _make_activity() From 67a46f9b35d0d14c2bda5bf8d8ac3457780f87fb Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 25 Mar 2026 12:08:09 -0700 Subject: [PATCH 47/55] Fixing SimpleSpanWrapper setting attribute after yielding --- .../hosting/core/telemetry/adapter/spans.py | 8 +++++--- .../hosting/core/telemetry/core/simple_span_wrapper.py | 4 ++-- tests/hosting_core/telemetry/test_adapter_spans.py | 6 +----- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py index 28a83e67..0c171d2e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py @@ -23,16 +23,16 @@ class AdapterProcess(SimpleSpanWrapper): def __init__(self, activity: Activity | None = None): """Initializes the AdapterProcess SpanWrapper.""" super().__init__(constants.SPAN_PROCESS) - self._activity: Activity | None = None + self._activity: Activity | None = activity def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span is ended. This is used to record metrics for the adapter processing based on the outcome of the span.""" - if self._active is None: + if self._activity is None: attrs = { attributes.ACTIVITY_TYPE: attributes.UNKNOWN, attributes.ACTIVITY_CHANNEL_ID: attributes.UNKNOWN, } - else: + else: attrs = { attributes.ACTIVITY_TYPE: self._activity.type, attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id @@ -42,6 +42,8 @@ def _callback(self, span: Span, duration: float, error: Exception | None) -> Non metrics.activities_received.add(1, attributes=attrs) def _get_attributes(self) -> AttributeMap: + if self._activity is None: + return {} return { attributes.ACTIVITY_TYPE: self._activity.type, attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py index 1531fb10..bdd04523 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py @@ -33,8 +33,8 @@ def _start_span(self) -> Iterator[Span]: with agents_telemetry.start_as_current_span( self._span_name, callback=self._callback ) as span: + yield span if span is not None: attributes = self._get_attributes() if attributes: - span.set_attributes(attributes) - yield span + span.set_attributes(attributes) \ No newline at end of file diff --git a/tests/hosting_core/telemetry/test_adapter_spans.py b/tests/hosting_core/telemetry/test_adapter_spans.py index b82dd825..c5e13e29 100644 --- a/tests/hosting_core/telemetry/test_adapter_spans.py +++ b/tests/hosting_core/telemetry/test_adapter_spans.py @@ -1,8 +1,4 @@ -from datetime import datetime -from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.core.telemetry import ( - agents_telemetry, - SimpleSpanWrapper, attributes, ) from microsoft_agents.hosting.core.telemetry.adapter.spans import ( @@ -76,7 +72,7 @@ def test_adapter_process_span_attributes_shared_activity(test_exporter): with AdapterProcess() as span: span.share(activity) - + span = test_exporter.get_finished_spans()[0] span_attrs = dict(span.attributes) assert span_attrs[attributes.ACTIVITY_TYPE] == "invoke" From 6cabad810992aa6b01684e7f35e35e4dfd753acb Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 25 Mar 2026 12:22:08 -0700 Subject: [PATCH 48/55] Integration testing --- .../sdk/hosting-core/telemetry/_fixtures.py | 166 ++++++++++++++++++ .../sdk/hosting-core/telemetry/_utils.py | 38 ++++ .../hosting-core/telemetry/test_telemetry.py | 114 +++++------- .../authentication/msal/msal_auth.py | 6 +- .../hosting/aiohttp/cloud_adapter.py | 1 - .../hosting/core/channel_service_adapter.py | 8 +- .../core/connector/client/connector_client.py | 8 +- .../hosting/core/http/_http_adapter_base.py | 2 +- .../hosting/core/storage/memory_storage.py | 3 - .../telemetry/core/simple_span_wrapper.py | 2 +- .../hosting/core/turn_context.py | 2 +- tests/_common/fixtures/telemetry.py | 2 + .../telemetry/test_adapter_spans.py | 3 +- .../hosting_core/telemetry/test_app_spans.py | 9 +- .../hosting_core/telemetry/test_auth_spans.py | 1 - .../telemetry/test_oauth_spans.py | 1 - .../telemetry/test_storage_spans.py | 1 - 17 files changed, 271 insertions(+), 96 deletions(-) create mode 100644 dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/_fixtures.py create mode 100644 dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/_utils.py diff --git a/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/_fixtures.py b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/_fixtures.py new file mode 100644 index 00000000..ae2fcf95 --- /dev/null +++ b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/_fixtures.py @@ -0,0 +1,166 @@ +import pytest +from types import SimpleNamespace + +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 + + +class DeltaMetricReader: + """Wraps an InMemoryMetricReader so each test only sees metrics + accrued *after* the wrapper was created (or last reset). + + InMemoryMetricReader uses cumulative aggregation by default and has + no ``clear()`` method, so counters and histograms accumulate across + the whole session. This wrapper snapshots the cumulative values at + construction time and subtracts them from every subsequent + ``get_metrics_data()`` call, producing a delta view that is + compatible with the ``find_metric`` / ``sum_counter`` / + ``sum_hist_count`` helpers. + """ + + def __init__(self, inner: InMemoryMetricReader): + self._inner = inner + self._baseline: dict[tuple, tuple] = {} + self.reset() + + def reset(self): + """Capture the current cumulative values as the new zero-line.""" + data = self._inner.get_metrics_data() + self._baseline = self._snapshot(data) + + def force_flush(self): + self._inner.force_flush() + + def get_metrics_data(self): + """Return a metrics-data object containing only the delta + since the last ``reset()``.""" + raw = self._inner.get_metrics_data() + return self._subtract(raw, self._baseline) + + # -- internals -------------------------------------------------- + + @staticmethod + def _dp_key(metric_name, dp): + attrs = dp.attributes or {} + return (metric_name, tuple(sorted(attrs.items()))) + + @staticmethod + def _snapshot(data): + snap: dict[tuple, tuple] = {} + if data is None: + return snap + for rm in data.resource_metrics: + for sm in rm.scope_metrics: + for m in sm.metrics: + for dp in m.data.data_points: + k = DeltaMetricReader._dp_key(m.name, dp) + if hasattr(dp, "bucket_counts"): + snap[k] = ("hist", dp.count) + else: + snap[k] = ("counter", dp.value) + return snap + + @staticmethod + def _empty_data(): + return SimpleNamespace( + resource_metrics=[ + SimpleNamespace(scope_metrics=[SimpleNamespace(metrics=[])]) + ] + ) + + @staticmethod + def _subtract(data, baseline): + if data is None: + return DeltaMetricReader._empty_data() + all_metrics: list = [] + for rm in data.resource_metrics: + for sm in rm.scope_metrics: + for m in sm.metrics: + points: list = [] + for dp in m.data.data_points: + k = DeltaMetricReader._dp_key(m.name, dp) + base = baseline.get(k) + if hasattr(dp, "bucket_counts"): + base_count = base[1] if base else 0 + points.append( + SimpleNamespace( + attributes=dp.attributes, + count=dp.count - base_count, + ) + ) + else: + base_val = base[1] if base else 0 + points.append( + SimpleNamespace( + attributes=dp.attributes, + value=dp.value - base_val, + ) + ) + if points: + all_metrics.append( + SimpleNamespace( + name=m.name, + data=SimpleNamespace(data_points=points), + ) + ) + return SimpleNamespace( + resource_metrics=[ + SimpleNamespace(scope_metrics=[SimpleNamespace(metrics=all_metrics)]) + ] + ) + + +_metric_reader = None +_exporter = None + + +@pytest.fixture(scope="session") +def test_telemetry(): + """Set up fresh in-memory exporter for testing.""" + global _exporter, _metric_reader + + if _exporter is None: + exporter = InMemorySpanExporter() + 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) + + _exporter = exporter + _metric_reader = metric_reader + else: + meter_provider = metrics.get_meter_provider() + tracer_provider = trace.get_tracer_provider() + + exporter = _exporter + metric_reader = _metric_reader + + yield _exporter, metric_reader + + exporter.clear() + + +@pytest.fixture(scope="function") +def test_exporter(test_telemetry): + """Provide the in-memory span exporter for each test.""" + exporter, _ = test_telemetry + exporter.clear() + return exporter + + +@pytest.fixture(scope="function") +def test_metric_reader(test_telemetry): + """Provide a delta view of the metric reader for each test. + Only metrics recorded *during* the test are visible.""" + _, metric_reader = test_telemetry + metric_reader.force_flush() + return DeltaMetricReader(metric_reader) diff --git a/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/_utils.py b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/_utils.py new file mode 100644 index 00000000..3acb4008 --- /dev/null +++ b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/_utils.py @@ -0,0 +1,38 @@ +def find_metric(metrics_data, metric_name): + """Helper function to find a metric by name in the collected metrics data. + + Usage: + metric = find_metric(metrics_data, "my_metric_name") + """ + for resource_metric in metrics_data.resource_metrics: + for scope_metric in resource_metric.scope_metrics: + for metric in scope_metric.metrics: + if metric.name == metric_name: + return metric + return None + + +def sum_counter(metric, attribute_filter=None): + if metric is None: + return 0 + total = 0 + for point in metric.data.data_points: + if attribute_filter is None or all( + point.attributes.get(key) == value + for key, value in attribute_filter.items() + ): + total += point.value + return total + + +def sum_hist_count(metric, attribute_filter=None): + if metric is None: + return 0 + total = 0 + for point in metric.data.data_points: + if attribute_filter is None or all( + point.attributes.get(key) == value + for key, value in attribute_filter.items() + ): + total += point.count + return total diff --git a/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/test_telemetry.py b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/test_telemetry.py index 695c4fb9..84fb810d 100644 --- a/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/test_telemetry.py +++ b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/test_telemetry.py @@ -7,49 +7,48 @@ from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import InMemoryMetricReader -from microsoft_agents.hosting.core.telemetry import constants +from microsoft_agents.hosting.core.telemetry import ( + attributes, + SERVICE_NAME, + SERVICE_VERSION +) +from microsoft_agents.hosting.core.telemetry.adapter import constants as adapter_constants +from microsoft_agents.hosting.core.telemetry.turn_context import constants as turn_context_constants +from microsoft_agents.hosting.core.app.telemetry import constants as app_constants +from microsoft_agents.hosting.core.app.oauth.telemetry import constants as oauth_constants +from microsoft_agents.hosting.core.authorization.telemetry import constants as auth_constants +from microsoft_agents.hosting.core.connector.telemetry import constants as connector_constants +from microsoft_agents.hosting.core.storage.telemetry import constants as storage_constants from tests.scenarios import load_scenario -_SCENARIO = load_scenario("quickstart", use_jwt_middleware=False) - -@pytest.fixture(scope="module") -def test_telemetry(): - """Set up fresh in-memory exporter for testing.""" - exporter = InMemorySpanExporter() - 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) +from ._fixtures import ( + test_telemetry, + test_exporter, + test_metric_reader, +) +from ._utils import ( + sum_counter, + sum_hist_count, + find_metric +) - yield exporter, metric_reader - - exporter.clear() - 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 +_SCENARIO = load_scenario("quickstart", use_jwt_middleware=False) -@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() +def get_span(spans, name): + for span in spans: + if span.name == name: + return span + return None + +def assert_span(spans, name, expected_attributes: dict | None = None): + if not expected_attributes: + expected_attributes = {} + span = get_span(spans, name) + assert span is not None, f"Span '{name}' not found" + for key, value in expected_attributes.items(): + assert key in span.attributes, f"Attribute '{key}' not found in span '{name}'" + assert span.attributes[key] == value, f"Attribute '{key}' in span '{name}' has value '{span.attributes[key]}', expected '{value}'" @pytest.mark.asyncio @pytest.mark.agent_test(_SCENARIO) @@ -61,37 +60,15 @@ async def test_basic(test_exporter, agent_client): spans = test_exporter.get_finished_spans() # We should have a span for the overall turn - assert any( - span.name == constants.SPAN_APP_ON_TURN - for span in spans - ) - turn_span = next(span for span in spans if span.name == constants.SPAN_APP_ON_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 - ) + turn_span = get_span(spans, app_constants.SPAN_ON_TURN) + assert turn_span is not None, "Turn span not found" 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 == constants.SPAN_ADAPTER_PROCESS - for span in spans - ) - - # storage is read when accessing conversation state - assert any( - span.name == constants.SPAN_STORAGE_READ - for span in spans - ) + assert_span(spans, adapter_constants.SPAN_PROCESS) - assert len(spans) >= 3 + assert len(spans) >= 2 @pytest.mark.asyncio @pytest.mark.agent_test(_SCENARIO) @@ -114,16 +91,13 @@ async def test_multiple_users(test_exporter, agent_client): spans = test_exporter.get_finished_spans() def assert_span_for_user(user_id: str): - assert any( - span.name == constants.SPAN_APP_ON_TURN and span.attributes.get("from.id") == user_id - for span in spans - ) + assert_span(spans, app_constants.SPAN_ON_TURN, {"from.id": user_id}) assert_span_for_user("user1") assert_span_for_user("user2") - assert len(list(filter(lambda span: span.name == constants.SPAN_APP_ON_TURN, spans))) == 2 - assert len(list(filter(lambda span: span.name == constants.SPAN_ADAPTER_PROCESS, spans))) == 2 + assert len(list(filter(lambda span: span.name == app_constants.SPAN_ON_TURN, spans))) == 2 + assert len(list(filter(lambda span: span.name == adapter_constants.SPAN_PROCESS, spans))) == 2 @pytest.mark.asyncio @pytest.mark.agent_test(_SCENARIO) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 1b528445..6802b2ef 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -443,11 +443,9 @@ async def get_agentic_user_token( """ if not agent_app_instance_id or not agentic_user_id: raise ValueError( - str( - authentication_errors.AgentApplicationInstanceIdAndUserIdRequired - ) + str(authentication_errors.AgentApplicationInstanceIdAndUserIdRequired) ) - + with spans.GetAgenticUserToken(agent_app_instance_id, agentic_user_id, scopes): logger.info( 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 13b55ae3..88373953 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,7 +11,6 @@ HttpResponse, ) from microsoft_agents.hosting.core import ChannelServiceClientFactoryBase -from microsoft_agents.hosting.core.telemetry import spans from .agent_http_adapter import AgentHttpAdapter diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index 29783bd8..fe6a3ef1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -75,9 +75,7 @@ async def send_activities( raise TypeError("Expected Activities list but got None instead") if len(activities) == 0: - raise TypeError( - "Expecting one or more activities, but the list was empty." - ) + raise TypeError("Expecting one or more activities, but the list was empty.") with spans.AdapterSendActivities(activities): @@ -175,7 +173,7 @@ async def delete_activity( if not reference: raise TypeError("Expected ConversationReference but got None instead") - + with spans.AdapterDeleteActivity(context.activity): connector_client = cast( @@ -213,7 +211,7 @@ async def continue_conversation( # pylint: disable=arguments-differ raise TypeError( "Expected Callback (Callable[[TurnContext], Awaitable]) but got None instead" ) - + with spans.AdapterContinueConversation(continuation_activity): self._validate_continuation_activity(continuation_activity) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 23d7f0cd..f82f161a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -111,7 +111,7 @@ async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: stack_info=True, ) raise ValueError("viewId is required") - + with spans.ConnectorGetAttachment(attachment_id, view_id) as span: url = f"v3/attachments/{attachment_id}/views/{view_id}" @@ -218,7 +218,7 @@ async def reply_to_activity( stack_info=True, ) raise ValueError("conversationId and activityId are required") - + with spans.ConnectorReplyToActivity(conversation_id, activity_id) as span: conversation_id = self._normalize_conversation_id(conversation_id) @@ -318,7 +318,7 @@ async def update_activity( stack_info=True, ) raise ValueError("conversationId and activityId are required") - + with spans.ConnectorUpdateActivity(conversation_id, activity_id) as span: conversation_id = self._normalize_conversation_id(conversation_id) @@ -358,7 +358,7 @@ async def delete_activity(self, conversation_id: str, activity_id: str) -> None: stack_info=True, ) raise ValueError("conversationId and activityId are required") - + with spans.ConnectorDeleteActivity(conversation_id, activity_id) as span: conversation_id = self._normalize_conversation_id(conversation_id) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py index 48d3cab7..6de63fb1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py @@ -84,7 +84,7 @@ async def process_request( raise TypeError("HttpAdapterBase.process_request: request can't be None") if not agent: raise TypeError("HttpAdapterBase.process_request: agent can't be None") - + with spans.AdapterProcess() as span: if request.method != "POST": 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 e685a1ce..31560b27 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,12 +4,9 @@ from threading import Lock from typing import TypeVar -from .telemetry import spans - from ._type_aliases import JSON from .storage import Storage from .store_item import StoreItem -from .telemetry import spans StoreItemT = TypeVar("StoreItemT", bound=StoreItem) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py index bdd04523..a7a2eb48 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py @@ -37,4 +37,4 @@ def _start_span(self) -> Iterator[Span]: if span is not None: attributes = self._get_attributes() if attributes: - span.set_attributes(attributes) \ No newline at end of file + span.set_attributes(attributes) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index b2f247a8..1a1f71ff 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -200,7 +200,7 @@ async def send_activity( :return: """ with spans.TurnContextSendActivity(self): - + if isinstance(activity_or_text, str): activity_or_text = Activity( type=ActivityTypes.message, diff --git a/tests/_common/fixtures/telemetry.py b/tests/_common/fixtures/telemetry.py index aeb9317c..5de63784 100644 --- a/tests/_common/fixtures/telemetry.py +++ b/tests/_common/fixtures/telemetry.py @@ -113,9 +113,11 @@ def _subtract(data, baseline): ] ) + _metric_reader = None _exporter = None + @pytest.fixture(scope="session") def test_telemetry(): """Set up fresh in-memory exporter for testing.""" diff --git a/tests/hosting_core/telemetry/test_adapter_spans.py b/tests/hosting_core/telemetry/test_adapter_spans.py index c5e13e29..bc5c8be1 100644 --- a/tests/hosting_core/telemetry/test_adapter_spans.py +++ b/tests/hosting_core/telemetry/test_adapter_spans.py @@ -67,12 +67,13 @@ def test_adapter_process_span_attributes(test_exporter): assert attributes.ACTIVITY_DELIVERY_MODE in span_attrs assert attributes.IS_AGENTIC in span_attrs + def test_adapter_process_span_attributes_shared_activity(test_exporter): activity = _make_activity(type="invoke", channel_id="webchat") with AdapterProcess() as span: span.share(activity) - + span = test_exporter.get_finished_spans()[0] span_attrs = dict(span.attributes) assert span_attrs[attributes.ACTIVITY_TYPE] == "invoke" diff --git a/tests/hosting_core/telemetry/test_app_spans.py b/tests/hosting_core/telemetry/test_app_spans.py index 1d3805a4..7d91b66a 100644 --- a/tests/hosting_core/telemetry/test_app_spans.py +++ b/tests/hosting_core/telemetry/test_app_spans.py @@ -56,7 +56,10 @@ def test_app_on_turn_span_attributes(test_exporter): span = test_exporter.get_finished_spans()[0] assert span.attributes[attributes.CONVERSATION_ID] == "conv-1" assert span.attributes[attributes.ACTIVITY_CHANNEL_ID] == "msteams" - assert span.attributes[attributes.SERVICE_URL] == "https://smba.trafficmanager.net/teams/" + assert ( + span.attributes[attributes.SERVICE_URL] + == "https://smba.trafficmanager.net/teams/" + ) def test_app_on_turn_records_turn_metrics(test_exporter, test_metric_reader): @@ -72,7 +75,9 @@ def test_app_on_turn_records_turn_metrics(test_exporter, test_metric_reader): assert duration == 1 -def test_app_on_turn_records_error_metric_on_exception(test_exporter, test_metric_reader): +def test_app_on_turn_records_error_metric_on_exception( + test_exporter, test_metric_reader +): ctx = _make_context() try: diff --git a/tests/hosting_core/telemetry/test_auth_spans.py b/tests/hosting_core/telemetry/test_auth_spans.py index 7d75c90d..8c61376f 100644 --- a/tests/hosting_core/telemetry/test_auth_spans.py +++ b/tests/hosting_core/telemetry/test_auth_spans.py @@ -14,7 +14,6 @@ ) from tests._common.telemetry_utils import find_metric, sum_counter, sum_hist_count - # ---- GetAccessToken ---- diff --git a/tests/hosting_core/telemetry/test_oauth_spans.py b/tests/hosting_core/telemetry/test_oauth_spans.py index 1b50a960..eb63935d 100644 --- a/tests/hosting_core/telemetry/test_oauth_spans.py +++ b/tests/hosting_core/telemetry/test_oauth_spans.py @@ -13,7 +13,6 @@ test_metric_reader, ) - # ---- AgenticToken ---- diff --git a/tests/hosting_core/telemetry/test_storage_spans.py b/tests/hosting_core/telemetry/test_storage_spans.py index 8769e45b..25ac7566 100644 --- a/tests/hosting_core/telemetry/test_storage_spans.py +++ b/tests/hosting_core/telemetry/test_storage_spans.py @@ -13,7 +13,6 @@ ) from tests._common.telemetry_utils import find_metric, sum_counter, sum_hist_count - # ---- StorageRead ---- From e963b7a6342eb5e7505f8693b520d5f94abfa0ae Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 25 Mar 2026 13:43:12 -0700 Subject: [PATCH 49/55] More comprehensive integration tests and fixed consistency for AppOnTurn --- dev/testing/python-sdk-tests/run_tests.ps1 | 3 + .../tests/integration/test_telemetry.py | 148 ++++++++++++++++++ .../python-sdk-tests/tests/sdk/__init__.py | 0 .../tests/sdk/hosting-core/__init__.py | 0 .../sdk/hosting-core/telemetry/__init__.py | 0 .../hosting-core/telemetry/test_telemetry.py | 113 ------------- .../telemetry_fixtures.py} | 0 .../_utils.py => utils/telemetry_utils.py} | 0 .../hosting/core/app/agent_application.py | 10 +- .../hosting/core/app/telemetry/spans.py | 8 +- .../hosting/core/channel_service_adapter.py | 84 +++++----- .../hosting/core/turn_context.py | 22 ++- .../telemetry/test_agents_telemetry.py | 2 +- 13 files changed, 213 insertions(+), 177 deletions(-) create mode 100644 dev/testing/python-sdk-tests/run_tests.ps1 create mode 100644 dev/testing/python-sdk-tests/tests/integration/test_telemetry.py delete mode 100644 dev/testing/python-sdk-tests/tests/sdk/__init__.py delete mode 100644 dev/testing/python-sdk-tests/tests/sdk/hosting-core/__init__.py delete mode 100644 dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/__init__.py delete mode 100644 dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/test_telemetry.py rename dev/testing/python-sdk-tests/tests/{sdk/hosting-core/telemetry/_fixtures.py => utils/telemetry_fixtures.py} (100%) rename dev/testing/python-sdk-tests/tests/{sdk/hosting-core/telemetry/_utils.py => utils/telemetry_utils.py} (100%) diff --git a/dev/testing/python-sdk-tests/run_tests.ps1 b/dev/testing/python-sdk-tests/run_tests.ps1 new file mode 100644 index 00000000..e35a2531 --- /dev/null +++ b/dev/testing/python-sdk-tests/run_tests.ps1 @@ -0,0 +1,3 @@ +Get-ChildItem -Path tests -Filter test_*.py -Recurse | ForEach-Object { + pytest $_.FullName +} \ No newline at end of file diff --git a/dev/testing/python-sdk-tests/tests/integration/test_telemetry.py b/dev/testing/python-sdk-tests/tests/integration/test_telemetry.py new file mode 100644 index 00000000..9d233d44 --- /dev/null +++ b/dev/testing/python-sdk-tests/tests/integration/test_telemetry.py @@ -0,0 +1,148 @@ +import pytest + +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 microsoft_agents.activity import DeliveryModes +from microsoft_agents.hosting.core.telemetry import ( + attributes, + SERVICE_NAME, + SERVICE_VERSION +) +from microsoft_agents.hosting.core.telemetry.adapter import constants as adapter_constants +from microsoft_agents.hosting.core.telemetry.turn_context import constants as turn_context_constants +from microsoft_agents.hosting.core.app.telemetry import constants as app_constants +from microsoft_agents.hosting.core.app.oauth.telemetry import constants as oauth_constants +from microsoft_agents.hosting.core.authorization.telemetry import constants as auth_constants +from microsoft_agents.hosting.core.connector.telemetry import constants as connector_constants +from microsoft_agents.hosting.core.storage.telemetry import constants as storage_constants + +from tests.scenarios import load_scenario + +from tests.utils.telemetry_fixtures import ( # unused imports are needed for fixtures + test_telemetry, + test_exporter, + test_metric_reader, +) +from tests.utils.telemetry_utils import ( + sum_counter, + sum_hist_count, + find_metric +) + +_SCENARIO = load_scenario("quickstart", use_jwt_middleware=False) + +def get_span(spans, name): + for span in spans: + if span.name == name: + return span + return None + +def assert_span(spans, name, expected_attributes: dict | None = None): + if not expected_attributes: + expected_attributes = {} + + for span in spans: + if span.name == name: + match = True + for key, value in expected_attributes.items(): + if key not in span.attributes or span.attributes[key] != value: + match = False + break + if match: + return + assert False, f"Span '{name}' with attributes {expected_attributes} not found" + +@pytest.mark.asyncio +@pytest.mark.agent_test(_SCENARIO) +async def test_basic(test_exporter, test_metric_reader, agent_client): + """Test that spans are created for a simple scenario.""" + + activity_id = "test-activity-id" + activity = agent_client.template.create( + { + "type": "message", + "id": activity_id + } + ) + + await agent_client.send_expect_replies(activity) + + spans = test_exporter.get_finished_spans() + + # We should have a span for the overall turn + assert_span(spans, app_constants.SPAN_ON_TURN, { + attributes.ROUTE_AUTHORIZED: True, + attributes.ROUTE_MATCHED: True, + attributes.ACTIVITY_ID: activity_id, + attributes.ACTIVITY_TYPE: "message" + }) + + assert_span(spans, app_constants.SPAN_BEFORE_TURN) + assert_span(spans, app_constants.SPAN_AFTER_TURN) + + assert_span(spans, app_constants.SPAN_ROUTE_HANDLER, { + attributes.ROUTE_IS_INVOKE: False, + attributes.ROUTE_IS_AGENTIC: False, + }) + + assert_span(spans, adapter_constants.SPAN_PROCESS, { + attributes.ACTIVITY_TYPE: "message", + attributes.ACTIVITY_CHANNEL_ID: activity.channel_id, + attributes.ACTIVITY_DELIVERY_MODE: DeliveryModes.expect_replies, + attributes.CONVERSATION_ID: activity.conversation.id, + attributes.IS_AGENTIC: False, + }) + + assert get_span(spans, adapter_constants.SPAN_CREATE_CONNECTOR_CLIENT) is None + assert_span(spans, adapter_constants.SPAN_CREATE_USER_TOKEN_CLIENT) + + metrics_data = test_metric_reader.get_metrics_data() + + received_activities = sum_counter(find_metric(metrics_data, adapter_constants.METRIC_ACTIVITIES_RECEIVED)) + assert received_activities >= 1 + + sent_activities = sum_counter(find_metric(metrics_data, adapter_constants.METRIC_ACTIVITIES_SENT)) + assert sent_activities >= 1 + + process_duration_count = sum_hist_count(find_metric(metrics_data, adapter_constants.METRIC_ADAPTER_PROCESS_DURATION)) + assert process_duration_count == 1 + + connector_request_count = sum_counter(find_metric(metrics_data, connector_constants.METRIC_CONNECTOR_REQUEST_COUNT)) + assert connector_request_count == 0 + + user_token_client_request_count = sum_counter(find_metric(metrics_data, connector_constants.METRIC_USER_TOKEN_CLIENT_REQUEST_COUNT)) + assert user_token_client_request_count == 0 + + turn_count = sum_counter(find_metric(metrics_data, app_constants.METRIC_TURN_COUNT)) + assert turn_count >= 1 + + turn_errors = sum_counter(find_metric(metrics_data, app_constants.METRIC_TURN_ERROR_COUNT)) + assert turn_errors == 0 + +@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() + + assert len(list(filter(lambda span: span.name == app_constants.SPAN_ON_TURN, spans))) == 2 + assert len(list(filter(lambda span: span.name == adapter_constants.SPAN_PROCESS, spans))) == 2 \ No newline at end of file diff --git a/dev/testing/python-sdk-tests/tests/sdk/__init__.py b/dev/testing/python-sdk-tests/tests/sdk/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/dev/testing/python-sdk-tests/tests/sdk/hosting-core/__init__.py b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/__init__.py b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/test_telemetry.py b/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/test_telemetry.py deleted file mode 100644 index 84fb810d..00000000 --- a/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/test_telemetry.py +++ /dev/null @@ -1,113 +0,0 @@ -import pytest - -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 microsoft_agents.hosting.core.telemetry import ( - attributes, - SERVICE_NAME, - SERVICE_VERSION -) -from microsoft_agents.hosting.core.telemetry.adapter import constants as adapter_constants -from microsoft_agents.hosting.core.telemetry.turn_context import constants as turn_context_constants -from microsoft_agents.hosting.core.app.telemetry import constants as app_constants -from microsoft_agents.hosting.core.app.oauth.telemetry import constants as oauth_constants -from microsoft_agents.hosting.core.authorization.telemetry import constants as auth_constants -from microsoft_agents.hosting.core.connector.telemetry import constants as connector_constants -from microsoft_agents.hosting.core.storage.telemetry import constants as storage_constants - -from tests.scenarios import load_scenario - -from ._fixtures import ( - test_telemetry, - test_exporter, - test_metric_reader, -) -from ._utils import ( - sum_counter, - sum_hist_count, - find_metric -) - -_SCENARIO = load_scenario("quickstart", use_jwt_middleware=False) - -def get_span(spans, name): - for span in spans: - if span.name == name: - return span - return None - -def assert_span(spans, name, expected_attributes: dict | None = None): - if not expected_attributes: - expected_attributes = {} - span = get_span(spans, name) - assert span is not None, f"Span '{name}' not found" - for key, value in expected_attributes.items(): - assert key in span.attributes, f"Attribute '{key}' not found in span '{name}'" - assert span.attributes[key] == value, f"Attribute '{key}' in span '{name}' has value '{span.attributes[key]}', expected '{value}'" - -@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() - - # We should have a span for the overall turn - turn_span = get_span(spans, app_constants.SPAN_ON_TURN) - assert turn_span is not None, "Turn span not found" - assert turn_span.attributes["activity.type"] == "message" - assert turn_span.attributes["agent.is_agentic"] == False - assert turn_span.attributes["message.text.length"] == len("Hello!") - - assert_span(spans, adapter_constants.SPAN_PROCESS) - - assert len(spans) >= 2 - -@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_span(spans, app_constants.SPAN_ON_TURN, {"from.id": user_id}) - - assert_span_for_user("user1") - assert_span_for_user("user2") - - assert len(list(filter(lambda span: span.name == app_constants.SPAN_ON_TURN, spans))) == 2 - assert len(list(filter(lambda span: span.name == adapter_constants.SPAN_PROCESS, 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(metrics) > 0 \ No newline at end of file diff --git a/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/_fixtures.py b/dev/testing/python-sdk-tests/tests/utils/telemetry_fixtures.py similarity index 100% rename from dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/_fixtures.py rename to dev/testing/python-sdk-tests/tests/utils/telemetry_fixtures.py diff --git a/dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/_utils.py b/dev/testing/python-sdk-tests/tests/utils/telemetry_utils.py similarity index 100% rename from dev/testing/python-sdk-tests/tests/sdk/hosting-core/telemetry/_utils.py rename to dev/testing/python-sdk-tests/tests/utils/telemetry_utils.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 94265144..c8633ecf 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 @@ -851,10 +851,12 @@ async def _on_activity( route_authorized = True with spans.AppRouteHandler(route.is_invoke, route.is_agentic): await route.handler(context, state) - return - logger.warning( - f"No route found for activity type: {context.activity.type} with text: {context.activity.text}" - ) + break + + if not route_matched: + logger.warning( + f"No route found for activity type: {context.activity.type} with text: {context.activity.text}" + ) if on_turn_span is not None: on_turn_span.share( route_authorized=route_authorized, route_matched=route_matched diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py index 618a8964..4edf770f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py @@ -44,12 +44,8 @@ def _callback(self, span: Span, duration: float, error: Exception | None) -> Non def _get_attributes(self) -> AttributeMap: return { - attributes.CONVERSATION_ID: get_conversation_id( - self._turn_context.activity - ), - attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id - or attributes.UNKNOWN, - attributes.SERVICE_URL: self._turn_context.activity.service_url, + attributes.ACTIVITY_TYPE: self._turn_context.activity.type, + attributes.ACTIVITY_ID: self._turn_context.activity.id or attributes.UNKNOWN, } def share(self, route_authorized: bool, route_matched: bool) -> None: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index fe6a3ef1..460c4010 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -77,33 +77,32 @@ async def send_activities( if len(activities) == 0: raise TypeError("Expecting one or more activities, but the list was empty.") - with spans.AdapterSendActivities(activities): - - responses = [] - - for activity in activities: - activity.id = None - - response = ResourceResponse() - - if activity.type == ActivityTypes.invoke_response: - context.turn_state[self.INVOKE_RESPONSE_KEY] = activity - elif ( - activity.type == ActivityTypes.trace - and activity.channel_id != Channels.emulator - ): - # no-op - pass - else: - connector_client = cast( - ConnectorClientBase, - context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), + responses = [] + + for activity in activities: + activity.id = None + + response = ResourceResponse() + + if activity.type == ActivityTypes.invoke_response: + context.turn_state[self.INVOKE_RESPONSE_KEY] = activity + elif ( + activity.type == ActivityTypes.trace + and activity.channel_id != Channels.emulator + ): + # no-op + pass + else: + connector_client = cast( + ConnectorClientBase, + context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), + ) + if not connector_client: + raise Error( + "Unable to extract ConnectorClient from turn context." ) - if not connector_client: - raise Error( - "Unable to extract ConnectorClient from turn context." - ) - + + with spans.AdapterSendActivities([activity]): if activity.reply_to_id: response = ( await connector_client.conversations.reply_to_activity( @@ -119,11 +118,11 @@ async def send_activities( activity, ) ) - response = response or ResourceResponse(id=activity.id or "") + response = response or ResourceResponse(id=activity.id or "") - responses.append(response) + responses.append(response) - return responses + return responses async def update_activity(self, context: TurnContext, activity: Activity): """ @@ -534,22 +533,25 @@ def _process_turn_results(self, context: TurnContext) -> Optional[InvokeResponse # Handle ExpectedReplies scenarios where all activities have been # buffered and sent back at once in an invoke response. if context.activity.delivery_mode == DeliveryModes.expect_replies: - return InvokeResponse( - status=HTTPStatus.OK, - body=ExpectedReplies( - activities=context.buffered_reply_activities - ).model_dump(mode="json", by_alias=True, exclude_unset=True), - ) + with spans.AdapterSendActivities([context.activity]): + return InvokeResponse( + status=HTTPStatus.OK, + body=ExpectedReplies( + activities=context.buffered_reply_activities + ).model_dump(mode="json", by_alias=True, exclude_unset=True), + ) # Handle Invoke scenarios where the agent will return a specific body and return code. if context.activity.type == ActivityTypes.invoke: - activity_invoke_response: Activity = context.turn_state.get( - self.INVOKE_RESPONSE_KEY - ) - if not activity_invoke_response: - return InvokeResponse(status=HTTPStatus.NOT_IMPLEMENTED) - return InvokeResponse.model_validate(activity_invoke_response.value) + with spans.AdapterSendActivities([context.activity]): + activity_invoke_response: Activity = context.turn_state.get( + self.INVOKE_RESPONSE_KEY + ) + if not activity_invoke_response: + return InvokeResponse(status=HTTPStatus.NOT_IMPLEMENTED) + + return InvokeResponse.model_validate(activity_invoke_response.value) # No body to return return None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index 1a1f71ff..b0c9a9a2 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -199,19 +199,17 @@ async def send_activity( :param activity_or_text: :return: """ - with spans.TurnContextSendActivity(self): - - if isinstance(activity_or_text, str): - activity_or_text = Activity( - type=ActivityTypes.message, - text=activity_or_text, - input_hint=input_hint or InputHints.accepting_input, - ) - if speak: - activity_or_text.speak = speak + if isinstance(activity_or_text, str): + activity_or_text = Activity( + type=ActivityTypes.message, + text=activity_or_text, + input_hint=input_hint or InputHints.accepting_input, + ) + if speak: + activity_or_text.speak = speak - result = await self.send_activities([activity_or_text]) - return result[0] if result else None + result = await self.send_activities([activity_or_text]) + return result[0] if result else None async def send_activities( self, activities: list[Activity] diff --git a/tests/hosting_core/telemetry/test_agents_telemetry.py b/tests/hosting_core/telemetry/test_agents_telemetry.py index 954daa9a..ab08453c 100644 --- a/tests/hosting_core/telemetry/test_agents_telemetry.py +++ b/tests/hosting_core/telemetry/test_agents_telemetry.py @@ -106,4 +106,4 @@ def test_start_as_current_span_with_callback_with_failure(mocker, test_exporter) assert callback_span.name == "test_span" assert duration_ms >= 0 assert callback_exception is not None - assert str(callback_exception) == "Test exception" + assert str(callback_exception) == "Test exception" \ No newline at end of file From 0f8c2c3cdf1390d6d4fb83bf4eaf7c61e6fae98f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 25 Mar 2026 13:44:20 -0700 Subject: [PATCH 50/55] Another commit --- .../tests/basic/test_quickstart.py | 2 +- .../hosting/core/app/telemetry/spans.py | 3 ++- .../hosting/core/channel_service_adapter.py | 6 ++---- .../telemetry/test_agents_telemetry.py | 2 +- .../hosting_core/telemetry/test_app_spans.py | 19 +++++++++++++------ 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/dev/testing/cross-sdk-tests/tests/basic/test_quickstart.py b/dev/testing/cross-sdk-tests/tests/basic/test_quickstart.py index 121cdbb9..4b43937e 100644 --- a/dev/testing/cross-sdk-tests/tests/basic/test_quickstart.py +++ b/dev/testing/cross-sdk-tests/tests/basic/test_quickstart.py @@ -38,7 +38,7 @@ async def test_conversation_update(self, agent_client: AgentClient): await agent_client.send(input_activity, wait=10) agent_client.expect().that_for_one(type="message", text="~Welcome") - # @pytest.mark.asyncio + @pytest.mark.asyncio async def test_send_hello(self, agent_client: AgentClient): """Test sending a 'hello' message and receiving a response.""" await agent_client.send("hello", wait=10) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py index 4edf770f..f48be961 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py @@ -45,7 +45,8 @@ def _callback(self, span: Span, duration: float, error: Exception | None) -> Non def _get_attributes(self) -> AttributeMap: return { attributes.ACTIVITY_TYPE: self._turn_context.activity.type, - attributes.ACTIVITY_ID: self._turn_context.activity.id or attributes.UNKNOWN, + attributes.ACTIVITY_ID: self._turn_context.activity.id + or attributes.UNKNOWN, } def share(self, route_authorized: bool, route_matched: bool) -> None: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index 460c4010..c81457c5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -98,10 +98,8 @@ async def send_activities( context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), ) if not connector_client: - raise Error( - "Unable to extract ConnectorClient from turn context." - ) - + raise Error("Unable to extract ConnectorClient from turn context.") + with spans.AdapterSendActivities([activity]): if activity.reply_to_id: response = ( diff --git a/tests/hosting_core/telemetry/test_agents_telemetry.py b/tests/hosting_core/telemetry/test_agents_telemetry.py index ab08453c..954daa9a 100644 --- a/tests/hosting_core/telemetry/test_agents_telemetry.py +++ b/tests/hosting_core/telemetry/test_agents_telemetry.py @@ -106,4 +106,4 @@ def test_start_as_current_span_with_callback_with_failure(mocker, test_exporter) assert callback_span.name == "test_span" assert duration_ms >= 0 assert callback_exception is not None - assert str(callback_exception) == "Test exception" \ No newline at end of file + assert str(callback_exception) == "Test exception" diff --git a/tests/hosting_core/telemetry/test_app_spans.py b/tests/hosting_core/telemetry/test_app_spans.py index 7d91b66a..8fe8ea71 100644 --- a/tests/hosting_core/telemetry/test_app_spans.py +++ b/tests/hosting_core/telemetry/test_app_spans.py @@ -48,18 +48,25 @@ def test_app_on_turn_creates_span(test_exporter): def test_app_on_turn_span_attributes(test_exporter): + ctx = _make_context(id="act-1") + + with AppOnTurn(ctx): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_TYPE] == "message" + assert span.attributes[attributes.ACTIVITY_ID] == "act-1" + + +def test_app_on_turn_span_attributes_missing_id(test_exporter): ctx = _make_context() with AppOnTurn(ctx): pass span = test_exporter.get_finished_spans()[0] - assert span.attributes[attributes.CONVERSATION_ID] == "conv-1" - assert span.attributes[attributes.ACTIVITY_CHANNEL_ID] == "msteams" - assert ( - span.attributes[attributes.SERVICE_URL] - == "https://smba.trafficmanager.net/teams/" - ) + assert span.attributes[attributes.ACTIVITY_TYPE] == "message" + assert span.attributes[attributes.ACTIVITY_ID] == attributes.UNKNOWN def test_app_on_turn_records_turn_metrics(test_exporter, test_metric_reader): From c7892ce220cecacb85e9c0ec873cbea475ea5ef8 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 25 Mar 2026 18:08:53 -0700 Subject: [PATCH 51/55] Formatting and updating OTEL sample --- .../core/connector/client/connector_client.py | 4 +- test_samples/otel/{src => }/env.TEMPLATE | 2 + test_samples/otel/src/agent.py | 25 ++++++- test_samples/otel/src/card.py | 73 +++++++++++++++++++ test_samples/otel/src/get_user_info.py | 18 +++++ test_samples/otel/src/requirements.txt | 14 ---- test_samples/otel/src/start_server.py | 11 +-- test_samples/otel/src/telemetry.py | 5 -- .../{dashboard.ps1 => start_dashboard.ps1} | 0 9 files changed, 122 insertions(+), 30 deletions(-) rename test_samples/otel/{src => }/env.TEMPLATE (84%) create mode 100644 test_samples/otel/src/card.py create mode 100644 test_samples/otel/src/get_user_info.py delete mode 100644 test_samples/otel/src/requirements.txt rename test_samples/otel/{dashboard.ps1 => start_dashboard.ps1} (100%) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 21750f27..4230b4a1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -252,7 +252,9 @@ async def reply_to_activity( if not response_text: resource_response = ResourceResponse() else: - resource_response = ResourceResponse.model_validate_json(response_text) + resource_response = ResourceResponse.model_validate_json( + response_text + ) logger.info( "Reply to conversation/activity: %s, %s", diff --git a/test_samples/otel/src/env.TEMPLATE b/test_samples/otel/env.TEMPLATE similarity index 84% rename from test_samples/otel/src/env.TEMPLATE rename to test_samples/otel/env.TEMPLATE index b7b556f9..bf65df42 100644 --- a/test_samples/otel/src/env.TEMPLATE +++ b/test_samples/otel/env.TEMPLATE @@ -2,6 +2,8 @@ CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=client-id CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=client-secret CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=tenant-id +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME=connection-name + LOGGING__LOGLEVEL__microsoft_agents.hosting.core=INFO OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 diff --git a/test_samples/otel/src/agent.py b/test_samples/otel/src/agent.py index 037a6e76..048abf04 100644 --- a/test_samples/otel/src/agent.py +++ b/test_samples/otel/src/agent.py @@ -14,10 +14,14 @@ TurnState, TurnContext, MemoryStorage, + MessageFactory, ) from microsoft_agents.authentication.msal import MsalConnectionManager from microsoft_agents.activity import load_configuration_from_env +from .get_user_info import get_user_info +from .card import create_profile_card + load_dotenv() agents_sdk_config = load_configuration_from_env(environ) @@ -41,6 +45,26 @@ async def on_members_added(context: TurnContext, _state: TurnState): return True +@AGENT_APP.message("/logout") +async def logout(context: TurnContext, state: TurnState) -> None: + await AGENT_APP.auth.sign_out(context, "GRAPH") + await context.send_activity(MessageFactory.text("You have been logged out.")) + + +@AGENT_APP.message( + re.compile(r"^/(me|profile)$", re.IGNORECASE), auth_handlers=["GRAPH"] +) +async def profile_request(context: TurnContext, state: TurnState) -> None: + user_token_response = await AGENT_APP.auth.get_token(context, "GRAPH") + if user_token_response and user_token_response is not None: + user_info = await get_user_info(user_token_response.token) + activity = MessageFactory.attachment(create_profile_card(user_info)) + await context.send_activity(activity) + else: + await context.send_activity( + 'Token not available. Enter "login" to sign in.' + ) + @AGENT_APP.message(re.compile(r"^hello$")) async def on_hello(context: TurnContext, _state: TurnState): await context.send_activity("Hello!") @@ -50,7 +74,6 @@ async def on_hello(context: TurnContext, _state: TurnState): async def on_message(context: TurnContext, _state: TurnState): 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. diff --git a/test_samples/otel/src/card.py b/test_samples/otel/src/card.py new file mode 100644 index 00000000..f2e44a28 --- /dev/null +++ b/test_samples/otel/src/card.py @@ -0,0 +1,73 @@ +from microsoft_agents.hosting.core import CardFactory + +def create_profile_card(profile): + return CardFactory.adaptive_card( + { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "type": "AdaptiveCard", + "body": [ + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "auto", + "items": ( + [ + { + "type": "Image", + "altText": "", + "url": profile.get("imageUri", ""), + "style": "Person", + "size": "Small", + } + ] + if profile.get("imageUri") + else [] + ), + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "TextBlock", + "weight": "Bolder", + "text": profile["displayName"], + }, + { + "type": "Container", + "spacing": "Small", + "items": [ + { + "type": "TextBlock", + "text": profile["jobTitle"], + "spacing": "Small", + }, + { + "type": "TextBlock", + "text": profile["mail"], + "spacing": "None", + }, + { + "type": "TextBlock", + "text": profile["givenName"], + "spacing": "None", + }, + { + "type": "TextBlock", + "text": profile["surname"], + "spacing": "None", + }, + ], + }, + ], + }, + ], + } + ], + } + ) + + diff --git a/test_samples/otel/src/get_user_info.py b/test_samples/otel/src/get_user_info.py new file mode 100644 index 00000000..4fded9b1 --- /dev/null +++ b/test_samples/otel/src/get_user_info.py @@ -0,0 +1,18 @@ +import aiohttp + +async def get_user_info(token): + """ + Get information about the current user from Microsoft Graph API. + """ + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + async with session.get( + "https://graph.microsoft.com/v1.0/me", headers=headers + ) as response: + if response.status == 200: + return await response.json() + error_text = await response.text() + raise Exception(f"Error from Graph API: {response.status} - {error_text}") diff --git a/test_samples/otel/src/requirements.txt b/test_samples/otel/src/requirements.txt deleted file mode 100644 index 879687ff..00000000 --- a/test_samples/otel/src/requirements.txt +++ /dev/null @@ -1,14 +0,0 @@ -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 index b781b208..df891f52 100644 --- a/test_samples/otel/src/start_server.py +++ b/test_samples/otel/src/start_server.py @@ -5,6 +5,7 @@ from microsoft_agents.hosting.aiohttp import ( start_agent_process, CloudAdapter, + jwt_authorization_middleware, ) from aiohttp.web import Request, Response, Application, run_app @@ -26,16 +27,8 @@ async def entry_point(req: Request) -> Response: adapter, ) - APP = Application(middlewares=[]) + APP = Application(middlewares=[jwt_authorization_middleware]) 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 diff --git a/test_samples/otel/src/telemetry.py b/test_samples/otel/src/telemetry.py index 4b4e2ccc..ddd81109 100644 --- a/test_samples/otel/src/telemetry.py +++ b/test_samples/otel/src/telemetry.py @@ -24,11 +24,6 @@ def instrument_libraries(): """Instrument libraries for OpenTelemetry.""" - # ## - # # instrument aiohttp server -> causes problems - # ## - # AioHttpServerInstrumentor().instrument(tracer_provider=tracer_provider) - # ## # # instrument aiohttp client # ## diff --git a/test_samples/otel/dashboard.ps1 b/test_samples/otel/start_dashboard.ps1 similarity index 100% rename from test_samples/otel/dashboard.ps1 rename to test_samples/otel/start_dashboard.ps1 From 2bab051d9c212808d2024e73923b83a7671cfb60 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 26 Mar 2026 11:17:15 -0700 Subject: [PATCH 52/55] Removing unused imports and other small improvements --- .../activity/token_exchange_request.py | 15 --------------- .../hosting/core/authorization/telemetry/spans.py | 2 +- .../core/connector/telemetry/connector_spans.py | 2 -- .../telemetry/user_token_client_spans.py | 2 +- .../hosting/core/telemetry/adapter/spans.py | 2 +- .../core/telemetry/core/base_span_wrapper.py | 3 --- libraries/microsoft-agents-hosting-core/setup.py | 4 ++-- .../hosting_core/telemetry/test_adapter_spans.py | 2 +- .../telemetry/test_simple_span_wrapper.py | 3 +-- 9 files changed, 7 insertions(+), 28 deletions(-) delete mode 100644 libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_request.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_request.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_request.py deleted file mode 100644 index 560908aa..00000000 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_request.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .agents_model import AgentsModel - -from ._type_aliases import NonEmptyString - - -class TokenExchangeResource(AgentsModel): - """ - A type containing information for token exchange. - """ - - uri: NonEmptyString = None - token: NonEmptyString = None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py index afef95ec..f0abfd4a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py @@ -39,7 +39,7 @@ def _callback(self, span: Span, duration: float, error: Exception | None) -> Non class GetAccessToken(_AuthenticationSpanWrapper): """Span wrapper for the operation of retrieving an access token.""" - def __init__(self, scopes: list[str], auth_method: str | Enum): + def __init__(self, scopes: list[str], auth_method: str): """Initializes the GetAccessToken span with the specified authentication scope and type.""" super().__init__(constants.SPAN_GET_ACCESS_TOKEN, auth_method) self._scopes = scopes diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_spans.py index 5353ae9f..a14a4cbe 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_spans.py @@ -5,8 +5,6 @@ from opentelemetry.trace import Span -from aiohttp.web import Request, Response - from microsoft_agents.hosting.core.telemetry import ( attributes, AttributeMap, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py index bba0848f..9a6bdb62 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py @@ -25,7 +25,7 @@ def __init__( super().__init__(span_name) self._connection_name = connection_name or attributes.UNKNOWN self._user_id = user_id or attributes.UNKNOWN - self._channel_id = channel_id or attributes + self._channel_id = channel_id or attributes.UNKNOWN def _callback(self, span: Span, duration: float, error: Exception | None) -> None: """Callback function that is called when the span is ended. This is used to record metrics for the user token client operation based on the outcome of the span.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py index 0c171d2e..44c08aa8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py @@ -53,7 +53,7 @@ def _get_attributes(self) -> AttributeMap: attributes.IS_AGENTIC: self._activity.is_agentic_request(), } - def share(self, activity: Activity) -> None: + def share(self, *, activity: Activity) -> None: """Shares the activity being processed with the span, so that it can be used in the callback to record metrics.""" self._activity = activity diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py index 9f752d24..841275e9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py @@ -8,7 +8,6 @@ from abc import ABC, abstractmethod from contextlib import ExitStack from typing import ContextManager -from venv import logger from opentelemetry.trace import Span @@ -27,8 +26,6 @@ def __init__(self): @property def otel_span(self) -> Span | None: """Returns the underlying OTEL span if it is active, or None if the span has not been started or has already ended. This can be used to access OTEL-specific functionality or attributes of the span when needed, while still providing a higher-level abstraction through the BaseSpanWrapper class.""" - if self._span is None: - raise RuntimeError("BaseSpanWrapper has not been started yet") return self._span @property diff --git a/libraries/microsoft-agents-hosting-core/setup.py b/libraries/microsoft-agents-hosting-core/setup.py index 84d43898..bcec11f3 100644 --- a/libraries/microsoft-agents-hosting-core/setup.py +++ b/libraries/microsoft-agents-hosting-core/setup.py @@ -17,7 +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", + "opentelemetry-api>=1.27.0", + "opentelemetry-sdk>=1.27.0", ], ) diff --git a/tests/hosting_core/telemetry/test_adapter_spans.py b/tests/hosting_core/telemetry/test_adapter_spans.py index bc5c8be1..d8262851 100644 --- a/tests/hosting_core/telemetry/test_adapter_spans.py +++ b/tests/hosting_core/telemetry/test_adapter_spans.py @@ -72,7 +72,7 @@ def test_adapter_process_span_attributes_shared_activity(test_exporter): activity = _make_activity(type="invoke", channel_id="webchat") with AdapterProcess() as span: - span.share(activity) + span.share(activity=activity) span = test_exporter.get_finished_spans()[0] span_attrs = dict(span.attributes) diff --git a/tests/hosting_core/telemetry/test_simple_span_wrapper.py b/tests/hosting_core/telemetry/test_simple_span_wrapper.py index ff74ce1b..e0e44c5b 100644 --- a/tests/hosting_core/telemetry/test_simple_span_wrapper.py +++ b/tests/hosting_core/telemetry/test_simple_span_wrapper.py @@ -164,8 +164,7 @@ def test_otel_span_accessible_inside_context(self, test_exporter): def test_otel_span_raises_when_not_started(self): """Accessing otel_span before start raises RuntimeError.""" wrapper = MinimalSpanWrapper("not_started") - with pytest.raises(RuntimeError): - _ = wrapper.otel_span + assert wrapper.otel_span is None def test_start_end_manual_lifecycle(self, test_exporter): """start() and end() can be used instead of the context manager.""" From 7590fbdd24e1f4bd5d69010fc8aec5f9880b3007 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 26 Mar 2026 13:53:35 -0700 Subject: [PATCH 53/55] Addressing PR comments --- .../core/authorization/telemetry/constants.py | 1 - .../hosting/core/telemetry/__init__.py | 3 ++- .../hosting/core/telemetry/adapter/spans.py | 2 +- .../core/telemetry/core/simple_span_wrapper.py | 14 +++++++++----- .../microsoft_agents/hosting/core/turn_context.py | 1 - .../hosting/fastapi/cloud_adapter.py | 1 - 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py index 277148c4..40c3a189 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py @@ -1,6 +1,5 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License.# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. # Spans diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py index b054f57c..ddd936f6 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py @@ -6,7 +6,8 @@ # # This design hides the "mess" of telemetry to one location rather than throughout the codebase. # -# NOTE: this module should not be auto-loaded from __init__.py in order to avoid +# NOTE: this module should not be auto-loaded from __init__.py in order to avoid creating +# of the telemetry providers too early for the user to configure them. from . import attributes from .core import ( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py index 44c08aa8..da784462 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py @@ -195,4 +195,4 @@ def _get_attributes(self) -> AttributeMap: attributes.SERVICE_URL: self._service_url, attributes.AUTH_SCOPES: format_scopes(self._scopes), attributes.IS_AGENTIC: self._is_agentic_request, - } + } \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py index a7a2eb48..bb2e6349 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py @@ -33,8 +33,12 @@ def _start_span(self) -> Iterator[Span]: with agents_telemetry.start_as_current_span( self._span_name, callback=self._callback ) as span: - yield span - if span is not None: - attributes = self._get_attributes() - if attributes: - span.set_attributes(attributes) + try: + yield span + except Exception: + raise + finally: + if span is not None: + attributes = self._get_attributes() + if attributes: + span.set_attributes(attributes) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index 4db6b9c0..56165e93 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -20,7 +20,6 @@ ) from microsoft_agents.activity.entity.entity_types import EntityTypes from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity -from microsoft_agents.hosting.core.telemetry.turn_context import spans class TurnContext(TurnContextProtocol): 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 41ce1776..a94f81df 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,7 +12,6 @@ HttpResponse, ) from microsoft_agents.hosting.core import ChannelServiceClientFactoryBase -from microsoft_agents.hosting.core.telemetry import agents_telemetry from .agent_http_adapter import AgentHttpAdapter From d6d1ca1a9f46cedfeacaecc47b2956c7a7e8715b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 26 Mar 2026 14:02:43 -0700 Subject: [PATCH 54/55] Formatting --- .../microsoft_agents/hosting/core/telemetry/adapter/spans.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py index da784462..44c08aa8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py @@ -195,4 +195,4 @@ def _get_attributes(self) -> AttributeMap: attributes.SERVICE_URL: self._service_url, attributes.AUTH_SCOPES: format_scopes(self._scopes), attributes.IS_AGENTIC: self._is_agentic_request, - } \ No newline at end of file + } From 0f94df852a5c07393f467adaa2b1c5207d239418 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:10:46 +0000 Subject: [PATCH 55/55] Fix review comments: constants header, storage metrics attributes, port cast Agent-Logs-Url: https://github.com/microsoft/Agents-for-python/sessions/cd0c27c6-f0b8-405a-9165-1c89df1b6365 Co-authored-by: rodrigobr-msft <216624043+rodrigobr-msft@users.noreply.github.com> --- .../hosting/core/authorization/telemetry/constants.py | 2 +- .../hosting/core/storage/telemetry/spans.py | 7 ++++++- test_samples/otel/src/start_server.py | 5 +---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py index 40c3a189..0bb23802 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py @@ -1,5 +1,5 @@ # Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License.# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. # Spans diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py index 49baaf9d..86ae7e90 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py @@ -28,7 +28,12 @@ def _callback(self, span: Span, duration: float, error: Exception | None) -> Non attributes.STORAGE_OPERATION: self._span_name, }, ) - metrics.storage_operation_total.add(1) + metrics.storage_operation_total.add( + 1, + attributes={ + attributes.STORAGE_OPERATION: self._span_name, + }, + ) def _get_attributes(self) -> dict[str, str | int]: """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the storage operation being performed. diff --git a/test_samples/otel/src/start_server.py b/test_samples/otel/src/start_server.py index df891f52..96e79f9b 100644 --- a/test_samples/otel/src/start_server.py +++ b/test_samples/otel/src/start_server.py @@ -34,7 +34,4 @@ async def entry_point(req: Request) -> Response: 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 + run_app(APP, host="localhost", port=int(environ.get("PORT", 3978)))