diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py index 6cdc9087..2061f415 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py @@ -38,3 +38,6 @@ class AgentDetails: provider_name: Optional[str] = None """The provider name (e.g., openai, anthropic).""" + + agent_version: Optional[str] = None + """Optional version of the agent (e.g., "1.0.0", "2025-05-01").""" 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 b400c848..a1afbe7b 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 @@ -38,6 +38,7 @@ GEN_AI_AGENT_ID_KEY = "gen_ai.agent.id" GEN_AI_AGENT_NAME_KEY = "gen_ai.agent.name" GEN_AI_AGENT_DESCRIPTION_KEY = "gen_ai.agent.description" +GEN_AI_AGENT_VERSION_KEY = "gen_ai.agent.version" GEN_AI_AGENT_PLATFORM_ID_KEY = "microsoft.a365.agent.platform.id" GEN_AI_AGENT_THOUGHT_PROCESS_KEY = "microsoft.a365.agent.thought.process" GEN_AI_CONVERSATION_ID_KEY = "gen_ai.conversation.id" @@ -73,6 +74,7 @@ GEN_AI_CALLER_AGENT_ID_KEY = "microsoft.a365.caller.agent.id" GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY = "microsoft.a365.caller.agent.blueprint.id" GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY = "microsoft.a365.caller.agent.platform.id" +GEN_AI_CALLER_AGENT_VERSION_KEY = "microsoft.a365.caller.agent.version" # Agent-specific dimensions AGENT_ID_KEY = "gen_ai.agent.id" 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 ce6acedd..eb0d9a56 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,6 +17,7 @@ GEN_AI_CALLER_AGENT_NAME_KEY, GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, GEN_AI_CALLER_AGENT_USER_ID_KEY, + GEN_AI_CALLER_AGENT_VERSION_KEY, GEN_AI_CALLER_CLIENT_IP_KEY, GEN_AI_CONVERSATION_ID_KEY, GEN_AI_INPUT_MESSAGES_KEY, @@ -162,6 +163,10 @@ def __init__( GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, caller_agent_details.agent_platform_id, ) + self.set_tag_maybe( + GEN_AI_CALLER_AGENT_VERSION_KEY, + caller_agent_details.agent_version, + ) def record_response(self, response: str) -> None: """Record response information for telemetry tracking. 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 33914ae1..5e0a638d 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 @@ -17,6 +17,7 @@ GEN_AI_AGENT_EMAIL_KEY, GEN_AI_AGENT_ID_KEY, GEN_AI_AGENT_NAME_KEY, + GEN_AI_AGENT_VERSION_KEY, GEN_AI_CALLER_CLIENT_IP_KEY, GEN_AI_CONVERSATION_ID_KEY, GEN_AI_CONVERSATION_ITEM_LINK_KEY, @@ -154,6 +155,11 @@ def agent_description(self, value: str | None) -> "BaggageBuilder": self._set(GEN_AI_AGENT_DESCRIPTION_KEY, value) return self + def agent_version(self, value: str | None) -> "BaggageBuilder": + """Set the agent version baggage value.""" + self._set(GEN_AI_AGENT_VERSION_KEY, value) + return self + def user_name(self, value: str | None) -> "BaggageBuilder": """Set the user name baggage value.""" self._set(USER_NAME_KEY, value) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py index 9720d268..73474cf1 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py @@ -33,6 +33,7 @@ GEN_AI_AGENT_ID_KEY, GEN_AI_AGENT_NAME_KEY, GEN_AI_AGENT_PLATFORM_ID_KEY, + GEN_AI_AGENT_VERSION_KEY, GEN_AI_ICON_URI_KEY, GEN_AI_OPERATION_NAME_KEY, GEN_AI_OUTPUT_MESSAGES_KEY, @@ -178,6 +179,7 @@ def __init__( self.set_tag_maybe( GEN_AI_AGENT_DESCRIPTION_KEY, agent_details.agent_description ) + self.set_tag_maybe(GEN_AI_AGENT_VERSION_KEY, agent_details.agent_version) self.set_tag_maybe(GEN_AI_AGENT_AUID_KEY, agent_details.agentic_user_id) self.set_tag_maybe(GEN_AI_AGENT_EMAIL_KEY, agent_details.agentic_user_email) self.set_tag_maybe( 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 883e0d58..bef32cd9 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 @@ -14,6 +14,7 @@ consts.GEN_AI_AGENT_ID_KEY, # gen_ai.agent.id consts.GEN_AI_AGENT_NAME_KEY, # gen_ai.agent.name consts.GEN_AI_AGENT_DESCRIPTION_KEY, # gen_ai.agent.description + consts.GEN_AI_AGENT_VERSION_KEY, # gen_ai.agent.version consts.GEN_AI_AGENT_EMAIL_KEY, # microsoft.agent.user.email consts.GEN_AI_AGENT_BLUEPRINT_ID_KEY, # microsoft.a365.agent.blueprint.id consts.GEN_AI_AGENT_AUID_KEY, # microsoft.agent.user.id @@ -41,6 +42,7 @@ consts.GEN_AI_CALLER_AGENT_EMAIL_KEY, # microsoft.a365.caller.agent.user.email consts.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, # microsoft.a365.caller.agent.blueprint.id consts.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, # microsoft.a365.caller.agent.platform.id + consts.GEN_AI_CALLER_AGENT_VERSION_KEY, # microsoft.a365.caller.agent.version # Server address/port for invoke agent target consts.SERVER_ADDRESS_KEY, # server.address consts.SERVER_PORT_KEY, # server.port diff --git a/tests/observability/core/test_baggage_builder.py b/tests/observability/core/test_baggage_builder.py index 73821203..e4a77e7c 100644 --- a/tests/observability/core/test_baggage_builder.py +++ b/tests/observability/core/test_baggage_builder.py @@ -11,6 +11,7 @@ GEN_AI_AGENT_BLUEPRINT_ID_KEY, GEN_AI_AGENT_EMAIL_KEY, GEN_AI_AGENT_ID_KEY, + GEN_AI_AGENT_VERSION_KEY, GEN_AI_CALLER_CLIENT_IP_KEY, SERVER_ADDRESS_KEY, SERVER_PORT_KEY, @@ -364,6 +365,27 @@ def test_invoke_agent_server_sets_address_only_when_port_none(self): self.assertEqual(current_baggage.get(SERVER_ADDRESS_KEY), address) self.assertIsNone(current_baggage.get(SERVER_PORT_KEY)) + def test_agent_version_method(self): + """Test agent_version method sets agent version baggage.""" + self.assertTrue(hasattr(self.builder, "agent_version")) + self.assertTrue(callable(self.builder.agent_version)) + + with self.builder.agent_version("1.0.0").build(): + current_baggage = baggage.get_all() + self.assertEqual(current_baggage.get(GEN_AI_AGENT_VERSION_KEY), "1.0.0") + + def test_agent_version_none_not_set(self): + """Test agent_version with None does not set baggage.""" + with BaggageBuilder().agent_version(None).build(): + current_baggage = baggage.get_all() + self.assertIsNone(current_baggage.get(GEN_AI_AGENT_VERSION_KEY)) + + def test_agent_version_whitespace_not_set(self): + """Test agent_version with whitespace-only value does not set baggage.""" + with BaggageBuilder().agent_version(" ").build(): + current_baggage = baggage.get_all() + self.assertIsNone(current_baggage.get(GEN_AI_AGENT_VERSION_KEY)) + 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 ed383e5b..2996aa82 100644 --- a/tests/observability/core/test_invoke_agent_scope.py +++ b/tests/observability/core/test_invoke_agent_scope.py @@ -24,6 +24,14 @@ from microsoft_agents_a365.observability.core.constants import ( CHANNEL_LINK_KEY, CHANNEL_NAME_KEY, + GEN_AI_AGENT_VERSION_KEY, + GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, + GEN_AI_CALLER_AGENT_EMAIL_KEY, + GEN_AI_CALLER_AGENT_ID_KEY, + GEN_AI_CALLER_AGENT_NAME_KEY, + GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, + GEN_AI_CALLER_AGENT_USER_ID_KEY, + GEN_AI_CALLER_AGENT_VERSION_KEY, GEN_AI_INPUT_MESSAGES_KEY, SERVER_ADDRESS_KEY, SERVER_PORT_KEY, @@ -92,6 +100,7 @@ def setUpClass(cls): agentic_user_email="agent@contoso.com", tenant_id="tenant-789", agent_platform_id="platform-123", + agent_version="2.1.0", ) def setUp(self): @@ -271,6 +280,94 @@ def test_span_processor_propagates_server_baggage_for_invoke_agent_span(self): self.assertEqual(span_attributes.get(SERVER_ADDRESS_KEY), server_address) self.assertEqual(span_attributes.get(SERVER_PORT_KEY), str(server_port)) + def test_agent_version_set_on_span(self): + """Test that agent_version from AgentDetails is set on span attributes.""" + agent_with_version = AgentDetails( + agent_id="versioned-agent", + agent_name="Versioned Agent", + agent_version="1.0.0", + ) + scope = InvokeAgentScope.start( + self.test_request, + self.invoke_scope_details, + agent_with_version, + ) + if scope is not None: + scope.dispose() + + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span") + span_attributes = getattr(finished_spans[-1], "attributes", {}) or {} + self.assertEqual(span_attributes.get(GEN_AI_AGENT_VERSION_KEY), "1.0.0") + + def test_agent_version_not_set_when_none(self): + """Test that agent_version is not set on span when it is None.""" + agent_without_version = AgentDetails( + agent_id="no-version-agent", + agent_name="No Version Agent", + ) + scope = InvokeAgentScope.start( + self.test_request, + self.invoke_scope_details, + agent_without_version, + ) + if scope is not None: + scope.dispose() + + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span") + span_attributes = getattr(finished_spans[-1], "attributes", {}) or {} + self.assertNotIn(GEN_AI_AGENT_VERSION_KEY, span_attributes) + + def test_caller_agent_version_set_on_span(self): + """Test that caller agent version is emitted on invoke_agent spans.""" + caller_with_version = CallerDetails( + user_details=self.user_details, + caller_agent_details=self.caller_agent_details, + ) + scope = InvokeAgentScope.start( + self.test_request, + self.invoke_scope_details, + self.agent_details, + caller_details=caller_with_version, + ) + if scope is not None: + scope.dispose() + + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span") + span_attributes = getattr(finished_spans[-1], "attributes", {}) or {} + self.assertEqual(span_attributes.get(GEN_AI_CALLER_AGENT_VERSION_KEY), "2.1.0") + + def test_caller_agent_details_all_fields_set_on_span(self): + """Test that all caller agent detail fields are set on span attributes.""" + caller_with_agent = CallerDetails( + user_details=self.user_details, + caller_agent_details=self.caller_agent_details, + ) + scope = InvokeAgentScope.start( + self.test_request, + self.invoke_scope_details, + self.agent_details, + caller_details=caller_with_agent, + ) + if scope is not None: + scope.dispose() + + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span") + span_attributes = getattr(finished_spans[-1], "attributes", {}) or {} + + self.assertEqual(span_attributes.get(GEN_AI_CALLER_AGENT_NAME_KEY), "Caller Agent") + self.assertEqual(span_attributes.get(GEN_AI_CALLER_AGENT_ID_KEY), "caller-agent-789") + self.assertEqual( + span_attributes.get(GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY), "blueprint-456" + ) + self.assertEqual(span_attributes.get(GEN_AI_CALLER_AGENT_USER_ID_KEY), "auid-123") + self.assertEqual(span_attributes.get(GEN_AI_CALLER_AGENT_EMAIL_KEY), "agent@contoso.com") + self.assertEqual(span_attributes.get(GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY), "platform-123") + self.assertEqual(span_attributes.get(GEN_AI_CALLER_AGENT_VERSION_KEY), "2.1.0") + if __name__ == "__main__": # Run pytest only on the current file diff --git a/tests/observability/core/test_span_processor.py b/tests/observability/core/test_span_processor.py index 2e59126b..1c91725a 100644 --- a/tests/observability/core/test_span_processor.py +++ b/tests/observability/core/test_span_processor.py @@ -6,6 +6,7 @@ from microsoft_agents_a365.observability.core.constants import ( GEN_AI_AGENT_ID_KEY, + GEN_AI_AGENT_VERSION_KEY, TENANT_ID_KEY, ) from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder @@ -44,6 +45,25 @@ def test_on_end_calls_super(self): except Exception as e: self.fail(f"on_end raised an exception: {e}") + def test_agent_version_baggage_propagates_to_span(self): + """Test that agent version baggage is propagated to span attributes.""" + self.mock_span.attributes = {} + + with ( + BaggageBuilder() + .tenant_id("test-tenant") + .agent_id("test-agent") + .agent_version("3.0.0") + .build() + ): + self.processor.on_start(self.mock_span, context.get_current()) + + calls = self.mock_span.set_attribute.call_args_list + call_dict = {call[0][0]: call[0][1] for call in calls} + self.assertEqual(call_dict.get(GEN_AI_AGENT_VERSION_KEY), "3.0.0") + self.assertEqual(call_dict.get(TENANT_ID_KEY), "test-tenant") + self.assertEqual(call_dict.get(GEN_AI_AGENT_ID_KEY), "test-agent") + if __name__ == "__main__": unittest.main()