From 168bad822461b69ee4488141b340ac8ff1d5d581 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 5 Dec 2025 11:10:22 -0800 Subject: [PATCH 01/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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")