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 4f10da10..c48b0d37 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 @@ -10,7 +10,7 @@ from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter -from .exporters.agent365_exporter import Agent365Exporter +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 @@ -96,6 +96,13 @@ def _configure_internal( ) -> bool: """Internal configuration method - not thread-safe, must be called with lock.""" + # Check if a365 observability is already configured + if self._tracer_provider is not None: + self._logger.warning( + "a365 observability already configured. Ignoring repeated configure() call." + ) + return True + # Create resource with service information resource = Resource.create( { @@ -104,23 +111,24 @@ def _configure_internal( } ) - # Get existing tracer provider or create new one - try: - tracer_provider = trace.get_tracer_provider() - # Check if it's already configured - if hasattr(tracer_provider, "resource") and tracer_provider.resource: - # Already configured, just add our span processor - agent_processor = SpanProcessor() - tracer_provider.add_span_processor(agent_processor) - self._tracer_provider = tracer_provider - self._span_processors["agent"] = agent_processor - return True - except Exception: - pass - - # Configure tracer provider - tracer_provider = TracerProvider(resource=resource) - trace.set_tracer_provider(tracer_provider) + # Check if there's an existing TracerProvider (from app's OTEL setup) + tracer_provider = trace.get_tracer_provider() + + # Determine if we should use existing provider or create new one + # Check if it's a real TracerProvider with a resource (not a proxy/no-op) + if getattr(tracer_provider, "resource", None): + # Use existing provider from application's OTEL setup + self._logger.info( + "Detected existing TracerProvider with resource. " + "Adding a365 observability processors to it." + ) + else: + # Create new TracerProvider with our resource + self._logger.info("Creating new TracerProvider for a365 observability.") + tracer_provider = TracerProvider(resource=resource) + trace.set_tracer_provider(tracer_provider) + + # Store reference self._tracer_provider = tracer_provider # Use exporter_options if provided, otherwise create default options with legacy parameters @@ -139,7 +147,7 @@ def _configure_internal( } if is_agent365_exporter_enabled() and exporter_options.token_resolver is not None: - exporter = Agent365Exporter( + exporter = _Agent365Exporter( token_resolver=exporter_options.token_resolver, cluster_category=exporter_options.cluster_category, use_s2s_endpoint=exporter_options.use_s2s_endpoint, diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/__init__.py new file mode 100644 index 00000000..b0e584f5 --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from .agent365_exporter_options import Agent365ExporterOptions + +# Agent365Exporter is not exported intentionally. +# It should only be used internally by the observability core module. +__all__ = ["Agent365ExporterOptions"] diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/__init__py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/__init__py deleted file mode 100644 index e69de29b..00000000 diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py index 2824416e..e2018c75 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py @@ -9,7 +9,7 @@ import threading import time from collections.abc import Callable, Sequence -from typing import Any +from typing import Any, final import requests from microsoft_agents_a365.runtime.power_platform_api_discovery import PowerPlatformApiDiscovery @@ -36,7 +36,8 @@ logger = logging.getLogger(__name__) -class Agent365Exporter(SpanExporter): +@final +class _Agent365Exporter(SpanExporter): """ Agent 365 span exporter for Agent 365: * Partitions spans by (tenantId, agentId) diff --git a/tests/observability/core/test_agent365.py b/tests/observability/core/test_agent365.py index 2ebde6ad..adef977c 100644 --- a/tests/observability/core/test_agent365.py +++ b/tests/observability/core/test_agent365.py @@ -9,6 +9,8 @@ Agent365ExporterOptions, ) from microsoft_agents_a365.observability.core.trace_processor import SpanProcessor +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider class TestAgent365Configure(unittest.TestCase): @@ -16,9 +18,29 @@ class TestAgent365Configure(unittest.TestCase): def setUp(self): """Set up test fixtures.""" + # Reset TelemetryManager state before each test + from microsoft_agents_a365.observability.core.config import _telemetry_manager + from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope + + _telemetry_manager._tracer_provider = None + _telemetry_manager._span_processors = {} + OpenTelemetryScope._tracer = None + self.mock_token_resolver = Mock() self.mock_token_resolver.return_value = "test_token_123" + def tearDown(self): + """Clean up after each test.""" + # Reset the telemetry manager singleton state + from microsoft_agents_a365.observability.core.config import _telemetry_manager + from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope + + _telemetry_manager._tracer_provider = None + _telemetry_manager._span_processors = {} + OpenTelemetryScope._tracer = None + + # Do NOT reset otel_trace._TRACER_PROVIDER to None to avoid NonRecordingSpan issues in other tests + def test_configure_basic_functionality(self): """Test configure function with basic parameters and legacy parameters.""" # Test basic configuration without exporter_options @@ -61,13 +83,13 @@ def test_configure_with_exporter_options_and_parameter_precedence(self, mock_is_ ) 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._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.""" + """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 @@ -112,6 +134,72 @@ def test_span_processor_creation(self): processor = SpanProcessor() self.assertIsNotNone(processor, "SpanProcessor should be created successfully") + def test_configure_prevents_duplicate_initialization(self): + """Test that calling configure() multiple times doesn't reinitialize.""" + result1 = configure( + service_name="test-service-1", + service_namespace="test-namespace-1", + ) + self.assertTrue(result1) + + with patch( + "microsoft_agents_a365.observability.core.config._telemetry_manager._logger" + ) as mock_logger: + result2 = configure( + service_name="test-service-2", + service_namespace="test-namespace-2", + ) + self.assertTrue(result2) + mock_logger.warning.assert_called_once() + self.assertIn("already configured", mock_logger.warning.call_args[0][0].lower()) + + @patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled") + @patch("microsoft_agents_a365.observability.core.config.trace.get_tracer_provider") + def test_configure_uses_existing_tracer_provider(self, mock_get_provider, mock_is_enabled): + """Test configure() uses existing TracerProvider and adds processors without calling set_tracer_provider.""" + mock_is_enabled.return_value = False + + existing_provider = TracerProvider( + resource=Resource.create({"service.name": "existing-service"}) + ) + mock_get_provider.return_value = existing_provider + + with patch( + "microsoft_agents_a365.observability.core.config._telemetry_manager._logger" + ) as mock_logger: + with patch( + "microsoft_agents_a365.observability.core.config.trace.set_tracer_provider" + ) as mock_set: + result = configure(service_name="new-service", service_namespace="new-namespace") + self.assertTrue(result) + + # Verify existing provider was detected + info_calls = [call[0][0] for call in mock_logger.info.call_args_list] + self.assertTrue( + any("Detected existing TracerProvider" in msg for msg in info_calls) + ) + + # Verify didn't call set_tracer_provider + mock_set.assert_not_called() + + # Verify both processors were added by inspecting the MultiSpanProcessor + + active_processor = existing_provider._active_span_processor + self.assertIsNotNone(active_processor) + + # MultiSpanProcessor has a _span_processors list + processors = active_processor._span_processors + self.assertEqual( + len(processors), + 2, + "Should have 2 processors: BatchSpanProcessor and SpanProcessor", + ) + + # Verify types of processors + processor_types = [type(p).__name__ for p in processors] + self.assertIn("BatchSpanProcessor", processor_types) + self.assertIn("SpanProcessor", processor_types) + if __name__ == "__main__": unittest.main() diff --git a/tests/observability/core/test_agent365_exporter.py b/tests/observability/core/test_agent365_exporter.py index e157f6c7..ec89b1cb 100644 --- a/tests/observability/core/test_agent365_exporter.py +++ b/tests/observability/core/test_agent365_exporter.py @@ -6,7 +6,7 @@ from microsoft_agents_a365.observability.core.constants import GEN_AI_AGENT_ID_KEY, TENANT_ID_KEY from microsoft_agents_a365.observability.core.exporters.agent365_exporter import ( - Agent365Exporter, + _Agent365Exporter, ) from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.sdk.trace.export import SpanExportResult @@ -21,7 +21,7 @@ def setUp(self): self.mock_token_resolver.return_value = "test_token_123" # Don't patch the class in setUp, do it per test - self.exporter = Agent365Exporter( + self.exporter = _Agent365Exporter( token_resolver=self.mock_token_resolver, cluster_category="test" ) @@ -216,7 +216,7 @@ def test_partitioning_by_scope(self): def test_s2s_endpoint_path_when_enabled(self): """Test 4: Test that S2S endpoint path is used when use_s2s_endpoint is True.""" # Arrange - Create exporter with S2S endpoint enabled - s2s_exporter = Agent365Exporter( + s2s_exporter = _Agent365Exporter( token_resolver=self.mock_token_resolver, cluster_category="test", use_s2s_endpoint=True ) @@ -252,7 +252,7 @@ def test_s2s_endpoint_path_when_enabled(self): def test_default_endpoint_path_when_s2s_disabled(self): """Test 5: Test that default endpoint path is used when use_s2s_endpoint is False.""" # Arrange - Create exporter with S2S endpoint disabled (default behavior) - default_exporter = Agent365Exporter( + default_exporter = _Agent365Exporter( token_resolver=self.mock_token_resolver, cluster_category="test", use_s2s_endpoint=False ) @@ -372,6 +372,18 @@ def test_export_error_logging(self, mock_logger): "No spans with tenant/agent identity found; nothing exported." ) + def test_exporter_is_internal(self): + """Test that _Agent365Exporter is marked as internal/private. + + The underscore prefix convention indicates this class is internal to the SDK + and should not be instantiated directly by developers. + """ + + self.assertTrue( + _Agent365Exporter.__name__.startswith("_"), + "Exporter class should be prefixed with underscore to indicate it's private/internal", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/observability/core/test_execute_tool_scope.py b/tests/observability/core/test_execute_tool_scope.py index 76e9c2b4..187ee269 100644 --- a/tests/observability/core/test_execute_tool_scope.py +++ b/tests/observability/core/test_execute_tool_scope.py @@ -2,15 +2,15 @@ # Licensed under the MIT License. import os -from pathlib import Path import sys import unittest -import pytest +from pathlib import Path +import pytest from microsoft_agents_a365.observability.core import ( AgentDetails, - ExecutionType, ExecuteToolScope, + ExecutionType, Request, SourceMetadata, TenantDetails, @@ -18,10 +18,12 @@ configure, get_tracer_provider, ) +from microsoft_agents_a365.observability.core.config import _telemetry_manager from microsoft_agents_a365.observability.core.constants import ( GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, GEN_AI_EXECUTION_SOURCE_NAME_KEY, ) +from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter @@ -56,6 +58,17 @@ def setUpClass(cls): def setUp(self): super().setUp() + # Reset TelemetryManager state to ensure fresh configuration + _telemetry_manager._tracer_provider = None + _telemetry_manager._span_processors = {} + OpenTelemetryScope._tracer = None + + # Reconfigure to get a fresh TracerProvider + configure( + service_name="test-execute-tool-service", + service_namespace="test-namespace", + ) + # Set up tracer to capture spans self.span_exporter = InMemorySpanExporter() tracer_provider = get_tracer_provider() diff --git a/tests/observability/core/test_inference_scope.py b/tests/observability/core/test_inference_scope.py index c7361bd0..01eef83f 100644 --- a/tests/observability/core/test_inference_scope.py +++ b/tests/observability/core/test_inference_scope.py @@ -2,11 +2,11 @@ # Licensed under the MIT License. import os -from pathlib import Path import sys import unittest -import pytest +from pathlib import Path +import pytest from microsoft_agents_a365.observability.core import ( ExecutionType, InferenceCallDetails, @@ -19,10 +19,12 @@ get_tracer_provider, ) from microsoft_agents_a365.observability.core.agent_details import AgentDetails +from microsoft_agents_a365.observability.core.config import _telemetry_manager from microsoft_agents_a365.observability.core.constants import ( GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, GEN_AI_EXECUTION_SOURCE_NAME_KEY, ) +from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter @@ -47,11 +49,21 @@ def setUpClass(cls): def setUp(self): super().setUp() + # Reset TelemetryManager state to ensure fresh configuration + _telemetry_manager._tracer_provider = None + _telemetry_manager._span_processors = {} + OpenTelemetryScope._tracer = None + + # Reconfigure to get a fresh TracerProvider + configure( + service_name="test-inference-service", + service_namespace="test-namespace", + ) + # Set up tracer to capture spans self.span_exporter = InMemorySpanExporter() tracer_provider = get_tracer_provider() tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) - # trace.set_tracer_provider(tracer_provider) def tearDown(self): super().tearDown() diff --git a/tests/observability/core/test_invoke_agent_scope.py b/tests/observability/core/test_invoke_agent_scope.py index eec2b30e..7541e221 100644 --- a/tests/observability/core/test_invoke_agent_scope.py +++ b/tests/observability/core/test_invoke_agent_scope.py @@ -2,12 +2,12 @@ # Licensed under the MIT License. import os -from pathlib import Path import sys import unittest -import pytest +from pathlib import Path from urllib.parse import urlparse +import pytest from microsoft_agents_a365.observability.core import ( AgentDetails, ExecutionType, @@ -19,6 +19,7 @@ configure, get_tracer_provider, ) +from microsoft_agents_a365.observability.core.config import _telemetry_manager from microsoft_agents_a365.observability.core.constants import ( GEN_AI_CALLER_AGENT_USER_CLIENT_IP, GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, @@ -27,6 +28,7 @@ GEN_AI_INPUT_MESSAGES_KEY, ) from microsoft_agents_a365.observability.core.models.caller_details import CallerDetails +from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter @@ -97,6 +99,18 @@ def setUpClass(cls): def setUp(self): super().setUp() + # Reset TelemetryManager state to ensure fresh configuration + + _telemetry_manager._tracer_provider = None + _telemetry_manager._span_processors = {} + OpenTelemetryScope._tracer = None + + # Reconfigure to get a fresh TracerProvider + configure( + service_name="test-invoke-agent-service", + service_namespace="test-namespace", + ) + # Set up tracer to capture spans self.span_exporter = InMemorySpanExporter() tracer_provider = get_tracer_provider() diff --git a/tests/observability/core/test_record_attributes.py b/tests/observability/core/test_record_attributes.py index c161b2df..95f1ee57 100644 --- a/tests/observability/core/test_record_attributes.py +++ b/tests/observability/core/test_record_attributes.py @@ -7,6 +7,7 @@ from unittest.mock import Mock, patch from microsoft_agents_a365.observability.core import AgentDetails, TenantDetails +from microsoft_agents_a365.observability.core.config import _telemetry_manager from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider @@ -53,6 +54,15 @@ def setUpClass(cls): def setUp(self): """Clear spans before each test.""" + # Reset TelemetryManager state to ensure fresh configuration + _telemetry_manager._tracer_provider = None + _telemetry_manager._span_processors = {} + + # Create a fresh TracerProvider for this test + provider = TracerProvider() + trace.set_tracer_provider(provider) + provider.add_span_processor(SimpleSpanProcessor(self.exporter)) + # Force OpenTelemetryScope to refresh its tracer reference OpenTelemetryScope._tracer = None self.exporter.clear()