diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py index e2e5df8e..ee314092 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py @@ -77,25 +77,26 @@ def __init__( user_details: Optional human user details span_details: Optional span configuration (parent context, timing, kind) """ - kind = SpanKind.INTERNAL - parent_context = None - start_time = None - end_time = None - if span_details is not None: - if span_details.span_kind is not None: - kind = span_details.span_kind - parent_context = span_details.parent_context - start_time = span_details.start_time - end_time = span_details.end_time + # spanKind defaults to INTERNAL; allow override via span_details + resolved_span_details = ( + SpanDetails( + span_kind=span_details.span_kind + if span_details and span_details.span_kind + else SpanKind.INTERNAL, + parent_context=span_details.parent_context if span_details else None, + start_time=span_details.start_time if span_details else None, + end_time=span_details.end_time if span_details else None, + span_links=span_details.span_links if span_details else None, + ) + if span_details + else SpanDetails(span_kind=SpanKind.INTERNAL) + ) super().__init__( - kind=kind, operation_name=EXECUTE_TOOL_OPERATION_NAME, activity_name=f"{EXECUTE_TOOL_OPERATION_NAME} {details.tool_name}", agent_details=agent_details, - parent_context=parent_context, - start_time=start_time, - end_time=end_time, + span_details=resolved_span_details, ) # Extract details diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py index 193a0d71..85833ce3 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py @@ -3,6 +3,8 @@ from typing import List +from opentelemetry.trace import SpanKind + from .agent_details import AgentDetails from .constants import ( CHANNEL_LINK_KEY, @@ -74,22 +76,24 @@ def __init__( user_details: Optional human user details span_details: Optional span configuration (parent context, timing) """ - parent_context = None - start_time = None - end_time = None - if span_details is not None: - parent_context = span_details.parent_context - start_time = span_details.start_time - end_time = span_details.end_time + # spanKind for InferenceScope is always CLIENT + resolved_span_details = ( + SpanDetails( + span_kind=SpanKind.CLIENT, + parent_context=span_details.parent_context if span_details else None, + start_time=span_details.start_time if span_details else None, + end_time=span_details.end_time if span_details else None, + span_links=span_details.span_links if span_details else None, + ) + if span_details + else SpanDetails(span_kind=SpanKind.CLIENT) + ) super().__init__( - kind="Client", operation_name=details.operationName.value, activity_name=f"{details.operationName.value} {details.model}", agent_details=agent_details, - parent_context=parent_context, - start_time=start_time, - end_time=end_time, + span_details=resolved_span_details, ) if request.content: 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 0e69fa81..ce6acedd 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 @@ -93,25 +93,26 @@ def __init__( if agent_details.agent_name: activity_name = f"{INVOKE_AGENT_OPERATION_NAME} {agent_details.agent_name}" - kind = SpanKind.CLIENT - parent_context = None - start_time = None - end_time = None - if span_details is not None: - if span_details.span_kind is not None: - kind = span_details.span_kind - parent_context = span_details.parent_context - start_time = span_details.start_time - end_time = span_details.end_time + # spanKind defaults to CLIENT; allow override via span_details + resolved_span_details = ( + SpanDetails( + span_kind=span_details.span_kind + if span_details and span_details.span_kind + else SpanKind.CLIENT, + parent_context=span_details.parent_context if span_details else None, + start_time=span_details.start_time if span_details else None, + end_time=span_details.end_time if span_details else None, + span_links=span_details.span_links if span_details else None, + ) + if span_details + else SpanDetails(span_kind=SpanKind.CLIENT) + ) super().__init__( - kind=kind, operation_name=INVOKE_AGENT_OPERATION_NAME, activity_name=activity_name, agent_details=agent_details, - parent_context=parent_context, - start_time=start_time, - end_time=end_time, + span_details=resolved_span_details, ) self.set_tag_maybe(SESSION_ID_KEY, request.session_id) 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 b61411f4..33914ae1 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,6 +20,8 @@ GEN_AI_CALLER_CLIENT_IP_KEY, GEN_AI_CONVERSATION_ID_KEY, GEN_AI_CONVERSATION_ITEM_LINK_KEY, + SERVER_ADDRESS_KEY, + SERVER_PORT_KEY, SERVICE_NAME_KEY, SESSION_DESCRIPTION_KEY, SESSION_ID_KEY, @@ -167,6 +169,21 @@ def user_client_ip(self, value: str | None) -> "BaggageBuilder": self._set(GEN_AI_CALLER_CLIENT_IP_KEY, validate_and_normalize_ip(value)) return self + def invoke_agent_server(self, address: str | None, port: int | None = None) -> "BaggageBuilder": + """Set the invoke agent server address and port baggage values. + + Args: + address: The server address (hostname) of the target agent service. + port: Optional server port. Only recorded when different from 443. + + Returns: + Self for method chaining + """ + self._set(SERVER_ADDRESS_KEY, address) + if port is not None and port != 443: + self._set(SERVER_PORT_KEY, str(port)) + return self + def conversation_id(self, value: str | None) -> "BaggageBuilder": """Set the conversation ID baggage value.""" self._set(GEN_AI_CONVERSATION_ID_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 1691300d..9720d268 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 @@ -49,6 +49,7 @@ if TYPE_CHECKING: from .agent_details import AgentDetails + from .span_details import SpanDetails # Create logger for this module - inherits from 'microsoft_agents_a365.observability.core' logger = logging.getLogger(__name__) @@ -93,32 +94,38 @@ def _datetime_to_ns(dt: datetime | None) -> int | None: def __init__( self, - kind: "str | SpanKind", operation_name: str, activity_name: str, agent_details: "AgentDetails | None" = None, - parent_context: Context | None = None, - start_time: datetime | None = None, - end_time: datetime | None = None, + span_details: "SpanDetails | None" = None, ): """Initialize the OpenTelemetry scope. Args: - kind: The kind of activity. Accepts a string (e.g. ``"Client"``, - ``"Server"``, ``"Internal"``) or an ``opentelemetry.trace.SpanKind`` - enum value directly. operation_name: The name of the operation being traced activity_name: The name of the activity for display purposes agent_details: Optional agent details - parent_context: Optional OpenTelemetry Context used to link this span to an - upstream operation. Use ``extract_context_from_headers()`` to extract a - Context from HTTP headers containing W3C traceparent. - start_time: Optional explicit start time as a datetime object. - Useful when recording an operation after it has already completed. - end_time: Optional explicit end time as a datetime object. - When provided, the span will use this timestamp when disposed - instead of the current wall-clock time. + span_details: Optional span configuration including parent context, + start/end times, span kind, and span links. Subclasses may override + ``span_details.span_kind`` before calling this constructor; + defaults to ``SpanKind.CLIENT``. """ + parent_context = span_details.parent_context if span_details else None + start_time = span_details.start_time if span_details else None + end_time = span_details.end_time if span_details else None + span_links = span_details.span_links if span_details else None + kind = ( + span_details.span_kind if span_details and span_details.span_kind else SpanKind.CLIENT + ) + if not isinstance(kind, SpanKind): + logger.warning( + "span_details.span_kind has invalid type %s (value: %r); " + "falling back to SpanKind.CLIENT", + type(kind).__name__, + kind, + ) + kind = SpanKind.CLIENT + self._span: Span | None = None self._custom_start_time: datetime | None = start_time self._custom_end_time: datetime | None = end_time @@ -130,20 +137,7 @@ def __init__( if self._is_telemetry_enabled(): tracer = self._get_tracer() - # Resolve activity_kind from either a SpanKind enum or a string - if isinstance(kind, SpanKind): - activity_kind = kind - else: - # Map string kind to SpanKind enum - activity_kind = SpanKind.INTERNAL - if kind.lower() == "client": - activity_kind = SpanKind.CLIENT - elif kind.lower() == "server": - activity_kind = SpanKind.SERVER - elif kind.lower() == "producer": - activity_kind = SpanKind.PRODUCER - elif kind.lower() == "consumer": - activity_kind = SpanKind.CONSUMER + activity_kind = kind # Get context for parent relationship # If parent_context is provided, use it directly @@ -158,6 +152,7 @@ def __init__( kind=activity_kind, context=span_context, start_time=otel_start_time, + links=span_links, ) # Log span creation diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/span_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/span_details.py index bb913108..12710e14 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/span_details.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/span_details.py @@ -5,7 +5,7 @@ from datetime import datetime from opentelemetry.context import Context -from opentelemetry.trace import SpanKind +from opentelemetry.trace import Link, SpanKind @dataclass @@ -23,3 +23,6 @@ class SpanDetails: end_time: datetime | None = None """Optional explicit end time as a datetime object.""" + + span_links: list[Link] | None = None + """Optional span links to associate with this span for causal relationships.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py index f4836787..2913fe70 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from opentelemetry.trace import SpanKind + from ..agent_details import AgentDetails from ..constants import ( GEN_AI_CALLER_CLIENT_IP_KEY, @@ -64,22 +66,24 @@ def __init__( user_details: Optional human user details span_details: Optional span configuration (parent context, timing) """ - parent_context = None - start_time = None - end_time = None - if span_details is not None: - parent_context = span_details.parent_context - start_time = span_details.start_time - end_time = span_details.end_time + # spanKind for OutputScope is always CLIENT + resolved_span_details = ( + SpanDetails( + span_kind=SpanKind.CLIENT, + parent_context=span_details.parent_context if span_details else None, + start_time=span_details.start_time if span_details else None, + end_time=span_details.end_time if span_details else None, + span_links=span_details.span_links if span_details else None, + ) + if span_details + else SpanDetails(span_kind=SpanKind.CLIENT) + ) super().__init__( - kind="Client", operation_name=OUTPUT_OPERATION_NAME, activity_name=(f"{OUTPUT_OPERATION_NAME} {agent_details.agent_id}"), agent_details=agent_details, - parent_context=parent_context, - start_time=start_time, - end_time=end_time, + span_details=resolved_span_details, ) self.set_tag_maybe(GEN_AI_CONVERSATION_ID_KEY, request.conversation_id) 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 45b95b56..883e0d58 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 @@ -24,14 +24,16 @@ # Channel dimensions consts.CHANNEL_NAME_KEY, # microsoft.channel.name consts.CHANNEL_LINK_KEY, # microsoft.channel.link + # User / Caller attributes + consts.USER_ID_KEY, # user.id + consts.USER_NAME_KEY, # user.name + consts.USER_EMAIL_KEY, # user.email + # Service attributes + consts.SERVICE_NAME_KEY, # service.name ] # Invoke Agent–specific attributes INVOKE_AGENT_ATTRIBUTES = [ - # Caller / Invoker attributes - consts.USER_ID_KEY, # user.id - consts.USER_NAME_KEY, # user.name - consts.USER_EMAIL_KEY, # user.email # Caller Agent (A2A) attributes consts.GEN_AI_CALLER_AGENT_ID_KEY, # microsoft.a365.caller.agent.id consts.GEN_AI_CALLER_AGENT_NAME_KEY, # microsoft.a365.caller.agent.name @@ -39,4 +41,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 + # 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 77002742..73821203 100644 --- a/tests/observability/core/test_baggage_builder.py +++ b/tests/observability/core/test_baggage_builder.py @@ -12,6 +12,8 @@ GEN_AI_AGENT_EMAIL_KEY, GEN_AI_AGENT_ID_KEY, GEN_AI_CALLER_CLIENT_IP_KEY, + SERVER_ADDRESS_KEY, + SERVER_PORT_KEY, SERVICE_NAME_KEY, SESSION_DESCRIPTION_KEY, SESSION_ID_KEY, @@ -329,6 +331,39 @@ def test_operation_source_method(self): current_baggage = baggage.get_all() self.assertIsNone(current_baggage.get(SERVICE_NAME_KEY)) + def test_invoke_agent_server_sets_address_and_port(self): + """Test that invoke_agent_server sets both address and non-443 port.""" + address = "app.azurewebsites.net" + port = 8080 + + with BaggageBuilder().invoke_agent_server(address, port).build(): + current_baggage = baggage.get_all() + self.assertEqual(current_baggage.get(SERVER_ADDRESS_KEY), address) + self.assertEqual(current_baggage.get(SERVER_PORT_KEY), str(port)) + + # After scope exit, baggage should be cleared + current_baggage = baggage.get_all() + self.assertIsNone(current_baggage.get(SERVER_ADDRESS_KEY)) + self.assertIsNone(current_baggage.get(SERVER_PORT_KEY)) + + def test_invoke_agent_server_omits_port_when_443(self): + """Test that invoke_agent_server omits port when it is the default 443.""" + address = "app.azurewebsites.net" + + with BaggageBuilder().invoke_agent_server(address, 443).build(): + current_baggage = baggage.get_all() + self.assertEqual(current_baggage.get(SERVER_ADDRESS_KEY), address) + self.assertIsNone(current_baggage.get(SERVER_PORT_KEY)) + + def test_invoke_agent_server_sets_address_only_when_port_none(self): + """Test that invoke_agent_server sets only address when port is None.""" + address = "app.azurewebsites.net" + + with BaggageBuilder().invoke_agent_server(address).build(): + current_baggage = baggage.get_all() + self.assertEqual(current_baggage.get(SERVER_ADDRESS_KEY), address) + self.assertIsNone(current_baggage.get(SERVER_PORT_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 d27a39a1..ed383e5b 100644 --- a/tests/observability/core/test_invoke_agent_scope.py +++ b/tests/observability/core/test_invoke_agent_scope.py @@ -25,6 +25,8 @@ CHANNEL_LINK_KEY, CHANNEL_NAME_KEY, GEN_AI_INPUT_MESSAGES_KEY, + SERVER_ADDRESS_KEY, + SERVER_PORT_KEY, ) from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope from opentelemetry.sdk.trace.export import SimpleSpanProcessor @@ -237,6 +239,38 @@ def test_invoke_agent_scope_span_kind(self): "InvokeAgentScope should create SERVER spans when span_kind parameter is set", ) + def test_span_processor_propagates_server_baggage_for_invoke_agent_span(self): + """Test that SpanProcessor propagates server address/port baggage onto invoke_agent spans.""" + from microsoft_agents_a365.observability.core.middleware.baggage_builder import ( + BaggageBuilder, + ) + + server_address = "myagent.azurewebsites.net" + server_port = 8443 + + # Use InvokeAgentScopeDetails without endpoint so the span processor + # propagates server address/port from baggage (not from endpoint). + scope_details_no_endpoint = InvokeAgentScopeDetails() + + # Set server address/port in baggage, then start an invoke_agent span + with BaggageBuilder().invoke_agent_server(server_address, server_port).build(): + scope = InvokeAgentScope.start( + self.test_request, + scope_details_no_endpoint, + self.agent_details, + ) + if scope is not None: + scope.dispose() + + # Processor should propagate server baggage onto the span + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span to be created") + + span = finished_spans[-1] + span_attributes = getattr(span, "attributes", {}) or {} + self.assertEqual(span_attributes.get(SERVER_ADDRESS_KEY), server_address) + self.assertEqual(span_attributes.get(SERVER_PORT_KEY), str(server_port)) + if __name__ == "__main__": # Run pytest only on the current file diff --git a/tests/observability/core/test_record_attributes.py b/tests/observability/core/test_record_attributes.py index 2faff6d4..c94a4255 100644 --- a/tests/observability/core/test_record_attributes.py +++ b/tests/observability/core/test_record_attributes.py @@ -9,10 +9,12 @@ from microsoft_agents_a365.observability.core import AgentDetails from microsoft_agents_a365.observability.core.config import _telemetry_manager from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope +from microsoft_agents_a365.observability.core.span_details import SpanDetails 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 +from opentelemetry.trace import SpanKind class TestRecordAttributes(unittest.TestCase): @@ -79,10 +81,10 @@ def test_record_attributes_with_dict(self): ) with OpenTelemetryScope( - kind="Internal", operation_name="test_operation", activity_name="test_activity", agent_details=agent_details, + span_details=SpanDetails(span_kind=SpanKind.INTERNAL), ) as scope: # Record custom attributes using a dictionary attributes = { @@ -106,10 +108,10 @@ def test_record_attributes_multiple_calls(self): agent_details = AgentDetails(agent_id="test-agent") with OpenTelemetryScope( - kind="Internal", operation_name="test_operation", activity_name="test_activity", agent_details=agent_details, + span_details=SpanDetails(span_kind=SpanKind.INTERNAL), ) as scope: # First batch of attributes scope.record_attributes({"batch1.key1": "value1", "batch1.key2": "value2"}) @@ -139,10 +141,10 @@ def test_record_attributes_with_telemetry_disabled(self): agent_details = AgentDetails(agent_id="test-agent") with OpenTelemetryScope( - kind="Internal", operation_name="test_operation", activity_name="test_activity", agent_details=agent_details, + span_details=SpanDetails(span_kind=SpanKind.INTERNAL), ) as scope: # This should be a no-op scope.record_attributes({"custom.key": "value"}) @@ -169,10 +171,10 @@ def test_opentelemetry_scope_logging(self, mock_logger): agent_details = AgentDetails(agent_id="test-agent-logging") with OpenTelemetryScope( - kind="Internal", operation_name="test_logging_operation", activity_name=activity_name, agent_details=agent_details, + span_details=SpanDetails(span_kind=SpanKind.INTERNAL), ): pass @@ -213,10 +215,10 @@ def test_opentelemetry_scope_error_logging(self, mock_logger): mock_get_tracer.return_value = mock_tracer with OpenTelemetryScope( - kind="Internal", operation_name="test_error_operation", activity_name=activity_name, agent_details=agent_details, + span_details=SpanDetails(span_kind=SpanKind.INTERNAL), ): pass diff --git a/tests/observability/core/test_span_links.py b/tests/observability/core/test_span_links.py new file mode 100644 index 00000000..3bc58475 --- /dev/null +++ b/tests/observability/core/test_span_links.py @@ -0,0 +1,215 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import os +import sys +import unittest +from pathlib import Path + +import pytest +from microsoft_agents_a365.observability.core import ( + AgentDetails, + ExecuteToolScope, + InferenceCallDetails, + InferenceOperationType, + InvokeAgentScope, + Request, + SpanDetails, + ToolCallDetails, + configure, + get_tracer_provider, +) +from microsoft_agents_a365.observability.core.config import _telemetry_manager +from microsoft_agents_a365.observability.core.invoke_agent_details import InvokeAgentScopeDetails +from microsoft_agents_a365.observability.core.models.response import Response +from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope +from microsoft_agents_a365.observability.core.spans_scopes.output_scope import OutputScope +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.trace import Link, SpanContext, TraceFlags + + +class TestSpanLinks(unittest.TestCase): + """Tests that span links are correctly forwarded to OTel spans on all scope types.""" + + @classmethod + def setUpClass(cls): + os.environ["ENABLE_A365_OBSERVABILITY"] = "true" + configure( + service_name="test-span-links-service", + service_namespace="test-namespace", + ) + + cls.agent_details = AgentDetails( + agent_id="test-agent-123", + agent_name="Test Agent", + agent_description="A test agent", + tenant_id="test-tenant-456", + ) + cls.test_request = Request(conversation_id="test-conv-123") + + cls.sample_links = [ + Link( + context=SpanContext( + trace_id=int("0aa4621e5ae09963a3de354f3d18aa65", 16), + span_id=int("c1aaa519600b1bf0", 16), + is_remote=True, + trace_flags=TraceFlags.SAMPLED, + ), + ), + Link( + context=SpanContext( + trace_id=int("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 16), + span_id=int("aaaaaaaaaaaaaaaa", 16), + is_remote=True, + trace_flags=TraceFlags.DEFAULT, + ), + attributes={"link.reason": "retry"}, + ), + ] + + def setUp(self): + super().setUp() + _telemetry_manager._tracer_provider = None + _telemetry_manager._span_processors = {} + OpenTelemetryScope._tracer = None + + configure( + service_name="test-span-links-service", + service_namespace="test-namespace", + ) + self.span_exporter = InMemorySpanExporter() + tracer_provider = get_tracer_provider() + tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) + + def tearDown(self): + super().tearDown() + self.span_exporter.clear() + + def _get_last_span(self): + spans = self.span_exporter.get_finished_spans() + self.assertTrue(spans, "Expected at least one span") + return spans[-1] + + def test_execute_tool_scope_records_span_links(self): + """Test span links are recorded on ExecuteToolScope spans.""" + tool_details = ToolCallDetails(tool_name="my-tool") + scope = ExecuteToolScope.start( + self.test_request, + tool_details, + self.agent_details, + span_details=SpanDetails(span_links=self.sample_links), + ) + scope.dispose() + + span = self._get_last_span() + self.assertEqual(len(span.links), 2) + self.assertEqual( + f"{span.links[0].context.trace_id:032x}", "0aa4621e5ae09963a3de354f3d18aa65" + ) + self.assertEqual(f"{span.links[0].context.span_id:016x}", "c1aaa519600b1bf0") + self.assertEqual(span.links[0].context.trace_flags, TraceFlags.SAMPLED) + self.assertEqual( + f"{span.links[1].context.trace_id:032x}", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ) + self.assertEqual(span.links[1].attributes.get("link.reason"), "retry") + + def test_invoke_agent_scope_records_span_links(self): + """Test span links are recorded on InvokeAgentScope spans.""" + scope = InvokeAgentScope.start( + self.test_request, + InvokeAgentScopeDetails(), + self.agent_details, + span_details=SpanDetails(span_links=self.sample_links), + ) + scope.dispose() + + span = self._get_last_span() + self.assertEqual(len(span.links), 2) + self.assertEqual( + f"{span.links[0].context.trace_id:032x}", "0aa4621e5ae09963a3de354f3d18aa65" + ) + + def test_inference_scope_records_span_links(self): + """Test span links are recorded on InferenceScope spans.""" + from microsoft_agents_a365.observability.core import InferenceScope + + details = InferenceCallDetails( + operationName=InferenceOperationType.CHAT, + model="gpt-4", + providerName="openai", + ) + scope = InferenceScope.start( + self.test_request, + details, + self.agent_details, + span_details=SpanDetails(span_links=self.sample_links), + ) + scope.dispose() + + span = self._get_last_span() + self.assertEqual(len(span.links), 2) + self.assertEqual( + f"{span.links[0].context.trace_id:032x}", "0aa4621e5ae09963a3de354f3d18aa65" + ) + + def test_output_scope_records_span_links(self): + """Test span links are recorded on OutputScope spans.""" + response = Response(messages=["hello"]) + scope = OutputScope.start( + self.test_request, + response, + self.agent_details, + span_details=SpanDetails(span_links=self.sample_links), + ) + scope.dispose() + + span = self._get_last_span() + self.assertEqual(len(span.links), 2) + self.assertEqual( + f"{span.links[0].context.trace_id:032x}", "0aa4621e5ae09963a3de354f3d18aa65" + ) + + def test_no_span_links_when_omitted(self): + """Test spans have empty links when span_links is not provided.""" + tool_details = ToolCallDetails(tool_name="my-tool") + scope = ExecuteToolScope.start( + self.test_request, + tool_details, + self.agent_details, + ) + scope.dispose() + + span = self._get_last_span() + self.assertEqual(len(span.links), 0) + + def test_span_links_with_typed_attributes(self): + """Test span links preserve typed attributes (string, int).""" + links_with_attrs = [ + Link( + context=SpanContext( + trace_id=int("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 16), + span_id=int("bbbbbbbbbbbbbbbb", 16), + is_remote=True, + trace_flags=TraceFlags.SAMPLED, + ), + attributes={"link.type": "causal", "link.index": 0}, + ), + ] + + scope = InvokeAgentScope.start( + self.test_request, + InvokeAgentScopeDetails(), + self.agent_details, + span_details=SpanDetails(span_links=links_with_attrs), + ) + scope.dispose() + + span = self._get_last_span() + self.assertEqual(len(span.links), 1) + self.assertEqual(span.links[0].attributes.get("link.type"), "causal") + self.assertEqual(span.links[0].attributes.get("link.index"), 0) + + +if __name__ == "__main__": + sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:]))