diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py index fcd801fa..4f10da10 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py @@ -11,6 +11,7 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter from .exporters.agent365_exporter import Agent365Exporter +from .exporters.agent365_exporter_options import Agent365ExporterOptions from .exporters.utils import is_agent365_exporter_enabled from .trace_processor.span_processor import SpanProcessor @@ -51,6 +52,7 @@ def configure( logger_name: str = DEFAULT_LOGGER_NAME, token_resolver: Callable[[str, str], str | None] | None = None, cluster_category: str = "prod", + exporter_options: Optional[Agent365ExporterOptions] = None, **kwargs: Any, ) -> bool: """ @@ -59,8 +61,12 @@ def configure( :param service_name: The name of the service. :param service_namespace: The namespace of the service. :param logger_name: The name of the logger to collect telemetry from. - :param token_resolver: Callable that returns an auth token for a given agent + tenant. - :param cluster_category: Environment / cluster category (e.g., "preprod", "prod"). + :param token_resolver: (Deprecated) Callable that returns an auth token for a given agent + tenant. + Use exporter_options instead. + :param cluster_category: (Deprecated) Environment / cluster category (e.g. "prod"). + Use exporter_options instead. + :param exporter_options: Agent365ExporterOptions instance for configuring the exporter. + If provided, exporter_options takes precedence. If exporter_options is None, the token_resolver and cluster_category parameters are used as fallback/legacy support to construct a default Agent365ExporterOptions instance. :return: True if configuration succeeded, False otherwise. """ try: @@ -71,6 +77,7 @@ def configure( logger_name, token_resolver, cluster_category, + exporter_options, **kwargs, ) except Exception as e: @@ -84,6 +91,7 @@ def _configure_internal( logger_name: str, token_resolver: Callable[[str, str], str | None] | None = None, cluster_category: str = "prod", + exporter_options: Optional[Agent365ExporterOptions] = None, **kwargs: Any, ) -> bool: """Internal configuration method - not thread-safe, must be called with lock.""" @@ -115,11 +123,26 @@ def _configure_internal( trace.set_tracer_provider(tracer_provider) self._tracer_provider = tracer_provider - if is_agent365_exporter_enabled() and token_resolver is not None: - exporter = Agent365Exporter( - token_resolver=token_resolver, + # Use exporter_options if provided, otherwise create default options with legacy parameters + if exporter_options is None: + exporter_options = Agent365ExporterOptions( cluster_category=cluster_category, - **kwargs, + token_resolver=token_resolver, + ) + + # Extract configuration for BatchSpanProcessor + batch_processor_kwargs = { + "max_queue_size": exporter_options.max_queue_size, + "schedule_delay_millis": exporter_options.scheduled_delay_ms, + "export_timeout_millis": exporter_options.exporter_timeout_ms, + "max_export_batch_size": exporter_options.max_export_batch_size, + } + + if is_agent365_exporter_enabled() and exporter_options.token_resolver is not None: + exporter = Agent365Exporter( + token_resolver=exporter_options.token_resolver, + cluster_category=exporter_options.cluster_category, + use_s2s_endpoint=exporter_options.use_s2s_endpoint, ) else: exporter = ConsoleSpanExporter() @@ -130,7 +153,7 @@ def _configure_internal( # Add span processors # Create BatchSpanProcessor with optimized settings - batch_processor = BatchSpanProcessor(exporter) + batch_processor = BatchSpanProcessor(exporter, **batch_processor_kwargs) agent_processor = SpanProcessor() tracer_provider.add_span_processor(batch_processor) @@ -197,6 +220,7 @@ def configure( logger_name: str = DEFAULT_LOGGER_NAME, token_resolver: Callable[[str, str], str | None] | None = None, cluster_category: str = "prod", + exporter_options: Optional[Agent365ExporterOptions] = None, **kwargs: Any, ) -> bool: """ @@ -205,6 +229,12 @@ def configure( :param service_name: The name of the service. :param service_namespace: The namespace of the service. :param logger_name: The name of the logger to collect telemetry from. + :param token_resolver: (Deprecated) Callable that returns an auth token for a given agent + tenant. + Use exporter_options instead. + :param cluster_category: (Deprecated) Environment / cluster category (e.g. "prod"). + Use exporter_options instead. + :param exporter_options: Agent365ExporterOptions instance for configuring the exporter. + If provided, exporter_options takes precedence. If exporter_options is None, the token_resolver and cluster_category parameters are used as fallback/legacy support to construct a default Agent365ExporterOptions instance. :return: True if configuration succeeded, False otherwise. """ return _telemetry_manager.configure( @@ -213,6 +243,7 @@ def configure( logger_name, token_resolver, cluster_category, + exporter_options, **kwargs, ) 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 eda9442b..823bc4c6 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 @@ -92,13 +92,13 @@ GEN_AI_AGENT_BLUEPRINT_ID_KEY = "gen_ai.agent.applicationid" CORRELATION_ID_KEY = "correlation.id" HIRING_MANAGER_ID_KEY = "hiring.manager.id" +SESSION_DESCRIPTION_KEY = "session.description" # Execution context dimensions GEN_AI_EXECUTION_TYPE_KEY = "gen_ai.execution.type" GEN_AI_EXECUTION_PAYLOAD_KEY = "gen_ai.execution.payload" # Source metadata dimensions -GEN_AI_EXECUTION_SOURCE_ID_KEY = "gen_ai.execution.sourceMetadata.id" GEN_AI_EXECUTION_SOURCE_NAME_KEY = "gen_ai.channel.name" GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY = "gen_ai.channel.link" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter_options.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter_options.py new file mode 100644 index 00000000..894f017d --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter_options.py @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Awaitable, Callable, Optional + + +class Agent365ExporterOptions: + """ + Configuration for Agent365Exporter. + Only cluster_category and token_resolver are required for core operation. + """ + + def __init__( + self, + cluster_category: str = "prod", + token_resolver: Optional[Callable[[str, str], Awaitable[Optional[str]]]] = None, + use_s2s_endpoint: bool = False, + max_queue_size: int = 2048, + scheduled_delay_ms: int = 5000, + exporter_timeout_ms: int = 30000, + max_export_batch_size: int = 512, + ): + """ + Args: + cluster_category: Cluster region argument. Defaults to 'prod'. + token_resolver: Async callable that resolves the auth token (REQUIRED). + use_s2s_endpoint: Use the S2S endpoint instead of standard endpoint. + max_queue_size: Maximum queue size for the batch processor. Default is 2048. + scheduled_delay_ms: Delay between export batches (ms). Default is 5000. + exporter_timeout_ms: Timeout for the export operation (ms). Default is 30000. + max_export_batch_size: Maximum batch size for export operations. Default is 512. + """ + self.cluster_category = cluster_category + self.token_resolver = token_resolver + self.use_s2s_endpoint = use_s2s_endpoint + self.max_queue_size = max_queue_size + self.scheduled_delay_ms = scheduled_delay_ms + self.exporter_timeout_ms = exporter_timeout_ms + self.max_export_batch_size = max_export_batch_size 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 63281159..b0d6fa2b 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 @@ -17,7 +17,6 @@ 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, @@ -111,7 +110,6 @@ def __init__( # Set request metadata if provided if request: 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 @@ -121,6 +119,7 @@ def __init__( GEN_AI_EXECUTION_TYPE_KEY, request.execution_type.value if request.execution_type else None, ) + self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, safe_json_dumps([request.content])) # Set caller details tags if caller_details: diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py index ba08a5f0..b321306d 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py @@ -20,12 +20,15 @@ GEN_AI_CONVERSATION_ID_KEY, GEN_AI_CONVERSATION_ITEM_LINK_KEY, GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, - GEN_AI_EXECUTION_SOURCE_ID_KEY, GEN_AI_EXECUTION_SOURCE_NAME_KEY, HIRING_MANAGER_ID_KEY, OPERATION_SOURCE_KEY, + SESSION_DESCRIPTION_KEY, + SESSION_ID_KEY, TENANT_ID_KEY, ) +from ..models.operation_source import OperationSource +from ..utils import deprecated from .turn_context_baggage import from_turn_context @@ -50,16 +53,18 @@ def __init__(self): """Initialize the baggage builder.""" self._pairs: dict[str, str] = {} - def operation_source(self, value: str | None) -> "BaggageBuilder": + def operation_source(self, value: OperationSource | None) -> "BaggageBuilder": """Set the operation source baggage value. Args: - value: The operation source value + value: The operation source enum value Returns: Self for method chaining """ - self._set(OPERATION_SOURCE_KEY, value) + # Convert enum to string value for baggage storage + str_value = value.value if value is not None else None + self._set(OPERATION_SOURCE_KEY, str_value) return self def tenant_id(self, value: str | None) -> "BaggageBuilder": @@ -188,18 +193,38 @@ def conversation_item_link(self, value: str | None) -> "BaggageBuilder": self._set(GEN_AI_CONVERSATION_ITEM_LINK_KEY, value) return self + @deprecated("This is a no-op. Use channel_name() or channel_links() instead.") def source_metadata_id(self, value: str | None) -> "BaggageBuilder": """Set the execution source metadata ID (e.g., channel ID).""" - self._set(GEN_AI_EXECUTION_SOURCE_ID_KEY, value) return self + @deprecated("Use channel_name() instead") def source_metadata_name(self, value: str | None) -> "BaggageBuilder": """Set the execution source metadata name (e.g., channel name).""" - self._set(GEN_AI_EXECUTION_SOURCE_NAME_KEY, value) - return self + return self.channel_name(value) + @deprecated("Use channel_links() instead") def source_metadata_description(self, value: str | None) -> "BaggageBuilder": """Set the execution source metadata description (e.g., channel description).""" + return self.channel_links(value) + + def session_id(self, value: str | None) -> "BaggageBuilder": + """Set the session ID baggage value.""" + self._set(SESSION_ID_KEY, value) + return self + + def session_description(self, value: str | None) -> "BaggageBuilder": + """Set the session description baggage value.""" + self._set(SESSION_DESCRIPTION_KEY, value) + return self + + def channel_name(self, value: str | None) -> "BaggageBuilder": + """Sets the channel name baggage value (e.g., 'Teams', 'msteams').""" + self._set(GEN_AI_EXECUTION_SOURCE_NAME_KEY, value) + return self + + def channel_links(self, value: str | None) -> "BaggageBuilder": + """Sets the channel link baggage value. (e.g., channel links or description).""" self._set(GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, value) return self diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/turn_context_baggage.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/turn_context_baggage.py index 8f8cddc3..a0f528e5 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/turn_context_baggage.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/turn_context_baggage.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import Any, Iterable, Iterator, Mapping +from typing import Any, Iterator, Mapping from ..constants import ( GEN_AI_AGENT_AUID_KEY, @@ -17,7 +17,6 @@ GEN_AI_CONVERSATION_ID_KEY, GEN_AI_CONVERSATION_ITEM_LINK_KEY, GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, - GEN_AI_EXECUTION_SOURCE_ID_KEY, GEN_AI_EXECUTION_SOURCE_NAME_KEY, GEN_AI_EXECUTION_TYPE_KEY, TENANT_ID_KEY, @@ -26,9 +25,6 @@ AGENT_ROLE = "agenticUser" CHANNEL_ID_AGENTS = "agents" -ENTITY_TYPE_WPX_COMMENT = "wpxcomment" -ENTITY_TYPE_EMAIL_NOTIFICATION = "emailNotification" -WPX_CONVERSATION_ID_FORMAT = "{document_id}_{parent_comment_id}" def _safe_get(obj: Any, *names: str) -> Any: @@ -135,37 +131,40 @@ def _iter_tenant_id_pair(activity: Any) -> Iterator[tuple[str, Any]]: def _iter_source_metadata_pairs(activity: Any) -> Iterator[tuple[str, Any]]: + """ + Generate source metadata pairs from activity, handling both string and ChannelId object cases. + + :param activity: The activity object (Activity instance or dict) + :return: Iterator of (key, value) tuples for source metadata + """ + # Handle channel_id (can be string or ChannelId object) channel_id = _safe_get(activity, "channel_id") - yield GEN_AI_EXECUTION_SOURCE_ID_KEY, channel_id - yield GEN_AI_EXECUTION_SOURCE_NAME_KEY, channel_id - yield GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, _safe_get(activity, "type", "Type") + + # Extract channel name from either string or ChannelId object + channel_name = None + sub_channel = None + + if channel_id is not None: + if isinstance(channel_id, str): + # Direct string value + channel_name = channel_id + elif hasattr(channel_id, "channel"): + # ChannelId object + channel_name = channel_id.channel + sub_channel = getattr(channel_id, "sub_channel", None) + elif isinstance(channel_id, dict): + # Serialized ChannelId as dict + channel_name = channel_id.get("channel") + sub_channel = channel_id.get("sub_channel") + + # Yield channel name as source name + yield GEN_AI_EXECUTION_SOURCE_NAME_KEY, channel_name + yield GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, sub_channel def _iter_conversation_pairs(activity: Any) -> Iterator[tuple[str, Any]]: - channel_id = _safe_get(activity, "channel_id") - entities = _safe_get(activity, "entities") or [] - conversation_id = None - - if channel_id == CHANNEL_ID_AGENTS and isinstance(entities, Iterable): - # search entities for wpxcomment or emailNotification - for e in entities: - etype = _safe_get(e, "type", "Type") - if etype == ENTITY_TYPE_WPX_COMMENT: - document_id = _safe_get(e, "documentId", "document_id") - parent_comment_id = _safe_get(e, "parentCommentId", "parent_comment_id") - if document_id and parent_comment_id: - conversation_id = WPX_CONVERSATION_ID_FORMAT.format( - document_id=document_id, - parent_comment_id=parent_comment_id, - ) - break - elif etype == ENTITY_TYPE_EMAIL_NOTIFICATION: - conversation_id = _safe_get(e, "conversationId", "conversation_id") - if conversation_id: - break - if not conversation_id: - conv = _safe_get(activity, "conversation") - conversation_id = _safe_get(conv, "id", "Id") + conv = _safe_get(activity, "conversation") + conversation_id = _safe_get(conv, "id") item_link = _safe_get(activity, "service_url") diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/operation_source.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/operation_source.py new file mode 100644 index 00000000..2395879b --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/operation_source.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Operation source enumeration for Agent365 SDK.""" + +from enum import Enum + + +class OperationSource(Enum): + """ + Enumeration representing the source of an operation. + """ + + SDK = "SDK" + """Operation executed by SDK.""" + + GATEWAY = "Gateway" + """Operation executed by Gateway.""" + + MCP_SERVER = "MCPServer" + """Operation executed by MCP Server.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/request.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/request.py index 418b7705..3e292641 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/request.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/request.py @@ -16,4 +16,3 @@ class Request: execution_type: ExecutionType session_id: str | None = None source_metadata: SourceMetadata | None = None - payload: str | None = None diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/span_processor.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/span_processor.py index aa44af6e..237be264 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/span_processor.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/span_processor.py @@ -16,7 +16,8 @@ from opentelemetry import baggage, context from opentelemetry.sdk.trace import SpanProcessor as BaseSpanProcessor -from ..constants import GEN_AI_OPERATION_NAME_KEY, INVOKE_AGENT_OPERATION_NAME +from ..constants import GEN_AI_OPERATION_NAME_KEY, INVOKE_AGENT_OPERATION_NAME, OPERATION_SOURCE_KEY +from ..models.operation_source import OperationSource from .util import COMMON_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES @@ -41,6 +42,14 @@ def on_start(self, span, parent_context=None): except Exception: baggage_map = {} + # Set operation source - coalesce baggage value with SDK default + if OPERATION_SOURCE_KEY not in existing: + operation_source = baggage_map.get(OPERATION_SOURCE_KEY) or OperationSource.SDK.value + try: + span.set_attribute(OPERATION_SOURCE_KEY, operation_source) + except Exception: + pass + operation_name = existing.get(GEN_AI_OPERATION_NAME_KEY) is_invoke_agent = False if operation_name == INVOKE_AGENT_OPERATION_NAME: 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 524413ab..f5283237 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 @@ -19,6 +19,10 @@ consts.GEN_AI_AGENT_BLUEPRINT_ID_KEY, # gen_ai.agent.applicationid consts.GEN_AI_AGENT_AUID_KEY, consts.GEN_AI_AGENT_TYPE_KEY, + consts.OPERATION_SOURCE_KEY, # operation.source + consts.SESSION_ID_KEY, + consts.SESSION_DESCRIPTION_KEY, + consts.HIRING_MANAGER_ID_KEY, ] # Invoke Agent–specific attributes @@ -38,7 +42,6 @@ consts.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, # gen_ai.caller.agent.applicationid # Execution context consts.GEN_AI_EXECUTION_TYPE_KEY, # gen_ai.execution.type - consts.GEN_AI_EXECUTION_SOURCE_ID_KEY, # gen_ai.execution.sourceMetadata.id consts.GEN_AI_EXECUTION_SOURCE_NAME_KEY, # gen_ai.execution.sourceMetadata.name consts.GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, # gen_ai.execution.sourceMetadata.description ] diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py index 7237242d..b1461b3f 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py @@ -1,15 +1,21 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import datetime +import functools import json import logging import traceback +import warnings from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping from enum import Enum from threading import RLock from typing import Any, Generic, TypeVar, cast -from opentelemetry.semconv.trace import SpanAttributes as OTELSpanAttributes +from opentelemetry.semconv.attributes.exception_attributes import ( + EXCEPTION_MESSAGE, + EXCEPTION_STACKTRACE, +) from opentelemetry.trace import Span from opentelemetry.util.types import AttributeValue from wrapt import ObjectProxy @@ -69,10 +75,10 @@ def record_exception(span: Span, error: BaseException) -> None: exception_message = repr(error) attributes: dict[str, AttributeValue] = { ERROR_TYPE_KEY: exception_type, - OTELSpanAttributes.EXCEPTION_MESSAGE: exception_message, + EXCEPTION_MESSAGE: exception_message, } try: - attributes[OTELSpanAttributes.EXCEPTION_STACKTRACE] = traceback.format_exc() + attributes[EXCEPTION_STACKTRACE] = traceback.format_exc() except Exception: logger.exception("Failed to record exception stacktrace.") span.add_event(name="exception", attributes=attributes) @@ -149,3 +155,21 @@ def extract_model_name(span_name: str) -> str | None: return model_name.strip() return None + + +def deprecated(reason: str): + """Decorator to mark functions as deprecated.""" + + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + warnings.warn( + f"{func.__name__}() is deprecated. {reason}", + category=DeprecationWarning, + stacklevel=2, + ) + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/tests/observability/core/test_agent365.py b/tests/observability/core/test_agent365.py index 02bbb32a..2ebde6ad 100644 --- a/tests/observability/core/test_agent365.py +++ b/tests/observability/core/test_agent365.py @@ -1,36 +1,117 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +import unittest +from unittest.mock import Mock, patch from microsoft_agents_a365.observability.core import configure +from microsoft_agents_a365.observability.core.exporters.agent365_exporter_options import ( + Agent365ExporterOptions, +) from microsoft_agents_a365.observability.core.trace_processor import SpanProcessor -def test_basic_functionality(): - """Test basic Microsoft Agent 365 SDK functionality""" - print("Testing Microsoft Agent 365 SDK...") +class TestAgent365Configure(unittest.TestCase): + """Test suite for Agent365 configuration functionality.""" - # Test configure function - try: - configure( + def setUp(self): + """Set up test fixtures.""" + self.mock_token_resolver = Mock() + self.mock_token_resolver.return_value = "test_token_123" + + def test_configure_basic_functionality(self): + """Test configure function with basic parameters and legacy parameters.""" + # Test basic configuration without exporter_options + result = configure( + service_name="test-service", + service_namespace="test-namespace", + ) + self.assertTrue(result, "configure() should return True") + + # Test configuration with legacy parameters + result = configure( + service_name="test-service", + service_namespace="test-namespace", + token_resolver=self.mock_token_resolver, + cluster_category="test", + ) + self.assertTrue(result, "configure() should return True with legacy parameters") + + @patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled") + def test_configure_with_exporter_options_and_parameter_precedence(self, mock_is_enabled): + """Test configure function with exporter_options and verify parameter precedence.""" + # Enable Agent365 exporter for this test + mock_is_enabled.return_value = True + + # Test 1: Basic exporter_options functionality + exporter_options = Agent365ExporterOptions( + cluster_category="dev", + token_resolver=self.mock_token_resolver, + use_s2s_endpoint=True, + max_queue_size=1024, + scheduled_delay_ms=2500, + exporter_timeout_ms=15000, + max_export_batch_size=256, + ) + + result = configure( service_name="test-service", service_namespace="test-namespace", + exporter_options=exporter_options, + ) + self.assertTrue(result, "configure() should return True with exporter_options") + + @patch("microsoft_agents_a365.observability.core.config.Agent365Exporter") + @patch("microsoft_agents_a365.observability.core.config.BatchSpanProcessor") + @patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled") + def test_batch_span_processor_and_exporter_called_with_correct_values( + self, mock_is_enabled, mock_batch_processor, mock_exporter + ): + """Test that BatchSpanProcessor and Agent365Exporter are called with correct values from exporter_options.""" + # Enable Agent365 exporter for this test + mock_is_enabled.return_value = True + + # Create exporter options with specific values + exporter_options = Agent365ExporterOptions( + cluster_category="staging", + token_resolver=self.mock_token_resolver, + use_s2s_endpoint=True, + max_queue_size=512, + scheduled_delay_ms=1000, + exporter_timeout_ms=10000, + max_export_batch_size=128, + ) + + # Configure with exporter_options + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=exporter_options, + ) + + # Verify configuration succeeded + self.assertTrue(result, "configure() should return True") + + # Verify Agent365Exporter was called with correct parameters + mock_exporter.assert_called_once_with( + token_resolver=self.mock_token_resolver, + cluster_category="staging", + use_s2s_endpoint=True, ) - print("✅ configure() executed successfully") - except Exception as e: - print(f"❌ configure() failed: {e}") - return False - # Test SpanProcessor class - try: - SpanProcessor() - print("✅ SpanProcessor created successfully") - except Exception as e: - print(f"❌ SpanProcessor creation failed: {e}") - return False + # Verify BatchSpanProcessor was called with correct parameters from exporter_options + mock_batch_processor.assert_called_once() + call_args = mock_batch_processor.call_args + self.assertEqual(call_args.kwargs["max_queue_size"], 512) + self.assertEqual(call_args.kwargs["schedule_delay_millis"], 1000) + self.assertEqual(call_args.kwargs["export_timeout_millis"], 10000) + self.assertEqual(call_args.kwargs["max_export_batch_size"], 128) - print("✅ All tests passed!") - return True + def test_span_processor_creation(self): + """Test SpanProcessor class creation.""" + processor = SpanProcessor() + self.assertIsNotNone(processor, "SpanProcessor should be created successfully") if __name__ == "__main__": - test_basic_functionality() + unittest.main() diff --git a/tests/observability/core/test_alignment.py b/tests/observability/core/test_alignment.py deleted file mode 100644 index 5dc45f8a..00000000 --- a/tests/observability/core/test_alignment.py +++ /dev/null @@ -1,200 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - - -import os -import sys -from urllib.parse import urlparse - - -def test_new_classes(): - """Test the new classes align with .NET SDK.""" - from microsoft_agents_a365.observability.core import ( - AgentDetails, - ExecutionType, - InvokeAgentDetails, - Request, - SourceMetadata, - TenantDetails, - ) - - print("✅ Testing new classes...") - - # Test TenantDetails - tenant_details = TenantDetails(tenant_id="12345678-1234-5678-1234-567812345678") - assert tenant_details.tenant_id == "12345678-1234-5678-1234-567812345678" - print(" ✅ TenantDetails works") - - # Test AgentDetails with icon_uri - agent_details = AgentDetails( - agent_id="test-agent-123", - agent_name="Test Agent", - agent_description="A test agent", - conversation_id="conv-456", - icon_uri="https://example.com/icon.png", - ) - assert agent_details.agent_id == "test-agent-123" - assert agent_details.icon_uri == "https://example.com/icon.png" - print(" ✅ AgentDetails with icon_uri works") - - # Test SourceMetadata - source_metadata = SourceMetadata( - id="source-123", - name="Source Agent", - icon_uri="https://example.com/source-icon.png", - description="Source agent description", - ) - assert source_metadata.id == "source-123" - print(" ✅ SourceMetadata works") - - # Test ExecutionType - exec_type = ExecutionType.AGENT_TO_AGENT - assert exec_type.value == "Agent2Agent" - print(" ✅ ExecutionType works") - - # Test Request - request = Request( - content="Test request content", - execution_type=ExecutionType.EVENT_TO_AGENT, - session_id="session-789", - source_metadata=source_metadata, - payload="Test payload", - ) - assert request.content == "Test request content" - assert request.session_id == "session-789" - print(" ✅ Request works") - - # Test InvokeAgentDetails with session_id - invoke_details = InvokeAgentDetails( - endpoint=urlparse("https://example.com:8080/agent"), - details=agent_details, - session_id="session-789", - ) - assert invoke_details.session_id == "session-789" - assert invoke_details.details.agent_id == "test-agent-123" - print(" ✅ InvokeAgentDetails with session_id works") - - -def test_scope_functionality(): - """Test that scopes work with new parameters.""" - - os.environ["ENABLE_OBSERVABILITY"] = "true" - - from urllib.parse import urlparse - - from microsoft_agents_a365.observability.core import ( - AgentDetails, - ExecutionType, - InvokeAgentDetails, - InvokeAgentScope, - Request, - SourceMetadata, - TenantDetails, - ) - - print("✅ Testing scope functionality...") - - # Create tenant details - tenant_details = TenantDetails(tenant_id="12345678-1234-5678-1234-567812345678") - - # Test scope functionality with Request - source_metadata = SourceMetadata( - id="source-agent-456", - name="Source Agent", - icon_uri="https://example.com/source.png", - description="Source agent description", - ) - - request = Request( - content="Execute agent request", - execution_type=ExecutionType.AGENT_TO_AGENT, - session_id="session-123", - source_metadata=source_metadata, - payload="Agent execution payload", - ) - - # Test InvokeAgentScope with enhanced details - agent_details = AgentDetails( - agent_id="target-agent-789", - agent_name="Target Agent", - agent_description="Agent being invoked", - conversation_id="conv-123", - icon_uri="https://example.com/target.png", - ) - - invoke_details = InvokeAgentDetails( - endpoint=urlparse("https://example.com:8080/agent"), - details=agent_details, - session_id="session-456", - ) - - with InvokeAgentScope.start(invoke_details, tenant_details, request) as scope: - assert scope is not None, "Scope should be created when telemetry is enabled" - print(" ✅ InvokeAgentScope with enhanced details works") - - -def test_constants_alignment(): - """Test that constants are aligned with .NET SDK.""" - from microsoft_agents_a365.observability.core.constants import ( - AGENT_ID_KEY, - GEN_AI_EXECUTION_PAYLOAD_KEY, - GEN_AI_EXECUTION_TYPE_KEY, - GEN_AI_ICON_URI_KEY, - SESSION_ID_KEY, - ) - - print("✅ Testing constants alignment...") - - # Key change: agent.id -> gen_ai.agent.id - assert AGENT_ID_KEY == "gen_ai.agent.id", f"Expected 'gen_ai.agent.id', got '{AGENT_ID_KEY}'" - print(" ✅ AGENT_ID_KEY is correctly aligned with .NET SDK") - - # New constants from .NET SDK - assert SESSION_ID_KEY == "session.id" - assert GEN_AI_ICON_URI_KEY == "gen_ai.agent365.icon_uri" - assert GEN_AI_EXECUTION_TYPE_KEY == "gen_ai.execution.type" - assert GEN_AI_EXECUTION_PAYLOAD_KEY == "gen_ai.execution.payload" - print(" ✅ New constants match .NET SDK") - - -def test_span_processor(): - """Test that span processor works with updated constants.""" - from microsoft_agents_a365.observability.core import SpanProcessor - - print("✅ Testing span processor...") - - # Create span processor - processor = SpanProcessor() - assert processor is not None - print(" ✅ SpanProcessor created successfully") - - -def main(): - """Run all tests.""" - print("🔍 Testing Microsoft Agent 365 Python SDK alignment with .NET SDK...\n") - - try: - test_constants_alignment() - print() - - test_new_classes() - print() - - test_scope_functionality() - print() - - test_span_processor() - print() - - print("🎉 All tests passed! Python SDK is aligned with .NET SDK.") - return 0 - - except Exception as e: - print(f"❌ Test failed: {e}") - import traceback - - traceback.print_exc() - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/observability/core/test_baggage_builder.py b/tests/observability/core/test_baggage_builder.py index 274232bb..df0bbe9b 100644 --- a/tests/observability/core/test_baggage_builder.py +++ b/tests/observability/core/test_baggage_builder.py @@ -11,11 +11,16 @@ GEN_AI_AGENT_ID_KEY, GEN_AI_AGENT_UPN_KEY, GEN_AI_CALLER_ID_KEY, + GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, + GEN_AI_EXECUTION_SOURCE_NAME_KEY, HIRING_MANAGER_ID_KEY, OPERATION_SOURCE_KEY, + SESSION_DESCRIPTION_KEY, + SESSION_ID_KEY, TENANT_ID_KEY, ) from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder +from microsoft_agents_a365.observability.core.models.operation_source import OperationSource from opentelemetry import baggage, context, trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor @@ -48,6 +53,9 @@ def setUp(self): # Clear any existing context/baggage before each test context.detach(context.attach({})) + # Create a fresh BaggageBuilder for each test + self.builder = BaggageBuilder() + def tearDown(self): """Clean up after each test.""" # Clear context @@ -78,7 +86,7 @@ def test_all_baggage_keys(self): """Test all baggage key setter methods.""" with ( BaggageBuilder() - .operation_source("sdk") + .operation_source(OperationSource.SDK) .tenant_id("tenant-1") .agent_id("agent-1") .agent_auid("auid-1") @@ -90,7 +98,7 @@ def test_all_baggage_keys(self): .build() ): current_baggage = baggage.get_all() - self.assertEqual(current_baggage.get(OPERATION_SOURCE_KEY), "sdk") + self.assertEqual(current_baggage.get(OPERATION_SOURCE_KEY), OperationSource.SDK.value) self.assertEqual(current_baggage.get(TENANT_ID_KEY), "tenant-1") self.assertEqual(current_baggage.get(GEN_AI_AGENT_ID_KEY), "agent-1") self.assertEqual(current_baggage.get(GEN_AI_AGENT_AUID_KEY), "auid-1") @@ -152,7 +160,7 @@ def test_baggage_reset_after_scope_exit(self): # Use BaggageBuilder to set all possible values with ( BaggageBuilder() - .operation_source("test_sdk") + .operation_source(OperationSource.SDK) .tenant_id("test-tenant") .agent_id("test-agent") .agent_auid("test-auid") @@ -165,7 +173,7 @@ def test_baggage_reset_after_scope_exit(self): ): # Inside scope - verify all baggage values are set scoped_baggage = baggage.get_all() - self.assertEqual(scoped_baggage.get(OPERATION_SOURCE_KEY), "test_sdk") + self.assertEqual(scoped_baggage.get(OPERATION_SOURCE_KEY), OperationSource.SDK.value) self.assertEqual(scoped_baggage.get(TENANT_ID_KEY), "test-tenant") self.assertEqual(scoped_baggage.get(GEN_AI_AGENT_ID_KEY), "test-agent") self.assertEqual(scoped_baggage.get(GEN_AI_AGENT_AUID_KEY), "test-auid") @@ -210,7 +218,7 @@ def test_set_pairs_accepts_dict_and_iterable(self): # Also verify that None / whitespace values are ignored dict_pairs_with_ignored = { - OPERATION_SOURCE_KEY: "sdk", + OPERATION_SOURCE_KEY: OperationSource.SDK.value, GEN_AI_CALLER_ID_KEY: None, # ignored } iter_pairs_with_ignored = [ @@ -231,7 +239,7 @@ def test_set_pairs_accepts_dict_and_iterable(self): self.assertEqual(baggage_contents.get(CORRELATION_ID_KEY), "corr-x") self.assertEqual(baggage_contents.get(GEN_AI_AGENT_AUID_KEY), "auid-x") self.assertEqual(baggage_contents.get(GEN_AI_AGENT_UPN_KEY), "upn-x") - self.assertEqual(baggage_contents.get(OPERATION_SOURCE_KEY), "sdk") + self.assertEqual(baggage_contents.get(OPERATION_SOURCE_KEY), OperationSource.SDK.value) # Ignored values should not be present self.assertIsNone(baggage_contents.get(GEN_AI_CALLER_ID_KEY)) self.assertIsNone(baggage_contents.get(HIRING_MANAGER_ID_KEY)) @@ -253,7 +261,7 @@ def fake_from_turn_context(turn_ctx: any): GEN_AI_AGENT_ID_KEY: "agent-ctx", CORRELATION_ID_KEY: " ", # will be ignored GEN_AI_AGENT_UPN_KEY: None, # will be ignored - OPERATION_SOURCE_KEY: "sdk-ctx", + OPERATION_SOURCE_KEY: OperationSource.SDK.value, } try: @@ -270,7 +278,9 @@ def fake_from_turn_context(turn_ctx: any): # Values from turn_context self.assertEqual(baggage_contents.get(TENANT_ID_KEY), "tenant-ctx") self.assertEqual(baggage_contents.get(GEN_AI_AGENT_ID_KEY), "agent-ctx") - self.assertEqual(baggage_contents.get(OPERATION_SOURCE_KEY), "sdk-ctx") + self.assertEqual( + baggage_contents.get(OPERATION_SOURCE_KEY), OperationSource.SDK.value + ) # Pre-existing (non-overlapping) still present self.assertEqual(baggage_contents.get(GEN_AI_AGENT_AUID_KEY), "auid-pre") # Ignored values should not be present @@ -280,6 +290,79 @@ def fake_from_turn_context(turn_ctx: any): # Restore original tempBaggageBuilder.from_turn_context = original_fn + def test_source_metadata_name_method(self): + """Test deprecated source_metadata_name method - should delegate to channel_name.""" + # Should exist and be callable + self.assertTrue(hasattr(self.builder, "source_metadata_name")) + self.assertTrue(callable(self.builder.source_metadata_name)) + + # Should set channel name baggage through delegation + with self.builder.source_metadata_name("test-channel").build(): + current_baggage = baggage.get_all() + self.assertEqual(current_baggage.get(GEN_AI_EXECUTION_SOURCE_NAME_KEY), "test-channel") + + def test_source_metadata_description_method(self): + """Test deprecated source_metadata_description method - should delegate to channel_links.""" + # Should exist and be callable + self.assertTrue(hasattr(self.builder, "source_metadata_description")) + self.assertTrue(callable(self.builder.source_metadata_description)) + + # Should set channel description baggage through delegation + with self.builder.source_metadata_description("test-description").build(): + current_baggage = baggage.get_all() + self.assertEqual( + current_baggage.get(GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY), "test-description" + ) + + def test_session_id_method(self): + """Test session_id method sets session ID baggage.""" + # Should exist and be callable + self.assertTrue(hasattr(self.builder, "session_id")) + self.assertTrue(callable(self.builder.session_id)) + + # Should set session ID baggage + with self.builder.session_id("test-session-123").build(): + current_baggage = baggage.get_all() + self.assertEqual(current_baggage.get(SESSION_ID_KEY), "test-session-123") + + def test_session_description_method(self): + """Test session_description method sets session description baggage.""" + # Should exist and be callable + self.assertTrue(hasattr(self.builder, "session_description")) + self.assertTrue(callable(self.builder.session_description)) + + # Should set session description baggage + with self.builder.session_description("test session description").build(): + current_baggage = baggage.get_all() + self.assertEqual( + current_baggage.get(SESSION_DESCRIPTION_KEY), "test session description" + ) + + def test_channel_name_method(self): + """Test channel_name method sets channel name baggage.""" + # Should exist and be callable + self.assertTrue(hasattr(self.builder, "channel_name")) + self.assertTrue(callable(self.builder.channel_name)) + + # Should set channel name baggage + with self.builder.channel_name("Teams Channel").build(): + current_baggage = baggage.get_all() + self.assertEqual(current_baggage.get(GEN_AI_EXECUTION_SOURCE_NAME_KEY), "Teams Channel") + + def test_channel_links_method(self): + """Test channel_links method sets channel description baggage.""" + # Should exist and be callable + self.assertTrue(hasattr(self.builder, "channel_links")) + self.assertTrue(callable(self.builder.channel_links)) + + # Should set channel description baggage + with self.builder.channel_links("https://teams.microsoft.com/channel/123").build(): + current_baggage = baggage.get_all() + self.assertEqual( + current_baggage.get(GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY), + "https://teams.microsoft.com/channel/123", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/observability/core/test_invoke_agent_scope.py b/tests/observability/core/test_invoke_agent_scope.py index ac43a9d9..14fbda96 100644 --- a/tests/observability/core/test_invoke_agent_scope.py +++ b/tests/observability/core/test_invoke_agent_scope.py @@ -6,11 +6,25 @@ from microsoft_agents_a365.observability.core import ( AgentDetails, + ExecutionType, InvokeAgentDetails, InvokeAgentScope, + Request, + SourceMetadata, TenantDetails, configure, ) +from microsoft_agents_a365.observability.core.models.caller_details import CallerDetails +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +# Constants for span attribute keys +GEN_AI_EXECUTION_SOURCE_NAME_KEY = "gen_ai.channel.name" +GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY = "gen_ai.channel.link" +GEN_AI_EXECUTION_TYPE_KEY = "gen_ai.execution.type" +GEN_AI_INPUT_MESSAGES_KEY = "gen_ai.input.messages" class TestInvokeAgentScope(unittest.TestCase): @@ -37,6 +51,42 @@ def setUpClass(cls): session_id="session-123", ) + # Create source metadata for requests + cls.source_metadata = SourceMetadata( + id="source-agent-456", + name="Source Channel", + icon_uri="https://example.com/source-icon.png", + description="Source channel description", + ) + + # Create a comprehensive request object + cls.test_request = Request( + content="Process customer inquiry about order status", + execution_type=ExecutionType.AGENT_TO_AGENT, + session_id="session-abc123", + source_metadata=cls.source_metadata, + ) + + # Create caller details (non-agentic caller) + cls.caller_details = CallerDetails( + caller_id="user-123", + caller_upn="user@contoso.com", + caller_name="John Doe", + caller_user_id="user-id-456", + tenant_id="tenant-789", + ) + + # Create caller agent details (agentic caller) + cls.caller_agent_details = AgentDetails( + agent_id="caller-agent-789", + agent_name="Caller Agent", + agent_description="The agent that initiated this request", + agent_blueprint_id="blueprint-456", + agent_auid="auid-123", + agent_upn="agent@contoso.com", + tenant_id="tenant-789", + ) + def test_record_response_method_exists(self): """Test that record_response method exists on InvokeAgentScope.""" scope = InvokeAgentScope.start(self.invoke_details, self.tenant_details) @@ -67,6 +117,62 @@ def test_record_output_messages_method_exists(self): self.assertTrue(callable(scope.record_output_messages)) scope.dispose() + def test_request_attributes_set_on_span(self): + """Test that request parameters from mock data are available on span attributes.""" + # Set up tracer to capture spans + span_exporter = InMemorySpanExporter() + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + trace.set_tracer_provider(tracer_provider) + + # Create scope with request + scope = InvokeAgentScope.start( + invoke_agent_details=self.invoke_details, + tenant_details=self.tenant_details, + request=self.test_request, + ) + + if scope is not None: + scope.dispose() + + # Check if mock data parameters are available in span attributes + finished_spans = span_exporter.get_finished_spans() + + if finished_spans: + # Get attributes from the span + span = finished_spans[-1] + span_attributes = getattr(span, "attributes", {}) or {} + + # Verify mock data request parameters are in span attributes + # Check source channel name from mock data + if GEN_AI_EXECUTION_SOURCE_NAME_KEY in span_attributes: + self.assertEqual( + span_attributes[GEN_AI_EXECUTION_SOURCE_NAME_KEY], + self.source_metadata.name, # From cls.source_metadata.name + ) + + # Check source channel description from mock data + if GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY in span_attributes: + self.assertEqual( + span_attributes[GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY], + self.source_metadata.description, # From cls.source_metadata.description + ) + + # Check execution type from mock data + if GEN_AI_EXECUTION_TYPE_KEY in span_attributes: + self.assertEqual( + span_attributes[GEN_AI_EXECUTION_TYPE_KEY], + self.test_request.execution_type.value, # From cls.test_request.execution_type + ) + + # Check input messages contain request content from mock data + if GEN_AI_INPUT_MESSAGES_KEY in span_attributes: + input_messages = span_attributes[GEN_AI_INPUT_MESSAGES_KEY] + self.assertIn( + self.test_request.content, # From cls.test_request.content + input_messages, + ) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/observability/core/test_span_processor.py b/tests/observability/core/test_span_processor.py index 088edf79..a10d52f9 100644 --- a/tests/observability/core/test_span_processor.py +++ b/tests/observability/core/test_span_processor.py @@ -4,9 +4,11 @@ import unittest from unittest.mock import MagicMock -from opentelemetry import context - +from microsoft_agents_a365.observability.core.constants import OPERATION_SOURCE_KEY +from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder +from microsoft_agents_a365.observability.core.models.operation_source import OperationSource from microsoft_agents_a365.observability.core.trace_processor.span_processor import SpanProcessor +from opentelemetry import context class TestSpanProcessor(unittest.TestCase): @@ -18,17 +20,37 @@ def setUp(self): self.mock_span = MagicMock() self.mock_context = None # Root span - def test_on_start_with_no_baggage(self): - # Call on_start with no baggage, should not set agent_id since there's none + def test_operation_source_defaults_to_sdk(self): + """Test that operation source is set to SDK by default when not in baggage.""" + # Mock span with no existing attributes + self.mock_span.attributes = {} + + # Call on_start with no baggage self.processor.on_start(self.mock_span, self.mock_context) - # Should not call set_attribute since there's no agent_id in baggage or config - self.mock_span.set_attribute.assert_not_called() - print("✅ Span on_start(Span, Parent) with no baggage testing passed!") + + # Verify SDK was set as default operation source + self.mock_span.set_attribute.assert_called_with( + OPERATION_SOURCE_KEY, OperationSource.SDK.value + ) + + def test_operation_source_honors_baggage_value(self): + """Test that operation source from baggage is used when available.""" + # Mock span with no existing attributes + self.mock_span.attributes = {} + + # Set operation source in baggage using BaggageBuilder + with BaggageBuilder().operation_source(OperationSource.GATEWAY).build(): + # Call on_start - should use baggage value + self.processor.on_start(self.mock_span, context.get_current()) + + # Verify GATEWAY was used from baggage + self.mock_span.set_attribute.assert_called_with( + OPERATION_SOURCE_KEY, OperationSource.GATEWAY.value + ) def test_on_end_calls_super(self): try: self.processor.on_end(self.mock_span) - print("✅ Span on_end(ReadableSpan) testing passed!") except Exception as e: self.fail(f"on_end raised an exception: {e}") diff --git a/tests/observability/core/test_turn_context_baggage.py b/tests/observability/core/test_turn_context_baggage.py index b1e7c79a..1fef1dce 100644 --- a/tests/observability/core/test_turn_context_baggage.py +++ b/tests/observability/core/test_turn_context_baggage.py @@ -13,7 +13,6 @@ GEN_AI_CONVERSATION_ID_KEY, GEN_AI_CONVERSATION_ITEM_LINK_KEY, GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, - GEN_AI_EXECUTION_SOURCE_ID_KEY, GEN_AI_EXECUTION_SOURCE_NAME_KEY, GEN_AI_EXECUTION_TYPE_KEY, TENANT_ID_KEY, @@ -117,9 +116,8 @@ def test_iter_source_metadata_pairs(self): "type": "message", }) pairs = dict(tcb._iter_source_metadata_pairs(activity)) - self.assertEqual(pairs[GEN_AI_EXECUTION_SOURCE_ID_KEY], "msteams") self.assertEqual(pairs[GEN_AI_EXECUTION_SOURCE_NAME_KEY], "msteams") - self.assertEqual(pairs[GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY], "message") + self.assertIsNone(pairs.get(GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY)) def test_iter_conversation_pairs_wpxcomment(self): activity = FakeActivity(**{ @@ -134,8 +132,7 @@ def test_iter_conversation_pairs_wpxcomment(self): "service_url": "https://service/link", }) pairs = dict(tcb._iter_conversation_pairs(activity)) - # Expect composed id doc-100_parent-200 - self.assertEqual(pairs[GEN_AI_CONVERSATION_ID_KEY], "doc-100_parent-200") + self.assertIsNone(pairs.get(GEN_AI_CONVERSATION_ID_KEY)) self.assertEqual(pairs[GEN_AI_CONVERSATION_ITEM_LINK_KEY], "https://service/link") def test_iter_conversation_pairs_email_notification(self): @@ -150,7 +147,7 @@ def test_iter_conversation_pairs_email_notification(self): "service_url": "http://service/url", }) pairs = dict(tcb._iter_conversation_pairs(activity)) - self.assertEqual(pairs[GEN_AI_CONVERSATION_ID_KEY], "email-conv-123") + self.assertIsNone(pairs.get(GEN_AI_CONVERSATION_ID_KEY)) self.assertEqual(pairs[GEN_AI_CONVERSATION_ITEM_LINK_KEY], "http://service/url") def test_iter_conversation_pairs_fallback_conversation(self): @@ -198,12 +195,11 @@ def test_from_turn_context_aggregates_all(self): # Execution type (agent-to-agent) self.assertEqual(result[GEN_AI_EXECUTION_TYPE_KEY], ExecutionType.HUMAN_TO_AGENT.value) # Conversation - self.assertEqual(result[GEN_AI_CONVERSATION_ID_KEY], "email-conv-123") + self.assertIsNone(result.get(GEN_AI_CONVERSATION_ID_KEY)) self.assertEqual(result[GEN_AI_CONVERSATION_ITEM_LINK_KEY], "svc-url") # Source metadata - self.assertEqual(result[GEN_AI_EXECUTION_SOURCE_ID_KEY], "agents") self.assertEqual(result[GEN_AI_EXECUTION_SOURCE_NAME_KEY], "agents") - self.assertEqual(result[GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY], "message") + self.assertIsNone(result.get(GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY)) def test_from_turn_context_missing_activity(self): ctx = FakeTurnContext(activity=None)