diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ede8344a..6cf2cc2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,18 +120,19 @@ jobs: - name: Run unit tests run: | - uv run --frozen python -m pytest tests/ -v --tb=short -m "not integration" + uv run --frozen pytest tests/ -v --tb=short -m "not integration" - name: Run integration tests # Only run integration tests if secrets are available if: ${{ vars.RUN_INTEGRATION_TESTS == 'true' }} run: | - uv run --frozen python -m pytest tests/integration/ -v --tb=short -m integration + uv run --frozen pytest -m integration -v --tb=short env: AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }} AZURE_OPENAI_DEPLOYMENT: ${{ vars.AZURE_OPENAI_DEPLOYMENT }} AZURE_OPENAI_API_VERSION: ${{ vars.AZURE_OPENAI_API_VERSION }} + ENABLE_OBSERVABILITY: true # Copy package and samples to drop folder - name: Copy package and samples to drop folder diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py index 42b7445a..53989833 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py @@ -1,15 +1,42 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. -# Agent details class. from dataclasses import dataclass +from typing import Optional + +from .models.agent_type import AgentType @dataclass class AgentDetails: - """Details about an AI agent.""" + """Details about an AI agent in the system.""" agent_id: str - conversation_id: str | None = None - agent_name: str | None = None - agent_description: str | None = None - icon_uri: str | None = None + """The unique identifier for the AI agent.""" + + agent_name: Optional[str] = None + """The human-readable name of the AI agent.""" + + agent_description: Optional[str] = None + """A description of the AI agent's purpose or capabilities.""" + + agent_auid: Optional[str] = None + """Optional Agent User ID for the agent.""" + + agent_upn: Optional[str] = None + """Optional User Principal Name (UPN) for the agent.""" + + agent_blueprint_id: Optional[str] = None + """Optional Blueprint/Application ID for the agent.""" + + agent_type: Optional[AgentType] = None + """The agent type.""" + + tenant_id: Optional[str] = None + """Optional Tenant ID for the agent.""" + + conversation_id: Optional[str] = None + """Optional conversation ID for compatibility.""" + + icon_uri: Optional[str] = None + """Optional icon URI for the agent.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py index e5fcf277..eda9442b 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py @@ -34,6 +34,7 @@ GEN_AI_RESPONSE_MODEL_KEY = "gen_ai.response.model" GEN_AI_SYSTEM_KEY = "gen_ai.system" GEN_AI_SYSTEM_VALUE = "az.ai.agent365" +GEN_AI_THOUGHT_PROCESS_KEY = "gen_ai.agent.thought.process" GEN_AI_AGENT_ID_KEY = "gen_ai.agent.id" GEN_AI_AGENT_NAME_KEY = "gen_ai.agent.name" @@ -45,6 +46,7 @@ GEN_AI_USAGE_OUTPUT_TOKENS_KEY = "gen_ai.usage.output_tokens" GEN_AI_CHOICE = "gen_ai.choice" GEN_AI_PROVIDER_NAME_KEY = "gen_ai.provider.name" +GEN_AI_AGENT_TYPE_KEY = "gen_ai.agent.type" GEN_AI_SYSTEM_INSTRUCTIONS_KEY = "gen_ai.system_instructions" GEN_AI_INPUT_MESSAGES_KEY = "gen_ai.input.messages" @@ -74,6 +76,7 @@ GEN_AI_CALLER_AGENT_NAME_KEY = "gen_ai.caller.agent.name" GEN_AI_CALLER_AGENT_ID_KEY = "gen_ai.caller.agent.id" GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY = "gen_ai.caller.agent.applicationid" +GEN_AI_CALLER_AGENT_TYPE_KEY = "gen_ai.caller.agent.type" # Agent-specific dimensions AGENT_ID_KEY = "gen_ai.agent.id" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py index e9a6c045..6a05728d 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py @@ -1,10 +1,11 @@ -# Copyright (c) Microsoft. All rights reserved. - -# Execute tool scope for tracing tool execution. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. from .agent_details import AgentDetails from .constants import ( EXECUTE_TOOL_OPERATION_NAME, + GEN_AI_EVENT_CONTENT, + GEN_AI_TOOL_ARGS_KEY, GEN_AI_TOOL_CALL_ID_KEY, GEN_AI_TOOL_DESCRIPTION_KEY, GEN_AI_TOOL_NAME_KEY, @@ -26,7 +27,7 @@ def start( agent_details: AgentDetails, tenant_details: TenantDetails, ) -> "ExecuteToolScope": - """Create and start a new scope for tool execution tracing. + """Creates and starts a new scope for tool execution tracing. Args: details: The details of the tool call @@ -54,18 +55,34 @@ def __init__( super().__init__( kind="Internal", operation_name=EXECUTE_TOOL_OPERATION_NAME, - activity_name=f"execute_tool {details.tool_name}", + activity_name=f"{EXECUTE_TOOL_OPERATION_NAME} {details.tool_name}", agent_details=agent_details, tenant_details=tenant_details, ) - self.set_tag_maybe(GEN_AI_TOOL_NAME_KEY, details.tool_name) - self.set_tag_maybe("gen_ai.tool.arguments", details.arguments) - self.set_tag_maybe(GEN_AI_TOOL_TYPE_KEY, details.tool_type) - self.set_tag_maybe(GEN_AI_TOOL_CALL_ID_KEY, details.tool_call_id) - self.set_tag_maybe(GEN_AI_TOOL_DESCRIPTION_KEY, details.description) + # Extract details using deconstruction-like approach + tool_name = details.tool_name + arguments = details.arguments + tool_call_id = details.tool_call_id + description = details.description + tool_type = details.tool_type + endpoint = details.endpoint + + self.set_tag_maybe(GEN_AI_TOOL_NAME_KEY, tool_name) + self.set_tag_maybe(GEN_AI_TOOL_ARGS_KEY, arguments) + self.set_tag_maybe(GEN_AI_TOOL_TYPE_KEY, tool_type) + self.set_tag_maybe(GEN_AI_TOOL_CALL_ID_KEY, tool_call_id) + self.set_tag_maybe(GEN_AI_TOOL_DESCRIPTION_KEY, description) + + if endpoint: + self.set_tag_maybe(SERVER_ADDRESS_KEY, endpoint.hostname) + if endpoint.port and endpoint.port != 443: + self.set_tag_maybe(SERVER_PORT_KEY, endpoint.port) - if details.endpoint: - self.set_tag_maybe(SERVER_ADDRESS_KEY, details.endpoint.hostname) - if details.endpoint.port and details.endpoint.port != 443: - self.set_tag_maybe(SERVER_PORT_KEY, details.endpoint.port) + def record_response(self, response: str) -> None: + """Records response information for telemetry tracking. + + Args: + response: The response to record + """ + self.set_tag_maybe(GEN_AI_EVENT_CONTENT, response) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py index f5efe883..7274a1b6 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py @@ -1,12 +1,18 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import List from .agent_details import AgentDetails from .constants import ( GEN_AI_INPUT_MESSAGES_KEY, + GEN_AI_OPERATION_NAME_KEY, + GEN_AI_OUTPUT_MESSAGES_KEY, GEN_AI_PROVIDER_NAME_KEY, GEN_AI_REQUEST_MODEL_KEY, GEN_AI_RESPONSE_FINISH_REASONS_KEY, GEN_AI_RESPONSE_ID_KEY, + GEN_AI_THOUGHT_PROCESS_KEY, GEN_AI_USAGE_INPUT_TOKENS_KEY, GEN_AI_USAGE_OUTPUT_TOKENS_KEY, ) @@ -14,10 +20,11 @@ from .opentelemetry_scope import OpenTelemetryScope from .request import Request from .tenant_details import TenantDetails +from .utils import safe_json_dumps class InferenceScope(OpenTelemetryScope): - """Provides OpenTelemetry tracing scope for inference call.""" + """Provides OpenTelemetry tracing scope for generative AI inference operations.""" @staticmethod def start( @@ -26,7 +33,7 @@ def start( tenant_details: TenantDetails, request: Request | None = None, ) -> "InferenceScope": - """Create and start a new scope for inference call. + """Creates and starts a new scope for inference tracing. Args: details: The details of the inference call @@ -41,15 +48,15 @@ def start( def __init__( self, - inference_call_details: InferenceCallDetails, + details: InferenceCallDetails, agent_details: AgentDetails, tenant_details: TenantDetails, request: Request | None = None, ): - """Initialize the agent invocation scope. + """Initialize the inference scope. Args: - inference_call_details: The details of the inference call + details: The details of the inference call agent_details: The details of the agent making the call tenant_details: The details of the tenant request: Optional request details for additional context @@ -57,24 +64,77 @@ def __init__( super().__init__( kind="Client", - operation_name=inference_call_details.operationName.value, - activity_name=f"{inference_call_details.operationName.value} {inference_call_details.model}", + operation_name=details.operationName.value, + activity_name=f"{details.operationName.value} {details.model}", agent_details=agent_details, tenant_details=tenant_details, ) - # Set request content if provided if request: self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, request.content) - self.set_tag_maybe(GEN_AI_REQUEST_MODEL_KEY, inference_call_details.model) - self.set_tag_maybe(GEN_AI_PROVIDER_NAME_KEY, inference_call_details.providerName) - self.set_tag_maybe(GEN_AI_USAGE_INPUT_TOKENS_KEY, inference_call_details.inputTokens) - self.set_tag_maybe(GEN_AI_USAGE_OUTPUT_TOKENS_KEY, inference_call_details.outputTokens) + self.set_tag_maybe(GEN_AI_OPERATION_NAME_KEY, details.operationName.value) + self.set_tag_maybe(GEN_AI_REQUEST_MODEL_KEY, details.model) + self.set_tag_maybe(GEN_AI_PROVIDER_NAME_KEY, details.providerName) + self.set_tag_maybe( + GEN_AI_USAGE_INPUT_TOKENS_KEY, + str(details.inputTokens) if details.inputTokens is not None else None, + ) + self.set_tag_maybe( + GEN_AI_USAGE_OUTPUT_TOKENS_KEY, + str(details.outputTokens) if details.outputTokens is not None else None, + ) self.set_tag_maybe( GEN_AI_RESPONSE_FINISH_REASONS_KEY, - ",".join(inference_call_details.finishReasons) - if inference_call_details.finishReasons - else None, + safe_json_dumps(details.finishReasons) if details.finishReasons else None, ) - self.set_tag_maybe(GEN_AI_RESPONSE_ID_KEY, inference_call_details.responseId) + self.set_tag_maybe(GEN_AI_RESPONSE_ID_KEY, details.responseId) + + def record_input_messages(self, messages: List[str]) -> None: + """Records the input messages for telemetry tracking. + + Args: + messages: List of input messages + """ + self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, safe_json_dumps(messages)) + + def record_output_messages(self, messages: List[str]) -> None: + """Records the output messages for telemetry tracking. + + Args: + messages: List of output messages + """ + self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(messages)) + + def record_input_tokens(self, input_tokens: int) -> None: + """Records the number of input tokens for telemetry tracking. + + Args: + input_tokens: Number of input tokens + """ + self.set_tag_maybe(GEN_AI_USAGE_INPUT_TOKENS_KEY, str(input_tokens)) + + def record_output_tokens(self, output_tokens: int) -> None: + """Records the number of output tokens for telemetry tracking. + + Args: + output_tokens: Number of output tokens + """ + self.set_tag_maybe(GEN_AI_USAGE_OUTPUT_TOKENS_KEY, str(output_tokens)) + + def record_finish_reasons(self, finish_reasons: List[str]) -> None: + """Records the finish reasons for telemetry tracking. + + Args: + finish_reasons: List of finish reasons + """ + if finish_reasons: + self.set_tag_maybe(GEN_AI_RESPONSE_FINISH_REASONS_KEY, safe_json_dumps(finish_reasons)) + + def record_thought_process(self, thought_process: str) -> None: + """Records the thought process. + + Args: + thought_process: The thought process to record + """ + self.set_tag_maybe(GEN_AI_THOUGHT_PROCESS_KEY, thought_process) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py index 9a2801bc..63281159 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py @@ -1,18 +1,38 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Invoke agent scope for tracing agent invocation. +from .agent_details import AgentDetails from .constants import ( + GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, + GEN_AI_CALLER_AGENT_ID_KEY, + GEN_AI_CALLER_AGENT_NAME_KEY, + GEN_AI_CALLER_AGENT_TENANT_ID_KEY, + GEN_AI_CALLER_AGENT_UPN_KEY, + GEN_AI_CALLER_AGENT_USER_ID_KEY, + GEN_AI_CALLER_ID_KEY, + GEN_AI_CALLER_NAME_KEY, + GEN_AI_CALLER_TENANT_ID_KEY, + GEN_AI_CALLER_UPN_KEY, + GEN_AI_CALLER_USER_ID_KEY, + GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, + GEN_AI_EXECUTION_SOURCE_ID_KEY, + GEN_AI_EXECUTION_SOURCE_NAME_KEY, + GEN_AI_EXECUTION_TYPE_KEY, GEN_AI_INPUT_MESSAGES_KEY, + GEN_AI_OUTPUT_MESSAGES_KEY, INVOKE_AGENT_OPERATION_NAME, SERVER_ADDRESS_KEY, SERVER_PORT_KEY, SESSION_ID_KEY, ) from .invoke_agent_details import InvokeAgentDetails +from .models.caller_details import CallerDetails from .opentelemetry_scope import OpenTelemetryScope from .request import Request from .tenant_details import TenantDetails +from .utils import safe_json_dumps class InvokeAgentScope(OpenTelemetryScope): @@ -23,6 +43,8 @@ def start( invoke_agent_details: InvokeAgentDetails, tenant_details: TenantDetails, request: Request | None = None, + caller_agent_details: AgentDetails | None = None, + caller_details: CallerDetails | None = None, ) -> "InvokeAgentScope": """Create and start a new scope for agent invocation tracing. @@ -31,17 +53,23 @@ def start( agent information, and session context tenant_details: The details of the tenant request: Optional request details for additional context + caller_agent_details: Optional details of the caller agent + caller_details: Optional details of the non-agentic caller Returns: A new InvokeAgentScope instance """ - return InvokeAgentScope(invoke_agent_details, tenant_details, request) + return InvokeAgentScope( + invoke_agent_details, tenant_details, request, caller_agent_details, caller_details + ) def __init__( self, invoke_agent_details: InvokeAgentDetails, tenant_details: TenantDetails, request: Request | None = None, + caller_agent_details: AgentDetails | None = None, + caller_details: CallerDetails | None = None, ): """Initialize the agent invocation scope. @@ -49,6 +77,8 @@ def __init__( invoke_agent_details: The details of the agent invocation tenant_details: The details of the tenant request: Optional request details for additional context + caller_agent_details: Optional details of the caller agent + caller_details: Optional details of the non-agentic caller """ activity_name = INVOKE_AGENT_OPERATION_NAME if invoke_agent_details.details.agent_name: @@ -64,20 +94,73 @@ def __init__( tenant_details=tenant_details, ) - self.set_tag_maybe(SESSION_ID_KEY, invoke_agent_details.session_id) + endpoint, _, session_id = ( + invoke_agent_details.endpoint, + invoke_agent_details.details, + invoke_agent_details.session_id, + ) - # Set server details - if invoke_agent_details.endpoint: - self.set_tag_maybe(SERVER_ADDRESS_KEY, invoke_agent_details.endpoint.hostname) + self.set_tag_maybe(SESSION_ID_KEY, session_id) + if endpoint: + self.set_tag_maybe(SERVER_ADDRESS_KEY, endpoint.hostname) - # Only record port if it is different from 443 - if ( - invoke_agent_details.endpoint - and invoke_agent_details.endpoint.port - and invoke_agent_details.endpoint.port != 443 - ): - self.set_tag_maybe(SERVER_PORT_KEY, invoke_agent_details.endpoint.port) + # Only record port if it is different from 443 + if endpoint.port and endpoint.port != 443: + self.set_tag_maybe(SERVER_PORT_KEY, endpoint.port) - # Set request content if provided + # Set request metadata if provided if request: - self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, request.content) + if request.source_metadata: + self.set_tag_maybe(GEN_AI_EXECUTION_SOURCE_ID_KEY, request.source_metadata.id) + self.set_tag_maybe(GEN_AI_EXECUTION_SOURCE_NAME_KEY, request.source_metadata.name) + self.set_tag_maybe( + GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, request.source_metadata.description + ) + + self.set_tag_maybe( + GEN_AI_EXECUTION_TYPE_KEY, + request.execution_type.value if request.execution_type else None, + ) + + # Set caller details tags + if caller_details: + self.set_tag_maybe(GEN_AI_CALLER_ID_KEY, caller_details.caller_id) + self.set_tag_maybe(GEN_AI_CALLER_UPN_KEY, caller_details.caller_upn) + self.set_tag_maybe(GEN_AI_CALLER_NAME_KEY, caller_details.caller_name) + self.set_tag_maybe(GEN_AI_CALLER_USER_ID_KEY, caller_details.caller_user_id) + self.set_tag_maybe(GEN_AI_CALLER_TENANT_ID_KEY, caller_details.tenant_id) + + # Set caller agent details tags + if caller_agent_details: + self.set_tag_maybe(GEN_AI_CALLER_AGENT_NAME_KEY, caller_agent_details.agent_name) + self.set_tag_maybe(GEN_AI_CALLER_AGENT_ID_KEY, caller_agent_details.agent_id) + self.set_tag_maybe( + GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, caller_agent_details.agent_blueprint_id + ) + self.set_tag_maybe(GEN_AI_CALLER_AGENT_USER_ID_KEY, caller_agent_details.agent_auid) + self.set_tag_maybe(GEN_AI_CALLER_AGENT_UPN_KEY, caller_agent_details.agent_upn) + self.set_tag_maybe(GEN_AI_CALLER_AGENT_TENANT_ID_KEY, caller_agent_details.tenant_id) + + def record_response(self, response: str) -> None: + """Record response information for telemetry tracking. + + Args: + response: The response string to record + """ + self.record_output_messages([response]) + + def record_input_messages(self, messages: list[str]) -> None: + """Record the input messages for telemetry tracking. + + Args: + messages: List of input messages to record + """ + self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, safe_json_dumps(messages)) + + def record_output_messages(self, messages: list[str]) -> None: + """Record the output messages for telemetry tracking. + + Args: + messages: List of output messages to record + """ + self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(messages)) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/__init__.py new file mode 100644 index 00000000..1d8b4766 --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/agent_type.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/agent_type.py new file mode 100644 index 00000000..813df96c --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/agent_type.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from enum import Enum + + +class AgentType(Enum): + """ + Supported agent types for generative AI. + """ + + ENTRA_EMBODIED = "EntraEmbodied" + """Entra embodied agent.""" + + ENTRA_NON_EMBODIED = "EntraNonEmbodied" + """Entra non-embodied agent.""" + + MICROSOFT_COPILOT = "MicrosoftCopilot" + """Microsoft Copilot agent.""" + + DECLARATIVE_AGENT = "DeclarativeAgent" + """Declarative agent.""" + + FOUNDRY = "Foundry" + """Foundry agent.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/caller_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/caller_details.py new file mode 100644 index 00000000..38bb1113 --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/caller_details.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class CallerDetails: + """Details about the caller that invoked an agent.""" + + caller_id: Optional[str] = None + """The unique identifier for the caller.""" + + caller_upn: Optional[str] = None + """The User Principal Name (UPN) of the caller.""" + + caller_name: Optional[str] = None + """The human-readable name of the caller.""" + + caller_user_id: Optional[str] = None + """The user ID of the caller.""" + + tenant_id: Optional[str] = None + """The tenant ID of the caller.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py index 726c75f7..ba0311d4 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py @@ -14,9 +14,13 @@ ENABLE_A365_OBSERVABILITY, ENABLE_OBSERVABILITY, ERROR_TYPE_KEY, + GEN_AI_AGENT_AUID_KEY, + GEN_AI_AGENT_BLUEPRINT_ID_KEY, GEN_AI_AGENT_DESCRIPTION_KEY, GEN_AI_AGENT_ID_KEY, GEN_AI_AGENT_NAME_KEY, + GEN_AI_AGENT_TYPE_KEY, + GEN_AI_AGENT_UPN_KEY, GEN_AI_CONVERSATION_ID_KEY, GEN_AI_EVENT_CONTENT, GEN_AI_ICON_URI_KEY, @@ -112,6 +116,16 @@ def __init__( self.set_tag_maybe( GEN_AI_AGENT_DESCRIPTION_KEY, agent_details.agent_description ) + self.set_tag_maybe(GEN_AI_AGENT_AUID_KEY, agent_details.agent_auid) + self.set_tag_maybe(GEN_AI_AGENT_UPN_KEY, agent_details.agent_upn) + self.set_tag_maybe( + GEN_AI_AGENT_BLUEPRINT_ID_KEY, agent_details.agent_blueprint_id + ) + self.set_tag_maybe( + GEN_AI_AGENT_TYPE_KEY, + agent_details.agent_type.value if agent_details.agent_type else None, + ) + self.set_tag_maybe(TENANT_ID_KEY, agent_details.tenant_id) self.set_tag_maybe(GEN_AI_CONVERSATION_ID_KEY, agent_details.conversation_id) self.set_tag_maybe(GEN_AI_ICON_URI_KEY, agent_details.icon_uri) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/util.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/util.py index 70ebfc2c..524413ab 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/util.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/util.py @@ -18,6 +18,7 @@ consts.GEN_AI_AGENT_UPN_KEY, # gen_ai.agent.upn consts.GEN_AI_AGENT_BLUEPRINT_ID_KEY, # gen_ai.agent.applicationid consts.GEN_AI_AGENT_AUID_KEY, + consts.GEN_AI_AGENT_TYPE_KEY, ] # Invoke Agent–specific attributes diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/wrappers/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py similarity index 100% rename from libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/wrappers/utils.py rename to libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer.py b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer.py index 37a4f89e..221f20d2 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer.py +++ b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer.py @@ -14,6 +14,13 @@ from langchain_core.tracers import BaseTracer, LangChainTracer from langchain_core.tracers.schemas import Run +from microsoft_agents_a365.observability.core.inference_operation_type import InferenceOperationType +from microsoft_agents_a365.observability.core.utils import ( + DictWithLock, + as_utc_nano, + flatten, + record_exception, +) from opentelemetry import context as context_api from opentelemetry import trace as trace_api from opentelemetry.context import ( @@ -23,7 +30,6 @@ from opentelemetry.trace import Span from opentelemetry.util.types import AttributeValue -from microsoft_agents_a365.observability.core.inference_operation_type import InferenceOperationType from microsoft_agents_a365.observability.extensions.langchain.utils import ( IGNORED_EXCEPTION_PATTERNS, add_operation_type, @@ -38,12 +44,6 @@ token_counts, tools, ) -from microsoft_agents_a365.observability.core.wrappers.utils import ( - DictWithLock, - as_utc_nano, - flatten, - record_exception, -) logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/utils.py b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/utils.py index 36a74e28..f634808c 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/utils.py +++ b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/utils.py @@ -27,7 +27,7 @@ SESSION_ID_KEY, ) from microsoft_agents_a365.observability.core.inference_operation_type import InferenceOperationType -from microsoft_agents_a365.observability.core.wrappers.utils import ( +from microsoft_agents_a365.observability.core.utils import ( get_first_value, safe_json_dumps, stop_on_exception, diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_processor.py b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_processor.py index 94e1de69..02720716 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_processor.py +++ b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_processor.py @@ -28,7 +28,7 @@ GEN_AI_SYSTEM_KEY, INVOKE_AGENT_OPERATION_NAME, ) -from microsoft_agents_a365.observability.core.wrappers.utils import as_utc_nano, safe_json_dumps +from microsoft_agents_a365.observability.core.utils import as_utc_nano, safe_json_dumps from opentelemetry import trace as ot_trace from opentelemetry.context import attach, detach from opentelemetry.trace import Span as OtelSpan diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/utils.py b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/utils.py index 7502c057..91952f0c 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/utils.py +++ b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/utils.py @@ -37,7 +37,7 @@ GEN_AI_USAGE_INPUT_TOKENS_KEY, GEN_AI_USAGE_OUTPUT_TOKENS_KEY, ) -from microsoft_agents_a365.observability.core.wrappers.utils import safe_json_dumps +from microsoft_agents_a365.observability.core.utils import safe_json_dumps from opentelemetry.trace import ( Status, StatusCode, diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/span_processor.py b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/span_processor.py index 1e0b6c7f..d7a78133 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/span_processor.py +++ b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/span_processor.py @@ -2,11 +2,10 @@ # Custom Span Processor -from opentelemetry.sdk.trace.export import SpanProcessor - from microsoft_agents_a365.observability.core.constants import GEN_AI_OPERATION_NAME_KEY from microsoft_agents_a365.observability.core.inference_operation_type import InferenceOperationType -from microsoft_agents_a365.observability.core.wrappers.utils import extract_model_name +from microsoft_agents_a365.observability.core.utils import extract_model_name +from opentelemetry.sdk.trace.export import SpanProcessor class SemanticKernelSpanProcessor(SpanProcessor): diff --git a/tests/observability/__init__.py b/tests/observability/__init__.py new file mode 100644 index 00000000..1d8b4766 --- /dev/null +++ b/tests/observability/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/observability/core/__init__.py b/tests/observability/core/__init__.py new file mode 100644 index 00000000..1d8b4766 --- /dev/null +++ b/tests/observability/core/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/test_agent365.py b/tests/observability/core/test_agent365.py similarity index 100% rename from tests/test_agent365.py rename to tests/observability/core/test_agent365.py diff --git a/tests/test_agent365_exporter.py b/tests/observability/core/test_agent365_exporter.py similarity index 100% rename from tests/test_agent365_exporter.py rename to tests/observability/core/test_agent365_exporter.py diff --git a/tests/test_alignment.py b/tests/observability/core/test_alignment.py similarity index 100% rename from tests/test_alignment.py rename to tests/observability/core/test_alignment.py diff --git a/tests/test_baggage_builder.py b/tests/observability/core/test_baggage_builder.py similarity index 100% rename from tests/test_baggage_builder.py rename to tests/observability/core/test_baggage_builder.py diff --git a/tests/observability/core/test_execute_tool_scope.py b/tests/observability/core/test_execute_tool_scope.py new file mode 100644 index 00000000..55be2820 --- /dev/null +++ b/tests/observability/core/test_execute_tool_scope.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import unittest + +from microsoft_agents_a365.observability.core import ( + AgentDetails, + ExecuteToolScope, + TenantDetails, + ToolCallDetails, + configure, +) + + +class TestExecuteToolScope(unittest.TestCase): + """Unit tests for ExecuteToolScope and its methods.""" + + @classmethod + def setUpClass(cls): + """Set up test environment once for all tests.""" + # Configure Agent365 for testing + configure( + service_name="test-execute-tool-service", + service_namespace="test-namespace", + ) + # Create test data + cls.tenant_details = TenantDetails(tenant_id="12345678-1234-5678-1234-567812345678") + cls.agent_details = AgentDetails( + agent_id="test-agent-123", + agent_name="Test Agent", + agent_description="A test agent for tool execution testing", + ) + cls.tool_details = ToolCallDetails( + tool_name="weather_tool", + arguments='{"location": "Seattle", "units": "metric"}', + tool_call_id="call-123", + description="Get current weather information for a location", + ) + + def test_record_response_method_exists(self): + """Test that record_response method exists on ExecuteToolScope.""" + scope = ExecuteToolScope.start(self.tool_details, self.agent_details, self.tenant_details) + + if scope is not None: + # Test that the method exists + self.assertTrue(hasattr(scope, "record_response")) + self.assertTrue(callable(scope.record_response)) + scope.dispose() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_inference_scope.py b/tests/observability/core/test_inference_scope.py similarity index 61% rename from tests/test_inference_scope.py rename to tests/observability/core/test_inference_scope.py index d53ff529..2c9c7b94 100644 --- a/tests/test_inference_scope.py +++ b/tests/observability/core/test_inference_scope.py @@ -129,9 +129,6 @@ def test_inference_scope_context_manager(self): if hasattr(scope, "record_output_tokens"): scope.record_output_tokens(60) - if hasattr(scope, "record_response_id"): - scope.record_response_id("new-resp-456") - if hasattr(scope, "record_finish_reasons"): scope.record_finish_reasons(["stop", "length"]) @@ -149,6 +146,94 @@ def test_inference_scope_dispose(self): # Should not raise an exception self.assertIsInstance(scope, InferenceScope) + def test_record_input_messages(self): + """Test record_input_messages method.""" + details = InferenceCallDetails( + operationName=InferenceOperationType.CHAT, model="gpt-4", providerName="openai" + ) + + scope = InferenceScope.start(details, self.agent_details, self.tenant_details) + + if scope is not None: + # Test recording input messages + messages = ["Hello", "How are you?"] + scope.record_input_messages(messages) + # Should not raise an exception + self.assertTrue(hasattr(scope, "record_input_messages")) + + def test_record_output_messages(self): + """Test record_output_messages method.""" + details = InferenceCallDetails( + operationName=InferenceOperationType.CHAT, model="gpt-4", providerName="openai" + ) + + scope = InferenceScope.start(details, self.agent_details, self.tenant_details) + + if scope is not None: + # Test recording output messages + messages = ["I'm doing well", "Thanks for asking!"] + scope.record_output_messages(messages) + # Should not raise an exception + self.assertTrue(hasattr(scope, "record_output_messages")) + + def test_record_input_tokens(self): + """Test record_input_tokens method.""" + details = InferenceCallDetails( + operationName=InferenceOperationType.CHAT, model="gpt-4", providerName="openai" + ) + + scope = InferenceScope.start(details, self.agent_details, self.tenant_details) + + if scope is not None: + # Test recording input tokens + scope.record_input_tokens(150) + # Should not raise an exception + self.assertTrue(hasattr(scope, "record_input_tokens")) + + def test_record_output_tokens(self): + """Test record_output_tokens method.""" + details = InferenceCallDetails( + operationName=InferenceOperationType.CHAT, model="gpt-4", providerName="openai" + ) + + scope = InferenceScope.start(details, self.agent_details, self.tenant_details) + + if scope is not None: + # Test recording output tokens + scope.record_output_tokens(75) + # Should not raise an exception + self.assertTrue(hasattr(scope, "record_output_tokens")) + + def test_record_finish_reasons(self): + """Test record_finish_reasons method.""" + details = InferenceCallDetails( + operationName=InferenceOperationType.CHAT, model="gpt-4", providerName="openai" + ) + + scope = InferenceScope.start(details, self.agent_details, self.tenant_details) + + if scope is not None: + # Test recording finish reasons + finish_reasons = ["stop", "length"] + scope.record_finish_reasons(finish_reasons) + # Should not raise an exception + self.assertTrue(hasattr(scope, "record_finish_reasons")) + + def test_record_thought_process(self): + """Test record_thought_process method.""" + details = InferenceCallDetails( + operationName=InferenceOperationType.CHAT, model="gpt-4", providerName="openai" + ) + + scope = InferenceScope.start(details, self.agent_details, self.tenant_details) + + if scope is not None: + # Test recording thought process + thought_process = "Analyzing user input and generating appropriate response" + scope.record_thought_process(thought_process) + # Should not raise an exception + self.assertTrue(hasattr(scope, "record_thought_process")) + if __name__ == "__main__": # Run the tests diff --git a/tests/observability/core/test_invoke_agent_scope.py b/tests/observability/core/test_invoke_agent_scope.py new file mode 100644 index 00000000..e923a9db --- /dev/null +++ b/tests/observability/core/test_invoke_agent_scope.py @@ -0,0 +1,72 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import unittest +from urllib.parse import urlparse + +from microsoft_agents_a365.observability.core import ( + AgentDetails, + InvokeAgentDetails, + InvokeAgentScope, + TenantDetails, + configure, +) + + +class TestInvokeAgentScope(unittest.TestCase): + """Unit tests for InvokeAgentScope and its methods.""" + + @classmethod + def setUpClass(cls): + """Set up test environment once for all tests.""" + # Configure Agent365 for testing + configure( + service_name="test-invoke-agent-service", + service_namespace="test-namespace", + ) + # Create test data + cls.tenant_details = TenantDetails(tenant_id="12345678-1234-5678-1234-567812345678") + cls.agent_details = AgentDetails( + agent_id="test-agent-123", + agent_name="Test Agent", + agent_description="A test agent for invoke scope testing", + ) + cls.invoke_details = InvokeAgentDetails( + endpoint=urlparse("https://example.com/agent"), + details=cls.agent_details, + session_id="session-123", + ) + + def test_record_response_method_exists(self): + """Test that record_response method exists on InvokeAgentScope.""" + scope = InvokeAgentScope.start(self.invoke_details, self.tenant_details) + + if scope is not None: + # Test that the method exists + self.assertTrue(hasattr(scope, "record_response")) + self.assertTrue(callable(scope.record_response)) + scope.dispose() + + def test_record_input_messages_method_exists(self): + """Test that record_input_messages method exists on InvokeAgentScope.""" + scope = InvokeAgentScope.start(self.invoke_details, self.tenant_details) + + if scope is not None: + # Test that the method exists + self.assertTrue(hasattr(scope, "record_input_messages")) + self.assertTrue(callable(scope.record_input_messages)) + scope.dispose() + + def test_record_output_messages_method_exists(self): + """Test that record_output_messages method exists on InvokeAgentScope.""" + scope = InvokeAgentScope.start(self.invoke_details, self.tenant_details) + + if scope is not None: + # Test that the method exists + self.assertTrue(hasattr(scope, "record_output_messages")) + self.assertTrue(callable(scope.record_output_messages)) + scope.dispose() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_record_attributes.py b/tests/observability/core/test_record_attributes.py similarity index 100% rename from tests/test_record_attributes.py rename to tests/observability/core/test_record_attributes.py diff --git a/tests/test_span_processor.py b/tests/observability/core/test_span_processor.py similarity index 100% rename from tests/test_span_processor.py rename to tests/observability/core/test_span_processor.py diff --git a/tests/test_turn_context_baggage.py b/tests/observability/core/test_turn_context_baggage.py similarity index 100% rename from tests/test_turn_context_baggage.py rename to tests/observability/core/test_turn_context_baggage.py diff --git a/tests/observability/extensions/__init__.py b/tests/observability/extensions/__init__.py new file mode 100644 index 00000000..1d8b4766 --- /dev/null +++ b/tests/observability/extensions/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/observability/extensions/langchain/__init__.py b/tests/observability/extensions/langchain/__init__.py new file mode 100644 index 00000000..1d8b4766 --- /dev/null +++ b/tests/observability/extensions/langchain/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/test_wrapper_langchain.py b/tests/observability/extensions/langchain/test_wrapper_langchain.py similarity index 100% rename from tests/test_wrapper_langchain.py rename to tests/observability/extensions/langchain/test_wrapper_langchain.py diff --git a/tests/observability/extensions/openai/__init__.py b/tests/observability/extensions/openai/__init__.py new file mode 100644 index 00000000..1d8b4766 --- /dev/null +++ b/tests/observability/extensions/openai/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/integration/__init__.py b/tests/observability/extensions/openai/integration/__init__.py similarity index 100% rename from tests/integration/__init__.py rename to tests/observability/extensions/openai/integration/__init__.py diff --git a/tests/integration/conftest.py b/tests/observability/extensions/openai/integration/conftest.py similarity index 84% rename from tests/integration/conftest.py rename to tests/observability/extensions/openai/integration/conftest.py index 9064a4dc..82faa952 100644 --- a/tests/integration/conftest.py +++ b/tests/observability/extensions/openai/integration/conftest.py @@ -12,7 +12,10 @@ from dotenv import load_dotenv # Look for .env file in tests directory - env_file = Path(__file__).parent.parent / ".env" + # Navigate from conftest.py location: integration -> openai -> extensions -> observability -> tests + current_file = Path(__file__) + tests_dir = current_file.parent.parent.parent.parent.parent # Go up to tests/ directory + env_file = tests_dir / ".env" if env_file.exists(): load_dotenv(env_file) except ImportError: diff --git a/tests/integration/test_openai_trace_processor.py b/tests/observability/extensions/openai/integration/test_openai_trace_processor.py similarity index 100% rename from tests/integration/test_openai_trace_processor.py rename to tests/observability/extensions/openai/integration/test_openai_trace_processor.py diff --git a/tests/test_wrapper_openaiagents.py b/tests/observability/extensions/openai/test_wrapper_openaiagents.py similarity index 100% rename from tests/test_wrapper_openaiagents.py rename to tests/observability/extensions/openai/test_wrapper_openaiagents.py diff --git a/tests/observability/extensions/semantickernel/__init__.py b/tests/observability/extensions/semantickernel/__init__.py new file mode 100644 index 00000000..1d8b4766 --- /dev/null +++ b/tests/observability/extensions/semantickernel/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/test_wrapper_semantic_kernel.py b/tests/observability/extensions/semantickernel/test_wrapper_semantic_kernel.py similarity index 100% rename from tests/test_wrapper_semantic_kernel.py rename to tests/observability/extensions/semantickernel/test_wrapper_semantic_kernel.py diff --git a/tests/runtime/__init__.py b/tests/runtime/__init__.py new file mode 100644 index 00000000..1d8b4766 --- /dev/null +++ b/tests/runtime/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/test_power_platform_api_discovery.py b/tests/runtime/test_power_platform_api_discovery.py similarity index 100% rename from tests/test_power_platform_api_discovery.py rename to tests/runtime/test_power_platform_api_discovery.py