From 30593dd7c38c3cebbac91042688b56ec3f47ea90 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Tue, 24 Feb 2026 18:16:27 -0800 Subject: [PATCH 01/14] weather agent draft --- .../weather-agent-framework/.env.example | 13 + .../weather-agent-framework/README.md | 101 +++++ test_samples/weather-agent-framework/app.py | 409 ++++++++++++++++++ .../weather-agent-framework/requirements.txt | 42 ++ .../telemetry/__init__.py | 71 +++ .../telemetry/a365_otel_wrapper.py | 127 ++++++ .../telemetry/agent_metrics.py | 236 ++++++++++ .../telemetry/agent_otel_extensions.py | 360 +++++++++++++++ .../weather-agent-framework/tools/__init__.py | 9 + .../tools/datetime_tools.py | 25 ++ .../tools/weather_tools.py | 138 ++++++ 11 files changed, 1531 insertions(+) create mode 100644 test_samples/weather-agent-framework/.env.example create mode 100644 test_samples/weather-agent-framework/README.md create mode 100644 test_samples/weather-agent-framework/app.py create mode 100644 test_samples/weather-agent-framework/requirements.txt create mode 100644 test_samples/weather-agent-framework/telemetry/__init__.py create mode 100644 test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py create mode 100644 test_samples/weather-agent-framework/telemetry/agent_metrics.py create mode 100644 test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py create mode 100644 test_samples/weather-agent-framework/tools/__init__.py create mode 100644 test_samples/weather-agent-framework/tools/datetime_tools.py create mode 100644 test_samples/weather-agent-framework/tools/weather_tools.py diff --git a/test_samples/weather-agent-framework/.env.example b/test_samples/weather-agent-framework/.env.example new file mode 100644 index 00000000..c10ffc99 --- /dev/null +++ b/test_samples/weather-agent-framework/.env.example @@ -0,0 +1,13 @@ +# Azure OpenAI Configuration +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ +AZURE_OPENAI_API_KEY=your-api-key-here +AZURE_OPENAI_DEPLOYMENT=gpt-4o-mini +AZURE_OPENAI_API_VERSION=2024-12-01-preview + +# OpenWeatherMap API Key +# Get your free API key from: https://openweathermap.org/price +OPENWEATHER_API_KEY=your-openweather-api-key + +# Server Configuration +HOST=localhost +PORT=3978 diff --git a/test_samples/weather-agent-framework/README.md b/test_samples/weather-agent-framework/README.md new file mode 100644 index 00000000..fe382274 --- /dev/null +++ b/test_samples/weather-agent-framework/README.md @@ -0,0 +1,101 @@ +# Overview + +This sample demonstrates how to build an agent using the Microsoft 365 Agents SDK for Python that: +- Hosts an agent using `agent-framework` or `microsoft-agents-hosting-aiohttp` +- Integrates Azure OpenAI for natural language understanding +- Implements weather lookup tools using OpenWeatherMap API +- Supports streaming responses + +## Project Structure + +``` +python-agent/ +├── README.md # This file +├── requirements.txt # Python dependencies +├── app.py # M365 Agents SDK with aiohttp +├── agent/ +│ ├── __init__.py +│ └── weather_agent.py # Weather agent class (M365 SDK version) +└── tools/ + ├── __init__.py + ├── weather_tools.py # Weather lookup tools + └── datetime_tools.py # DateTime helper tools +``` + +## Prerequisites + +- Python 3.11 or later (3.10+ supported, 3.11+ recommended for optimal performance) +- Azure OpenAI deployment +- OpenWeatherMap API key (free tier available at https://openweathermap.org/) + +## Installation + +1. Create a virtual environment: +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +2. Install dependencies: +```bash +pip install -r requirements.txt +``` + +## Configuration + +1. Copy the `.env.example` to `.env` and configure: + +```bash +# Azure OpenAI Configuration +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ +AZURE_OPENAI_API_KEY=your-api-key-here +AZURE_OPENAI_DEPLOYMENT=gpt-4o-mini + +# OpenWeatherMap API Key +OPENWEATHER_API_KEY=your-openweather-api-key + +# Server Configuration (optional) +PORT=3978 +HOST=localhost +``` + +2. For production, use Azure Key Vault or environment variables instead of `.env` file. + +## Running the Agent + +For Teams/M365 integration with web endpoint: + +```bash +python app.py +``` + +The agent will be available at `http://localhost:3978/api/messages` + +Test with Agent Playground or Teams: +- Ensure Agent Playground is installed +- Configure the endpoint in your agent manifest +- Test through Agent Playground or Teams interface + +## Tools Implemented + +1. **Weather Lookup Tools** (`tools/weather_tools.py`) + - `get_current_weather_for_location(location, state)` - Current weather conditions + - `get_weather_forecast_for_location(location, state)` - 5-day forecast + +2. **DateTime Tools** (`tools/datetime_tools.py`) + - `get_date_time()` - Current date and time + +## Testing + +Test the agent with queries like: +- "What's the weather in Seattle, Washington?" +- "What's the forecast for New York, New York?" +- "What's the current date and time?" + + +## Further Reading + +- [Microsoft 365 Agents SDK Documentation](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/) +- [Agent Framework Documentation](https://learn.microsoft.com/en-us/agent-framework/) +- [Azure OpenAI Service](https://learn.microsoft.com/en-us/azure/ai-services/openai/) +- [OpenWeatherMap API](https://openweathermap.org/api) diff --git a/test_samples/weather-agent-framework/app.py b/test_samples/weather-agent-framework/app.py new file mode 100644 index 00000000..7cec3658 --- /dev/null +++ b/test_samples/weather-agent-framework/app.py @@ -0,0 +1,409 @@ +""" +Weather Agent using Microsoft 365 Agents SDK with aiohttp. + +This approach is closer to the C# implementation and supports Teams/M365 integration. +Uses the microsoft-agents-hosting-aiohttp package for web endpoint hosting. +""" +import sys +import os +from typing import Optional +from aiohttp import web +from aiohttp.web import Request, Response, Application +from openai import AzureOpenAI +from azure.core.credentials import AzureKeyCredential +from azure.identity import DefaultAzureCredential + +# --------------------------------------------------------------------------- +# OpenTelemetry — configure providers FIRST so that the proxy tracers/meters +# in telemetry/agent_metrics.py resolve to real implementations. +# Equivalent to ConfigureOpenTelemetry() called in Program.cs before building +# the host in the C# sample. +# --------------------------------------------------------------------------- +from telemetry import ( + configure_opentelemetry, + create_aiohttp_tracing_middleware, + setup_health_routes, + invoke_observed_agent_operation_with_context, +) + +# config is imported here so the settings are available for configure_opentelemetry +# (the full 'from config import settings' import happens below after the SDK block) +from config import settings as _early_settings + +configure_opentelemetry( + app_name=_early_settings.agent_name, + environment=os.environ.get("ENVIRONMENT", "development"), + otlp_endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"), + azure_monitor_connection_string=os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING"), +) + +# M365 Agents SDK imports +# Note: Import structure may vary based on actual package implementation +# This is a conceptual implementation based on the C# patterns +try: + from microsoft_agents.hosting.core import ( + AgentApplication, + TurnContext, + TurnState, + MemoryStorage, + ActivityHandler, + ) + from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + start_agent_process, + ) + from microsoft_agents.activity import Activity, ActivityTypes +except ImportError: + print("⚠️ Microsoft Agents SDK packages not found.") + print("This implementation requires:") + print(" - microsoft-agents-hosting-aiohttp") + print(" - microsoft-agents-hosting-core") + print("\nInstall with: pip install microsoft-agents-hosting-aiohttp") + print("\nNote: Using conceptual implementation based on C# patterns.") + print("The actual API may differ. Refer to official documentation.\n") + sys.exit(1) + +from config import settings +from tools.weather_tools import get_current_weather_for_location, get_weather_forecast_for_location +from tools.datetime_tools import get_date_time + +# Suppress the early-import alias now that the real settings object is imported +del _early_settings + + +class WeatherAgent(ActivityHandler): + """ + Weather Agent implementation similar to the C# WeatherAgent class. + + This agent handles incoming messages and uses Azure OpenAI to process + user requests with weather lookup tools. + """ + + def __init__(self): + """Initialize the Weather Agent.""" + super().__init__() + + # Validate configuration + settings.validate_required_settings() + + # Initialize Azure OpenAI client + if settings.azure_openai_api_key: + self.openai_client = AzureOpenAI( + azure_endpoint=settings.azure_openai_endpoint, + api_key=settings.azure_openai_api_key, + api_version=settings.azure_openai_api_version, + ) + else: + self.openai_client = AzureOpenAI( + azure_endpoint=settings.azure_openai_endpoint, + azure_ad_token_provider=DefaultAzureCredential(), + api_version=settings.azure_openai_api_version, + ) + + print(f"✅ {settings.agent_name} initialized") + + async def on_members_added_activity(self, context: TurnContext, state: TurnState): + """ + Handle members added to conversation (similar to C# WelcomeMessageAsync). + + Args: + context: The turn context for this activity. + state: The turn state. + """ + for member in context.activity.members_added or []: + if member.id != context.activity.recipient.id: + await context.send_activity(settings.agent_welcome_message) + + async def on_message_activity(self, context: TurnContext, state: TurnState): + """ + Handle incoming message activities (similar to C# OnMessageAsync). + + Wraps the message logic with A365 observability — equivalent to the + ``A365OtelWrapper.InvokeObservedAgentOperation`` call in WeatherAgent.cs. + + Args: + context: The turn context for this message. + state: The turn state. + """ + user_text = (context.activity.text or "").strip() + + if not user_text: + return + + print(f"Received: {user_text}") + + async def _handle_message(): + # Build conversation history from state + conversation_history = state.conversation.get("history", []) + + # Add user message + conversation_history.append({ + "role": "user", + "content": user_text + }) + + # Prepare tools for Azure OpenAI + tools = [ + { + "type": "function", + "function": { + "name": "get_current_weather_for_location", + "description": "Retrieves the current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city name" + }, + "state": { + "type": "string", + "description": "The US state name or empty string for international cities" + } + }, + "required": ["location"] + } + } + }, + { + "type": "function", + "function": { + "name": "get_weather_forecast_for_location", + "description": "Retrieves the 5-day weather forecast for a location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city name" + }, + "state": { + "type": "string", + "description": "The US state name or empty string for international cities" + } + }, + "required": ["location"] + } + } + }, + { + "type": "function", + "function": { + "name": "get_date_time", + "description": "Get the current date and time", + "parameters": { + "type": "object", + "properties": { + "input_text": { + "type": "string", + "description": "User input (not used)" + } + } + } + } + } + ] + + # Call Azure OpenAI with function calling + messages = [ + {"role": "system", "content": settings.agent_instructions}, + *conversation_history + ] + + response = self.openai_client.chat.completions.create( + model=settings.azure_openai_deployment, + messages=messages, + tools=tools, + tool_choice="auto", + temperature=0.2, + ) + + response_message = response.choices[0].message + tool_calls = response_message.tool_calls + + # Handle tool calls + if tool_calls: + # Add assistant message with tool calls + conversation_history.append({ + "role": "assistant", + "content": response_message.content, + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments + } + } + for tc in tool_calls + ] + }) + + # Execute tool calls + import json + for tool_call in tool_calls: + function_name = tool_call.function.name + function_args = json.loads(tool_call.function.arguments) + + print(f"Calling tool: {function_name}({function_args})") + + if function_name == "get_current_weather_for_location": + function_response = get_current_weather_for_location( + location=function_args.get("location", ""), + state=function_args.get("state", "") + ) + elif function_name == "get_weather_forecast_for_location": + function_response = get_weather_forecast_for_location( + location=function_args.get("location", ""), + state=function_args.get("state", "") + ) + elif function_name == "get_date_time": + function_response = get_date_time() + else: + function_response = f"Unknown function: {function_name}" + + # Add function response to conversation + conversation_history.append({ + "role": "tool", + "content": function_response, + "tool_call_id": tool_call.id + }) + + # Get final response from model + second_response = self.openai_client.chat.completions.create( + model=settings.azure_openai_deployment, + messages=[ + {"role": "system", "content": settings.agent_instructions}, + *conversation_history + ], + temperature=0.2, + ) + + final_message = second_response.choices[0].message.content + else: + final_message = response_message.content + + # Add assistant response to history + conversation_history.append({ + "role": "assistant", + "content": final_message + }) + + # Keep last 10 messages in history + if len(conversation_history) > 10: + conversation_history = conversation_history[-10:] + + # Save conversation history to state + state.conversation["history"] = conversation_history + + # Send response + await context.send_activity(final_message) + print("Sent response") + + # Wrap the message handler with A365 observability — equivalent to + # A365OtelWrapper.InvokeObservedAgentOperation() in WeatherAgent.cs. + # Any exception propagates after being recorded on the span. + try: + await invoke_observed_agent_operation_with_context( + "OnMessageActivity", + context, + state, + _handle_message, + ) + except Exception as e: + error_msg = f"Sorry, I encountered an error: {str(e)}" + print(f"Error: {e}") + import traceback + traceback.print_exc() + await context.send_activity(error_msg) + + +async def messages_endpoint(request: Request) -> Response: + """ + Handle POST requests to /api/messages endpoint. + + Args: + request: The incoming HTTP request. + + Returns: + HTTP response. + """ + agent: AgentApplication = request.app["agent_app"] + adapter: CloudAdapter = request.app["adapter"] + + return await start_agent_process(request, agent, adapter) + + +def create_app() -> Application: + """ + Create and configure the aiohttp application. + + Returns: + Configured aiohttp Application. + """ + # Create storage + storage = MemoryStorage() + + # Create adapter + adapter = CloudAdapter() + + # Create agent application + agent_app = AgentApplication[TurnState]( + storage=storage, + adapter=adapter, + ) + + # Instantiate our weather agent + weather_agent = WeatherAgent() + + # Register event handlers + @agent_app.activity(ActivityTypes.MESSAGE) + async def on_message(context: TurnContext, state: TurnState): + await weather_agent.on_message_activity(context, state) + + @agent_app.conversation_update("membersAdded") + async def on_members_added(context: TurnContext, state: TurnState): + await weather_agent.on_members_added_activity(context, state) + + # Create aiohttp app with tracing middleware. + # Equivalent to AddAspNetCoreInstrumentation() + health-check filter in C#. + app = Application(middlewares=[create_aiohttp_tracing_middleware()]) + + # Add routes + app.router.add_post("/api/messages", messages_endpoint) + app.router.add_get("/", lambda _: Response(text="Weather Agent is running", status=200)) + + # Register /health and /alive endpoints (development only). + # Equivalent to app.MapDefaultEndpoints() in C#. + is_development = os.environ.get("ENVIRONMENT", "development").lower() == "development" + setup_health_routes(app, development=is_development) + + # Store agent components + app["agent_app"] = agent_app + app["adapter"] = adapter + + return app + + +def main(): + """Main application entry point.""" + try: + settings.validate_required_settings() + except ValueError as e: + print(f"\n❌ Configuration Error:\n{e}\n") + print("Please create a .env file based on .env.example and configure required settings.") + sys.exit(1) + + print(f"\n{'='*60}") + print(f"🤖 Starting {settings.agent_name} (M365 SDK)") + print(f"{'='*60}") + print(f"Endpoint: http://{settings.host}:{settings.port}/api/messages") + print(f"{'='*60}\n") + + app = create_app() + web.run_app(app, host=settings.host, port=settings.port) + + +if __name__ == "__main__": + main() diff --git a/test_samples/weather-agent-framework/requirements.txt b/test_samples/weather-agent-framework/requirements.txt new file mode 100644 index 00000000..6821bb14 --- /dev/null +++ b/test_samples/weather-agent-framework/requirements.txt @@ -0,0 +1,42 @@ +# Modern Agent Framework (recommended) +agent-framework --pre + +# M365 Agents SDK for Teams/M365 integration +microsoft-agents-hosting-aiohttp +microsoft-agents-hosting-core +microsoft-agents-activity +microsoft-agents-authentication-msal + +# Azure services +azure-identity + +#OpenAI +openai + +# Web framework +aiohttp +aiohttp-cors + +# Configuration +python-dotenv +pydantic +pydantic-settings + +# Weather API client +pyowm + +# Utilities +httpx +typing-extensions + +# OpenTelemetry — core (required for telemetry/) +opentelemetry-api +opentelemetry-sdk + +# OpenTelemetry — optional exporters (uncomment to activate) +# OTLP (works with Aspire dashboard, Jaeger, Grafana, etc.) +# opentelemetry-exporter-otlp-proto-grpc +# Azure Monitor / Application Insights +# azure-monitor-opentelemetry-exporter +# Correlate Python log records with trace context +# opentelemetry-instrumentation-logging diff --git a/test_samples/weather-agent-framework/telemetry/__init__.py b/test_samples/weather-agent-framework/telemetry/__init__.py new file mode 100644 index 00000000..4837fbbe --- /dev/null +++ b/test_samples/weather-agent-framework/telemetry/__init__.py @@ -0,0 +1,71 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Telemetry package for the Python Weather Agent. + +Python port of sample-agent/telemetry (AgentMetrics.cs, A365OtelWrapper.cs, +AgentOTELExtensions.cs). + +Typical usage in app.py:: + + from telemetry import configure_opentelemetry, setup_health_routes + from telemetry import create_aiohttp_tracing_middleware + from telemetry import invoke_observed_agent_operation_with_context + + # 1. Configure providers once at startup (before any span/metric activity) + configure_opentelemetry(app_name="WeatherAgent", environment="development") + + # 2. Add tracing middleware to the aiohttp app + app = web.Application(middlewares=[create_aiohttp_tracing_middleware()]) + + # 3. Register /health and /alive endpoints (development only) + setup_health_routes(app, development=True) + + # 4. Wrap message handlers with observed operations + await invoke_observed_agent_operation_with_context( + "OnMessageActivity", turn_context, turn_state, handler_func + ) +""" + +from .a365_otel_wrapper import invoke_observed_agent_operation_with_context +from .agent_metrics import ( + SOURCE_NAME, + active_conversations, + finalize_message_handling_span, + initialize_message_handling_span, + invoke_observed_agent_operation, + invoke_observed_http_operation, + message_processed_counter, + message_processing_duration, + meter, + route_executed_counter, + route_execution_duration, + tracer, +) +from .agent_otel_extensions import ( + configure_opentelemetry, + create_aiohttp_tracing_middleware, + setup_health_routes, +) + +__all__ = [ + # agent_metrics + "SOURCE_NAME", + "tracer", + "meter", + "message_processed_counter", + "route_executed_counter", + "message_processing_duration", + "route_execution_duration", + "active_conversations", + "initialize_message_handling_span", + "finalize_message_handling_span", + "invoke_observed_http_operation", + "invoke_observed_agent_operation", + # a365_otel_wrapper + "invoke_observed_agent_operation_with_context", + # agent_otel_extensions + "configure_opentelemetry", + "setup_health_routes", + "create_aiohttp_tracing_middleware", +] diff --git a/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py b/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py new file mode 100644 index 00000000..1b780da8 --- /dev/null +++ b/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py @@ -0,0 +1,127 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Azure 365 observability wrapper for agent operations. + +Python port of A365OtelWrapper.cs from sample-agent/telemetry. + +The C# original depends on the internal +``Microsoft.Agents.A365.Observability`` packages which are .NET-only. +This Python port preserves the observable contract: + +1. Resolve the tenant ID and agent ID from the current turn activity. +2. Propagate them as `OpenTelemetry baggage + `_ so that + downstream services can consume the context. +3. Delegate to :func:`~telemetry.agent_metrics.invoke_observed_agent_operation` + for span creation and metric recording. + +The token-cache observability hook (``RegisterObservability``) present in the +C# wrapper has no equivalent in Python and is omitted. +""" + +import logging +import uuid +from typing import Awaitable, Callable + +from opentelemetry import baggage +from opentelemetry import context as otel_context + +from .agent_metrics import invoke_observed_agent_operation + +logger = logging.getLogger(__name__) + +_EMPTY_GUID = str(uuid.UUID(int=0)) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def invoke_observed_agent_operation_with_context( + operation_name: str, + turn_context, + turn_state, + func: Callable[[], Awaitable[None]], +) -> None: + """Wrap an agent operation with A365 observability baggage context. + + Equivalent to ``A365OtelWrapper.InvokeObservedAgentOperation()`` in C#. + + Resolves the tenant ID and agent ID from the activity, sets them as + OpenTelemetry baggage (equivalent to the C# ``BaggageBuilder``), then + delegates to :func:`~telemetry.agent_metrics.invoke_observed_agent_operation` + for span management and metric recording. + + Args: + operation_name: Human-readable name of the operation / handler. + turn_context: The current :class:`TurnContext`. + turn_state: The current :class:`TurnState` (kept for API parity with + the C# version; auth resolution is not performed here). + func: Async function containing the agent logic to execute. + """ + agent_id, tenant_id = _resolve_tenant_and_agent_id(turn_context) + + async def _with_baggage(): + # Set tenant.id and agent.id as baggage — equivalent to BaggageBuilder + # in C#, which adds these values to the W3C baggage header so that + # downstream services can read them. + ctx = baggage.set_baggage("tenant.id", tenant_id) + ctx = baggage.set_baggage("agent.id", agent_id, context=ctx) + token = otel_context.attach(ctx) + try: + await func() + finally: + otel_context.detach(token) + + await invoke_observed_agent_operation(operation_name, turn_context, _with_baggage) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _resolve_tenant_and_agent_id(turn_context) -> tuple[str, str]: + """Extract tenant and agent IDs from the turn activity. + + Equivalent to ``ResolveTenantAndAgentId()`` in C#. + + The C# version calls into M365 auth helpers + (``GetAgenticInstanceId`` / ``ResolveAgentIdentity``) that are not + available in Python. This implementation derives the values directly + from the activity fields that carry the same semantics: + + * **agent_id** — the ``recipient.id`` of the activity (the bot/agent + identity that received the message). + * **tenant_id** — ``conversation.tenantId`` or ``recipient.tenantId``, + matching the fields inspected by the C# helper. + + Falls back to ``00000000-0000-0000-0000-000000000000`` (Guid.Empty) for + any value that cannot be resolved, matching C# behaviour. + + Args: + turn_context: The current :class:`TurnContext`. + + Returns: + ``(agent_id, tenant_id)`` as strings. + """ + activity = getattr(turn_context, "activity", None) + if activity is None: + return _EMPTY_GUID, _EMPTY_GUID + + # Agent ID — use recipient identity (the agent/bot that received the turn) + recipient = getattr(activity, "recipient", None) + agent_id = str(getattr(recipient, "id", None) or _EMPTY_GUID) + + # Tenant ID — prefer conversation.tenantId, then recipient.tenantId + conversation = getattr(activity, "conversation", None) + tenant_id = ( + getattr(conversation, "tenant_id", None) + or getattr(recipient, "tenant_id", None) + or _EMPTY_GUID + ) + tenant_id = str(tenant_id) + + return agent_id, tenant_id diff --git a/test_samples/weather-agent-framework/telemetry/agent_metrics.py b/test_samples/weather-agent-framework/telemetry/agent_metrics.py new file mode 100644 index 00000000..e1fe4230 --- /dev/null +++ b/test_samples/weather-agent-framework/telemetry/agent_metrics.py @@ -0,0 +1,236 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Agent metrics and distributed tracing for OpenTelemetry instrumentation. + +Python port of AgentMetrics.cs from sample-agent/telemetry. + +Provides: +- An ActivitySource-equivalent tracer named "A365.AgentFramework" +- A Meter with the same name for counters, histograms, and up-down counters +- Helper functions to start/finalize message-handling spans +- Observed-operation wrappers (sync and async) +""" + +import logging +import time +from typing import Awaitable, Callable + +from opentelemetry import context as otel_context +from opentelemetry import metrics, trace +from opentelemetry.trace import StatusCode + +logger = logging.getLogger(__name__) + +# Equivalent to ActivitySource name in C# +SOURCE_NAME = "A365.AgentFramework" + +# Tracer — equivalent to `new ActivitySource(SourceName)` in C# +# Uses a ProxyTracer until configure_opentelemetry() sets a real TracerProvider. +tracer = trace.get_tracer(SOURCE_NAME, "1.0.0") + +# Meter — equivalent to `new Meter("A365.AgentFramework", "1.0.0")` in C# +meter = metrics.get_meter(SOURCE_NAME, "1.0.0") + +# --------------------------------------------------------------------------- +# Metrics — mirrors the static Counter/Histogram/UpDownCounter fields in C# +# --------------------------------------------------------------------------- + +message_processed_counter = meter.create_counter( + "agent.messages.processed", + unit="messages", + description="Number of messages processed by the agent", +) + +route_executed_counter = meter.create_counter( + "agent.routes.executed", + unit="routes", + description="Number of routes executed by the agent", +) + +message_processing_duration = meter.create_histogram( + "agent.message.processing.duration", + unit="ms", + description="Duration of message processing in milliseconds", +) + +route_execution_duration = meter.create_histogram( + "agent.route.execution.duration", + unit="ms", + description="Duration of route execution in milliseconds", +) + +active_conversations = meter.create_up_down_counter( + "agent.conversations.active", + unit="conversations", + description="Number of active conversations", +) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _get_activity_attrs(context) -> dict: + """Extract activity attributes from a TurnContext.""" + attrs: dict = {} + activity = getattr(context, "activity", None) + if activity is None: + return attrs + + attrs["Activity.Type"] = str(getattr(activity, "type", "") or "") + + # 'from' is a Python keyword; the SDK stores it as from_property or _from. + from_obj = ( + getattr(activity, "from_property", None) + or getattr(activity, "_from", None) + # getattr with string "from" works at runtime despite being a keyword + or getattr(activity, "from", None) + ) + attrs["Caller.Id"] = str(getattr(from_obj, "id", "") or "") + + conversation = getattr(activity, "conversation", None) + attrs["Conversation.Id"] = str(getattr(conversation, "id", "") or "") + attrs["Channel.Id"] = str(getattr(activity, "channel_id", "") or "") + + text = getattr(activity, "text", "") or "" + attrs["Message.Text.Length"] = len(text) + attrs["Message.Id"] = str(getattr(activity, "id", "") or "") + attrs["Message.Text"] = text[:200] # truncate to avoid oversized attributes + + return attrs + + +# --------------------------------------------------------------------------- +# Public API — mirrors AgentMetrics static methods in C# +# --------------------------------------------------------------------------- + +def initialize_message_handling_span(handler_name: str, context) -> trace.Span: + """Start a tracing span with contextual tags from the turn activity. + + Equivalent to ``InitializeMessageHandlingActivity()`` in C#. + + The caller is responsible for ending the span (use + ``finalize_message_handling_span`` or call ``span.end()`` directly). + + Args: + handler_name: Name used as the span name (e.g. ``"OnMessageActivity"``). + context: TurnContext whose activity fields are attached as span attributes. + + Returns: + A started (but not yet current) :class:`opentelemetry.trace.Span`. + """ + span = tracer.start_span(handler_name) + attrs = _get_activity_attrs(context) + + # Set individual attributes on the span (mirrors activity?.SetTag calls in C#) + for key in ("Activity.Type", "Caller.Id", "Conversation.Id", "Channel.Id"): + span.set_attribute(key, attrs.get(key, "")) + span.set_attribute("Message.Text.Length", attrs.get("Message.Text.Length", 0)) + # Tag whether the request came from an agentic caller + span.set_attribute("Agent.IsAgentic", bool(getattr(getattr(context, "activity", None), "is_agentic", False))) + + # Equivalent to activity?.AddEvent(new ActivityEvent("Message.Processed", ...)) + span.add_event( + "Message.Processed", + attributes={ + "Caller.Id": attrs.get("Caller.Id", ""), + "Channel.Id": attrs.get("Channel.Id", ""), + "Message.Id": attrs.get("Message.Id", ""), + "Message.Text": attrs.get("Message.Text", ""), + }, + ) + return span + + +def finalize_message_handling_span( + span: trace.Span, + context, + duration_ms: float, + success: bool, +) -> None: + """Record duration metrics and end the span. + + Equivalent to ``FinalizeMessageHandlingActivity()`` in C#. + + Args: + span: The span returned by :func:`initialize_message_handling_span`. + context: TurnContext used to label the metric dimensions. + duration_ms: Elapsed time in milliseconds. + success: ``True`` → span status OK; ``False`` → span status ERROR. + """ + attrs = _get_activity_attrs(context) + conversation_id = attrs.get("Conversation.Id") or "unknown" + channel_id = attrs.get("Channel.Id") or "unknown" + + message_processing_duration.record( + duration_ms, + {"Conversation.Id": conversation_id, "Channel.Id": channel_id}, + ) + + route_executed_counter.add( + 1, + {"Route.Type": "message_handler", "Conversation.Id": conversation_id}, + ) + + span.set_status(StatusCode.OK if success else StatusCode.ERROR) + span.end() + + +def invoke_observed_http_operation(operation_name: str, func: Callable) -> None: + """Wrap a synchronous callable with a tracing span. + + Equivalent to ``InvokeObservedHttpOperation()`` in C#. + + Args: + operation_name: Span name. + func: Synchronous callable to execute. + """ + with tracer.start_as_current_span(operation_name) as span: + try: + func() + span.set_status(StatusCode.OK) + except Exception as ex: + span.set_status(StatusCode.ERROR, str(ex)) + span.record_exception(ex) + raise + + +async def invoke_observed_agent_operation( + operation_name: str, + context, + func: Callable[[], Awaitable[None]], +) -> None: + """Async wrapper for an agent operation with full metrics and tracing. + + Equivalent to ``AgentMetrics.InvokeObservedAgentOperation()`` in C#. + + Increments the message-processed counter, opens a span, sets it as the + current context, awaits *func*, then records the duration and finalises + the span. + + Args: + operation_name: Span / operation name. + context: TurnContext used for span attributes and metric labels. + func: Async function containing the agent logic. + """ + message_processed_counter.add(1) + + span = initialize_message_handling_span(operation_name, context) + # Make the span the active span for the duration of the call + ctx = trace.set_span_in_context(span) + token = otel_context.attach(ctx) + + start_time = time.monotonic() + success = True + try: + await func() + except Exception as ex: + success = False + span.set_status(StatusCode.ERROR, str(ex)) + span.record_exception(ex) + raise + finally: + otel_context.detach(token) + duration_ms = (time.monotonic() - start_time) * 1000 + finalize_message_handling_span(span, context, duration_ms, success) diff --git a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py new file mode 100644 index 00000000..942f5d66 --- /dev/null +++ b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py @@ -0,0 +1,360 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""OpenTelemetry configuration helpers for the Python Weather Agent. + +Python port of AgentOTELExtensions.cs from sample-agent/telemetry. + +Adds common observability services: + - TracerProvider with resource metadata + - MeterProvider with custom agent meters + - aiohttp tracing middleware (excludes health-check paths) + - /health and /alive endpoint registration + +Exporter support (install the matching package to activate): + - OTLP (Aspire, Jaeger, etc.): + pip install opentelemetry-exporter-otlp-proto-grpc + - Azure Monitor / Application Insights: + pip install azure-monitor-opentelemetry-exporter + - Console (default when no other exporter is configured) + +To learn more about the local Aspire dashboard, see: + https://learn.microsoft.com/dotnet/aspire/fundamentals/dashboard/standalone +""" + +import logging +import os +import socket +from typing import Optional + +from opentelemetry import trace +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.resources import ( + SERVICE_INSTANCE_ID, + SERVICE_NAME, + SERVICE_VERSION, + Resource, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.trace import StatusCode + +logger = logging.getLogger(__name__) + +HEALTH_ENDPOINT_PATH = "/health" +ALIVENESS_ENDPOINT_PATH = "/alive" + +_SERVICE_NAMESPACE = "Microsoft.Agents" +_SOURCE_NAME = "A365.AgentFramework" + + +# --------------------------------------------------------------------------- +# Primary public API +# --------------------------------------------------------------------------- + + +def configure_opentelemetry( + app_name: str = "A365.AgentFramework", + environment: str = "development", + otlp_endpoint: Optional[str] = None, + azure_monitor_connection_string: Optional[str] = None, +) -> None: + """Configure global TracerProvider, MeterProvider, and logging bridge. + + Equivalent to ``ConfigureOpenTelemetry()`` in C#. + + Call this **once at startup**, before any code creates spans or records + metrics. The OTel proxy objects in *agent_metrics.py* will automatically + route to the real providers once this function has run. + + Args: + app_name: Service name shown in traces/dashboards. + environment: Deployment environment tag (e.g. ``"development"``, + ``"production"``). + otlp_endpoint: OTLP collector URL. Falls back to the + ``OTEL_EXPORTER_OTLP_ENDPOINT`` environment variable when *None*. + azure_monitor_connection_string: Application Insights connection + string. Falls back to ``APPLICATIONINSIGHTS_CONNECTION_STRING`` + when *None*. + """ + resource = Resource.create( + { + SERVICE_NAME: _SOURCE_NAME, + SERVICE_VERSION: "1.0.0", + SERVICE_INSTANCE_ID: socket.gethostname(), + "deployment.environment": environment, + "service.namespace": _SERVICE_NAMESPACE, + } + ) + + _configure_tracing(resource, app_name, otlp_endpoint, azure_monitor_connection_string) + _configure_metrics(resource, otlp_endpoint, azure_monitor_connection_string) + _configure_logging() + + logger.info( + "OpenTelemetry configured — service: %s, environment: %s", + app_name, + environment, + ) + + +def setup_health_routes(app, development: bool = True) -> None: + """Register ``/health`` and ``/alive`` endpoints on an aiohttp Application. + + Equivalent to ``MapDefaultEndpoints()`` in C#. Mirrors the C# behaviour + of only registering the endpoints in non-production environments. + + Args: + app: :class:`aiohttp.web.Application` instance. + development: When ``False`` the endpoints are **not** registered + (matches the C# guard on ``IsDevelopment()``). + """ + if not development: + return + + from aiohttp import web + + async def health_handler(_request): + return web.Response( + text='{"status":"Healthy"}', + content_type="application/json", + ) + + async def alive_handler(_request): + return web.Response( + text='{"status":"Alive"}', + content_type="application/json", + ) + + app.router.add_get(HEALTH_ENDPOINT_PATH, health_handler) + app.router.add_get(ALIVENESS_ENDPOINT_PATH, alive_handler) + logger.info( + "Health check endpoints registered: %s, %s", + HEALTH_ENDPOINT_PATH, + ALIVENESS_ENDPOINT_PATH, + ) + + +def create_aiohttp_tracing_middleware(): + """Return an aiohttp middleware that traces every non-health-check request. + + Equivalent to the ``AddAspNetCoreInstrumentation()`` configuration in C#: + - Filters out ``/health`` and ``/alive`` paths. + - Enriches spans with ``http.request.body.size`` and ``user_agent`` on + request, and ``http.status_code`` / ``http.response.body.size`` on + response. + - Records exceptions and sets the span status accordingly. + """ + from aiohttp import web + + _tracer = trace.get_tracer(_SOURCE_NAME, "1.0.0") + + @web.middleware + async def tracing_middleware(request, handler): + # Exclude health check requests from tracing (mirrors C# filter lambda) + if request.path.startswith(HEALTH_ENDPOINT_PATH) or request.path.startswith( + ALIVENESS_ENDPOINT_PATH + ): + return await handler(request) + + span_name = f"{request.method} {request.path}" + with _tracer.start_as_current_span(span_name) as span: + # Enrich with request details — equivalent to EnrichWithHttpRequest + span.set_attribute("http.method", request.method) + span.set_attribute("http.url", str(request.url)) + span.set_attribute("http.request.body.size", request.content_length or 0) + user_agent = request.headers.get("User-Agent", "") + if user_agent: + span.set_attribute("user_agent", user_agent) + + try: + response = await handler(request) + # Enrich with response details — equivalent to EnrichWithHttpResponse + span.set_attribute("http.status_code", response.status) + span.set_attribute( + "http.response.body.size", response.content_length or 0 + ) + span.set_status(StatusCode.OK) + return response + except Exception as ex: + span.set_status(StatusCode.ERROR, str(ex)) + span.record_exception(ex) + raise + + return tracing_middleware + + +# --------------------------------------------------------------------------- +# Internal helpers — mirrors private methods in AgentOTELExtensions.cs +# --------------------------------------------------------------------------- + + +def _configure_tracing( + resource: Resource, + app_name: str, + otlp_endpoint: Optional[str], + azure_monitor_connection_string: Optional[str], +) -> None: + """Build and register the global TracerProvider.""" + tracer_provider = TracerProvider(resource=resource) + + has_real_exporter = False + + # OTLP exporter — equivalent to UseOtlpExporter() in C# + resolved_otlp = otlp_endpoint or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") + if resolved_otlp: + _add_otlp_trace_exporter(tracer_provider, resolved_otlp) + has_real_exporter = True + + # Azure Monitor exporter — equivalent to UseAzureMonitor() in C# + resolved_az = azure_monitor_connection_string or os.environ.get( + "APPLICATIONINSIGHTS_CONNECTION_STRING" + ) + if resolved_az: + _add_azure_monitor_trace_exporter(tracer_provider, resolved_az) + has_real_exporter = True + + # Console exporter for local development (no production exporter configured) + if not has_real_exporter: + _add_console_trace_exporter(tracer_provider) + + trace.set_tracer_provider(tracer_provider) + + +def _configure_metrics( + resource: Resource, + otlp_endpoint: Optional[str], + azure_monitor_connection_string: Optional[str], +) -> None: + """Build and register the global MeterProvider.""" + from opentelemetry import metrics + + readers = [] + + resolved_otlp = otlp_endpoint or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") + if resolved_otlp: + reader = _create_otlp_metric_reader(resolved_otlp) + if reader: + readers.append(reader) + + resolved_az = azure_monitor_connection_string or os.environ.get( + "APPLICATIONINSIGHTS_CONNECTION_STRING" + ) + if resolved_az: + reader = _create_azure_monitor_metric_reader(resolved_az) + if reader: + readers.append(reader) + + if not readers: + readers.append(_create_console_metric_reader()) + + meter_provider = MeterProvider(resource=resource, metric_readers=readers) + metrics.set_meter_provider(meter_provider) + + +def _configure_logging() -> None: + """Bridge Python logging to the OTEL LoggerProvider when available. + + Equivalent to ``builder.Logging.AddOpenTelemetry(...)`` in C#. + Requires ``pip install opentelemetry-instrumentation-logging``. + """ + try: + from opentelemetry.instrumentation.logging import LoggingInstrumentor + + LoggingInstrumentor().instrument(set_logging_format=True) + logger.debug("OTEL logging instrumentation enabled") + except ImportError: + logger.debug( + "opentelemetry-instrumentation-logging not installed — " + "Python log records will not be correlated with traces. " + "Install with: pip install opentelemetry-instrumentation-logging" + ) + + +# -- Trace exporter helpers -------------------------------------------------- + + +def _add_otlp_trace_exporter( + tracer_provider: TracerProvider, endpoint: str +) -> None: + try: + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, + ) + + exporter = OTLPSpanExporter(endpoint=endpoint) + tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) + logger.info("OTLP trace exporter → %s", endpoint) + except ImportError: + logger.warning( + "OTLP trace exporter requested but package not found. " + "Install: pip install opentelemetry-exporter-otlp-proto-grpc" + ) + + +def _add_azure_monitor_trace_exporter( + tracer_provider: TracerProvider, connection_string: str +) -> None: + try: + from azure.monitor.opentelemetry.exporter import AzureMonitorTraceExporter + + exporter = AzureMonitorTraceExporter(connection_string=connection_string) + tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) + logger.info("Azure Monitor trace exporter configured") + except ImportError: + logger.warning( + "Azure Monitor trace exporter requested but package not found. " + "Install: pip install azure-monitor-opentelemetry-exporter" + ) + + +def _add_console_trace_exporter(tracer_provider: TracerProvider) -> None: + from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor + + tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) + logger.debug("Console span exporter active (development mode)") + + +# -- Metric reader helpers ---------------------------------------------------- + + +def _create_otlp_metric_reader(endpoint: str) -> Optional[PeriodicExportingMetricReader]: + try: + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( + OTLPMetricExporter, + ) + + exporter = OTLPMetricExporter(endpoint=endpoint) + logger.info("OTLP metric exporter → %s", endpoint) + return PeriodicExportingMetricReader(exporter) + except ImportError: + logger.warning( + "OTLP metric exporter requested but package not found. " + "Install: pip install opentelemetry-exporter-otlp-proto-grpc" + ) + return None + + +def _create_azure_monitor_metric_reader( + connection_string: str, +) -> Optional[PeriodicExportingMetricReader]: + try: + from azure.monitor.opentelemetry.exporter import AzureMonitorMetricsExporter + + exporter = AzureMonitorMetricsExporter(connection_string=connection_string) + logger.info("Azure Monitor metric exporter configured") + return PeriodicExportingMetricReader(exporter) + except ImportError: + logger.warning( + "Azure Monitor metric exporter requested but package not found. " + "Install: pip install azure-monitor-opentelemetry-exporter" + ) + return None + + +def _create_console_metric_reader() -> PeriodicExportingMetricReader: + from opentelemetry.sdk.metrics.export import ConsoleMetricExporter + + logger.debug("Console metric exporter active (development mode)") + return PeriodicExportingMetricReader(ConsoleMetricExporter()) diff --git a/test_samples/weather-agent-framework/tools/__init__.py b/test_samples/weather-agent-framework/tools/__init__.py new file mode 100644 index 00000000..dce73e80 --- /dev/null +++ b/test_samples/weather-agent-framework/tools/__init__.py @@ -0,0 +1,9 @@ +"""Tools package for Weather Agent.""" +from .weather_tools import get_current_weather_for_location, get_weather_forecast_for_location +from .datetime_tools import get_date_time + +__all__ = [ + "get_current_weather_for_location", + "get_weather_forecast_for_location", + "get_date_time", +] diff --git a/test_samples/weather-agent-framework/tools/datetime_tools.py b/test_samples/weather-agent-framework/tools/datetime_tools.py new file mode 100644 index 00000000..2f746b08 --- /dev/null +++ b/test_samples/weather-agent-framework/tools/datetime_tools.py @@ -0,0 +1,25 @@ +"""DateTime helper tools.""" +from datetime import datetime +from typing import Annotated +from pydantic import Field + + +def get_date_time( + input_text: Annotated[str, Field(description="User input (not used, can be empty)")] = "" +) -> str: + """ + Get the current date and time. + + Args: + input_text: Optional user input (not used by this function). + + Returns: + A formatted string with the current date and time. + """ + now = datetime.now() + # Format similar to C#: DateTimeOffset.Now.ToString("D", null) + # "D" format is long date pattern + formatted_date = now.strftime("%A, %B %d, %Y") + formatted_time = now.strftime("%I:%M:%S %p") + + return f"{formatted_date} at {formatted_time}" diff --git a/test_samples/weather-agent-framework/tools/weather_tools.py b/test_samples/weather-agent-framework/tools/weather_tools.py new file mode 100644 index 00000000..e6d2ccbd --- /dev/null +++ b/test_samples/weather-agent-framework/tools/weather_tools.py @@ -0,0 +1,138 @@ +"""Weather lookup tools using OpenWeatherMap API.""" +from typing import Annotated, Optional, Dict, Any, List +from pydantic import Field +from pyowm import OWM +from pyowm.weatherapi30.weather import Weather +from pyowm.weatherapi30.forecast import Forecast +from config import settings + + +def get_current_weather_for_location( + location: Annotated[str, Field(description="The city name")], + state: Annotated[str, Field(description="The US state name or empty string for international cities")] +) -> str: + """ + Retrieves the current weather for a specified location. + + This function uses the OpenWeatherMap API to fetch current weather data for a given city and state. + + Args: + location: The name of the city for which to retrieve the weather. + state: The name of the state where the city is located (US only, empty for international). + + Returns: + A formatted string containing the current weather details including temperature, + conditions, humidity, and wind speed. + + Raises: + ValueError: If the location cannot be found or API key is invalid. + """ + print(f"Looking up current weather in {location}, {state if state else 'international'}") + + try: + # Initialize OpenWeatherMap client + owm = OWM(settings.openweather_api_key) + mgr = owm.weather_manager() + + # Build location query + query = f"{location},{state},US" if state else location + + # Get current weather + observation = mgr.weather_at_place(query) + weather: Weather = observation.weather + + # Extract weather details + temp = weather.temperature('fahrenheit') + current_temp = temp.get('temp', 'N/A') + temp_min = temp.get('temp_min', 'N/A') + temp_max = temp.get('temp_max', 'N/A') + + humidity = weather.humidity + wind = weather.wind().get('speed', 'N/A') + status = weather.detailed_status + + # Format response + result = f"""Current Weather for {location}, {state if state else 'international'}: +- Temperature: {current_temp}°F +- Low: {temp_min}°F / High: {temp_max}°F +- Conditions: {status.capitalize()} +- Humidity: {humidity}% +- Wind Speed: {wind} mph""" + + print(f"Successfully retrieved weather for {location}") + return result + + except Exception as e: + error_msg = f"Unable to retrieve weather for {location}, {state}: {str(e)}" + print(error_msg) + return error_msg + + +def get_weather_forecast_for_location( + location: Annotated[str, Field(description="The city name")], + state: Annotated[str, Field(description="The US state name or empty string for international cities")] +) -> str: + """ + Retrieves the 5-day weather forecast for a specified location. + + This function uses the OpenWeatherMap API to fetch forecast data for a given city and state. + + Args: + location: The name of the city for which to retrieve the forecast. + state: The name of the state where the city is located (US only, empty for international). + + Returns: + A formatted string containing the 5-day forecast with dates, temperatures, and conditions. + + Raises: + ValueError: If the location cannot be found or API key is invalid. + """ + print(f"Looking up weather forecast in {location}, {state if state else 'international'}") + + try: + # Initialize OpenWeatherMap client + owm = OWM(settings.openweather_api_key) + mgr = owm.weather_manager() + + # Build location query + query = f"{location},{state},US" if state else location + + # Get forecast + forecast: Forecast = mgr.forecast_at_place(query, '3h').forecast + + # Process forecast data - get daily forecasts + daily_forecasts: Dict[str, List[Weather]] = {} + + for weather in forecast.weathers: + date_str = weather.reference_time('iso').split('T')[0] + if date_str not in daily_forecasts: + daily_forecasts[date_str] = [] + daily_forecasts[date_str].append(weather) + + # Format forecast for up to 5 days + result_lines = [f"5-Day Weather Forecast for {location}, {state if state else 'international'}:\n"] + + for i, (date, weathers) in enumerate(list(daily_forecasts.items())[:5]): + # Get temperature range for the day + temps = [w.temperature('fahrenheit').get('temp', 0) for w in weathers] + temp_min = min(temps) if temps else 'N/A' + temp_max = max(temps) if temps else 'N/A' + + # Get most common weather condition + statuses = [w.detailed_status for w in weathers] + status = max(set(statuses), key=statuses.count) if statuses else 'Unknown' + + result_lines.append( + f"Day {i+1} ({date}):\n" + f" Low: {temp_min:.1f}°F / High: {temp_max:.1f}°F\n" + f" Conditions: {status.capitalize()}" + ) + + result = "\n".join(result_lines) + print(f"Successfully retrieved forecast for {location}") + return result + + except Exception as e: + error_msg = f"Unable to retrieve forecast for {location}, {state}: {str(e)}" + print(error_msg) + return error_msg From 79b325c6ce5f6991024422fbaa16bf64c8dbaec6 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 25 Feb 2026 12:32:55 -0800 Subject: [PATCH 02/14] Structure and configuration fixes --- .../weather-agent-framework/.env.example | 12 +- .../agents/__init__.py | 3 + .../agents/weather_agent.py | 231 ++++++++++++ test_samples/weather-agent-framework/app.py | 341 ++---------------- .../weather-agent-framework/requirements.txt | 12 +- .../tools/weather_tools.py | 8 +- 6 files changed, 279 insertions(+), 328 deletions(-) create mode 100644 test_samples/weather-agent-framework/agents/__init__.py create mode 100644 test_samples/weather-agent-framework/agents/weather_agent.py diff --git a/test_samples/weather-agent-framework/.env.example b/test_samples/weather-agent-framework/.env.example index c10ffc99..b0ab255f 100644 --- a/test_samples/weather-agent-framework/.env.example +++ b/test_samples/weather-agent-framework/.env.example @@ -1,3 +1,8 @@ +# M365 Agents SDK configuration +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=client-id-here +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=client-secret-here +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=tenant-id-here + # Azure OpenAI Configuration AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ AZURE_OPENAI_API_KEY=your-api-key-here @@ -8,6 +13,7 @@ AZURE_OPENAI_API_VERSION=2024-12-01-preview # Get your free API key from: https://openweathermap.org/price OPENWEATHER_API_KEY=your-openweather-api-key -# Server Configuration -HOST=localhost -PORT=3978 + +# Observability (optional) +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +# APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=your-key-here diff --git a/test_samples/weather-agent-framework/agents/__init__.py b/test_samples/weather-agent-framework/agents/__init__.py new file mode 100644 index 00000000..942727eb --- /dev/null +++ b/test_samples/weather-agent-framework/agents/__init__.py @@ -0,0 +1,3 @@ +from .weather_agent import WeatherAgent + +__all__ = ["WeatherAgent"] diff --git a/test_samples/weather-agent-framework/agents/weather_agent.py b/test_samples/weather-agent-framework/agents/weather_agent.py new file mode 100644 index 00000000..9371460c --- /dev/null +++ b/test_samples/weather-agent-framework/agents/weather_agent.py @@ -0,0 +1,231 @@ +""" +Weather Agent implementation. + +Handles incoming messages and uses Azure OpenAI to process +user requests with weather lookup tools. +""" +import json +import traceback +from os import environ +from openai import AzureOpenAI +from azure.identity import DefaultAzureCredential + +from microsoft_agents.hosting.core import TurnContext, TurnState + +from tools.weather_tools import get_current_weather_for_location, get_weather_forecast_for_location +from tools.datetime_tools import get_date_time +from telemetry import invoke_observed_agent_operation_with_context + +_WELCOME_MESSAGE = "Hello! I'm your Weather Agent. Ask me about the current weather or forecast for any city." +_INSTRUCTIONS = ( + "You are a helpful weather assistant. Use the provided tools to look up " + "current weather conditions and forecasts for locations requested by the user." +) + + +_TOOLS = [ + { + "type": "function", + "function": { + "name": "get_current_weather_for_location", + "description": "Retrieves the current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city name", + }, + "state": { + "type": "string", + "description": "The US state name or empty string for international cities", + }, + }, + "required": ["location"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_weather_forecast_for_location", + "description": "Retrieves the 5-day weather forecast for a location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city name", + }, + "state": { + "type": "string", + "description": "The US state name or empty string for international cities", + }, + }, + "required": ["location"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_date_time", + "description": "Get the current date and time", + "parameters": { + "type": "object", + "properties": { + "input_text": { + "type": "string", + "description": "User input (not used)", + } + }, + }, + }, + }, +] + + +class WeatherAgent: + """Weather Agent that processes user messages with Azure OpenAI and weather tools.""" + + def __init__(self): + endpoint = environ["AZURE_OPENAI_ENDPOINT"] + api_version = environ.get("AZURE_OPENAI_API_VERSION", "2024-12-01-preview") + api_key = environ.get("AZURE_OPENAI_API_KEY") + + if api_key: + self.openai_client = AzureOpenAI( + azure_endpoint=endpoint, + api_key=api_key, + api_version=api_version, + ) + else: + self.openai_client = AzureOpenAI( + azure_endpoint=endpoint, + azure_ad_token_provider=DefaultAzureCredential(), + api_version=api_version, + ) + + self._deployment = environ["AZURE_OPENAI_DEPLOYMENT"] + self._instructions = environ.get("AGENT_INSTRUCTIONS", _INSTRUCTIONS) + self._welcome_message = environ.get("AGENT_WELCOME_MESSAGE", _WELCOME_MESSAGE) + + print(f"✅ {environ.get('AGENT_NAME', 'WeatherAgent')} initialized") + + async def send_welcome(self, context: TurnContext, state: TurnState): + """Send a welcome message to new conversation members.""" + for member in context.activity.members_added or []: + if member.id != context.activity.recipient.id: + await context.send_activity(self._welcome_message) + + async def handle_message(self, context: TurnContext, state: TurnState): + """ + Process an incoming user message. + + Wraps the message logic with A365 observability — equivalent to + ``A365OtelWrapper.InvokeObservedAgentOperation`` in WeatherAgent.cs. + """ + user_text = (context.activity.text or "").strip() + + if not user_text: + return + + print(f"Received: {user_text}") + + async def _process(): + conversation_history = state.conversation.get("history", []) + conversation_history.append({"role": "user", "content": user_text}) + + messages = [ + {"role": "system", "content": self._instructions}, + *conversation_history, + ] + + response = self.openai_client.chat.completions.create( + model=self._deployment, + messages=messages, + tools=_TOOLS, + tool_choice="auto", + temperature=0.2, + ) + + response_message = response.choices[0].message + tool_calls = response_message.tool_calls + + if tool_calls: + conversation_history.append({ + "role": "assistant", + "content": response_message.content, + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in tool_calls + ], + }) + + for tool_call in tool_calls: + function_name = tool_call.function.name + function_args = json.loads(tool_call.function.arguments) + + print(f"Calling tool: {function_name}({function_args})") + + if function_name == "get_current_weather_for_location": + result = get_current_weather_for_location( + location=function_args.get("location", ""), + state=function_args.get("state", ""), + ) + elif function_name == "get_weather_forecast_for_location": + result = get_weather_forecast_for_location( + location=function_args.get("location", ""), + state=function_args.get("state", ""), + ) + elif function_name == "get_date_time": + result = get_date_time() + else: + result = f"Unknown function: {function_name}" + + conversation_history.append({ + "role": "tool", + "content": result, + "tool_call_id": tool_call.id, + }) + + second_response = self.openai_client.chat.completions.create( + model=self._deployment, + messages=[ + {"role": "system", "content": self._instructions}, + *conversation_history, + ], + temperature=0.2, + ) + final_message = second_response.choices[0].message.content + else: + final_message = response_message.content + + conversation_history.append({"role": "assistant", "content": final_message}) + + if len(conversation_history) > 10: + conversation_history = conversation_history[-10:] + + state.conversation["history"] = conversation_history + + await context.send_activity(final_message) + print("Sent response") + + try: + await invoke_observed_agent_operation_with_context( + "OnMessageActivity", + context, + state, + _process, + ) + except Exception as e: + print(f"Error: {e}") + traceback.print_exc() + await context.send_activity(f"Sorry, I encountered an error: {str(e)}") diff --git a/test_samples/weather-agent-framework/app.py b/test_samples/weather-agent-framework/app.py index 7cec3658..ec80cd42 100644 --- a/test_samples/weather-agent-framework/app.py +++ b/test_samples/weather-agent-framework/app.py @@ -1,17 +1,12 @@ """ Weather Agent using Microsoft 365 Agents SDK with aiohttp. - -This approach is closer to the C# implementation and supports Teams/M365 integration. -Uses the microsoft-agents-hosting-aiohttp package for web endpoint hosting. """ -import sys -import os -from typing import Optional +from os import environ, path from aiohttp import web from aiohttp.web import Request, Response, Application -from openai import AzureOpenAI -from azure.core.credentials import AzureKeyCredential -from azure.identity import DefaultAzureCredential +from dotenv import load_dotenv + +load_dotenv(path.join(path.dirname(__file__), ".env")) # --------------------------------------------------------------------------- # OpenTelemetry — configure providers FIRST so that the proxy tracers/meters @@ -23,300 +18,28 @@ configure_opentelemetry, create_aiohttp_tracing_middleware, setup_health_routes, - invoke_observed_agent_operation_with_context, ) -# config is imported here so the settings are available for configure_opentelemetry -# (the full 'from config import settings' import happens below after the SDK block) -from config import settings as _early_settings - configure_opentelemetry( - app_name=_early_settings.agent_name, - environment=os.environ.get("ENVIRONMENT", "development"), - otlp_endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"), - azure_monitor_connection_string=os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING"), + app_name=environ.get("AGENT_NAME", "WeatherAgent"), + environment=environ.get("ENVIRONMENT", "development"), + otlp_endpoint=environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"), + azure_monitor_connection_string=environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING"), ) # M365 Agents SDK imports -# Note: Import structure may vary based on actual package implementation -# This is a conceptual implementation based on the C# patterns -try: - from microsoft_agents.hosting.core import ( - AgentApplication, - TurnContext, - TurnState, - MemoryStorage, - ActivityHandler, - ) - from microsoft_agents.hosting.aiohttp import ( - CloudAdapter, - start_agent_process, - ) - from microsoft_agents.activity import Activity, ActivityTypes -except ImportError: - print("⚠️ Microsoft Agents SDK packages not found.") - print("This implementation requires:") - print(" - microsoft-agents-hosting-aiohttp") - print(" - microsoft-agents-hosting-core") - print("\nInstall with: pip install microsoft-agents-hosting-aiohttp") - print("\nNote: Using conceptual implementation based on C# patterns.") - print("The actual API may differ. Refer to official documentation.\n") - sys.exit(1) - -from config import settings -from tools.weather_tools import get_current_weather_for_location, get_weather_forecast_for_location -from tools.datetime_tools import get_date_time - -# Suppress the early-import alias now that the real settings object is imported -del _early_settings - - -class WeatherAgent(ActivityHandler): - """ - Weather Agent implementation similar to the C# WeatherAgent class. - - This agent handles incoming messages and uses Azure OpenAI to process - user requests with weather lookup tools. - """ - - def __init__(self): - """Initialize the Weather Agent.""" - super().__init__() - - # Validate configuration - settings.validate_required_settings() - - # Initialize Azure OpenAI client - if settings.azure_openai_api_key: - self.openai_client = AzureOpenAI( - azure_endpoint=settings.azure_openai_endpoint, - api_key=settings.azure_openai_api_key, - api_version=settings.azure_openai_api_version, - ) - else: - self.openai_client = AzureOpenAI( - azure_endpoint=settings.azure_openai_endpoint, - azure_ad_token_provider=DefaultAzureCredential(), - api_version=settings.azure_openai_api_version, - ) - - print(f"✅ {settings.agent_name} initialized") - - async def on_members_added_activity(self, context: TurnContext, state: TurnState): - """ - Handle members added to conversation (similar to C# WelcomeMessageAsync). - - Args: - context: The turn context for this activity. - state: The turn state. - """ - for member in context.activity.members_added or []: - if member.id != context.activity.recipient.id: - await context.send_activity(settings.agent_welcome_message) - - async def on_message_activity(self, context: TurnContext, state: TurnState): - """ - Handle incoming message activities (similar to C# OnMessageAsync). - - Wraps the message logic with A365 observability — equivalent to the - ``A365OtelWrapper.InvokeObservedAgentOperation`` call in WeatherAgent.cs. - - Args: - context: The turn context for this message. - state: The turn state. - """ - user_text = (context.activity.text or "").strip() - - if not user_text: - return - - print(f"Received: {user_text}") - - async def _handle_message(): - # Build conversation history from state - conversation_history = state.conversation.get("history", []) - - # Add user message - conversation_history.append({ - "role": "user", - "content": user_text - }) - - # Prepare tools for Azure OpenAI - tools = [ - { - "type": "function", - "function": { - "name": "get_current_weather_for_location", - "description": "Retrieves the current weather for a location", - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "The city name" - }, - "state": { - "type": "string", - "description": "The US state name or empty string for international cities" - } - }, - "required": ["location"] - } - } - }, - { - "type": "function", - "function": { - "name": "get_weather_forecast_for_location", - "description": "Retrieves the 5-day weather forecast for a location", - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "The city name" - }, - "state": { - "type": "string", - "description": "The US state name or empty string for international cities" - } - }, - "required": ["location"] - } - } - }, - { - "type": "function", - "function": { - "name": "get_date_time", - "description": "Get the current date and time", - "parameters": { - "type": "object", - "properties": { - "input_text": { - "type": "string", - "description": "User input (not used)" - } - } - } - } - } - ] - - # Call Azure OpenAI with function calling - messages = [ - {"role": "system", "content": settings.agent_instructions}, - *conversation_history - ] - - response = self.openai_client.chat.completions.create( - model=settings.azure_openai_deployment, - messages=messages, - tools=tools, - tool_choice="auto", - temperature=0.2, - ) - - response_message = response.choices[0].message - tool_calls = response_message.tool_calls - - # Handle tool calls - if tool_calls: - # Add assistant message with tool calls - conversation_history.append({ - "role": "assistant", - "content": response_message.content, - "tool_calls": [ - { - "id": tc.id, - "type": "function", - "function": { - "name": tc.function.name, - "arguments": tc.function.arguments - } - } - for tc in tool_calls - ] - }) - - # Execute tool calls - import json - for tool_call in tool_calls: - function_name = tool_call.function.name - function_args = json.loads(tool_call.function.arguments) - - print(f"Calling tool: {function_name}({function_args})") - - if function_name == "get_current_weather_for_location": - function_response = get_current_weather_for_location( - location=function_args.get("location", ""), - state=function_args.get("state", "") - ) - elif function_name == "get_weather_forecast_for_location": - function_response = get_weather_forecast_for_location( - location=function_args.get("location", ""), - state=function_args.get("state", "") - ) - elif function_name == "get_date_time": - function_response = get_date_time() - else: - function_response = f"Unknown function: {function_name}" - - # Add function response to conversation - conversation_history.append({ - "role": "tool", - "content": function_response, - "tool_call_id": tool_call.id - }) - - # Get final response from model - second_response = self.openai_client.chat.completions.create( - model=settings.azure_openai_deployment, - messages=[ - {"role": "system", "content": settings.agent_instructions}, - *conversation_history - ], - temperature=0.2, - ) - - final_message = second_response.choices[0].message.content - else: - final_message = response_message.content - - # Add assistant response to history - conversation_history.append({ - "role": "assistant", - "content": final_message - }) - - # Keep last 10 messages in history - if len(conversation_history) > 10: - conversation_history = conversation_history[-10:] - - # Save conversation history to state - state.conversation["history"] = conversation_history - - # Send response - await context.send_activity(final_message) - print("Sent response") - - # Wrap the message handler with A365 observability — equivalent to - # A365OtelWrapper.InvokeObservedAgentOperation() in WeatherAgent.cs. - # Any exception propagates after being recorded on the span. - try: - await invoke_observed_agent_operation_with_context( - "OnMessageActivity", - context, - state, - _handle_message, - ) - except Exception as e: - error_msg = f"Sorry, I encountered an error: {str(e)}" - print(f"Error: {e}") - import traceback - traceback.print_exc() - await context.send_activity(error_msg) +from microsoft_agents.hosting.core import ( + AgentApplication, + TurnContext, + TurnState, + MemoryStorage, +) +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + start_agent_process, +) +from microsoft_agents.activity import ActivityTypes +from agents import WeatherAgent async def messages_endpoint(request: Request) -> Response: @@ -358,13 +81,13 @@ def create_app() -> Application: weather_agent = WeatherAgent() # Register event handlers - @agent_app.activity(ActivityTypes.MESSAGE) + @agent_app.activity(ActivityTypes.message) async def on_message(context: TurnContext, state: TurnState): - await weather_agent.on_message_activity(context, state) + await weather_agent.handle_message(context, state) @agent_app.conversation_update("membersAdded") async def on_members_added(context: TurnContext, state: TurnState): - await weather_agent.on_members_added_activity(context, state) + await weather_agent.send_welcome(context, state) # Create aiohttp app with tracing middleware. # Equivalent to AddAspNetCoreInstrumentation() + health-check filter in C#. @@ -375,8 +98,7 @@ async def on_members_added(context: TurnContext, state: TurnState): app.router.add_get("/", lambda _: Response(text="Weather Agent is running", status=200)) # Register /health and /alive endpoints (development only). - # Equivalent to app.MapDefaultEndpoints() in C#. - is_development = os.environ.get("ENVIRONMENT", "development").lower() == "development" + is_development = environ.get("ENVIRONMENT", "development").lower() == "development" setup_health_routes(app, development=is_development) # Store agent components @@ -388,21 +110,18 @@ async def on_members_added(context: TurnContext, state: TurnState): def main(): """Main application entry point.""" - try: - settings.validate_required_settings() - except ValueError as e: - print(f"\n❌ Configuration Error:\n{e}\n") - print("Please create a .env file based on .env.example and configure required settings.") - sys.exit(1) + agent_name = environ.get("AGENT_NAME", "WeatherAgent") + host = environ.get("HOST", "localhost") + port = int(environ.get("PORT", 3978)) print(f"\n{'='*60}") - print(f"🤖 Starting {settings.agent_name} (M365 SDK)") + print(f"Starting {agent_name} (M365 SDK)") print(f"{'='*60}") - print(f"Endpoint: http://{settings.host}:{settings.port}/api/messages") + print(f"Endpoint: http://{host}:{port}/api/messages") print(f"{'='*60}\n") app = create_app() - web.run_app(app, host=settings.host, port=settings.port) + web.run_app(app, host=host, port=port) if __name__ == "__main__": diff --git a/test_samples/weather-agent-framework/requirements.txt b/test_samples/weather-agent-framework/requirements.txt index 6821bb14..adacc136 100644 --- a/test_samples/weather-agent-framework/requirements.txt +++ b/test_samples/weather-agent-framework/requirements.txt @@ -1,32 +1,24 @@ -# Modern Agent Framework (recommended) -agent-framework --pre - # M365 Agents SDK for Teams/M365 integration microsoft-agents-hosting-aiohttp -microsoft-agents-hosting-core -microsoft-agents-activity -microsoft-agents-authentication-msal +# microsoft-agents-authentication-msal # uncomment to enable MSAL-based auth # Azure services azure-identity -#OpenAI +# OpenAI openai # Web framework aiohttp -aiohttp-cors # Configuration python-dotenv pydantic -pydantic-settings # Weather API client pyowm # Utilities -httpx typing-extensions # OpenTelemetry — core (required for telemetry/) diff --git a/test_samples/weather-agent-framework/tools/weather_tools.py b/test_samples/weather-agent-framework/tools/weather_tools.py index e6d2ccbd..d8fa868d 100644 --- a/test_samples/weather-agent-framework/tools/weather_tools.py +++ b/test_samples/weather-agent-framework/tools/weather_tools.py @@ -1,10 +1,10 @@ """Weather lookup tools using OpenWeatherMap API.""" -from typing import Annotated, Optional, Dict, Any, List +from os import environ +from typing import Annotated, Dict, List from pydantic import Field from pyowm import OWM from pyowm.weatherapi30.weather import Weather from pyowm.weatherapi30.forecast import Forecast -from config import settings def get_current_weather_for_location( @@ -31,7 +31,7 @@ def get_current_weather_for_location( try: # Initialize OpenWeatherMap client - owm = OWM(settings.openweather_api_key) + owm = OWM(environ["OPENWEATHER_API_KEY"]) mgr = owm.weather_manager() # Build location query @@ -91,7 +91,7 @@ def get_weather_forecast_for_location( try: # Initialize OpenWeatherMap client - owm = OWM(settings.openweather_api_key) + owm = OWM(environ["OPENWEATHER_API_KEY"]) mgr = owm.weather_manager() # Build location query From 1f4304c2a593cb348fe9f7e22d5d9fa06f84841e Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Thu, 26 Feb 2026 13:25:58 -0800 Subject: [PATCH 03/14] State management fixes --- .../agents/weather_agent.py | 26 ++++++++++++++++--- test_samples/weather-agent-framework/app.py | 19 +++++++++++--- .../weather-agent-framework/requirements.txt | 2 +- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/test_samples/weather-agent-framework/agents/weather_agent.py b/test_samples/weather-agent-framework/agents/weather_agent.py index 9371460c..96df61d0 100644 --- a/test_samples/weather-agent-framework/agents/weather_agent.py +++ b/test_samples/weather-agent-framework/agents/weather_agent.py @@ -10,7 +10,7 @@ from openai import AzureOpenAI from azure.identity import DefaultAzureCredential -from microsoft_agents.hosting.core import TurnContext, TurnState +from microsoft_agents.hosting.core import TurnContext, TurnState, StoreItem from tools.weather_tools import get_current_weather_for_location, get_weather_forecast_for_location from tools.datetime_tools import get_date_time @@ -85,6 +85,20 @@ ] +class ConversationHistoryStoreItem(StoreItem): + """Wraps the OpenAI message list so it can be persisted via AgentState.""" + + def __init__(self, messages: list = None): + self.messages = messages or [] + + def store_item_to_json(self) -> dict: + return {"messages": self.messages} + + @staticmethod + def from_json_to_store_item(json_data: dict) -> "ConversationHistoryStoreItem": + return ConversationHistoryStoreItem(messages=json_data.get("messages", [])) + + class WeatherAgent: """Weather Agent that processes user messages with Azure OpenAI and weather tools.""" @@ -133,7 +147,12 @@ async def handle_message(self, context: TurnContext, state: TurnState): print(f"Received: {user_text}") async def _process(): - conversation_history = state.conversation.get("history", []) + history_item = state.get_value( + "ConversationState.history", + lambda: ConversationHistoryStoreItem(), + target_cls=ConversationHistoryStoreItem, + ) + conversation_history = history_item.messages conversation_history.append({"role": "user", "content": user_text}) messages = [ @@ -213,7 +232,8 @@ async def _process(): if len(conversation_history) > 10: conversation_history = conversation_history[-10:] - state.conversation["history"] = conversation_history + history_item.messages = conversation_history + state.set_value("ConversationState.history", history_item) await context.send_activity(final_message) print("Sent response") diff --git a/test_samples/weather-agent-framework/app.py b/test_samples/weather-agent-framework/app.py index ec80cd42..87281ee4 100644 --- a/test_samples/weather-agent-framework/app.py +++ b/test_samples/weather-agent-framework/app.py @@ -36,9 +36,12 @@ ) from microsoft_agents.hosting.aiohttp import ( CloudAdapter, + jwt_authorization_middleware, start_agent_process, ) -from microsoft_agents.activity import ActivityTypes +from microsoft_agents.hosting.core.app.oauth.authorization import Authorization +from microsoft_agents.activity import ActivityTypes, load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager from agents import WeatherAgent @@ -65,16 +68,25 @@ def create_app() -> Application: Returns: Configured aiohttp Application. """ + agents_sdk_config = load_configuration_from_env(environ) # Create storage storage = MemoryStorage() + + # Create connection manager for MSAL-based authentication + connection_manager = MsalConnectionManager(**agents_sdk_config) # Create adapter - adapter = CloudAdapter() + adapter = CloudAdapter(connection_manager=connection_manager) + + #Create authorization + authorization = Authorization(storage, connection_manager, **agents_sdk_config) # Create agent application agent_app = AgentApplication[TurnState]( storage=storage, adapter=adapter, + authorization=authorization, + **agents_sdk_config ) # Instantiate our weather agent @@ -91,7 +103,7 @@ async def on_members_added(context: TurnContext, state: TurnState): # Create aiohttp app with tracing middleware. # Equivalent to AddAspNetCoreInstrumentation() + health-check filter in C#. - app = Application(middlewares=[create_aiohttp_tracing_middleware()]) + app = Application(middlewares=[create_aiohttp_tracing_middleware(), jwt_authorization_middleware]) # Add routes app.router.add_post("/api/messages", messages_endpoint) @@ -104,6 +116,7 @@ async def on_members_added(context: TurnContext, state: TurnState): # Store agent components app["agent_app"] = agent_app app["adapter"] = adapter + app["agent_configuration"] = connection_manager.get_default_connection_configuration() return app diff --git a/test_samples/weather-agent-framework/requirements.txt b/test_samples/weather-agent-framework/requirements.txt index adacc136..520017c9 100644 --- a/test_samples/weather-agent-framework/requirements.txt +++ b/test_samples/weather-agent-framework/requirements.txt @@ -1,6 +1,6 @@ # M365 Agents SDK for Teams/M365 integration microsoft-agents-hosting-aiohttp -# microsoft-agents-authentication-msal # uncomment to enable MSAL-based auth +microsoft-agents-authentication-msal # uncomment to enable MSAL-based auth # Azure services azure-identity From 856a4e0dd0789851585e60425f0b19261eb1fff6 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 2 Mar 2026 16:11:11 -0800 Subject: [PATCH 04/14] AgentFramework OTEL extansion --- .../weather-agent-framework/.env.example | 5 +++-- test_samples/weather-agent-framework/app.py | 2 ++ .../weather-agent-framework/requirements.txt | 4 ++++ .../telemetry/__init__.py | 2 ++ .../telemetry/agent_otel_extensions.py | 22 +++++++++++++++++++ 5 files changed, 33 insertions(+), 2 deletions(-) diff --git a/test_samples/weather-agent-framework/.env.example b/test_samples/weather-agent-framework/.env.example index b0ab255f..196655df 100644 --- a/test_samples/weather-agent-framework/.env.example +++ b/test_samples/weather-agent-framework/.env.example @@ -14,6 +14,7 @@ AZURE_OPENAI_API_VERSION=2024-12-01-preview OPENWEATHER_API_KEY=your-openweather-api-key -# Observability (optional) -# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +# Observability +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +ENABLE_OTLP_EXPORTER=True # APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=your-key-here diff --git a/test_samples/weather-agent-framework/app.py b/test_samples/weather-agent-framework/app.py index 87281ee4..4882e72a 100644 --- a/test_samples/weather-agent-framework/app.py +++ b/test_samples/weather-agent-framework/app.py @@ -17,6 +17,7 @@ from telemetry import ( configure_opentelemetry, create_aiohttp_tracing_middleware, + enable_agentframework_instrumentation, setup_health_routes, ) @@ -26,6 +27,7 @@ otlp_endpoint=environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"), azure_monitor_connection_string=environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING"), ) +enable_agentframework_instrumentation() # M365 Agents SDK imports from microsoft_agents.hosting.core import ( diff --git a/test_samples/weather-agent-framework/requirements.txt b/test_samples/weather-agent-framework/requirements.txt index 520017c9..3d9b0bcb 100644 --- a/test_samples/weather-agent-framework/requirements.txt +++ b/test_samples/weather-agent-framework/requirements.txt @@ -32,3 +32,7 @@ opentelemetry-sdk # azure-monitor-opentelemetry-exporter # Correlate Python log records with trace context # opentelemetry-instrumentation-logging + +# A365 observability — AgentFramework automatic instrumentation +# Provides AgentFrameworkInstrumentor used in telemetry/agent_otel_extensions.py +microsoft-agents-a365-observability-extensions-agent-framework diff --git a/test_samples/weather-agent-framework/telemetry/__init__.py b/test_samples/weather-agent-framework/telemetry/__init__.py index 4837fbbe..eeb4b6da 100644 --- a/test_samples/weather-agent-framework/telemetry/__init__.py +++ b/test_samples/weather-agent-framework/telemetry/__init__.py @@ -45,6 +45,7 @@ from .agent_otel_extensions import ( configure_opentelemetry, create_aiohttp_tracing_middleware, + enable_agentframework_instrumentation, setup_health_routes, ) @@ -66,6 +67,7 @@ "invoke_observed_agent_operation_with_context", # agent_otel_extensions "configure_opentelemetry", + "enable_agentframework_instrumentation", "setup_health_routes", "create_aiohttp_tracing_middleware", ] diff --git a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py index 942f5d66..078a42e5 100644 --- a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py +++ b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py @@ -40,6 +40,8 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.trace import StatusCode +from microsoft_agents_a365.observability import AgentFrameworkInstrumentor + logger = logging.getLogger(__name__) HEALTH_ENDPOINT_PATH = "/health" @@ -253,6 +255,26 @@ def _configure_metrics( metrics.set_meter_provider(meter_provider) +def enable_agentframework_instrumentation() -> None: + """Enable AgentFramework automatic instrumentation. + + Equivalent to ``AgentFrameworkInstrumentor().instrument()`` called in + ``_enable_agentframework_instrumentation()`` of the reference + sample-agent/agent.py. + + Must be called **after** :func:`configure_opentelemetry` so that the + TracerProvider is already in place when the instrumentor registers its + hooks. If the ``microsoft-agents-a365`` package is not installed the + call is a graceful no-op, matching the try/except pattern used in the + reference sample. + """ + try: + AgentFrameworkInstrumentor().instrument() + logger.info("✅ AgentFramework instrumentation enabled") + except Exception as e: + logger.warning("⚠️ AgentFramework instrumentation failed: %s", e) + + def _configure_logging() -> None: """Bridge Python logging to the OTEL LoggerProvider when available. From 2a7b2b56b7b4b029f9659f2f4dd7e6a1b797050d Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 3 Mar 2026 17:08:38 -0800 Subject: [PATCH 05/14] AgentFramework OTEL extansion fixes --- test_samples/weather-agent-framework/requirements.txt | 10 ++++------ .../telemetry/agent_otel_extensions.py | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/test_samples/weather-agent-framework/requirements.txt b/test_samples/weather-agent-framework/requirements.txt index 3d9b0bcb..5c0c6f42 100644 --- a/test_samples/weather-agent-framework/requirements.txt +++ b/test_samples/weather-agent-framework/requirements.txt @@ -25,14 +25,12 @@ typing-extensions opentelemetry-api opentelemetry-sdk -# OpenTelemetry — optional exporters (uncomment to activate) -# OTLP (works with Aspire dashboard, Jaeger, Grafana, etc.) -# opentelemetry-exporter-otlp-proto-grpc +# OpenTelemetry — optional exporters # Azure Monitor / Application Insights -# azure-monitor-opentelemetry-exporter +azure-monitor-opentelemetry-exporter # Correlate Python log records with trace context -# opentelemetry-instrumentation-logging +opentelemetry-instrumentation-logging # A365 observability — AgentFramework automatic instrumentation # Provides AgentFrameworkInstrumentor used in telemetry/agent_otel_extensions.py -microsoft-agents-a365-observability-extensions-agent-framework +microsoft-agents-a365-observability-extensions-agent-framework>=0.2.1.dev32 diff --git a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py index 078a42e5..d990677b 100644 --- a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py +++ b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py @@ -40,7 +40,7 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.trace import StatusCode -from microsoft_agents_a365.observability import AgentFrameworkInstrumentor +from microsoft_agents_a365.observability.extensions.agentframework import AgentFrameworkInstrumentor logger = logging.getLogger(__name__) From b322ca10c33877c8f18c0a8902d59129a30d3792 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 4 Mar 2026 18:07:17 -0800 Subject: [PATCH 06/14] AgentFramework aiohttp instrumentation proposal --- test_samples/weather-agent-framework/app.py | 2 - .../weather-agent-framework/requirements.txt | 13 +- .../telemetry/__init__.py | 4 +- .../telemetry/agent_otel_extensions.py | 153 +++++++++++++++--- 4 files changed, 141 insertions(+), 31 deletions(-) diff --git a/test_samples/weather-agent-framework/app.py b/test_samples/weather-agent-framework/app.py index 4882e72a..87281ee4 100644 --- a/test_samples/weather-agent-framework/app.py +++ b/test_samples/weather-agent-framework/app.py @@ -17,7 +17,6 @@ from telemetry import ( configure_opentelemetry, create_aiohttp_tracing_middleware, - enable_agentframework_instrumentation, setup_health_routes, ) @@ -27,7 +26,6 @@ otlp_endpoint=environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"), azure_monitor_connection_string=environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING"), ) -enable_agentframework_instrumentation() # M365 Agents SDK imports from microsoft_agents.hosting.core import ( diff --git a/test_samples/weather-agent-framework/requirements.txt b/test_samples/weather-agent-framework/requirements.txt index 5c0c6f42..08ff528d 100644 --- a/test_samples/weather-agent-framework/requirements.txt +++ b/test_samples/weather-agent-framework/requirements.txt @@ -25,12 +25,21 @@ typing-extensions opentelemetry-api opentelemetry-sdk -# OpenTelemetry — optional exporters +# OpenTelemetry — exporters +# OTLP (traces, metrics, logs) — for Aspire dashboard, Jaeger, etc. +opentelemetry-exporter-otlp # Azure Monitor / Application Insights azure-monitor-opentelemetry-exporter + +# OpenTelemetry — library auto-instrumentation +# aiohttp server (inbound) and client (outbound) +opentelemetry-instrumentation-aiohttp-server +opentelemetry-instrumentation-aiohttp-client +# requests library (outbound HTTP) +opentelemetry-instrumentation-requests # Correlate Python log records with trace context opentelemetry-instrumentation-logging -# A365 observability — AgentFramework automatic instrumentation +# A365 observability — AgentFramework automatic instrumentation (optional) # Provides AgentFrameworkInstrumentor used in telemetry/agent_otel_extensions.py microsoft-agents-a365-observability-extensions-agent-framework>=0.2.1.dev32 diff --git a/test_samples/weather-agent-framework/telemetry/__init__.py b/test_samples/weather-agent-framework/telemetry/__init__.py index eeb4b6da..73502d42 100644 --- a/test_samples/weather-agent-framework/telemetry/__init__.py +++ b/test_samples/weather-agent-framework/telemetry/__init__.py @@ -45,7 +45,7 @@ from .agent_otel_extensions import ( configure_opentelemetry, create_aiohttp_tracing_middleware, - enable_agentframework_instrumentation, + instrument_libraries, setup_health_routes, ) @@ -67,7 +67,7 @@ "invoke_observed_agent_operation_with_context", # agent_otel_extensions "configure_opentelemetry", - "enable_agentframework_instrumentation", + "instrument_libraries", "setup_health_routes", "create_aiohttp_tracing_middleware", ] diff --git a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py index d990677b..1eb44185 100644 --- a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py +++ b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py @@ -40,10 +40,14 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.trace import StatusCode -from microsoft_agents_a365.observability.extensions.agentframework import AgentFrameworkInstrumentor - logger = logging.getLogger(__name__) +try: + from microsoft_agents_a365.observability.extensions.agentframework import AgentFrameworkInstrumentor as _AgentFrameworkInstrumentor + _HAS_AGENT_FRAMEWORK_INSTRUMENTOR = True +except ImportError: + _HAS_AGENT_FRAMEWORK_INSTRUMENTOR = False + HEALTH_ENDPOINT_PATH = "/health" ALIVENESS_ENDPOINT_PATH = "/alive" @@ -56,6 +60,89 @@ # --------------------------------------------------------------------------- +def instrument_libraries() -> None: + """Instrument common HTTP libraries for automatic OpenTelemetry tracing. + + Equivalent to the ``Use*Instrumentation()`` calls in the C# host builder. + Instruments: + - aiohttp server (inbound requests) + - aiohttp client (outbound requests) with URL enrichment hooks + - requests library (outbound requests) with URL enrichment hooks + + Call this once at startup, before the server starts handling requests. + Each instrumentor is guarded with a try/except so missing optional packages + do not prevent the server from starting. + """ + # aiohttp server + try: + from opentelemetry.instrumentation.aiohttp_server import AioHttpServerInstrumentor + AioHttpServerInstrumentor().instrument() + logger.debug("aiohttp server instrumentation enabled") + except ImportError: + logger.debug( + "opentelemetry-instrumentation-aiohttp-server not installed. " + "Install: pip install opentelemetry-instrumentation-aiohttp-server" + ) + + # aiohttp client + try: + import aiohttp + from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor + from opentelemetry.trace import Span + + 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): + 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, + ) + logger.debug("aiohttp client instrumentation enabled") + except ImportError: + logger.debug( + "opentelemetry-instrumentation-aiohttp-client not installed. " + "Install: pip install opentelemetry-instrumentation-aiohttp-client" + ) + + # requests library + try: + import requests as _requests + from opentelemetry.instrumentation.requests import RequestsInstrumentor + from opentelemetry.trace import Span + + 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, + ) + logger.debug("requests instrumentation enabled") + except ImportError: + logger.debug( + "opentelemetry-instrumentation-requests not installed. " + "Install: pip install opentelemetry-instrumentation-requests" + ) + + # AgentFramework instrumentor (optional A365 package) + if _HAS_AGENT_FRAMEWORK_INSTRUMENTOR: + try: + _AgentFrameworkInstrumentor().instrument() + logger.debug("AgentFramework instrumentation enabled") + except Exception as exc: + logger.warning("AgentFramework instrumentation failed: %s", exc) + + def configure_opentelemetry( app_name: str = "A365.AgentFramework", environment: str = "development", @@ -92,7 +179,8 @@ def configure_opentelemetry( _configure_tracing(resource, app_name, otlp_endpoint, azure_monitor_connection_string) _configure_metrics(resource, otlp_endpoint, azure_monitor_connection_string) - _configure_logging() + _configure_logging(resource, otlp_endpoint) + instrument_libraries() logger.info( "OpenTelemetry configured — service: %s, environment: %s", @@ -255,32 +343,47 @@ def _configure_metrics( metrics.set_meter_provider(meter_provider) -def enable_agentframework_instrumentation() -> None: - """Enable AgentFramework automatic instrumentation. - - Equivalent to ``AgentFrameworkInstrumentor().instrument()`` called in - ``_enable_agentframework_instrumentation()`` of the reference - sample-agent/agent.py. - - Must be called **after** :func:`configure_opentelemetry` so that the - TracerProvider is already in place when the instrumentor registers its - hooks. If the ``microsoft-agents-a365`` package is not installed the - call is a graceful no-op, matching the try/except pattern used in the - reference sample. - """ - try: - AgentFrameworkInstrumentor().instrument() - logger.info("✅ AgentFramework instrumentation enabled") - except Exception as e: - logger.warning("⚠️ AgentFramework instrumentation failed: %s", e) +def _configure_logging( + resource: Resource, + otlp_endpoint: Optional[str], +) -> None: + """Configure OTEL LoggerProvider and bridge Python logging. + Equivalent to ``builder.Logging.AddOpenTelemetry(...)`` in C#. -def _configure_logging() -> None: - """Bridge Python logging to the OTEL LoggerProvider when available. + When an OTLP endpoint is available, a full LoggerProvider is created and + log records are exported via OTLP (matching the otel sample behaviour). + In all cases, Python log records are correlated with the active trace + context when ``opentelemetry-instrumentation-logging`` is installed. - Equivalent to ``builder.Logging.AddOpenTelemetry(...)`` in C#. - Requires ``pip install opentelemetry-instrumentation-logging``. + Args: + resource: The shared OTel Resource created in configure_opentelemetry. + otlp_endpoint: OTLP collector URL, or None to skip OTLP log export. """ + resolved_otlp = otlp_endpoint or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") + if resolved_otlp: + try: + from opentelemetry._logs import set_logger_provider + from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler + from opentelemetry.sdk._logs.export import BatchLogRecordProcessor + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter + + logger_provider = LoggerProvider(resource=resource) + logger_provider.add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter(endpoint=resolved_otlp)) + ) + set_logger_provider(logger_provider) + + handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider) + logging.getLogger().addHandler(handler) + logger.info("OTLP log exporter → %s", resolved_otlp) + except ImportError: + logger.warning( + "OTLP log exporter requested but package not found. " + "Install: pip install opentelemetry-exporter-otlp-proto-grpc" + ) + + # Bridge Python logging to trace context (adds trace_id/span_id to log records) try: from opentelemetry.instrumentation.logging import LoggingInstrumentor From 3604eb31835499ab40a5ab6cfa054b2ea4c6af77 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Thu, 5 Mar 2026 16:44:05 -0800 Subject: [PATCH 07/14] Sample working with aspire dashboard --- .../weather-agent-framework/.env.example | 21 +++ .../weather-agent-framework/README.md | 168 +++++++++++------- .../weather-agent-framework/requirements.txt | 8 +- .../telemetry/agent_otel_extensions.py | 6 +- 4 files changed, 137 insertions(+), 66 deletions(-) diff --git a/test_samples/weather-agent-framework/.env.example b/test_samples/weather-agent-framework/.env.example index 196655df..12294cd8 100644 --- a/test_samples/weather-agent-framework/.env.example +++ b/test_samples/weather-agent-framework/.env.example @@ -17,4 +17,25 @@ OPENWEATHER_API_KEY=your-openweather-api-key # Observability OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 ENABLE_OTLP_EXPORTER=True +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=".*" # APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=your-key-here + +# Required for observability SDK +ENABLE_OBSERVABILITY=true +#ENABLE_A365_OBSERVABILITY_EXPORTER=false +PYTHON_ENVIRONMENT=development +ENABLE_OPENTELEMETRY_SWITCH=true + +# Observability Configuration +OBSERVABILITY_SERVICE_NAME=agent-framework-sample +OBSERVABILITY_SERVICE_NAMESPACE=agents-framework.samples + +# Enable otel logs on AgentFramework SDK. Required for auto instrumentation +ENABLE_OTEL=true +ENABLE_SENSITIVE_DATA=true diff --git a/test_samples/weather-agent-framework/README.md b/test_samples/weather-agent-framework/README.md index fe382274..919a01f4 100644 --- a/test_samples/weather-agent-framework/README.md +++ b/test_samples/weather-agent-framework/README.md @@ -1,101 +1,145 @@ -# Overview +# Weather Agent Sample -This sample demonstrates how to build an agent using the Microsoft 365 Agents SDK for Python that: -- Hosts an agent using `agent-framework` or `microsoft-agents-hosting-aiohttp` -- Integrates Azure OpenAI for natural language understanding -- Implements weather lookup tools using OpenWeatherMap API -- Supports streaming responses +A sample agent built with the Microsoft 365 Agents SDK for Python. It uses Azure OpenAI for natural language understanding, OpenWeatherMap for weather lookups, and exports telemetry to an Aspire dashboard via OpenTelemetry. -## Project Structure +## Prerequisites -``` -python-agent/ -├── README.md # This file -├── requirements.txt # Python dependencies -├── app.py # M365 Agents SDK with aiohttp -├── agent/ -│ ├── __init__.py -│ └── weather_agent.py # Weather agent class (M365 SDK version) -└── tools/ - ├── __init__.py - ├── weather_tools.py # Weather lookup tools - └── datetime_tools.py # DateTime helper tools +- **Python 3.11+** (3.10+ supported) +- **Docker** (for the Aspire observability dashboard) +- **Azure OpenAI** deployment +- **OpenWeatherMap API key** — get a free key at +- **Azure AD app registration** (for M365/Teams auth) — or use the default dev values + +## 1. Start the Aspire Dashboard + +The sample exports traces, metrics, and logs via OTLP. Run the Aspire dashboard container so there is a collector listening: + +```bash +docker run --rm -it -p 18888:18888 -p 4317:18889 \ + --name aspire-dashboard \ + mcr.microsoft.com/dotnet/aspire-dashboard:latest ``` -## Prerequisites +Once running, open **http://localhost:18888** in your browser to view telemetry. + +> The agent sends OTLP data to `localhost:4317`, which the container maps to its internal port `18889`. -- Python 3.11 or later (3.10+ supported, 3.11+ recommended for optimal performance) -- Azure OpenAI deployment -- OpenWeatherMap API key (free tier available at https://openweathermap.org/) +## 2. Create a Virtual Environment & Install Dependencies -## Installation +From the repository root: -1. Create a virtual environment: ```bash -python -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate +python -m venv .venv +# Linux / macOS +source .venv/bin/activate +# Windows +.venv\Scripts\activate ``` -2. Install dependencies: +Then install the SDK libraries in editable mode (if not already): + ```bash -pip install -r requirements.txt +pip install -e ./libraries/microsoft-agents-activity/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-hosting-core/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-authentication-msal/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-hosting-aiohttp/ --config-settings editable_mode=compat ``` -## Configuration - -1. Copy the `.env.example` to `.env` and configure: +Install the sample's own dependencies: ```bash -# Azure OpenAI Configuration -AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_API_KEY=your-api-key-here -AZURE_OPENAI_DEPLOYMENT=gpt-4o-mini +pip install -r test_samples/weather-agent-framework/requirements.txt +``` + +## 3. Configure the `.env` File -# OpenWeatherMap API Key -OPENWEATHER_API_KEY=your-openweather-api-key +Copy the example and fill in your values: -# Server Configuration (optional) -PORT=3978 -HOST=localhost +```bash +cp test_samples/weather-agent-framework/.env.example test_samples/weather-agent-framework/.env ``` -2. For production, use Azure Key Vault or environment variables instead of `.env` file. +Open the `.env` file and set the following variables: + +### M365 Agents SDK / Azure AD -## Running the Agent +| Variable | Where to get it | +|---|---| +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID` | Azure Portal → **App registrations** → your app → **Application (client) ID** | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET` | Azure Portal → **App registrations** → your app → **Certificates & secrets** → create a new client secret | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID` | Azure Portal → **App registrations** → your app → **Directory (tenant) ID** | -For Teams/M365 integration with web endpoint: +> For local development without Teams/M365 auth you can leave these as the placeholder values. + +### Azure OpenAI + +| Variable | Where to get it | +|---|---| +| `AZURE_OPENAI_ENDPOINT` | Azure Portal → **Azure OpenAI** resource → **Keys and Endpoint** → copy the endpoint URL | +| `AZURE_OPENAI_API_KEY` | Same page → copy **Key 1** or **Key 2** | +| `AZURE_OPENAI_DEPLOYMENT` | Azure Portal → **Azure OpenAI** → **Model deployments** → the deployment name (e.g. `gpt-4o-mini`) | +| `AZURE_OPENAI_API_VERSION` | Use `2024-12-01-preview` or a later supported version | + +### OpenWeatherMap + +| Variable | Where to get it | +|---|---| +| `OPENWEATHER_API_KEY` | → sign up for a free account, then copy the API key from your account page | + +### Observability (pre-configured defaults) + +The remaining variables in `.env.example` point to the local Aspire dashboard and enable OpenTelemetry export. The defaults work out of the box when the Docker container from Step 1 is running. No changes needed unless you want to send telemetry elsewhere (e.g. Azure Monitor via `APPLICATIONINSIGHTS_CONNECTION_STRING`). + +## 4. Run the Agent ```bash +cd test_samples/weather-agent-framework python app.py ``` -The agent will be available at `http://localhost:3978/api/messages` +The agent starts at **http://localhost:3978/api/messages**. -Test with Agent Playground or Teams: -- Ensure Agent Playground is installed -- Configure the endpoint in your agent manifest -- Test through Agent Playground or Teams interface +## 5. Test the Agent -## Tools Implemented +Use [M365 Agents Playground](https://github.com/OfficeDev/microsoft-365-agents-toolkit) or a Teams channel pointed at a dev tunnel: -1. **Weather Lookup Tools** (`tools/weather_tools.py`) - - `get_current_weather_for_location(location, state)` - Current weather conditions - - `get_weather_forecast_for_location(location, state)` - 5-day forecast - -2. **DateTime Tools** (`tools/datetime_tools.py`) - - `get_date_time()` - Current date and time +```bash +# Dev tunnel setup (optional, for Teams testing) +devtunnel user login +devtunnel create my-tunnel -a +devtunnel port create -p 3978 my-tunnel +devtunnel host -a my-tunnel +``` -## Testing +Example queries: +- *"What's the weather in Seattle, Washington?"* +- *"What's the forecast for New York, New York?"* +- *"What's the current date and time?"* -Test the agent with queries like: -- "What's the weather in Seattle, Washington?" -- "What's the forecast for New York, New York?" -- "What's the current date and time?" +## Project Structure +``` +weather-agent-framework/ +├── app.py # aiohttp entry point +├── .env.example # Environment variable template +├── requirements.txt # Python dependencies +├── agents/ +│ ├── __init__.py +│ └── weather_agent.py # Agent logic (Azure OpenAI + tool calls) +├── tools/ +│ ├── __init__.py +│ ├── weather_tools.py # Current weather & 5-day forecast +│ └── datetime_tools.py # Current date/time helper +└── telemetry/ + ├── __init__.py + ├── agent_otel_extensions.py # OTel setup helpers + ├── agent_metrics.py # Custom metrics + └── a365_otel_wrapper.py # A365 observability wrapper +``` ## Further Reading - [Microsoft 365 Agents SDK Documentation](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/) -- [Agent Framework Documentation](https://learn.microsoft.com/en-us/agent-framework/) - [Azure OpenAI Service](https://learn.microsoft.com/en-us/azure/ai-services/openai/) - [OpenWeatherMap API](https://openweathermap.org/api) +- [.NET Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone) diff --git a/test_samples/weather-agent-framework/requirements.txt b/test_samples/weather-agent-framework/requirements.txt index 08ff528d..eb64d742 100644 --- a/test_samples/weather-agent-framework/requirements.txt +++ b/test_samples/weather-agent-framework/requirements.txt @@ -1,6 +1,6 @@ # M365 Agents SDK for Teams/M365 integration -microsoft-agents-hosting-aiohttp -microsoft-agents-authentication-msal # uncomment to enable MSAL-based auth +#microsoft-agents-hosting-aiohttp +#microsoft-agents-authentication-msal # uncomment to enable MSAL-based auth # Azure services azure-identity @@ -40,6 +40,8 @@ opentelemetry-instrumentation-requests # Correlate Python log records with trace context opentelemetry-instrumentation-logging -# A365 observability — AgentFramework automatic instrumentation (optional) +# A365 observability — AgentFramework automatic instrumentation # Provides AgentFrameworkInstrumentor used in telemetry/agent_otel_extensions.py +microsoft-agents-a365-runtime==0.2.1.dev33 +microsoft-agents-a365-observability-core==0.2.1.dev33 microsoft-agents-a365-observability-extensions-agent-framework>=0.2.1.dev32 diff --git a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py index 1eb44185..d0ad1072 100644 --- a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py +++ b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py @@ -45,7 +45,11 @@ try: from microsoft_agents_a365.observability.extensions.agentframework import AgentFrameworkInstrumentor as _AgentFrameworkInstrumentor _HAS_AGENT_FRAMEWORK_INSTRUMENTOR = True -except ImportError: +except ImportError as exc: + logger.debug( + "AgentFrameworkInstrumentor not available — Agent Framework-specific telemetry will be disabled. " + f"ImportError: {exc}" + ) _HAS_AGENT_FRAMEWORK_INSTRUMENTOR = False HEALTH_ENDPOINT_PATH = "/health" From 706b2892381274424f732216ea970735fb5532a8 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Mon, 9 Mar 2026 11:42:10 -0700 Subject: [PATCH 08/14] token_cache WIP --- .../weather-agent-framework/requirements.txt | 5 ++- .../telemetry/agent_otel_extensions.py | 7 ++++- .../telemetry/token_cache.py | 31 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 test_samples/weather-agent-framework/telemetry/token_cache.py diff --git a/test_samples/weather-agent-framework/requirements.txt b/test_samples/weather-agent-framework/requirements.txt index eb64d742..6e8006f8 100644 --- a/test_samples/weather-agent-framework/requirements.txt +++ b/test_samples/weather-agent-framework/requirements.txt @@ -44,4 +44,7 @@ opentelemetry-instrumentation-logging # Provides AgentFrameworkInstrumentor used in telemetry/agent_otel_extensions.py microsoft-agents-a365-runtime==0.2.1.dev33 microsoft-agents-a365-observability-core==0.2.1.dev33 -microsoft-agents-a365-observability-extensions-agent-framework>=0.2.1.dev32 +microsoft-agents-a365-observability-extensions-agent-framework>=0.2.1.dev33 + +# Agent Framework +agent-framework-azure-ai diff --git a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py index d0ad1072..0bc4b65b 100644 --- a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py +++ b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py @@ -44,6 +44,7 @@ try: from microsoft_agents_a365.observability.extensions.agentframework import AgentFrameworkInstrumentor as _AgentFrameworkInstrumentor + from microsoft_agents_a365.observability.core.config import configure as _configure_agent_framework_observability _HAS_AGENT_FRAMEWORK_INSTRUMENTOR = True except ImportError as exc: logger.debug( @@ -141,7 +142,11 @@ def _requests_response_hook(span: Span, request: _requests.Request, response: _r # AgentFramework instrumentor (optional A365 package) if _HAS_AGENT_FRAMEWORK_INSTRUMENTOR: try: - _AgentFrameworkInstrumentor().instrument() + _configure_agent_framework_observability( + service_name="AgentFrameworkTracingWithAzureOpenAI", + service_namespace="AgentFrameworkTesting", + ) + _AgentFrameworkInstrumentor().instrument(skip_dep_check=True) logger.debug("AgentFramework instrumentation enabled") except Exception as exc: logger.warning("AgentFramework instrumentation failed: %s", exc) diff --git a/test_samples/weather-agent-framework/telemetry/token_cache.py b/test_samples/weather-agent-framework/telemetry/token_cache.py new file mode 100644 index 00000000..31339a13 --- /dev/null +++ b/test_samples/weather-agent-framework/telemetry/token_cache.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +""" +Token caching utilities for Agent 365 Observability exporter authentication. +""" + +import logging + +logger = logging.getLogger(__name__) + +# Global token cache for Agent 365 Observability exporter +_agentic_token_cache = {} + + +def cache_agentic_token(tenant_id: str, agent_id: str, token: str) -> None: + """Cache the agentic token for use by Agent 365 Observability exporter.""" + key = f"{tenant_id}:{agent_id}" + _agentic_token_cache[key] = token + logger.debug(f"Cached agentic token for {key}") + + +def get_cached_agentic_token(tenant_id: str, agent_id: str) -> str | None: + """Retrieve cached agentic token for Agent 365 Observability exporter.""" + key = f"{tenant_id}:{agent_id}" + token = _agentic_token_cache.get(key) + if token: + logger.debug(f"Retrieved cached agentic token for {key}") + else: + logger.debug(f"No cached token found for {key}") + return token \ No newline at end of file From f425f7f6a21518629a446603a47abc1fc4c31b07 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 9 Mar 2026 16:51:58 -0700 Subject: [PATCH 09/14] AgentFrameworkInstrumentator initialization with token_cache testing pending --- test_samples/weather-agent-framework/app.py | 43 +++++++++++++++++++ .../telemetry/agent_otel_extensions.py | 14 +++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/test_samples/weather-agent-framework/app.py b/test_samples/weather-agent-framework/app.py index 87281ee4..1c6efa54 100644 --- a/test_samples/weather-agent-framework/app.py +++ b/test_samples/weather-agent-framework/app.py @@ -1,11 +1,14 @@ """ Weather Agent using Microsoft 365 Agents SDK with aiohttp. """ +import logging from os import environ, path from aiohttp import web from aiohttp.web import Request, Response, Application from dotenv import load_dotenv +logger = logging.getLogger(__name__) + load_dotenv(path.join(path.dirname(__file__), ".env")) # --------------------------------------------------------------------------- @@ -43,6 +46,7 @@ from microsoft_agents.activity import ActivityTypes, load_configuration_from_env from microsoft_agents.authentication.msal import MsalConnectionManager from agents import WeatherAgent +from telemetry.token_cache import cache_agentic_token async def messages_endpoint(request: Request) -> Response: @@ -118,6 +122,45 @@ async def on_members_added(context: TurnContext, state: TurnState): app["adapter"] = adapter app["agent_configuration"] = connection_manager.get_default_connection_configuration() + # Register startup handler to prime the observability token cache. + # Mirrors _setup_observability_token() in host_agent_server.py of the reference sample. + async def _setup_observability_token(_app: Application) -> None: + try: + from microsoft_agents_a365.observability.core.config import get_observability_authentication_scope + except ImportError: + logger.debug( + "A365 observability package not available — skipping observability token setup" + ) + return + + try: + config = connection_manager.get_default_connection_configuration() + tenant_id = config.TENANT_ID + agent_id = config.CLIENT_ID + + if not tenant_id or not agent_id: + logger.warning( + "Missing TENANT_ID or CLIENT_ID — cannot cache observability token" + ) + return + + msal_auth = connection_manager.get_default_connection() + scope = get_observability_authentication_scope() + scopes = [scope] if isinstance(scope, str) else list(scope) + + token = await msal_auth.get_access_token( + resource_url=f"https://login.microsoftonline.com/{tenant_id}", + scopes=scopes, + ) + cache_agentic_token(tenant_id, agent_id, token) + logger.info( + "Observability token cached (tenant=%s, agent=%s)", tenant_id, agent_id + ) + except Exception as exc: + logger.warning("Failed to cache observability token: %s", exc) + + app.on_startup.append(_setup_observability_token) + return app diff --git a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py index 0bc4b65b..3cadb3d7 100644 --- a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py +++ b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py @@ -142,11 +142,23 @@ def _requests_response_hook(span: Span, request: _requests.Request, response: _r # AgentFramework instrumentor (optional A365 package) if _HAS_AGENT_FRAMEWORK_INSTRUMENTOR: try: + from .token_cache import get_cached_agentic_token + + def _token_resolver(agent_id: str, tenant_id: str) -> str | None: + # Note: get_cached_agentic_token takes (tenant_id, agent_id) + return get_cached_agentic_token(tenant_id, agent_id) + _configure_agent_framework_observability( service_name="AgentFrameworkTracingWithAzureOpenAI", service_namespace="AgentFrameworkTesting", ) - _AgentFrameworkInstrumentor().instrument(skip_dep_check=True) + try: + _AgentFrameworkInstrumentor().instrument( + skip_dep_check=True, token_resolver=_token_resolver + ) + except TypeError: + # Older versions of the instrumentor may not support token_resolver + _AgentFrameworkInstrumentor().instrument(skip_dep_check=True) logger.debug("AgentFramework instrumentation enabled") except Exception as exc: logger.warning("AgentFramework instrumentation failed: %s", exc) From 6f620cf300d550dec28547ac4a7f35f77f0e5475 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Mon, 9 Mar 2026 23:35:14 -0700 Subject: [PATCH 10/14] getting 400 for Failed to export span batch for Agent SDK Framework telemetry --- .../agents/weather_agent.py | 4 +- test_samples/weather-agent-framework/app.py | 46 ++------------- .../telemetry/a365_otel_wrapper.py | 59 ++++++++++++------- .../telemetry/agent_otel_extensions.py | 5 +- 4 files changed, 47 insertions(+), 67 deletions(-) diff --git a/test_samples/weather-agent-framework/agents/weather_agent.py b/test_samples/weather-agent-framework/agents/weather_agent.py index 96df61d0..be0599cf 100644 --- a/test_samples/weather-agent-framework/agents/weather_agent.py +++ b/test_samples/weather-agent-framework/agents/weather_agent.py @@ -102,7 +102,8 @@ def from_json_to_store_item(json_data: dict) -> "ConversationHistoryStoreItem": class WeatherAgent: """Weather Agent that processes user messages with Azure OpenAI and weather tools.""" - def __init__(self): + def __init__(self, msal_auth=None): + self._msal_auth = msal_auth endpoint = environ["AZURE_OPENAI_ENDPOINT"] api_version = environ.get("AZURE_OPENAI_API_VERSION", "2024-12-01-preview") api_key = environ.get("AZURE_OPENAI_API_KEY") @@ -244,6 +245,7 @@ async def _process(): context, state, _process, + msal_auth=self._msal_auth, ) except Exception as e: print(f"Error: {e}") diff --git a/test_samples/weather-agent-framework/app.py b/test_samples/weather-agent-framework/app.py index 1c6efa54..99fb11b7 100644 --- a/test_samples/weather-agent-framework/app.py +++ b/test_samples/weather-agent-framework/app.py @@ -46,7 +46,6 @@ from microsoft_agents.activity import ActivityTypes, load_configuration_from_env from microsoft_agents.authentication.msal import MsalConnectionManager from agents import WeatherAgent -from telemetry.token_cache import cache_agentic_token async def messages_endpoint(request: Request) -> Response: @@ -93,8 +92,10 @@ def create_app() -> Application: **agents_sdk_config ) - # Instantiate our weather agent - weather_agent = WeatherAgent() + # Instantiate our weather agent, passing msal_auth for per-turn observability + # token caching — mirrors C# injecting IExporterTokenCache + # into WeatherAgent and passing UserAuthorization to InvokeObservedAgentOperation. + weather_agent = WeatherAgent(msal_auth=connection_manager.get_default_connection()) # Register event handlers @agent_app.activity(ActivityTypes.message) @@ -122,45 +123,6 @@ async def on_members_added(context: TurnContext, state: TurnState): app["adapter"] = adapter app["agent_configuration"] = connection_manager.get_default_connection_configuration() - # Register startup handler to prime the observability token cache. - # Mirrors _setup_observability_token() in host_agent_server.py of the reference sample. - async def _setup_observability_token(_app: Application) -> None: - try: - from microsoft_agents_a365.observability.core.config import get_observability_authentication_scope - except ImportError: - logger.debug( - "A365 observability package not available — skipping observability token setup" - ) - return - - try: - config = connection_manager.get_default_connection_configuration() - tenant_id = config.TENANT_ID - agent_id = config.CLIENT_ID - - if not tenant_id or not agent_id: - logger.warning( - "Missing TENANT_ID or CLIENT_ID — cannot cache observability token" - ) - return - - msal_auth = connection_manager.get_default_connection() - scope = get_observability_authentication_scope() - scopes = [scope] if isinstance(scope, str) else list(scope) - - token = await msal_auth.get_access_token( - resource_url=f"https://login.microsoftonline.com/{tenant_id}", - scopes=scopes, - ) - cache_agentic_token(tenant_id, agent_id, token) - logger.info( - "Observability token cached (tenant=%s, agent=%s)", tenant_id, agent_id - ) - except Exception as exc: - logger.warning("Failed to cache observability token: %s", exc) - - app.on_startup.append(_setup_observability_token) - return app diff --git a/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py b/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py index 1b780da8..3082b2da 100644 --- a/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py +++ b/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py @@ -15,19 +15,21 @@ downstream services can consume the context. 3. Delegate to :func:`~telemetry.agent_metrics.invoke_observed_agent_operation` for span creation and metric recording. - -The token-cache observability hook (``RegisterObservability``) present in the -C# wrapper has no equivalent in Python and is omitted. +4. Cache the observability token per-turn using the activity-derived IDs, + equivalent to ``agentTokenCache.RegisterObservability()`` in C#. """ import logging import uuid -from typing import Awaitable, Callable +from typing import Awaitable, Callable, Optional from opentelemetry import baggage from opentelemetry import context as otel_context +from microsoft_agents.hosting.core import AccessTokenProviderBase + from .agent_metrics import invoke_observed_agent_operation +from .token_cache import cache_agentic_token logger = logging.getLogger(__name__) @@ -44,25 +46,32 @@ async def invoke_observed_agent_operation_with_context( turn_context, turn_state, func: Callable[[], Awaitable[None]], + msal_auth: Optional[AccessTokenProviderBase] = None, ) -> None: """Wrap an agent operation with A365 observability baggage context. Equivalent to ``A365OtelWrapper.InvokeObservedAgentOperation()`` in C#. Resolves the tenant ID and agent ID from the activity, sets them as - OpenTelemetry baggage (equivalent to the C# ``BaggageBuilder``), then - delegates to :func:`~telemetry.agent_metrics.invoke_observed_agent_operation` - for span management and metric recording. + OpenTelemetry baggage (equivalent to the C# ``BaggageBuilder``), caches + the observability token per-turn when ``msal_auth`` is provided (equivalent + to ``agentTokenCache.RegisterObservability()``), then delegates to + :func:`~telemetry.agent_metrics.invoke_observed_agent_operation` for span + management and metric recording. Args: operation_name: Human-readable name of the operation / handler. turn_context: The current :class:`TurnContext`. - turn_state: The current :class:`TurnState` (kept for API parity with - the C# version; auth resolution is not performed here). + turn_state: The current :class:`TurnState`. func: Async function containing the agent logic to execute. + msal_auth: Optional MSAL authentication provider used to fetch and + cache the observability token for the activity-derived IDs. """ agent_id, tenant_id = _resolve_tenant_and_agent_id(turn_context) + if msal_auth is not None: + await _cache_observability_token(tenant_id, agent_id, msal_auth) + async def _with_baggage(): # Set tenant.id and agent.id as baggage — equivalent to BaggageBuilder # in C#, which adds these values to the W3C baggage header so that @@ -83,23 +92,29 @@ async def _with_baggage(): # --------------------------------------------------------------------------- -def _resolve_tenant_and_agent_id(turn_context) -> tuple[str, str]: - """Extract tenant and agent IDs from the turn activity. +async def _cache_observability_token(tenant_id: str, agent_id: str, msal_auth: AccessTokenProviderBase) -> None: + """Fetch and cache the observability token using activity-derived IDs. - Equivalent to ``ResolveTenantAndAgentId()`` in C#. + Equivalent to ``agentTokenCache.RegisterObservability()`` in C#. + MSAL caches tokens internally, so repeated calls within the token lifetime + do not incur additional network requests. + """ + try: + from microsoft_agents_a365.observability.core.config import get_observability_authentication_scope + except ImportError: + return - The C# version calls into M365 auth helpers - (``GetAgenticInstanceId`` / ``ResolveAgentIdentity``) that are not - available in Python. This implementation derives the values directly - from the activity fields that carry the same semantics: + try: + token = await msal_auth.get_agentic_application_token( + tenant_id, agent_id) + cache_agentic_token(tenant_id, agent_id, token) + logger.debug("Observability token cached (tenant=%s, agent=%s)", tenant_id, agent_id) + except Exception as exc: + logger.warning("Failed to cache observability token: %s", exc) - * **agent_id** — the ``recipient.id`` of the activity (the bot/agent - identity that received the message). - * **tenant_id** — ``conversation.tenantId`` or ``recipient.tenantId``, - matching the fields inspected by the C# helper. - Falls back to ``00000000-0000-0000-0000-000000000000`` (Guid.Empty) for - any value that cannot be resolved, matching C# behaviour. +def _resolve_tenant_and_agent_id(turn_context) -> tuple[str, str]: + """Extract tenant and agent IDs from the turn activity. Args: turn_context: The current :class:`TurnContext`. diff --git a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py index 3cadb3d7..2429504e 100644 --- a/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py +++ b/test_samples/weather-agent-framework/telemetry/agent_otel_extensions.py @@ -151,12 +151,13 @@ def _token_resolver(agent_id: str, tenant_id: str) -> str | None: _configure_agent_framework_observability( service_name="AgentFrameworkTracingWithAzureOpenAI", service_namespace="AgentFrameworkTesting", + token_resolver=_token_resolver, ) try: _AgentFrameworkInstrumentor().instrument( - skip_dep_check=True, token_resolver=_token_resolver + skip_dep_check=True, ) - except TypeError: + except TypeError as exc: # Older versions of the instrumentor may not support token_resolver _AgentFrameworkInstrumentor().instrument(skip_dep_check=True) logger.debug("AgentFramework instrumentation enabled") From 01bd9f8ac141e10e3469fff77b37aaeb95097ef7 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Tue, 10 Mar 2026 00:37:39 -0700 Subject: [PATCH 11/14] Updating todos --- .../weather-agent-framework/telemetry/a365_otel_wrapper.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py b/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py index 3082b2da..1dacd7eb 100644 --- a/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py +++ b/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py @@ -99,10 +99,6 @@ async def _cache_observability_token(tenant_id: str, agent_id: str, msal_auth: A MSAL caches tokens internally, so repeated calls within the token lifetime do not incur additional network requests. """ - try: - from microsoft_agents_a365.observability.core.config import get_observability_authentication_scope - except ImportError: - return try: token = await msal_auth.get_agentic_application_token( @@ -132,6 +128,8 @@ def _resolve_tenant_and_agent_id(turn_context) -> tuple[str, str]: # Tenant ID — prefer conversation.tenantId, then recipient.tenantId conversation = getattr(activity, "conversation", None) + + #TODO: fix tenant id resolution. tenant_id = ( getattr(conversation, "tenant_id", None) or getattr(recipient, "tenant_id", None) From 2458cf97176ed9cdccfa84849324996d9145c846 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Sat, 21 Mar 2026 16:18:37 -0700 Subject: [PATCH 12/14] Agentic updates --- .../agents/weather_agent.py | 8 +-- test_samples/weather-agent-framework/app.py | 9 ++- .../weather-agent-framework/requirements.txt | 6 +- .../telemetry/a365_otel_wrapper.py | 59 +++++++++++-------- 4 files changed, 47 insertions(+), 35 deletions(-) diff --git a/test_samples/weather-agent-framework/agents/weather_agent.py b/test_samples/weather-agent-framework/agents/weather_agent.py index be0599cf..86a45d90 100644 --- a/test_samples/weather-agent-framework/agents/weather_agent.py +++ b/test_samples/weather-agent-framework/agents/weather_agent.py @@ -10,7 +10,7 @@ from openai import AzureOpenAI from azure.identity import DefaultAzureCredential -from microsoft_agents.hosting.core import TurnContext, TurnState, StoreItem +from microsoft_agents.hosting.core import Authorization, TurnContext, TurnState, StoreItem from tools.weather_tools import get_current_weather_for_location, get_weather_forecast_for_location from tools.datetime_tools import get_date_time @@ -102,8 +102,8 @@ def from_json_to_store_item(json_data: dict) -> "ConversationHistoryStoreItem": class WeatherAgent: """Weather Agent that processes user messages with Azure OpenAI and weather tools.""" - def __init__(self, msal_auth=None): - self._msal_auth = msal_auth + def __init__(self, user_authorization: Authorization=None): + self._user_authorization = user_authorization endpoint = environ["AZURE_OPENAI_ENDPOINT"] api_version = environ.get("AZURE_OPENAI_API_VERSION", "2024-12-01-preview") api_key = environ.get("AZURE_OPENAI_API_KEY") @@ -245,7 +245,7 @@ async def _process(): context, state, _process, - msal_auth=self._msal_auth, + user_authorization=self._user_authorization, ) except Exception as e: print(f"Error: {e}") diff --git a/test_samples/weather-agent-framework/app.py b/test_samples/weather-agent-framework/app.py index 99fb11b7..6d057b4b 100644 --- a/test_samples/weather-agent-framework/app.py +++ b/test_samples/weather-agent-framework/app.py @@ -7,6 +7,10 @@ from aiohttp.web import Request, Response, Application from dotenv import load_dotenv +from agent_framework.observability import configure_otel_providers + +configure_otel_providers() + logger = logging.getLogger(__name__) load_dotenv(path.join(path.dirname(__file__), ".env")) @@ -95,11 +99,12 @@ def create_app() -> Application: # Instantiate our weather agent, passing msal_auth for per-turn observability # token caching — mirrors C# injecting IExporterTokenCache # into WeatherAgent and passing UserAuthorization to InvokeObservedAgentOperation. - weather_agent = WeatherAgent(msal_auth=connection_manager.get_default_connection()) + weather_agent = WeatherAgent(user_authorization=agent_app.auth) # Register event handlers - @agent_app.activity(ActivityTypes.message) + @agent_app.activity(ActivityTypes.message, auth_handlers=["AGENTIC"]) async def on_message(context: TurnContext, state: TurnState): + # aau_token = await agent_app.auth.get_token(context, "AGENTIC") await weather_agent.handle_message(context, state) @agent_app.conversation_update("membersAdded") diff --git a/test_samples/weather-agent-framework/requirements.txt b/test_samples/weather-agent-framework/requirements.txt index 6e8006f8..30fce16a 100644 --- a/test_samples/weather-agent-framework/requirements.txt +++ b/test_samples/weather-agent-framework/requirements.txt @@ -42,9 +42,9 @@ opentelemetry-instrumentation-logging # A365 observability — AgentFramework automatic instrumentation # Provides AgentFrameworkInstrumentor used in telemetry/agent_otel_extensions.py -microsoft-agents-a365-runtime==0.2.1.dev33 -microsoft-agents-a365-observability-core==0.2.1.dev33 -microsoft-agents-a365-observability-extensions-agent-framework>=0.2.1.dev33 +microsoft-agents-a365-runtime==0.2.1.dev43 +microsoft-agents-a365-observability-core==0.2.1.dev43 +microsoft-agents-a365-observability-extensions-agent-framework>=0.2.1.dev43 # Agent Framework agent-framework-azure-ai diff --git a/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py b/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py index 1dacd7eb..3ed4ea58 100644 --- a/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py +++ b/test_samples/weather-agent-framework/telemetry/a365_otel_wrapper.py @@ -21,8 +21,10 @@ import logging import uuid +import jwt # PyJWT library from typing import Awaitable, Callable, Optional +from microsoft_agents.hosting.core import Authorization, TurnContext from opentelemetry import baggage from opentelemetry import context as otel_context @@ -43,10 +45,11 @@ async def invoke_observed_agent_operation_with_context( operation_name: str, - turn_context, + turn_context: TurnContext, turn_state, func: Callable[[], Awaitable[None]], - msal_auth: Optional[AccessTokenProviderBase] = None, + user_authorization: Optional[Authorization] = None, + auth_handler: Optional[str] = None, ) -> None: """Wrap an agent operation with A365 observability baggage context. @@ -54,7 +57,7 @@ async def invoke_observed_agent_operation_with_context( Resolves the tenant ID and agent ID from the activity, sets them as OpenTelemetry baggage (equivalent to the C# ``BaggageBuilder``), caches - the observability token per-turn when ``msal_auth`` is provided (equivalent + the observability token per-turn when ``user_authorization`` is provided (equivalent to ``agentTokenCache.RegisterObservability()``), then delegates to :func:`~telemetry.agent_metrics.invoke_observed_agent_operation` for span management and metric recording. @@ -64,13 +67,13 @@ async def invoke_observed_agent_operation_with_context( turn_context: The current :class:`TurnContext`. turn_state: The current :class:`TurnState`. func: Async function containing the agent logic to execute. - msal_auth: Optional MSAL authentication provider used to fetch and + user_authorization: Optional MSAL authentication provider used to fetch and cache the observability token for the activity-derived IDs. """ - agent_id, tenant_id = _resolve_tenant_and_agent_id(turn_context) + agent_id, tenant_id = await _resolve_tenant_and_agent_id(turn_context, user_authorization, auth_handler) - if msal_auth is not None: - await _cache_observability_token(tenant_id, agent_id, msal_auth) + if user_authorization is not None: + await _cache_observability_token(tenant_id, agent_id, user_authorization) async def _with_baggage(): # Set tenant.id and agent.id as baggage — equivalent to BaggageBuilder @@ -92,7 +95,7 @@ async def _with_baggage(): # --------------------------------------------------------------------------- -async def _cache_observability_token(tenant_id: str, agent_id: str, msal_auth: AccessTokenProviderBase) -> None: +async def _cache_observability_token(tenant_id: str, agent_id: str, authorization: Authorization) -> None: """Fetch and cache the observability token using activity-derived IDs. Equivalent to ``agentTokenCache.RegisterObservability()`` in C#. @@ -101,40 +104,44 @@ async def _cache_observability_token(tenant_id: str, agent_id: str, msal_auth: A """ try: - token = await msal_auth.get_agentic_application_token( - tenant_id, agent_id) + token = await authorization.get_token(tenant_id, agent_id) cache_agentic_token(tenant_id, agent_id, token) logger.debug("Observability token cached (tenant=%s, agent=%s)", tenant_id, agent_id) except Exception as exc: logger.warning("Failed to cache observability token: %s", exc) -def _resolve_tenant_and_agent_id(turn_context) -> tuple[str, str]: +async def _resolve_tenant_and_agent_id(turn_context: TurnContext, user_authorization: Optional[Authorization] = None, auth_handler: Optional[str] = None) -> tuple[str, str]: """Extract tenant and agent IDs from the turn activity. Args: turn_context: The current :class:`TurnContext`. + user_authorization: Optional MSAL authentication provider used to fetch the token. + auth_handler: Optional string representing the authentication handler. Returns: ``(agent_id, tenant_id)`` as strings. """ - activity = getattr(turn_context, "activity", None) + activity = turn_context.activity if activity is None: return _EMPTY_GUID, _EMPTY_GUID + + agentic_token = await user_authorization.get_token(turn_context, auth_handler or "AGENTIC") if user_authorization else None + - # Agent ID — use recipient identity (the agent/bot that received the turn) - recipient = getattr(activity, "recipient", None) - agent_id = str(getattr(recipient, "id", None) or _EMPTY_GUID) - - # Tenant ID — prefer conversation.tenantId, then recipient.tenantId - conversation = getattr(activity, "conversation", None) - - #TODO: fix tenant id resolution. - tenant_id = ( - getattr(conversation, "tenant_id", None) - or getattr(recipient, "tenant_id", None) - or _EMPTY_GUID - ) - tenant_id = str(tenant_id) + agent_id = activity.get_agentic_instance_id() if activity.is_agentic_request() else _get_app_id_from_token(agentic_token) + agent_id = agent_id or _EMPTY_GUID + + tenant_id = activity.conversation.tenant_id if activity.conversation and activity.conversation.tenant_id else _EMPTY_GUID return agent_id, tenant_id + +def _get_app_id_from_token(token: str) -> str: + """Extract the app ID from the JWT token.""" + try: + decoded = jwt.decode(token, options={"verify_signature": False}) + app_id = decoded.get("appid") or decoded.get("azp") or _EMPTY_GUID + return str(app_id) + except Exception as exc: + logger.warning("Failed to decode token for app ID extraction: %s", exc) + return _EMPTY_GUID From 71a11e2800ae451799dfbc021084916fb2132153 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Thu, 26 Mar 2026 16:26:58 -0700 Subject: [PATCH 13/14] Base sample WIP --- test_samples/echo-a365-telemetry/.env.example | 56 +++ test_samples/echo-a365-telemetry/app.py | 134 +++++ .../echo-a365-telemetry/echo_agent.py | 116 +++++ .../echo-a365-telemetry/requirements.txt | 42 ++ .../echo-a365-telemetry/telemetry/__init__.py | 68 +++ .../telemetry/a365_otel_wrapper.py | 147 ++++++ .../telemetry/agent_metrics.py | 236 +++++++++ .../telemetry/agent_otel_extensions.py | 461 ++++++++++++++++++ .../telemetry/token_cache.py | 31 ++ test_samples/weather-agent-framework/app.py | 4 +- 10 files changed, 1293 insertions(+), 2 deletions(-) create mode 100644 test_samples/echo-a365-telemetry/.env.example create mode 100644 test_samples/echo-a365-telemetry/app.py create mode 100644 test_samples/echo-a365-telemetry/echo_agent.py create mode 100644 test_samples/echo-a365-telemetry/requirements.txt create mode 100644 test_samples/echo-a365-telemetry/telemetry/__init__.py create mode 100644 test_samples/echo-a365-telemetry/telemetry/a365_otel_wrapper.py create mode 100644 test_samples/echo-a365-telemetry/telemetry/agent_metrics.py create mode 100644 test_samples/echo-a365-telemetry/telemetry/agent_otel_extensions.py create mode 100644 test_samples/echo-a365-telemetry/telemetry/token_cache.py diff --git a/test_samples/echo-a365-telemetry/.env.example b/test_samples/echo-a365-telemetry/.env.example new file mode 100644 index 00000000..ff633746 --- /dev/null +++ b/test_samples/echo-a365-telemetry/.env.example @@ -0,0 +1,56 @@ +# ============================================================================= +# M365 Agents SDK — service principal credentials +# ============================================================================= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=your-client-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=your-client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=your-tenant-id + +# ============================================================================= +# Azure AI Foundry +# Endpoint format for serverless / model-catalog deployments: +# https://.services.ai.azure.com/models +# For Azure OpenAI via Foundry: +# https://.openai.azure.com/openai/deployments/ +# ============================================================================= +AZURE_AI_FOUNDRY_ENDPOINT=https://your-resource.services.ai.azure.com/models +AZURE_AI_FOUNDRY_API_KEY=your-api-key +AZURE_AI_FOUNDRY_DEPLOYMENT=gpt-4o-mini + +# Optional: override the system prompt sent to the model +# AGENT_SYSTEM_PROMPT=You are a helpful assistant. Respond concisely. + +# ============================================================================= +# Agent settings +# ============================================================================= +AGENT_NAME=EchoAgent +ENVIRONMENT=development +HOST=localhost +PORT=3978 + +# ============================================================================= +# OpenTelemetry — exporters +# ============================================================================= + +# OTLP (Aspire dashboard, Jaeger, OpenTelemetry Collector) +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +ENABLE_OTLP_EXPORTER=True +OTEL_EXPORTER_OTLP_INSECURE=true + +# Capture all request/response headers in spans +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=".*" + +# Azure Monitor / Application Insights (optional — leave blank to skip) +# APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=your-key-here + +# ============================================================================= +# A365 observability SDK settings +# ============================================================================= +ENABLE_OBSERVABILITY=true +ENABLE_OPENTELEMETRY_SWITCH=true +ENABLE_OTEL=true +ENABLE_SENSITIVE_DATA=true +OBSERVABILITY_SERVICE_NAME=echo-a365-telemetry +OBSERVABILITY_SERVICE_NAMESPACE=agents-framework.samples diff --git a/test_samples/echo-a365-telemetry/app.py b/test_samples/echo-a365-telemetry/app.py new file mode 100644 index 00000000..97ff2c5b --- /dev/null +++ b/test_samples/echo-a365-telemetry/app.py @@ -0,0 +1,134 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Echo Agent — Microsoft 365 Agents SDK with full A365 telemetry. + +Startup order (mirrors the C# sample): + 1. configure_otel_providers() — A365 observability framework bootstrap + 2. configure_opentelemetry() — local TracerProvider / MeterProvider / log bridge + 3. M365 SDK wiring — storage, adapter, auth, AgentApplication + 4. Route registration — /api/messages, /health, /alive +""" + +import logging +from os import environ, path + +from aiohttp import web +from aiohttp.web import Application, Request, Response +from dotenv import load_dotenv + +from agent_framework.observability import configure_otel_providers + +# Phase 1 — must run before any OTel proxy object is accessed +configure_otel_providers() + +logger = logging.getLogger(__name__) + +load_dotenv(path.join(path.dirname(__file__), ".env")) + +# --------------------------------------------------------------------------- +# Phase 2 — set up TracerProvider / MeterProvider / log bridge. +# OTel proxy objects in telemetry/agent_metrics.py resolve to real +# implementations once configure_opentelemetry() has run. +# --------------------------------------------------------------------------- +from telemetry import ( + configure_opentelemetry, + create_aiohttp_tracing_middleware, +) + +configure_opentelemetry( + app_name=environ.get("AGENT_NAME", "EchoAgent"), + environment=environ.get("ENVIRONMENT", "development"), + otlp_endpoint=environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"), + azure_monitor_connection_string=environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING"), +) + +# --------------------------------------------------------------------------- +# Phase 3 — M365 Agents SDK +# --------------------------------------------------------------------------- +from microsoft_agents.hosting.core import ( + AgentApplication, + MemoryStorage, + TurnContext, + TurnState, +) +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core.app.oauth.authorization import Authorization +from microsoft_agents.activity import ActivityTypes, load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager + +from echo_agent import EchoAgent + + +async def messages_endpoint(request: Request) -> Response: + agent_app: AgentApplication = request.app["agent_app"] + adapter: CloudAdapter = request.app["adapter"] + return await start_agent_process(request, agent_app, adapter) + + +def create_app() -> Application: + 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, + ) + + # The echo agent receives user_authorization so the A365 wrapper can cache + # the observability token — equivalent to injecting IExporterTokenCache in C#. + echo = EchoAgent(user_authorization=agent_app.auth) + + @agent_app.activity(ActivityTypes.message) + async def on_message(context: TurnContext, state: TurnState): + # Pre-fetch the agentic token so it is warm in the cache before the + # A365 wrapper tries to use it (mirrors the weather agent pattern). + await agent_app.auth.get_token(context, "AGENTIC") + await echo.handle_message(context, state) + + @agent_app.conversation_update("membersAdded") + async def on_members_added(context: TurnContext, state: TurnState): + await echo.send_welcome(context, state) + + app = Application( + middlewares=[create_aiohttp_tracing_middleware(), jwt_authorization_middleware] + ) + + app.router.add_post("/api/messages", messages_endpoint) + app.router.add_get("/", lambda _: Response(text="Echo Agent is running", status=200)) + + is_development = environ.get("ENVIRONMENT", "development").lower() == "development" + + app["agent_app"] = agent_app + app["adapter"] = adapter + + return app + + +def main() -> None: + agent_name = environ.get("AGENT_NAME", "EchoAgent") + host = environ.get("HOST", "localhost") + port = int(environ.get("PORT", 3978)) + + print(f"\n{'='*60}") + print(f"Starting {agent_name} (M365 SDK + A365 Telemetry)") + print(f"{'='*60}") + print(f"Endpoint: http://{host}:{port}/api/messages") + print(f"Health: http://{host}:{port}/health") + print(f"{'='*60}\n") + + web.run_app(create_app(), host=host, port=port) + + +if __name__ == "__main__": + main() diff --git a/test_samples/echo-a365-telemetry/echo_agent.py b/test_samples/echo-a365-telemetry/echo_agent.py new file mode 100644 index 00000000..4d4f824a --- /dev/null +++ b/test_samples/echo-a365-telemetry/echo_agent.py @@ -0,0 +1,116 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Echo Agent — relays user text to an Azure AI Foundry endpoint. + +The entire agent logic is intentionally minimal so that the telemetry +infrastructure (spans, metrics, baggage, token caching) stays in the +foreground. The AI call is a single-turn, stateless chat completion: + + User text → Azure AI Foundry (azure-ai-inference) → reply to user + +No conversation history, no tools, no state persistence. +""" + +import logging +import traceback +from os import environ + +from azure.ai.inference.aio import ChatCompletionsClient +from azure.ai.inference.models import SystemMessage, UserMessage +from azure.core.credentials import AzureKeyCredential +from azure.identity.aio import DefaultAzureCredential + +from microsoft_agents.hosting.core import Authorization, TurnContext, TurnState + +from telemetry import invoke_observed_agent_operation_with_context + +logger = logging.getLogger(__name__) + +_SYSTEM_PROMPT = environ.get( + "AGENT_SYSTEM_PROMPT", + "You are a helpful assistant. Respond concisely to the user's message.", +) +_WELCOME_MESSAGE = environ.get( + "AGENT_WELCOME_MESSAGE", + "Hello! I'm the Echo Agent. Send me any message and I'll relay it to Azure AI Foundry.", +) + + +def _build_client() -> ChatCompletionsClient: + """Build an async Azure AI Foundry ChatCompletionsClient. + + Prefers an explicit API key; falls back to DefaultAzureCredential (managed + identity / az login) when no key is set. + """ + endpoint = environ["AZURE_AI_FOUNDRY_ENDPOINT"] + api_key = environ.get("AZURE_AI_FOUNDRY_API_KEY") + + if api_key: + return ChatCompletionsClient( + endpoint=endpoint, + credential=AzureKeyCredential(api_key), + ) + + return ChatCompletionsClient( + endpoint=endpoint, + credential=DefaultAzureCredential(), + ) + + +class EchoAgent: + """Stateless agent that echoes user text through Azure AI Foundry.""" + + def __init__(self, user_authorization: Authorization = None): + self._user_authorization = user_authorization + self._client = _build_client() + self._model = environ.get("AZURE_AI_FOUNDRY_DEPLOYMENT", "gpt-4o-mini") + logger.info("EchoAgent initialised (model=%s)", self._model) + + async def send_welcome(self, context: TurnContext, state: TurnState) -> None: + """Greet each new participant when they join the conversation.""" + for member in context.activity.members_added or []: + if member.id != context.activity.recipient.id: + await context.send_activity(_WELCOME_MESSAGE) + + async def handle_message(self, context: TurnContext, state: TurnState) -> None: + """Process an incoming message and relay the Foundry reply to the user. + + The core logic is wrapped inside + ``invoke_observed_agent_operation_with_context`` so that every + invocation produces: + - a distributed trace span with activity attributes + - W3C baggage carrying tenant.id / agent.id + - per-turn observability token caching + - message counters and duration histograms + """ + user_text = (context.activity.text or "").strip() + if not user_text: + return + + logger.info("Received: %s", user_text) + + async def _process() -> None: + response = await self._client.complete( + messages=[ + SystemMessage(content=_SYSTEM_PROMPT), + UserMessage(content=user_text), + ], + model=self._model, + ) + reply = response.choices[0].message.content + logger.info("Foundry reply: %s", reply) + await context.send_activity(reply) + + try: + await invoke_observed_agent_operation_with_context( + "OnMessageActivity", + context, + state, + _process, + user_authorization=self._user_authorization, + ) + except Exception as exc: + logger.error("Error processing message: %s", exc) + traceback.print_exc() + await context.send_activity(f"Sorry, I encountered an error: {exc}") diff --git a/test_samples/echo-a365-telemetry/requirements.txt b/test_samples/echo-a365-telemetry/requirements.txt new file mode 100644 index 00000000..ca1e307c --- /dev/null +++ b/test_samples/echo-a365-telemetry/requirements.txt @@ -0,0 +1,42 @@ +# M365 Agents SDK +microsoft-agents-hosting-aiohttp +microsoft-agents-authentication-msal + +# Azure AI Foundry — chat completions client (async) +azure-ai-inference + +# Azure authentication (DefaultAzureCredential fallback) +azure-identity + +# Web framework +aiohttp + +# Configuration +python-dotenv + +# JWT decoding (for A365 observability wrapper) +PyJWT + +# OpenTelemetry — core +opentelemetry-api +opentelemetry-sdk + +# OpenTelemetry — exporters +# OTLP (traces, metrics, logs) — for Aspire dashboard, Jaeger, etc. +opentelemetry-exporter-otlp +# Azure Monitor / Application Insights +azure-monitor-opentelemetry-exporter + +# OpenTelemetry — library auto-instrumentation +opentelemetry-instrumentation-aiohttp-server +opentelemetry-instrumentation-aiohttp-client +opentelemetry-instrumentation-requests +opentelemetry-instrumentation-logging + +# A365 observability — AgentFramework automatic instrumentation +microsoft-agents-a365-runtime==0.2.1.dev43 +microsoft-agents-a365-observability-core==0.2.1.dev43 +microsoft-agents-a365-observability-extensions-agent-framework>=0.2.1.dev43 + +# Agent Framework (required for configure_otel_providers) +agent-framework-azure-ai diff --git a/test_samples/echo-a365-telemetry/telemetry/__init__.py b/test_samples/echo-a365-telemetry/telemetry/__init__.py new file mode 100644 index 00000000..b4eba734 --- /dev/null +++ b/test_samples/echo-a365-telemetry/telemetry/__init__.py @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Telemetry package for the Python Echo Agent. + +Python port of sample-agent/telemetry (AgentMetrics.cs, A365OtelWrapper.cs, +AgentOTELExtensions.cs). + +Typical usage in app.py:: + + from telemetry import configure_opentelemetry + from telemetry import create_aiohttp_tracing_middleware + from telemetry import invoke_observed_agent_operation_with_context + + # 1. Configure providers once at startup (before any span/metric activity) + configure_opentelemetry(app_name="EchoAgent", environment="development") + + # 2. Add tracing middleware to the aiohttp app + app = web.Application(middlewares=[create_aiohttp_tracing_middleware()]) + + # 3. Wrap message handlers with observed operations + await invoke_observed_agent_operation_with_context( + "OnMessageActivity", turn_context, turn_state, handler_func + ) +""" + +from .a365_otel_wrapper import invoke_observed_agent_operation_with_context +from .agent_metrics import ( + SOURCE_NAME, + active_conversations, + finalize_message_handling_span, + initialize_message_handling_span, + invoke_observed_agent_operation, + invoke_observed_http_operation, + message_processed_counter, + message_processing_duration, + meter, + route_executed_counter, + route_execution_duration, + tracer, +) +from .agent_otel_extensions import ( + configure_opentelemetry, + create_aiohttp_tracing_middleware, + instrument_libraries, +) + +__all__ = [ + # agent_metrics + "SOURCE_NAME", + "tracer", + "meter", + "message_processed_counter", + "route_executed_counter", + "message_processing_duration", + "route_execution_duration", + "active_conversations", + "initialize_message_handling_span", + "finalize_message_handling_span", + "invoke_observed_http_operation", + "invoke_observed_agent_operation", + # a365_otel_wrapper + "invoke_observed_agent_operation_with_context", + # agent_otel_extensions + "configure_opentelemetry", + "instrument_libraries", + "create_aiohttp_tracing_middleware", +] diff --git a/test_samples/echo-a365-telemetry/telemetry/a365_otel_wrapper.py b/test_samples/echo-a365-telemetry/telemetry/a365_otel_wrapper.py new file mode 100644 index 00000000..a881c072 --- /dev/null +++ b/test_samples/echo-a365-telemetry/telemetry/a365_otel_wrapper.py @@ -0,0 +1,147 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Azure 365 observability wrapper for agent operations. + +Python port of A365OtelWrapper.cs from sample-agent/telemetry. + +The C# original depends on the internal +``Microsoft.Agents.A365.Observability`` packages which are .NET-only. +This Python port preserves the observable contract: + +1. Resolve the tenant ID and agent ID from the current turn activity. +2. Propagate them as `OpenTelemetry baggage + `_ so that + downstream services can consume the context. +3. Delegate to :func:`~telemetry.agent_metrics.invoke_observed_agent_operation` + for span creation and metric recording. +4. Cache the observability token per-turn using the activity-derived IDs, + equivalent to ``agentTokenCache.RegisterObservability()`` in C#. +""" + +import logging +import uuid +import jwt # PyJWT library +from typing import Awaitable, Callable, Optional + +from microsoft_agents.hosting.core import Authorization, TurnContext +from opentelemetry import baggage +from opentelemetry import context as otel_context + +from microsoft_agents.hosting.core import AccessTokenProviderBase + +from .agent_metrics import invoke_observed_agent_operation +from .token_cache import cache_agentic_token + +logger = logging.getLogger(__name__) + +_EMPTY_GUID = str(uuid.UUID(int=0)) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def invoke_observed_agent_operation_with_context( + operation_name: str, + turn_context: TurnContext, + turn_state, + func: Callable[[], Awaitable[None]], + user_authorization: Optional[Authorization] = None, + auth_handler: Optional[str] = None, +) -> None: + """Wrap an agent operation with A365 observability baggage context. + + Equivalent to ``A365OtelWrapper.InvokeObservedAgentOperation()`` in C#. + + Resolves the tenant ID and agent ID from the activity, sets them as + OpenTelemetry baggage (equivalent to the C# ``BaggageBuilder``), caches + the observability token per-turn when ``user_authorization`` is provided (equivalent + to ``agentTokenCache.RegisterObservability()``), then delegates to + :func:`~telemetry.agent_metrics.invoke_observed_agent_operation` for span + management and metric recording. + + Args: + operation_name: Human-readable name of the operation / handler. + turn_context: The current :class:`TurnContext`. + turn_state: The current :class:`TurnState`. + func: Async function containing the agent logic to execute. + user_authorization: Optional MSAL authentication provider used to fetch and + cache the observability token for the activity-derived IDs. + auth_handler: Optional string representing the authentication handler. + """ + agent_id, tenant_id = await _resolve_tenant_and_agent_id(turn_context, user_authorization, auth_handler) + + if user_authorization is not None: + await _cache_observability_token(tenant_id, agent_id, user_authorization) + + async def _with_baggage(): + # Set tenant.id and agent.id as baggage — equivalent to BaggageBuilder + # in C#, which adds these values to the W3C baggage header so that + # downstream services can read them. + ctx = baggage.set_baggage("tenant.id", tenant_id) + ctx = baggage.set_baggage("agent.id", agent_id, context=ctx) + token = otel_context.attach(ctx) + try: + await func() + finally: + otel_context.detach(token) + + await invoke_observed_agent_operation(operation_name, turn_context, _with_baggage) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +async def _cache_observability_token(tenant_id: str, agent_id: str, authorization: Authorization) -> None: + """Fetch and cache the observability token using activity-derived IDs. + + Equivalent to ``agentTokenCache.RegisterObservability()`` in C#. + MSAL caches tokens internally, so repeated calls within the token lifetime + do not incur additional network requests. + """ + try: + token = await authorization.get_token(tenant_id, agent_id) + cache_agentic_token(tenant_id, agent_id, token) + logger.debug("Observability token cached (tenant=%s, agent=%s)", tenant_id, agent_id) + except Exception as exc: + logger.warning("Failed to cache observability token: %s", exc) + + +async def _resolve_tenant_and_agent_id(turn_context: TurnContext, user_authorization: Optional[Authorization] = None, auth_handler: Optional[str] = None) -> tuple[str, str]: + """Extract tenant and agent IDs from the turn activity. + + Args: + turn_context: The current :class:`TurnContext`. + user_authorization: Optional MSAL authentication provider used to fetch the token. + auth_handler: Optional string representing the authentication handler. + + Returns: + ``(agent_id, tenant_id)`` as strings. + """ + activity = turn_context.activity + if activity is None: + return _EMPTY_GUID, _EMPTY_GUID + + agentic_token = await user_authorization.get_token(turn_context, auth_handler or "AGENTIC") if user_authorization else None + + agent_id = activity.get_agentic_instance_id() if activity.is_agentic_request() else _get_app_id_from_token(agentic_token) + agent_id = agent_id or _EMPTY_GUID + + tenant_id = activity.conversation.tenant_id if activity.conversation and activity.conversation.tenant_id else _EMPTY_GUID + + return agent_id, tenant_id + + +def _get_app_id_from_token(token: str) -> str: + """Extract the app ID from the JWT token.""" + try: + decoded = jwt.decode(token, options={"verify_signature": False}) + app_id = decoded.get("appid") or decoded.get("azp") or _EMPTY_GUID + return str(app_id) + except Exception as exc: + logger.warning("Failed to decode token for app ID extraction: %s", exc) + return _EMPTY_GUID diff --git a/test_samples/echo-a365-telemetry/telemetry/agent_metrics.py b/test_samples/echo-a365-telemetry/telemetry/agent_metrics.py new file mode 100644 index 00000000..e1fe4230 --- /dev/null +++ b/test_samples/echo-a365-telemetry/telemetry/agent_metrics.py @@ -0,0 +1,236 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Agent metrics and distributed tracing for OpenTelemetry instrumentation. + +Python port of AgentMetrics.cs from sample-agent/telemetry. + +Provides: +- An ActivitySource-equivalent tracer named "A365.AgentFramework" +- A Meter with the same name for counters, histograms, and up-down counters +- Helper functions to start/finalize message-handling spans +- Observed-operation wrappers (sync and async) +""" + +import logging +import time +from typing import Awaitable, Callable + +from opentelemetry import context as otel_context +from opentelemetry import metrics, trace +from opentelemetry.trace import StatusCode + +logger = logging.getLogger(__name__) + +# Equivalent to ActivitySource name in C# +SOURCE_NAME = "A365.AgentFramework" + +# Tracer — equivalent to `new ActivitySource(SourceName)` in C# +# Uses a ProxyTracer until configure_opentelemetry() sets a real TracerProvider. +tracer = trace.get_tracer(SOURCE_NAME, "1.0.0") + +# Meter — equivalent to `new Meter("A365.AgentFramework", "1.0.0")` in C# +meter = metrics.get_meter(SOURCE_NAME, "1.0.0") + +# --------------------------------------------------------------------------- +# Metrics — mirrors the static Counter/Histogram/UpDownCounter fields in C# +# --------------------------------------------------------------------------- + +message_processed_counter = meter.create_counter( + "agent.messages.processed", + unit="messages", + description="Number of messages processed by the agent", +) + +route_executed_counter = meter.create_counter( + "agent.routes.executed", + unit="routes", + description="Number of routes executed by the agent", +) + +message_processing_duration = meter.create_histogram( + "agent.message.processing.duration", + unit="ms", + description="Duration of message processing in milliseconds", +) + +route_execution_duration = meter.create_histogram( + "agent.route.execution.duration", + unit="ms", + description="Duration of route execution in milliseconds", +) + +active_conversations = meter.create_up_down_counter( + "agent.conversations.active", + unit="conversations", + description="Number of active conversations", +) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _get_activity_attrs(context) -> dict: + """Extract activity attributes from a TurnContext.""" + attrs: dict = {} + activity = getattr(context, "activity", None) + if activity is None: + return attrs + + attrs["Activity.Type"] = str(getattr(activity, "type", "") or "") + + # 'from' is a Python keyword; the SDK stores it as from_property or _from. + from_obj = ( + getattr(activity, "from_property", None) + or getattr(activity, "_from", None) + # getattr with string "from" works at runtime despite being a keyword + or getattr(activity, "from", None) + ) + attrs["Caller.Id"] = str(getattr(from_obj, "id", "") or "") + + conversation = getattr(activity, "conversation", None) + attrs["Conversation.Id"] = str(getattr(conversation, "id", "") or "") + attrs["Channel.Id"] = str(getattr(activity, "channel_id", "") or "") + + text = getattr(activity, "text", "") or "" + attrs["Message.Text.Length"] = len(text) + attrs["Message.Id"] = str(getattr(activity, "id", "") or "") + attrs["Message.Text"] = text[:200] # truncate to avoid oversized attributes + + return attrs + + +# --------------------------------------------------------------------------- +# Public API — mirrors AgentMetrics static methods in C# +# --------------------------------------------------------------------------- + +def initialize_message_handling_span(handler_name: str, context) -> trace.Span: + """Start a tracing span with contextual tags from the turn activity. + + Equivalent to ``InitializeMessageHandlingActivity()`` in C#. + + The caller is responsible for ending the span (use + ``finalize_message_handling_span`` or call ``span.end()`` directly). + + Args: + handler_name: Name used as the span name (e.g. ``"OnMessageActivity"``). + context: TurnContext whose activity fields are attached as span attributes. + + Returns: + A started (but not yet current) :class:`opentelemetry.trace.Span`. + """ + span = tracer.start_span(handler_name) + attrs = _get_activity_attrs(context) + + # Set individual attributes on the span (mirrors activity?.SetTag calls in C#) + for key in ("Activity.Type", "Caller.Id", "Conversation.Id", "Channel.Id"): + span.set_attribute(key, attrs.get(key, "")) + span.set_attribute("Message.Text.Length", attrs.get("Message.Text.Length", 0)) + # Tag whether the request came from an agentic caller + span.set_attribute("Agent.IsAgentic", bool(getattr(getattr(context, "activity", None), "is_agentic", False))) + + # Equivalent to activity?.AddEvent(new ActivityEvent("Message.Processed", ...)) + span.add_event( + "Message.Processed", + attributes={ + "Caller.Id": attrs.get("Caller.Id", ""), + "Channel.Id": attrs.get("Channel.Id", ""), + "Message.Id": attrs.get("Message.Id", ""), + "Message.Text": attrs.get("Message.Text", ""), + }, + ) + return span + + +def finalize_message_handling_span( + span: trace.Span, + context, + duration_ms: float, + success: bool, +) -> None: + """Record duration metrics and end the span. + + Equivalent to ``FinalizeMessageHandlingActivity()`` in C#. + + Args: + span: The span returned by :func:`initialize_message_handling_span`. + context: TurnContext used to label the metric dimensions. + duration_ms: Elapsed time in milliseconds. + success: ``True`` → span status OK; ``False`` → span status ERROR. + """ + attrs = _get_activity_attrs(context) + conversation_id = attrs.get("Conversation.Id") or "unknown" + channel_id = attrs.get("Channel.Id") or "unknown" + + message_processing_duration.record( + duration_ms, + {"Conversation.Id": conversation_id, "Channel.Id": channel_id}, + ) + + route_executed_counter.add( + 1, + {"Route.Type": "message_handler", "Conversation.Id": conversation_id}, + ) + + span.set_status(StatusCode.OK if success else StatusCode.ERROR) + span.end() + + +def invoke_observed_http_operation(operation_name: str, func: Callable) -> None: + """Wrap a synchronous callable with a tracing span. + + Equivalent to ``InvokeObservedHttpOperation()`` in C#. + + Args: + operation_name: Span name. + func: Synchronous callable to execute. + """ + with tracer.start_as_current_span(operation_name) as span: + try: + func() + span.set_status(StatusCode.OK) + except Exception as ex: + span.set_status(StatusCode.ERROR, str(ex)) + span.record_exception(ex) + raise + + +async def invoke_observed_agent_operation( + operation_name: str, + context, + func: Callable[[], Awaitable[None]], +) -> None: + """Async wrapper for an agent operation with full metrics and tracing. + + Equivalent to ``AgentMetrics.InvokeObservedAgentOperation()`` in C#. + + Increments the message-processed counter, opens a span, sets it as the + current context, awaits *func*, then records the duration and finalises + the span. + + Args: + operation_name: Span / operation name. + context: TurnContext used for span attributes and metric labels. + func: Async function containing the agent logic. + """ + message_processed_counter.add(1) + + span = initialize_message_handling_span(operation_name, context) + # Make the span the active span for the duration of the call + ctx = trace.set_span_in_context(span) + token = otel_context.attach(ctx) + + start_time = time.monotonic() + success = True + try: + await func() + except Exception as ex: + success = False + span.set_status(StatusCode.ERROR, str(ex)) + span.record_exception(ex) + raise + finally: + otel_context.detach(token) + duration_ms = (time.monotonic() - start_time) * 1000 + finalize_message_handling_span(span, context, duration_ms, success) diff --git a/test_samples/echo-a365-telemetry/telemetry/agent_otel_extensions.py b/test_samples/echo-a365-telemetry/telemetry/agent_otel_extensions.py new file mode 100644 index 00000000..bfe93307 --- /dev/null +++ b/test_samples/echo-a365-telemetry/telemetry/agent_otel_extensions.py @@ -0,0 +1,461 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""OpenTelemetry configuration helpers for the Python Echo Agent. + +Python port of AgentOTELExtensions.cs from sample-agent/telemetry. + +Adds common observability services: + - TracerProvider with resource metadata + - MeterProvider with custom agent meters + - aiohttp tracing middleware (excludes health-check paths) + - /health and /alive endpoint registration + +Exporter support (install the matching package to activate): + - OTLP (Aspire, Jaeger, etc.): + pip install opentelemetry-exporter-otlp-proto-grpc + - Azure Monitor / Application Insights: + pip install azure-monitor-opentelemetry-exporter + - Console (default when no other exporter is configured) + +To learn more about the local Aspire dashboard, see: + https://learn.microsoft.com/dotnet/aspire/fundamentals/dashboard/standalone +""" + +import logging +import os +import socket +from typing import Optional + +from opentelemetry import trace +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.resources import ( + SERVICE_INSTANCE_ID, + SERVICE_NAME, + SERVICE_VERSION, + Resource, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.trace import StatusCode + +logger = logging.getLogger(__name__) + +try: + from microsoft_agents_a365.observability.extensions.agentframework import AgentFrameworkInstrumentor as _AgentFrameworkInstrumentor + from microsoft_agents_a365.observability.core.config import configure as _configure_agent_framework_observability + _HAS_AGENT_FRAMEWORK_INSTRUMENTOR = True +except ImportError as exc: + logger.debug( + "AgentFrameworkInstrumentor not available — Agent Framework-specific telemetry will be disabled. " + f"ImportError: {exc}" + ) + _HAS_AGENT_FRAMEWORK_INSTRUMENTOR = False + +HEALTH_ENDPOINT_PATH = "/health" +ALIVENESS_ENDPOINT_PATH = "/alive" + +_SERVICE_NAMESPACE = "Microsoft.Agents" +_SOURCE_NAME = "A365.AgentFramework" + + +# --------------------------------------------------------------------------- +# Primary public API +# --------------------------------------------------------------------------- + + +def instrument_libraries() -> None: + """Instrument common HTTP libraries for automatic OpenTelemetry tracing. + + Equivalent to the ``Use*Instrumentation()`` calls in the C# host builder. + Instruments: + - aiohttp server (inbound requests) + - aiohttp client (outbound requests) with URL enrichment hooks + - requests library (outbound requests) with URL enrichment hooks + + Call this once at startup, before the server starts handling requests. + Each instrumentor is guarded with a try/except so missing optional packages + do not prevent the server from starting. + """ + # aiohttp server + try: + from opentelemetry.instrumentation.aiohttp_server import AioHttpServerInstrumentor + AioHttpServerInstrumentor().instrument() + logger.debug("aiohttp server instrumentation enabled") + except ImportError: + logger.debug( + "opentelemetry-instrumentation-aiohttp-server not installed. " + "Install: pip install opentelemetry-instrumentation-aiohttp-server" + ) + + # aiohttp client + try: + import aiohttp + from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor + from opentelemetry.trace import Span + + 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): + 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, + ) + logger.debug("aiohttp client instrumentation enabled") + except ImportError: + logger.debug( + "opentelemetry-instrumentation-aiohttp-client not installed. " + "Install: pip install opentelemetry-instrumentation-aiohttp-client" + ) + + # requests library + try: + import requests as _requests + from opentelemetry.instrumentation.requests import RequestsInstrumentor + from opentelemetry.trace import Span + + 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, + ) + logger.debug("requests instrumentation enabled") + except ImportError: + logger.debug( + "opentelemetry-instrumentation-requests not installed. " + "Install: pip install opentelemetry-instrumentation-requests" + ) + + # AgentFramework instrumentor (optional A365 package) + if _HAS_AGENT_FRAMEWORK_INSTRUMENTOR: + try: + from .token_cache import get_cached_agentic_token + + def _token_resolver(agent_id: str, tenant_id: str) -> str | None: + return get_cached_agentic_token(tenant_id, agent_id) + + _configure_agent_framework_observability( + service_name="AgentFrameworkTracingWithAzureAIFoundry", + service_namespace="AgentFrameworkTesting", + token_resolver=_token_resolver, + ) + try: + _AgentFrameworkInstrumentor().instrument( + skip_dep_check=True, + ) + except TypeError: + _AgentFrameworkInstrumentor().instrument(skip_dep_check=True) + logger.debug("AgentFramework instrumentation enabled") + except Exception as exc: + logger.warning("AgentFramework instrumentation failed: %s", exc) + + +def configure_opentelemetry( + app_name: str = "A365.AgentFramework", + environment: str = "development", + otlp_endpoint: Optional[str] = None, + azure_monitor_connection_string: Optional[str] = None, +) -> None: + """Configure global TracerProvider, MeterProvider, and logging bridge. + + Equivalent to ``ConfigureOpenTelemetry()`` in C#. + + Call this **once at startup**, before any code creates spans or records + metrics. The OTel proxy objects in *agent_metrics.py* will automatically + route to the real providers once this function has run. + + Args: + app_name: Service name shown in traces/dashboards. + environment: Deployment environment tag (e.g. ``"development"``, + ``"production"``). + otlp_endpoint: OTLP collector URL. Falls back to the + ``OTEL_EXPORTER_OTLP_ENDPOINT`` environment variable when *None*. + azure_monitor_connection_string: Application Insights connection + string. Falls back to ``APPLICATIONINSIGHTS_CONNECTION_STRING`` + when *None*. + """ + resource = Resource.create( + { + SERVICE_NAME: _SOURCE_NAME, + SERVICE_VERSION: "1.0.0", + SERVICE_INSTANCE_ID: socket.gethostname(), + "deployment.environment": environment, + "service.namespace": _SERVICE_NAMESPACE, + } + ) + + _configure_tracing(resource, app_name, otlp_endpoint, azure_monitor_connection_string) + _configure_metrics(resource, otlp_endpoint, azure_monitor_connection_string) + _configure_logging(resource, otlp_endpoint) + instrument_libraries() + + logger.info( + "OpenTelemetry configured — service: %s, environment: %s", + app_name, + environment, + ) + + +def create_aiohttp_tracing_middleware(): + """Return an aiohttp middleware that traces every request. + + Equivalent to the ``AddAspNetCoreInstrumentation()`` configuration in C#: + - Enriches spans with ``http.request.body.size`` and ``user_agent`` on + request, and ``http.status_code`` / ``http.response.body.size`` on + response. + - Records exceptions and sets the span status accordingly. + """ + from aiohttp import web + + _tracer = trace.get_tracer(_SOURCE_NAME, "1.0.0") + + @web.middleware + async def tracing_middleware(request, handler): + span_name = f"{request.method} {request.path}" + with _tracer.start_as_current_span(span_name) as span: + # Enrich with request details — equivalent to EnrichWithHttpRequest + span.set_attribute("http.method", request.method) + span.set_attribute("http.url", str(request.url)) + span.set_attribute("http.request.body.size", request.content_length or 0) + user_agent = request.headers.get("User-Agent", "") + if user_agent: + span.set_attribute("user_agent", user_agent) + + try: + response = await handler(request) + # Enrich with response details — equivalent to EnrichWithHttpResponse + span.set_attribute("http.status_code", response.status) + span.set_attribute( + "http.response.body.size", response.content_length or 0 + ) + span.set_status(StatusCode.OK) + return response + except Exception as ex: + span.set_status(StatusCode.ERROR, str(ex)) + span.record_exception(ex) + raise + + return tracing_middleware + + +# --------------------------------------------------------------------------- +# Internal helpers — mirrors private methods in AgentOTELExtensions.cs +# --------------------------------------------------------------------------- + + +def _configure_tracing( + resource: Resource, + app_name: str, + otlp_endpoint: Optional[str], + azure_monitor_connection_string: Optional[str], +) -> None: + """Build and register the global TracerProvider.""" + tracer_provider = TracerProvider(resource=resource) + + has_real_exporter = False + + # OTLP exporter — equivalent to UseOtlpExporter() in C# + resolved_otlp = otlp_endpoint or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") + if resolved_otlp: + _add_otlp_trace_exporter(tracer_provider, resolved_otlp) + has_real_exporter = True + + # Azure Monitor exporter — equivalent to UseAzureMonitor() in C# + resolved_az = azure_monitor_connection_string or os.environ.get( + "APPLICATIONINSIGHTS_CONNECTION_STRING" + ) + if resolved_az: + _add_azure_monitor_trace_exporter(tracer_provider, resolved_az) + has_real_exporter = True + + # Console exporter for local development (no production exporter configured) + if not has_real_exporter: + _add_console_trace_exporter(tracer_provider) + + trace.set_tracer_provider(tracer_provider) + + +def _configure_metrics( + resource: Resource, + otlp_endpoint: Optional[str], + azure_monitor_connection_string: Optional[str], +) -> None: + """Build and register the global MeterProvider.""" + from opentelemetry import metrics + + readers = [] + + resolved_otlp = otlp_endpoint or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") + if resolved_otlp: + reader = _create_otlp_metric_reader(resolved_otlp) + if reader: + readers.append(reader) + + resolved_az = azure_monitor_connection_string or os.environ.get( + "APPLICATIONINSIGHTS_CONNECTION_STRING" + ) + if resolved_az: + reader = _create_azure_monitor_metric_reader(resolved_az) + if reader: + readers.append(reader) + + if not readers: + readers.append(_create_console_metric_reader()) + + meter_provider = MeterProvider(resource=resource, metric_readers=readers) + metrics.set_meter_provider(meter_provider) + + +def _configure_logging( + resource: Resource, + otlp_endpoint: Optional[str], +) -> None: + """Configure OTEL LoggerProvider and bridge Python logging. + + Equivalent to ``builder.Logging.AddOpenTelemetry(...)`` in C#. + + When an OTLP endpoint is available, a full LoggerProvider is created and + log records are exported via OTLP (matching the otel sample behaviour). + In all cases, Python log records are correlated with the active trace + context when ``opentelemetry-instrumentation-logging`` is installed. + + Args: + resource: The shared OTel Resource created in configure_opentelemetry. + otlp_endpoint: OTLP collector URL, or None to skip OTLP log export. + """ + resolved_otlp = otlp_endpoint or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") + if resolved_otlp: + try: + from opentelemetry._logs import set_logger_provider + from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler + from opentelemetry.sdk._logs.export import BatchLogRecordProcessor + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter + + logger_provider = LoggerProvider(resource=resource) + logger_provider.add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter(endpoint=resolved_otlp)) + ) + set_logger_provider(logger_provider) + + handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider) + logging.getLogger().addHandler(handler) + logger.info("OTLP log exporter → %s", resolved_otlp) + except ImportError: + logger.warning( + "OTLP log exporter requested but package not found. " + "Install: pip install opentelemetry-exporter-otlp-proto-grpc" + ) + + # Bridge Python logging to trace context (adds trace_id/span_id to log records) + try: + from opentelemetry.instrumentation.logging import LoggingInstrumentor + + LoggingInstrumentor().instrument(set_logging_format=True) + logger.debug("OTEL logging instrumentation enabled") + except ImportError: + logger.debug( + "opentelemetry-instrumentation-logging not installed — " + "Python log records will not be correlated with traces. " + "Install with: pip install opentelemetry-instrumentation-logging" + ) + + +# -- Trace exporter helpers -------------------------------------------------- + + +def _add_otlp_trace_exporter( + tracer_provider: TracerProvider, endpoint: str +) -> None: + try: + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, + ) + + exporter = OTLPSpanExporter(endpoint=endpoint) + tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) + logger.info("OTLP trace exporter → %s", endpoint) + except ImportError: + logger.warning( + "OTLP trace exporter requested but package not found. " + "Install: pip install opentelemetry-exporter-otlp-proto-grpc" + ) + + +def _add_azure_monitor_trace_exporter( + tracer_provider: TracerProvider, connection_string: str +) -> None: + try: + from azure.monitor.opentelemetry.exporter import AzureMonitorTraceExporter + + exporter = AzureMonitorTraceExporter(connection_string=connection_string) + tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) + logger.info("Azure Monitor trace exporter configured") + except ImportError: + logger.warning( + "Azure Monitor trace exporter requested but package not found. " + "Install: pip install azure-monitor-opentelemetry-exporter" + ) + + +def _add_console_trace_exporter(tracer_provider: TracerProvider) -> None: + from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor + + tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) + logger.debug("Console span exporter active (development mode)") + + +# -- Metric reader helpers ---------------------------------------------------- + + +def _create_otlp_metric_reader(endpoint: str) -> Optional[PeriodicExportingMetricReader]: + try: + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( + OTLPMetricExporter, + ) + + exporter = OTLPMetricExporter(endpoint=endpoint) + logger.info("OTLP metric exporter → %s", endpoint) + return PeriodicExportingMetricReader(exporter) + except ImportError: + logger.warning( + "OTLP metric exporter requested but package not found. " + "Install: pip install opentelemetry-exporter-otlp-proto-grpc" + ) + return None + + +def _create_azure_monitor_metric_reader( + connection_string: str, +) -> Optional[PeriodicExportingMetricReader]: + try: + from azure.monitor.opentelemetry.exporter import AzureMonitorMetricsExporter + + exporter = AzureMonitorMetricsExporter(connection_string=connection_string) + logger.info("Azure Monitor metric exporter configured") + return PeriodicExportingMetricReader(exporter) + except ImportError: + logger.warning( + "Azure Monitor metric exporter requested but package not found. " + "Install: pip install azure-monitor-opentelemetry-exporter" + ) + return None + + +def _create_console_metric_reader() -> PeriodicExportingMetricReader: + from opentelemetry.sdk.metrics.export import ConsoleMetricExporter + + logger.debug("Console metric exporter active (development mode)") + return PeriodicExportingMetricReader(ConsoleMetricExporter()) diff --git a/test_samples/echo-a365-telemetry/telemetry/token_cache.py b/test_samples/echo-a365-telemetry/telemetry/token_cache.py new file mode 100644 index 00000000..0fb9872e --- /dev/null +++ b/test_samples/echo-a365-telemetry/telemetry/token_cache.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +""" +Token caching utilities for Agent 365 Observability exporter authentication. +""" + +import logging + +logger = logging.getLogger(__name__) + +# Global token cache for Agent 365 Observability exporter +_agentic_token_cache = {} + + +def cache_agentic_token(tenant_id: str, agent_id: str, token: str) -> None: + """Cache the agentic token for use by Agent 365 Observability exporter.""" + key = f"{tenant_id}:{agent_id}" + _agentic_token_cache[key] = token + logger.debug(f"Cached agentic token for {key}") + + +def get_cached_agentic_token(tenant_id: str, agent_id: str) -> str | None: + """Retrieve cached agentic token for Agent 365 Observability exporter.""" + key = f"{tenant_id}:{agent_id}" + token = _agentic_token_cache.get(key) + if token: + logger.debug(f"Retrieved cached agentic token for {key}") + else: + logger.debug(f"No cached token found for {key}") + return token diff --git a/test_samples/weather-agent-framework/app.py b/test_samples/weather-agent-framework/app.py index 6d057b4b..1262135f 100644 --- a/test_samples/weather-agent-framework/app.py +++ b/test_samples/weather-agent-framework/app.py @@ -102,9 +102,9 @@ def create_app() -> Application: weather_agent = WeatherAgent(user_authorization=agent_app.auth) # Register event handlers - @agent_app.activity(ActivityTypes.message, auth_handlers=["AGENTIC"]) + @agent_app.activity(ActivityTypes.message) async def on_message(context: TurnContext, state: TurnState): - # aau_token = await agent_app.auth.get_token(context, "AGENTIC") + aau_token = await agent_app.auth.get_token(context, "AGENTIC") await weather_agent.handle_message(context, state) @agent_app.conversation_update("membersAdded") From 04763feef5e4cef733b7485c0423069c2b6e7d62 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Tue, 31 Mar 2026 21:13:28 -0700 Subject: [PATCH 14/14] Agentic testing on weather sample wip --- .../hosting/core/channel_service_adapter.py | 2 +- test_samples/echo-a365-telemetry/.env.example | 16 +++--- test_samples/echo-a365-telemetry/app.py | 7 ++- .../echo-a365-telemetry/echo_agent.py | 50 +++++++------------ .../echo-a365-telemetry/requirements.txt | 7 +-- .../echo-a365-telemetry/telemetry/__init__.py | 9 +++- .../telemetry/a365_otel_wrapper.py | 4 +- .../telemetry/agent_otel_extensions.py | 46 ++++++++++++++++- test_samples/weather-agent-framework/app.py | 2 +- 9 files changed, 90 insertions(+), 53 deletions(-) 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..f1b1c98a 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 @@ -386,7 +386,7 @@ async def process_activity( if claims_identity.is_agent_claim(): outgoing_audience = claims_identity.get_token_audience() - activity.caller_id = f"{CallerIdConstants.agent_to_agent_prefix}{claims_identity.get_outgoing_app_id()}" + activity.caller_id = f"{CallerIdConstants.agent_to_agent_prefix.value}{claims_identity.get_outgoing_app_id()}" else: outgoing_audience = AuthenticationConstants.AGENTS_SDK_SCOPE diff --git a/test_samples/echo-a365-telemetry/.env.example b/test_samples/echo-a365-telemetry/.env.example index ff633746..4a05d679 100644 --- a/test_samples/echo-a365-telemetry/.env.example +++ b/test_samples/echo-a365-telemetry/.env.example @@ -6,15 +6,13 @@ CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=your-client-secret CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=your-tenant-id # ============================================================================= -# Azure AI Foundry -# Endpoint format for serverless / model-catalog deployments: -# https://.services.ai.azure.com/models -# For Azure OpenAI via Foundry: -# https://.openai.azure.com/openai/deployments/ -# ============================================================================= -AZURE_AI_FOUNDRY_ENDPOINT=https://your-resource.services.ai.azure.com/models -AZURE_AI_FOUNDRY_API_KEY=your-api-key -AZURE_AI_FOUNDRY_DEPLOYMENT=gpt-4o-mini +# Azure OpenAI +# Endpoint format: https://.openai.azure.com +# ============================================================================= +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com +AZURE_OPENAI_API_KEY=your-api-key +AZURE_OPENAI_DEPLOYMENT=gpt-4o-mini +AZURE_OPENAI_API_VERSION=2024-12-01-preview # Optional: override the system prompt sent to the model # AGENT_SYSTEM_PROMPT=You are a helpful assistant. Respond concisely. diff --git a/test_samples/echo-a365-telemetry/app.py b/test_samples/echo-a365-telemetry/app.py index 97ff2c5b..2168eb9c 100644 --- a/test_samples/echo-a365-telemetry/app.py +++ b/test_samples/echo-a365-telemetry/app.py @@ -34,6 +34,7 @@ from telemetry import ( configure_opentelemetry, create_aiohttp_tracing_middleware, + setup_health_routes, ) configure_opentelemetry( @@ -93,7 +94,7 @@ def create_app() -> Application: async def on_message(context: TurnContext, state: TurnState): # Pre-fetch the agentic token so it is warm in the cache before the # A365 wrapper tries to use it (mirrors the weather agent pattern). - await agent_app.auth.get_token(context, "AGENTIC") + # await agent_app.auth.get_token(context, "AGENTIC") await echo.handle_message(context, state) @agent_app.conversation_update("membersAdded") @@ -107,10 +108,13 @@ async def on_members_added(context: TurnContext, state: TurnState): app.router.add_post("/api/messages", messages_endpoint) app.router.add_get("/", lambda _: Response(text="Echo Agent is running", status=200)) + # Register /health and /alive endpoints (development only). is_development = environ.get("ENVIRONMENT", "development").lower() == "development" + setup_health_routes(app, development=is_development) app["agent_app"] = agent_app app["adapter"] = adapter + app["agent_configuration"] = connection_manager.get_default_connection_configuration() return app @@ -125,6 +129,7 @@ def main() -> None: print(f"{'='*60}") print(f"Endpoint: http://{host}:{port}/api/messages") print(f"Health: http://{host}:{port}/health") + print(f"Alive: http://{host}:{port}/alive") print(f"{'='*60}\n") web.run_app(create_app(), host=host, port=port) diff --git a/test_samples/echo-a365-telemetry/echo_agent.py b/test_samples/echo-a365-telemetry/echo_agent.py index 4d4f824a..0ab2fc36 100644 --- a/test_samples/echo-a365-telemetry/echo_agent.py +++ b/test_samples/echo-a365-telemetry/echo_agent.py @@ -1,13 +1,13 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Echo Agent — relays user text to an Azure AI Foundry endpoint. +"""Echo Agent — relays user text to an Azure OpenAI endpoint. The entire agent logic is intentionally minimal so that the telemetry infrastructure (spans, metrics, baggage, token caching) stays in the foreground. The AI call is a single-turn, stateless chat completion: - User text → Azure AI Foundry (azure-ai-inference) → reply to user + User text → Azure OpenAI (openai) → reply to user No conversation history, no tools, no state persistence. """ @@ -16,10 +16,7 @@ import traceback from os import environ -from azure.ai.inference.aio import ChatCompletionsClient -from azure.ai.inference.models import SystemMessage, UserMessage -from azure.core.credentials import AzureKeyCredential -from azure.identity.aio import DefaultAzureCredential +from openai import AzureOpenAI from microsoft_agents.hosting.core import Authorization, TurnContext, TurnState @@ -33,38 +30,29 @@ ) _WELCOME_MESSAGE = environ.get( "AGENT_WELCOME_MESSAGE", - "Hello! I'm the Echo Agent. Send me any message and I'll relay it to Azure AI Foundry.", + "Hello! I'm the Echo Agent. Send me any message and I'll relay it to Azure OpenAI.", ) -def _build_client() -> ChatCompletionsClient: - """Build an async Azure AI Foundry ChatCompletionsClient. +def _build_client() -> AzureOpenAI: + """Build an AzureOpenAI client. - Prefers an explicit API key; falls back to DefaultAzureCredential (managed - identity / az login) when no key is set. + Requires AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY to be set. """ - endpoint = environ["AZURE_AI_FOUNDRY_ENDPOINT"] - api_key = environ.get("AZURE_AI_FOUNDRY_API_KEY") - - if api_key: - return ChatCompletionsClient( - endpoint=endpoint, - credential=AzureKeyCredential(api_key), - ) - - return ChatCompletionsClient( - endpoint=endpoint, - credential=DefaultAzureCredential(), + return AzureOpenAI( + azure_endpoint=environ["AZURE_OPENAI_ENDPOINT"], + api_key=environ["AZURE_OPENAI_API_KEY"], + api_version=environ.get("AZURE_OPENAI_API_VERSION", "2024-12-01-preview"), ) class EchoAgent: - """Stateless agent that echoes user text through Azure AI Foundry.""" + """Stateless agent that echoes user text through Azure OpenAI.""" def __init__(self, user_authorization: Authorization = None): self._user_authorization = user_authorization self._client = _build_client() - self._model = environ.get("AZURE_AI_FOUNDRY_DEPLOYMENT", "gpt-4o-mini") + self._model = environ.get("AZURE_OPENAI_DEPLOYMENT", "gpt-4o-mini") logger.info("EchoAgent initialised (model=%s)", self._model) async def send_welcome(self, context: TurnContext, state: TurnState) -> None: @@ -74,7 +62,7 @@ async def send_welcome(self, context: TurnContext, state: TurnState) -> None: await context.send_activity(_WELCOME_MESSAGE) async def handle_message(self, context: TurnContext, state: TurnState) -> None: - """Process an incoming message and relay the Foundry reply to the user. + """Process an incoming message and relay the OpenAI reply to the user. The core logic is wrapped inside ``invoke_observed_agent_operation_with_context`` so that every @@ -91,15 +79,15 @@ async def handle_message(self, context: TurnContext, state: TurnState) -> None: logger.info("Received: %s", user_text) async def _process() -> None: - response = await self._client.complete( + response = self._client.chat.completions.create( + model=self._model, messages=[ - SystemMessage(content=_SYSTEM_PROMPT), - UserMessage(content=user_text), + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": user_text}, ], - model=self._model, ) reply = response.choices[0].message.content - logger.info("Foundry reply: %s", reply) + logger.info("OpenAI reply: %s", reply) await context.send_activity(reply) try: diff --git a/test_samples/echo-a365-telemetry/requirements.txt b/test_samples/echo-a365-telemetry/requirements.txt index ca1e307c..34ebf696 100644 --- a/test_samples/echo-a365-telemetry/requirements.txt +++ b/test_samples/echo-a365-telemetry/requirements.txt @@ -2,11 +2,8 @@ microsoft-agents-hosting-aiohttp microsoft-agents-authentication-msal -# Azure AI Foundry — chat completions client (async) -azure-ai-inference - -# Azure authentication (DefaultAzureCredential fallback) -azure-identity +# Azure OpenAI +openai # Web framework aiohttp diff --git a/test_samples/echo-a365-telemetry/telemetry/__init__.py b/test_samples/echo-a365-telemetry/telemetry/__init__.py index b4eba734..caec5c4a 100644 --- a/test_samples/echo-a365-telemetry/telemetry/__init__.py +++ b/test_samples/echo-a365-telemetry/telemetry/__init__.py @@ -8,7 +8,7 @@ Typical usage in app.py:: - from telemetry import configure_opentelemetry + from telemetry import configure_opentelemetry, setup_health_routes from telemetry import create_aiohttp_tracing_middleware from telemetry import invoke_observed_agent_operation_with_context @@ -18,7 +18,10 @@ # 2. Add tracing middleware to the aiohttp app app = web.Application(middlewares=[create_aiohttp_tracing_middleware()]) - # 3. Wrap message handlers with observed operations + # 3. Register /health and /alive endpoints (development only) + setup_health_routes(app, development=True) + + # 4. Wrap message handlers with observed operations await invoke_observed_agent_operation_with_context( "OnMessageActivity", turn_context, turn_state, handler_func ) @@ -43,6 +46,7 @@ configure_opentelemetry, create_aiohttp_tracing_middleware, instrument_libraries, + setup_health_routes, ) __all__ = [ @@ -64,5 +68,6 @@ # agent_otel_extensions "configure_opentelemetry", "instrument_libraries", + "setup_health_routes", "create_aiohttp_tracing_middleware", ] diff --git a/test_samples/echo-a365-telemetry/telemetry/a365_otel_wrapper.py b/test_samples/echo-a365-telemetry/telemetry/a365_otel_wrapper.py index a881c072..e3ba8d18 100644 --- a/test_samples/echo-a365-telemetry/telemetry/a365_otel_wrapper.py +++ b/test_samples/echo-a365-telemetry/telemetry/a365_otel_wrapper.py @@ -126,9 +126,9 @@ async def _resolve_tenant_and_agent_id(turn_context: TurnContext, user_authoriza if activity is None: return _EMPTY_GUID, _EMPTY_GUID - agentic_token = await user_authorization.get_token(turn_context, auth_handler or "AGENTIC") if user_authorization else None + # agentic_token = await user_authorization.get_token(turn_context, auth_handler or "AGENTIC") if user_authorization else None - agent_id = activity.get_agentic_instance_id() if activity.is_agentic_request() else _get_app_id_from_token(agentic_token) + agent_id = activity.get_agentic_instance_id() agent_id = agent_id or _EMPTY_GUID tenant_id = activity.conversation.tenant_id if activity.conversation and activity.conversation.tenant_id else _EMPTY_GUID diff --git a/test_samples/echo-a365-telemetry/telemetry/agent_otel_extensions.py b/test_samples/echo-a365-telemetry/telemetry/agent_otel_extensions.py index bfe93307..0c4cf9c9 100644 --- a/test_samples/echo-a365-telemetry/telemetry/agent_otel_extensions.py +++ b/test_samples/echo-a365-telemetry/telemetry/agent_otel_extensions.py @@ -209,10 +209,48 @@ def configure_opentelemetry( ) +def setup_health_routes(app, development: bool = True) -> None: + """Register ``/health`` and ``/alive`` endpoints on an aiohttp Application. + + Equivalent to ``MapDefaultEndpoints()`` in C#. Mirrors the C# behaviour + of only registering the endpoints in non-production environments. + + Args: + app: :class:`aiohttp.web.Application` instance. + development: When ``False`` the endpoints are **not** registered + (matches the C# guard on ``IsDevelopment()``). + """ + if not development: + return + + from aiohttp import web + + async def health_handler(_request): + return web.Response( + text='{"status":"Healthy"}', + content_type="application/json", + ) + + async def alive_handler(_request): + return web.Response( + text='{"status":"Alive"}', + content_type="application/json", + ) + + app.router.add_get(HEALTH_ENDPOINT_PATH, health_handler) + app.router.add_get(ALIVENESS_ENDPOINT_PATH, alive_handler) + logger.info( + "Health check endpoints registered: %s, %s", + HEALTH_ENDPOINT_PATH, + ALIVENESS_ENDPOINT_PATH, + ) + + def create_aiohttp_tracing_middleware(): - """Return an aiohttp middleware that traces every request. + """Return an aiohttp middleware that traces every non-health-check request. Equivalent to the ``AddAspNetCoreInstrumentation()`` configuration in C#: + - Filters out ``/health`` and ``/alive`` paths. - Enriches spans with ``http.request.body.size`` and ``user_agent`` on request, and ``http.status_code`` / ``http.response.body.size`` on response. @@ -224,6 +262,12 @@ def create_aiohttp_tracing_middleware(): @web.middleware async def tracing_middleware(request, handler): + # Exclude health check requests from tracing (mirrors C# filter lambda) + if request.path.startswith(HEALTH_ENDPOINT_PATH) or request.path.startswith( + ALIVENESS_ENDPOINT_PATH + ): + return await handler(request) + span_name = f"{request.method} {request.path}" with _tracer.start_as_current_span(span_name) as span: # Enrich with request details — equivalent to EnrichWithHttpRequest diff --git a/test_samples/weather-agent-framework/app.py b/test_samples/weather-agent-framework/app.py index 1262135f..7e03cb56 100644 --- a/test_samples/weather-agent-framework/app.py +++ b/test_samples/weather-agent-framework/app.py @@ -102,7 +102,7 @@ def create_app() -> Application: weather_agent = WeatherAgent(user_authorization=agent_app.auth) # Register event handlers - @agent_app.activity(ActivityTypes.message) + @agent_app.activity(ActivityTypes.message, auth_handlers=["AGENTIC"]) async def on_message(context: TurnContext, state: TurnState): aau_token = await agent_app.auth.get_token(context, "AGENTIC") await weather_agent.handle_message(context, state)