From bc56a7605b6768e0eca32a892dbdd145b1e1dec6 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 18 May 2026 07:40:32 -0700 Subject: [PATCH 1/6] Adding proactive instrumentation --- .../hosting/core/app/proactive/proactive.py | 99 ++++++++------ .../core/app/proactive/telemetry/__init__.py | 0 .../core/app/proactive/telemetry/constants.py | 9 ++ .../core/app/proactive/telemetry/spans.py | 129 ++++++++++++++++++ .../hosting/core/telemetry/attributes.py | 3 + 5 files changed, 196 insertions(+), 44 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/constants.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py index 68776d1b1..168b9e3f1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py @@ -16,6 +16,7 @@ from .conversation import Conversation from .create_conversation_options import CreateConversationOptions from .proactive_options import ProactiveOptions +from .telemetry import spans if TYPE_CHECKING: from microsoft_agents.hosting.core.turn_context import TurnContext @@ -95,7 +96,7 @@ def _storage_key(conversation_id: str) -> str: async def store_conversation( self, - context_or_conversation: "TurnContext | Conversation", + context_or_conversation: TurnContext | Conversation, ) -> None: """ Persist a :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` @@ -120,10 +121,11 @@ async def store_conversation( else: conversation = context_or_conversation - conversation.validate() - key = self._storage_key(conversation.conversation_reference.conversation.id) - logger.debug("Storing conversation with key: %s", key) - await self._storage.write({key: conversation}) + with spans.ProactiveStoreConversation(conversation.conversation_reference.conversation.id): + conversation.validate() + key = self._storage_key(conversation.conversation_reference.conversation.id) + logger.debug("Storing conversation with key: %s", key) + await self._storage.write({key: conversation}) async def get_conversation(self, conversation_id: str) -> Optional[Conversation]: """ @@ -136,9 +138,10 @@ async def get_conversation(self, conversation_id: str) -> Optional[Conversation] or ``None`` if not found. :rtype: Optional[:class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`] """ - key = self._storage_key(conversation_id) - results = await self._storage.read([key], target_cls=Conversation) - return results.get(key) + with spans.ProactiveGetConversation(conversation_id): + key = self._storage_key(conversation_id) + results = await self._storage.read([key], target_cls=Conversation) + return results.get(key) async def delete_conversation(self, conversation_id: str) -> None: """ @@ -147,9 +150,10 @@ async def delete_conversation(self, conversation_id: str) -> None: :param conversation_id: The conversation ID to delete. :type conversation_id: str """ - key = self._storage_key(conversation_id) - logger.debug("Deleting conversation with key: %s", key) - await self._storage.delete([key]) + with spans.ProactiveDeleteConversation(conversation_id): + key = self._storage_key(conversation_id) + logger.debug("Deleting conversation with key: %s", key) + await self._storage.delete([key]) # ------------------------------------------------------------------ # Send a single activity @@ -181,7 +185,9 @@ async def send_activity( conversation is not found in storage. """ conversation = await self._resolve_conversation(conversation_id_or_conversation) - return await Proactive._send_activity_impl(adapter, conversation, activity) + conversation_id = conversation.conversation_reference.conversation.id + with spans.ProactiveSendActivity(conversation_id, activity): + return await Proactive._send_activity_impl(adapter, conversation, activity) @staticmethod async def _send_activity_impl( @@ -252,6 +258,7 @@ async def continue_conversation( :attr:`~ProactiveOptions.fail_on_unsigned_in_connections` is ``True``. """ conversation = await self._resolve_conversation(conversation_id_or_conversation) + conversation_id = conversation.conversation_reference.conversation.id captured_exc: Optional[BaseException] = None claims = Conversation.identity_from_claims(conversation.claims) @@ -266,11 +273,13 @@ async def _callback(context: "TurnContext") -> None: await self._on_turn(context, handler, token_handlers) except Exception as exc: # noqa: BLE001 captured_exc = exc + + with spans.ProactiveContinueConversation(conversation_id, continuation): - await adapter.continue_conversation_with_claims(claims, continuation, _callback) + await adapter.continue_conversation_with_claims(claims, continuation, _callback) - if captured_exc is not None: - raise captured_exc + if captured_exc is not None: + raise captured_exc # ------------------------------------------------------------------ # Create a new conversation @@ -303,40 +312,42 @@ async def create_conversation( new_conversation: Optional[Conversation] = None captured_exc: Optional[BaseException] = None - audience = options.audience or options.identity.get_token_audience() - - async def _callback(context: "TurnContext") -> None: - nonlocal new_conversation, captured_exc - try: - reference = context.activity.get_conversation_reference() - new_conversation = Conversation( - claims=options.identity, - conversation_reference=reference, - ) + with spans.ProactiveCreateConversation(options): - if options.store_conversation: - await self.store_conversation(new_conversation) + audience = options.audience or options.identity.get_token_audience() - if handler is not None: - state = await self._load_state(context) - await handler(context, state) - await state.save(context) - except Exception as exc: # noqa: BLE001 - captured_exc = exc + async def _callback(context: "TurnContext") -> None: + nonlocal new_conversation, captured_exc + try: + reference = context.activity.get_conversation_reference() + new_conversation = Conversation( + claims=options.identity, + conversation_reference=reference, + ) - await adapter.create_conversation( - options.identity.get_app_id() or "", - options.channel_id, - options.service_url, - audience, - options.parameters, - _callback, - ) + if options.store_conversation: + await self.store_conversation(new_conversation) + + if handler is not None: + state = await self._load_state(context) + await handler(context, state) + await state.save(context) + except Exception as exc: # noqa: BLE001 + captured_exc = exc + + await adapter.create_conversation( + options.identity.get_app_id() or "", + options.channel_id, + options.service_url, + audience, + options.parameters, + _callback, + ) - if captured_exc is not None: - raise captured_exc + if captured_exc is not None: + raise captured_exc - return new_conversation + return new_conversation # ------------------------------------------------------------------ # Internal helpers diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/constants.py new file mode 100644 index 000000000..3840f775b --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/constants.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +SPAN_STORE_CONVERSATION = "agents.proactive.store_conversation" +SPAN_GET_CONVERSATION = "agents.proactive.get_conversation" +SPAN_DELETE_CONVERSATION = "agents.proactive.delete_conversation" +SPAN_SEND_ACTIVITY = "agents.proactive.send_activity" +SPAN_CONTINUE_CONVERSATION = "agents.proactive.continue_conversation" +SPAN_CREATE_CONVERSATION = "agents.proactive.create_conversation" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py new file mode 100644 index 000000000..9ea44e190 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py @@ -0,0 +1,129 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span +from microsoft_agents.activity import Activity +from microsoft_agents.hosting.core.telemetry import ( + AttributeMap, + attributes, + SimpleSpanWrapper, +) +from . import constants +from ..create_conversation_options import CreateConversationOptions + +class ProactiveStoreConversation(SimpleSpanWrapper): + """Span for storing a conversation reference in proactive scenarios, starting from when the store operation is initiated until it is completed. This span can be used to correlate telemetry related to storing conversation references in proactive scenarios.""" + + def __init__(self, conversation_id: str): + """Initializes the ProactiveStoreConversation SpanWrapper. + + :param conversation_id: The ID of the conversation being stored, used to extract attributes for the span + """ + super().__init__(constants.SPAN_STORE_CONVERSATION) + self._conversation_id = conversation_id + + def _get_attributes(self) -> AttributeMap: + return { + attributes.CONVERSATION_ID: self._conversation_id or attributes.UNKNOWN, + } + +class ProactiveGetConversation(SimpleSpanWrapper): + """Span for getting a conversation reference in proactive scenarios, starting from when the get operation is initiated until it is completed. This span can be used to correlate telemetry related to getting conversation references in proactive scenarios.""" + + def __init__(self, conversation_id: str): + """Initializes the ProactiveGetConversation SpanWrapper. + + :param conversation_id: The ID of the conversation being retrieved, used to extract attributes for the span + """ + super().__init__(constants.SPAN_GET_CONVERSATION) + self._conversation_id = conversation_id + self._found: bool | None = None + + def _get_attributes(self) -> AttributeMap: + return { + attributes.CONVERSATION_ID: self._conversation_id or attributes.UNKNOWN, + attributes.CONVERSATION_FOUND: self._found if self._found is not None else attributes.UNKNOWN, + } + + def share(self, found: bool) -> None: + """Records to the span whether the conversation being retrieved was found. + + :param found: Whether the conversation being retrieved was found + """ + self._found = found + +class ProactiveDeleteConversation(SimpleSpanWrapper): + """Span for deleting a conversation reference in proactive scenarios, starting from when the delete operation is initiated until it is completed. This span can be used to correlate telemetry related to deleting conversation references in proactive scenarios.""" + + def __init__(self, conversation_id: str): + """Initializes the ProactiveDeleteConversation SpanWrapper. + + :param conversation_id: The ID of the conversation being deleted, used to extract attributes for the span + """ + super().__init__(constants.SPAN_DELETE_CONVERSATION) + self._conversation_id = conversation_id + + def _get_attributes(self) -> AttributeMap: + return { + attributes.CONVERSATION_ID: self._conversation_id or attributes.UNKNOWN, + } + +class ProactiveSendActivity(SimpleSpanWrapper): + """Span for sending an activity in proactive scenarios, starting from when the send operation is initiated until it is completed. This span can be used to correlate telemetry related to sending activities in proactive scenarios.""" + + def __init__(self, conversation_id: str, activity: Activity): + """Initializes the ProactiveSendActivity SpanWrapper. + + :param conversation_id: The ID of the conversation the activity is being sent to, used to extract attributes for the span + :param activity: The activity being sent, used to extract attributes for the span + """ + super().__init__(constants.SPAN_SEND_ACTIVITY) + self._conversation_id = conversation_id + self._activity = activity + + def _get_attributes(self) -> AttributeMap: + return { + attributes.CONVERSATION_ID: self._conversation_id or attributes.UNKNOWN, + attributes.ACTIVITY_TYPE: self._activity.type or attributes.UNKNOWN, + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id or attributes.UNKNOWN, + } + +class ProactiveContinueConversation(SimpleSpanWrapper): + """Span for continuing a conversation in proactive scenarios, starting from when the continue operation is initiated until it is completed. This span can be used to correlate telemetry related to continuing conversations in proactive scenarios.""" + + def __init__(self, conversation_id: str, activity: Activity): + """Initializes the ProactiveContinueConversation SpanWrapper. + + :param conversation_id: The ID of the conversation being continued, used to extract attributes for the span + :param activity: The activity being sent, used to extract attributes for the span + """ + super().__init__(constants.SPAN_CONTINUE_CONVERSATION) + self._conversation_id = conversation_id + self._activity = activity + + def _get_attributes(self) -> AttributeMap: + return { + attributes.CONVERSATION_ID: self._conversation_id or attributes.UNKNOWN, + attributes.ACTIVITY_TYPE: self._activity.type or attributes.UNKNOWN, + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id or attributes.UNKNOWN, + } + +class ProactiveCreateConversation(SimpleSpanWrapper): + """Span for creating a conversation in proactive scenarios, starting from when the create operation is initiated until it is completed. This span can be used to correlate telemetry related to creating conversations in proactive scenarios.""" + + def __init__(self, options: CreateConversationOptions): + """Initializes the ProactiveCreateConversation SpanWrapper. + + :param options: The options used to create the conversation, used to extract attributes for the span + """ + super().__init__(constants.SPAN_CREATE_CONVERSATION) + self._channel_id = options.channel_id + self._members_count: str = str(len(options.parameters.members)) if options.parameters and options.parameters.members else attributes.UNKNOWN + + def _get_attributes(self) -> AttributeMap: + return { + attributes.ACTIVITY_CHANNEL_ID: self._channel_id, + attributes.MEMBERS_COUNT: self._members_count, + } diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py index ae35fe275..2edf488e9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py @@ -21,6 +21,7 @@ AUTH_SUCCESS = "auth.success" CONNECTION_NAME = "auth.connection.name" +CONVERSATION_FOUND = "proactive.conversation_found" CONVERSATION_ID = "activity.conversation.id" HTTP_METHOD = "http.method" @@ -30,6 +31,8 @@ KEY_COUNT = "storage.keys.count" +MEMBERS_COUNT = "proactive.members_count" + OPERATION = "operation" ROUTE_AUTHORIZED = "route.authorized" From 5f2437bec1df51455fc87143bd14996643fb512f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 18 May 2026 08:00:57 -0700 Subject: [PATCH 2/6] Instrumenting proactive scenarios and testing that functionality --- .../hosting/core/app/proactive/proactive.py | 16 +- .../core/app/proactive/telemetry/spans.py | 32 +- .../app/proactive/test_conversation.py | 8 +- .../proactive/test_conversation_builder.py | 15 +- .../test_conversation_reference_builder.py | 36 +- .../test_create_conversation_options.py | 4 +- .../app/proactive/test_proactive.py | 12 +- .../telemetry/test_proactive_spans.py | 343 ++++++++++++++++++ 8 files changed, 426 insertions(+), 40 deletions(-) create mode 100644 tests/hosting_core/telemetry/test_proactive_spans.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py index 168b9e3f1..b18017ad0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py @@ -121,7 +121,9 @@ async def store_conversation( else: conversation = context_or_conversation - with spans.ProactiveStoreConversation(conversation.conversation_reference.conversation.id): + with spans.ProactiveStoreConversation( + conversation.conversation_reference.conversation.id + ): conversation.validate() key = self._storage_key(conversation.conversation_reference.conversation.id) logger.debug("Storing conversation with key: %s", key) @@ -138,10 +140,12 @@ async def get_conversation(self, conversation_id: str) -> Optional[Conversation] or ``None`` if not found. :rtype: Optional[:class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`] """ - with spans.ProactiveGetConversation(conversation_id): + with spans.ProactiveGetConversation(conversation_id) as span: key = self._storage_key(conversation_id) results = await self._storage.read([key], target_cls=Conversation) - return results.get(key) + conversation = results.get(key) + span.share(found=conversation is not None) + return conversation async def delete_conversation(self, conversation_id: str) -> None: """ @@ -273,10 +277,12 @@ async def _callback(context: "TurnContext") -> None: await self._on_turn(context, handler, token_handlers) except Exception as exc: # noqa: BLE001 captured_exc = exc - + with spans.ProactiveContinueConversation(conversation_id, continuation): - await adapter.continue_conversation_with_claims(claims, continuation, _callback) + await adapter.continue_conversation_with_claims( + claims, continuation, _callback + ) if captured_exc is not None: raise captured_exc diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py index 9ea44e190..11a155f0e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py @@ -13,6 +13,7 @@ from . import constants from ..create_conversation_options import CreateConversationOptions + class ProactiveStoreConversation(SimpleSpanWrapper): """Span for storing a conversation reference in proactive scenarios, starting from when the store operation is initiated until it is completed. This span can be used to correlate telemetry related to storing conversation references in proactive scenarios.""" @@ -28,7 +29,8 @@ def _get_attributes(self) -> AttributeMap: return { attributes.CONVERSATION_ID: self._conversation_id or attributes.UNKNOWN, } - + + class ProactiveGetConversation(SimpleSpanWrapper): """Span for getting a conversation reference in proactive scenarios, starting from when the get operation is initiated until it is completed. This span can be used to correlate telemetry related to getting conversation references in proactive scenarios.""" @@ -44,16 +46,19 @@ def __init__(self, conversation_id: str): def _get_attributes(self) -> AttributeMap: return { attributes.CONVERSATION_ID: self._conversation_id or attributes.UNKNOWN, - attributes.CONVERSATION_FOUND: self._found if self._found is not None else attributes.UNKNOWN, + attributes.CONVERSATION_FOUND: ( + self._found if self._found is not None else attributes.UNKNOWN + ), } - + def share(self, found: bool) -> None: """Records to the span whether the conversation being retrieved was found. :param found: Whether the conversation being retrieved was found """ self._found = found - + + class ProactiveDeleteConversation(SimpleSpanWrapper): """Span for deleting a conversation reference in proactive scenarios, starting from when the delete operation is initiated until it is completed. This span can be used to correlate telemetry related to deleting conversation references in proactive scenarios.""" @@ -69,7 +74,8 @@ def _get_attributes(self) -> AttributeMap: return { attributes.CONVERSATION_ID: self._conversation_id or attributes.UNKNOWN, } - + + class ProactiveSendActivity(SimpleSpanWrapper): """Span for sending an activity in proactive scenarios, starting from when the send operation is initiated until it is completed. This span can be used to correlate telemetry related to sending activities in proactive scenarios.""" @@ -87,9 +93,11 @@ def _get_attributes(self) -> AttributeMap: return { attributes.CONVERSATION_ID: self._conversation_id or attributes.UNKNOWN, attributes.ACTIVITY_TYPE: self._activity.type or attributes.UNKNOWN, - attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id or attributes.UNKNOWN, + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id + or attributes.UNKNOWN, } + class ProactiveContinueConversation(SimpleSpanWrapper): """Span for continuing a conversation in proactive scenarios, starting from when the continue operation is initiated until it is completed. This span can be used to correlate telemetry related to continuing conversations in proactive scenarios.""" @@ -107,9 +115,11 @@ def _get_attributes(self) -> AttributeMap: return { attributes.CONVERSATION_ID: self._conversation_id or attributes.UNKNOWN, attributes.ACTIVITY_TYPE: self._activity.type or attributes.UNKNOWN, - attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id or attributes.UNKNOWN, + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id + or attributes.UNKNOWN, } - + + class ProactiveCreateConversation(SimpleSpanWrapper): """Span for creating a conversation in proactive scenarios, starting from when the create operation is initiated until it is completed. This span can be used to correlate telemetry related to creating conversations in proactive scenarios.""" @@ -120,7 +130,11 @@ def __init__(self, options: CreateConversationOptions): """ super().__init__(constants.SPAN_CREATE_CONVERSATION) self._channel_id = options.channel_id - self._members_count: str = str(len(options.parameters.members)) if options.parameters and options.parameters.members else attributes.UNKNOWN + self._members_count: str | int = ( + len(options.parameters.members) + if options.parameters and options.parameters.members + else attributes.UNKNOWN + ) def _get_attributes(self) -> AttributeMap: return { diff --git a/tests/hosting_core/app/proactive/test_conversation.py b/tests/hosting_core/app/proactive/test_conversation.py index edcffc5d6..3c65f1681 100644 --- a/tests/hosting_core/app/proactive/test_conversation.py +++ b/tests/hosting_core/app/proactive/test_conversation.py @@ -69,7 +69,9 @@ def test_init_empty_claims_dict(self): class TestConversationFromTurnContext: def test_from_turn_context_extracts_reference_and_identity(self): ref = _make_reference("ctx-conv") - identity = ClaimsIdentity(claims={"aud": "app-id", "tid": "t"}, is_authenticated=True) + identity = ClaimsIdentity( + claims={"aud": "app-id", "tid": "t"}, is_authenticated=True + ) ctx = MagicMock() ctx.activity.get_conversation_reference.return_value = ref @@ -195,7 +197,9 @@ def test_round_trip_preserves_conversation_id(self): def test_round_trip_preserves_service_url(self): original = Conversation( claims={}, - conversation_reference=_make_reference(service_url="https://custom.service/"), + conversation_reference=_make_reference( + service_url="https://custom.service/" + ), ) json_data = original.store_item_to_json() restored = Conversation.from_json_to_store_item(json_data) diff --git a/tests/hosting_core/app/proactive/test_conversation_builder.py b/tests/hosting_core/app/proactive/test_conversation_builder.py index 479c92166..eab7ded16 100644 --- a/tests/hosting_core/app/proactive/test_conversation_builder.py +++ b/tests/hosting_core/app/proactive/test_conversation_builder.py @@ -5,7 +5,10 @@ import pytest -from microsoft_agents.hosting.core.app.proactive import Conversation, ConversationBuilder +from microsoft_agents.hosting.core.app.proactive import ( + Conversation, + ConversationBuilder, +) from microsoft_agents.hosting.core.authorization import ClaimsIdentity @@ -51,7 +54,9 @@ def test_create_non_teams_no_prefix(self): assert builder._agent_id == "app-id" def test_create_with_requestor_id_sets_appid_claim(self): - builder = ConversationBuilder.create("app-id", "msteams", requestor_id="requestor-id") + builder = ConversationBuilder.create( + "app-id", "msteams", requestor_id="requestor-id" + ) assert builder._claims["appid"] == "requestor-id" def test_create_without_requestor_id_no_appid_claim(self): @@ -199,7 +204,10 @@ def test_build_sets_aud_claim(self): def test_build_sets_service_url(self): conv = _prep_build(ConversationBuilder.create("app-id", "msteams")).build() - assert conv.conversation_reference.service_url == "https://smba.trafficmanager.net/teams/" + assert ( + conv.conversation_reference.service_url + == "https://smba.trafficmanager.net/teams/" + ) def test_build_sets_agent_with_teams_prefix(self): conv = _prep_build(ConversationBuilder.create("app-id", "msteams")).build() @@ -210,6 +218,7 @@ def test_build_no_agent_when_id_none(self): # ConversationReference.agent has Field(None, alias="bot") with ChannelAccount type, # so explicitly passing bot=None raises a Pydantic ValidationError. from pydantic import ValidationError + builder = ConversationBuilder() builder._channel_id = "directline" builder._service_url = "https://directline.botframework.com/" diff --git a/tests/hosting_core/app/proactive/test_conversation_reference_builder.py b/tests/hosting_core/app/proactive/test_conversation_reference_builder.py index 20289cf3a..4ab646e29 100644 --- a/tests/hosting_core/app/proactive/test_conversation_reference_builder.py +++ b/tests/hosting_core/app/proactive/test_conversation_reference_builder.py @@ -22,9 +22,8 @@ def _buildable(channel_id="msteams", conv_id="conv-1"): The implementation passes name explicitly to ChannelAccount, so an agent with a name must always be present before calling .build(). """ - return ( - ConversationReferenceBuilder.create(channel_id, conv_id) - .with_agent("28:app-id", "Bot") + return ConversationReferenceBuilder.create(channel_id, conv_id).with_agent( + "28:app-id", "Bot" ) @@ -35,16 +34,27 @@ def _buildable(channel_id="msteams", conv_id="conv-1"): class TestServiceUrlForChannel: def test_teams_returns_smba_url(self): - assert _service_url_for_channel("msteams") == "https://smba.trafficmanager.net/teams/" + assert ( + _service_url_for_channel("msteams") + == "https://smba.trafficmanager.net/teams/" + ) def test_directline_returns_generic_url(self): - assert _service_url_for_channel("directline") == "https://directline.botframework.com/" + assert ( + _service_url_for_channel("directline") + == "https://directline.botframework.com/" + ) def test_webchat_returns_generic_url(self): - assert _service_url_for_channel("webchat") == "https://webchat.botframework.com/" + assert ( + _service_url_for_channel("webchat") == "https://webchat.botframework.com/" + ) def test_unknown_channel_uses_pattern(self): - assert _service_url_for_channel("mychannel") == "https://mychannel.botframework.com/" + assert ( + _service_url_for_channel("mychannel") + == "https://mychannel.botframework.com/" + ) # --------------------------------------------------------------------------- @@ -183,11 +193,7 @@ def test_build_default_service_url_generic(self): assert ref.service_url == "https://directline.botframework.com/" def test_build_respects_explicit_service_url(self): - ref = ( - _buildable() - .with_service_url("https://custom/") - .build() - ) + ref = _buildable().with_service_url("https://custom/").build() assert ref.service_url == "https://custom/" def test_build_sets_agent_account(self): @@ -200,11 +206,7 @@ def test_build_sets_agent_account(self): assert ref.agent.name == "My Bot" def test_build_sets_user_account(self): - ref = ( - _buildable() - .with_user("user-oid", "Alice") - .build() - ) + ref = _buildable().with_user("user-oid", "Alice").build() assert ref.user.id == "user-oid" assert ref.user.name == "Alice" diff --git a/tests/hosting_core/app/proactive/test_create_conversation_options.py b/tests/hosting_core/app/proactive/test_create_conversation_options.py index 3b7d43e2b..6353b3f2b 100644 --- a/tests/hosting_core/app/proactive/test_create_conversation_options.py +++ b/tests/hosting_core/app/proactive/test_create_conversation_options.py @@ -93,7 +93,9 @@ def test_validate_optional_fields_not_required(self): opts.validate() # must not raise def test_validate_raises_when_identity_missing(self): - opts = CreateConversationOptions(channel_id="msteams", parameters=_make_params()) + opts = CreateConversationOptions( + channel_id="msteams", parameters=_make_params() + ) with pytest.raises(ValueError, match="identity"): opts.validate() diff --git a/tests/hosting_core/app/proactive/test_proactive.py b/tests/hosting_core/app/proactive/test_proactive.py index 40264a3e0..f3afa664e 100644 --- a/tests/hosting_core/app/proactive/test_proactive.py +++ b/tests/hosting_core/app/proactive/test_proactive.py @@ -326,7 +326,9 @@ def _make_adapter(self, proactive_instance, state=None): async def fake_continue(claims, continuation, callback): ctx = MagicMock() - with patch.object(proactive_instance, "_load_state", AsyncMock(return_value=state)): + with patch.object( + proactive_instance, "_load_state", AsyncMock(return_value=state) + ): await callback(ctx) adapter = MagicMock() @@ -483,7 +485,9 @@ def options(self, identity): def _make_adapter(self, new_conversation_id="new-conv"): ref = _make_reference(new_conversation_id) - async def fake_create(app_id, channel_id, service_url, audience, params, callback): + async def fake_create( + app_id, channel_id, service_url, audience, params, callback + ): ctx = MagicMock() ctx.activity.get_conversation_reference.return_value = ref await callback(ctx) @@ -570,7 +574,9 @@ async def test_create_without_handler_does_not_raise(self, proactive, options): async def test_create_passes_channel_id_to_adapter(self, proactive, options): captured_channel_id = None - async def fake_create(app_id, channel_id, service_url, audience, params, callback): + async def fake_create( + app_id, channel_id, service_url, audience, params, callback + ): nonlocal captured_channel_id captured_channel_id = channel_id ref = _make_reference("x") diff --git a/tests/hosting_core/telemetry/test_proactive_spans.py b/tests/hosting_core/telemetry/test_proactive_spans.py new file mode 100644 index 000000000..8f998310c --- /dev/null +++ b/tests/hosting_core/telemetry/test_proactive_spans.py @@ -0,0 +1,343 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from types import SimpleNamespace + +from microsoft_agents.activity import ( + Activity, + ChannelAccount, + ConversationParameters, +) +from microsoft_agents.hosting.core.authorization import ClaimsIdentity +from microsoft_agents.hosting.core.telemetry import attributes +from microsoft_agents.hosting.core.app.proactive.create_conversation_options import ( + CreateConversationOptions, +) +from microsoft_agents.hosting.core.app.proactive.telemetry import constants +from microsoft_agents.hosting.core.app.proactive.telemetry.spans import ( + ProactiveStoreConversation, + ProactiveGetConversation, + ProactiveDeleteConversation, + ProactiveSendActivity, + ProactiveContinueConversation, + ProactiveCreateConversation, +) + +from tests._common.fixtures.telemetry import ( # noqa: F401 — fixture imports + test_telemetry, + test_exporter, + test_metric_reader, +) + + +def _make_activity(activity_type="message", channel_id="msteams"): + return Activity(type=activity_type, channel_id=channel_id) + + +_MEMBERS_SENTINEL = object() + + +def _make_create_options( + channel_id="msteams", + members=_MEMBERS_SENTINEL, + parameters_present=True, +): + params = None + if parameters_present: + if members is _MEMBERS_SENTINEL: + params = ConversationParameters() + else: + params = ConversationParameters(members=members) + return CreateConversationOptions( + identity=ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True), + channel_id=channel_id, + parameters=params, + service_url="https://smba.trafficmanager.net/teams/", + ) + + +# --------------------------------------------------------------------------- +# ProactiveStoreConversation +# --------------------------------------------------------------------------- + + +def test_store_conversation_creates_span(test_exporter): + with ProactiveStoreConversation("conv-1"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_STORE_CONVERSATION + + +def test_store_conversation_span_attributes(test_exporter): + with ProactiveStoreConversation("conv-1"): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == "conv-1" + + +def test_store_conversation_empty_id_falls_back_to_unknown(test_exporter): + with ProactiveStoreConversation(""): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == attributes.UNKNOWN + + +def test_store_conversation_none_id_falls_back_to_unknown(test_exporter): + with ProactiveStoreConversation(None): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == attributes.UNKNOWN + + +def test_store_conversation_records_span_even_on_exception(test_exporter): + try: + with ProactiveStoreConversation("conv-err"): + raise ValueError("storage exploded") + except ValueError: + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[attributes.CONVERSATION_ID] == "conv-err" + + +# --------------------------------------------------------------------------- +# ProactiveGetConversation +# --------------------------------------------------------------------------- + + +def test_get_conversation_creates_span(test_exporter): + with ProactiveGetConversation("conv-1"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_GET_CONVERSATION + + +def test_get_conversation_span_attributes_without_share(test_exporter): + """When share() is never called, CONVERSATION_FOUND defaults to UNKNOWN.""" + with ProactiveGetConversation("conv-1"): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == "conv-1" + assert span.attributes[attributes.CONVERSATION_FOUND] == attributes.UNKNOWN + + +def test_get_conversation_share_found_true(test_exporter): + with ProactiveGetConversation("conv-1") as span: + span.share(True) + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_FOUND] is True + + +def test_get_conversation_share_found_false(test_exporter): + """Specifically guards against the `bool if ... is not None` check: + False must not be coerced to UNKNOWN.""" + with ProactiveGetConversation("conv-1") as span: + span.share(False) + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_FOUND] is False + + +def test_get_conversation_empty_id_falls_back_to_unknown(test_exporter): + with ProactiveGetConversation(""): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == attributes.UNKNOWN + + +# --------------------------------------------------------------------------- +# ProactiveDeleteConversation +# --------------------------------------------------------------------------- + + +def test_delete_conversation_creates_span(test_exporter): + with ProactiveDeleteConversation("conv-1"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_DELETE_CONVERSATION + + +def test_delete_conversation_span_attributes(test_exporter): + with ProactiveDeleteConversation("conv-del"): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == "conv-del" + + +def test_delete_conversation_empty_id_falls_back_to_unknown(test_exporter): + with ProactiveDeleteConversation(""): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == attributes.UNKNOWN + + +# --------------------------------------------------------------------------- +# ProactiveSendActivity +# --------------------------------------------------------------------------- + + +def test_send_activity_creates_span(test_exporter): + with ProactiveSendActivity("conv-1", _make_activity()): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_SEND_ACTIVITY + + +def test_send_activity_span_attributes(test_exporter): + activity = _make_activity(activity_type="message", channel_id="webchat") + with ProactiveSendActivity("conv-1", activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == "conv-1" + assert span.attributes[attributes.ACTIVITY_TYPE] == "message" + assert span.attributes[attributes.ACTIVITY_CHANNEL_ID] == "webchat" + + +def test_send_activity_missing_activity_type_falls_back_to_unknown(test_exporter): + # Activity model requires `type`, so use a stand-in object that exposes the + # same attribute surface the span reads. + activity = SimpleNamespace(type=None, channel_id="msteams") + with ProactiveSendActivity("conv-1", activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_TYPE] == attributes.UNKNOWN + + +def test_send_activity_missing_channel_id_falls_back_to_unknown(test_exporter): + activity = SimpleNamespace(type="message", channel_id=None) + with ProactiveSendActivity("conv-1", activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_CHANNEL_ID] == attributes.UNKNOWN + + +def test_send_activity_empty_conversation_id_falls_back_to_unknown(test_exporter): + with ProactiveSendActivity("", _make_activity()): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == attributes.UNKNOWN + + +# --------------------------------------------------------------------------- +# ProactiveContinueConversation +# --------------------------------------------------------------------------- + + +def test_continue_conversation_creates_span(test_exporter): + with ProactiveContinueConversation("conv-1", _make_activity()): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_CONTINUE_CONVERSATION + + +def test_continue_conversation_span_attributes(test_exporter): + activity = _make_activity(activity_type="event", channel_id="directline") + with ProactiveContinueConversation("conv-cont", activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == "conv-cont" + assert span.attributes[attributes.ACTIVITY_TYPE] == "event" + assert span.attributes[attributes.ACTIVITY_CHANNEL_ID] == "directline" + + +def test_continue_conversation_missing_activity_fields_fall_back(test_exporter): + activity = SimpleNamespace(type=None, channel_id=None) + with ProactiveContinueConversation("conv-1", activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_TYPE] == attributes.UNKNOWN + assert span.attributes[attributes.ACTIVITY_CHANNEL_ID] == attributes.UNKNOWN + + +def test_continue_conversation_records_span_even_on_exception(test_exporter): + try: + with ProactiveContinueConversation("conv-err", _make_activity()): + raise RuntimeError("handler exploded") + except RuntimeError: + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[attributes.CONVERSATION_ID] == "conv-err" + + +# --------------------------------------------------------------------------- +# ProactiveCreateConversation +# --------------------------------------------------------------------------- + + +def test_create_conversation_creates_span(test_exporter): + with ProactiveCreateConversation(_make_create_options()): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_CREATE_CONVERSATION + + +def test_create_conversation_span_attributes(test_exporter): + members = [ChannelAccount(id="u-1"), ChannelAccount(id="u-2")] + with ProactiveCreateConversation( + _make_create_options(channel_id="msteams", members=members) + ): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_CHANNEL_ID] == "msteams" + # members_count is serialized as a string (UNKNOWN fallback is also a string) + assert span.attributes[attributes.MEMBERS_COUNT] == 2 + + +def test_create_conversation_members_count_unknown_when_empty_members(test_exporter): + """An empty list is treated the same as missing — the `and members` clause + in ProactiveCreateConversation short-circuits to UNKNOWN.""" + with ProactiveCreateConversation(_make_create_options(members=[])): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.MEMBERS_COUNT] == attributes.UNKNOWN + + +def test_create_conversation_members_count_unknown_when_no_parameters(test_exporter): + with ProactiveCreateConversation(_make_create_options(parameters_present=False)): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.MEMBERS_COUNT] == attributes.UNKNOWN + + +def test_create_conversation_records_span_even_on_exception(test_exporter): + try: + with ProactiveCreateConversation(_make_create_options()): + raise RuntimeError("create failed") + except RuntimeError: + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_CREATE_CONVERSATION From 08f788e805beccdb9f3bf964d3f1cbb74e625518 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 18 May 2026 08:13:23 -0700 Subject: [PATCH 3/6] removing unnecessary import --- .../hosting/core/app/proactive/telemetry/spans.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py index 11a155f0e..6ad619c16 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py @@ -3,7 +3,6 @@ from __future__ import annotations -from opentelemetry.trace import Span from microsoft_agents.activity import Activity from microsoft_agents.hosting.core.telemetry import ( AttributeMap, From e7ed50516ec6d844dff231e4a0a0afaf033aa075 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 18 May 2026 08:40:01 -0700 Subject: [PATCH 4/6] Revising attribute names --- .../hosting/core/app/proactive/telemetry/spans.py | 2 +- .../microsoft_agents/hosting/core/telemetry/attributes.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py index 6ad619c16..b80c5ecdd 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py @@ -31,7 +31,7 @@ def _get_attributes(self) -> AttributeMap: class ProactiveGetConversation(SimpleSpanWrapper): - """Span for getting a conversation reference in proactive scenarios, starting from when the get operation is initiated until it is completed. This span can be used to correlate telemetry related to getting conversation references in proactive scenarios.""" + """Span for getting a conversation reference in proactive scenarios.""" def __init__(self, conversation_id: str): """Initializes the ProactiveGetConversation SpanWrapper. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py index 2edf488e9..81b4e3228 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py @@ -21,7 +21,7 @@ AUTH_SUCCESS = "auth.success" CONNECTION_NAME = "auth.connection.name" -CONVERSATION_FOUND = "proactive.conversation_found" +CONVERSATION_FOUND = "proactive.conversation.found" CONVERSATION_ID = "activity.conversation.id" HTTP_METHOD = "http.method" @@ -31,7 +31,7 @@ KEY_COUNT = "storage.keys.count" -MEMBERS_COUNT = "proactive.members_count" +MEMBERS_COUNT = "proactive.members.count" OPERATION = "operation" From 294b0182f6810643c93a3105da5dfdaf4dcfd858 Mon Sep 17 00:00:00 2001 From: rodrigobr-msft Date: Mon, 18 May 2026 08:40:58 -0700 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/hosting_core/telemetry/test_proactive_spans.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/hosting_core/telemetry/test_proactive_spans.py b/tests/hosting_core/telemetry/test_proactive_spans.py index 8f998310c..cfecea880 100644 --- a/tests/hosting_core/telemetry/test_proactive_spans.py +++ b/tests/hosting_core/telemetry/test_proactive_spans.py @@ -309,7 +309,7 @@ def test_create_conversation_span_attributes(test_exporter): span = test_exporter.get_finished_spans()[0] assert span.attributes[attributes.ACTIVITY_CHANNEL_ID] == "msteams" - # members_count is serialized as a string (UNKNOWN fallback is also a string) + # members_count is recorded as an int when known; unknown falls back to attributes.UNKNOWN. assert span.attributes[attributes.MEMBERS_COUNT] == 2 From 5e63de1c2ca2e64ad60cbadbbc2ab8b0ff414ab9 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 18 May 2026 09:23:20 -0700 Subject: [PATCH 6/6] Edit to comment --- tests/hosting_core/telemetry/test_proactive_spans.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/hosting_core/telemetry/test_proactive_spans.py b/tests/hosting_core/telemetry/test_proactive_spans.py index 8f998310c..f9f23db17 100644 --- a/tests/hosting_core/telemetry/test_proactive_spans.py +++ b/tests/hosting_core/telemetry/test_proactive_spans.py @@ -309,7 +309,6 @@ def test_create_conversation_span_attributes(test_exporter): span = test_exporter.get_finished_spans()[0] assert span.attributes[attributes.ACTIVITY_CHANNEL_ID] == "msteams" - # members_count is serialized as a string (UNKNOWN fallback is also a string) assert span.attributes[attributes.MEMBERS_COUNT] == 2