Skip to content

Commit b42d522

Browse files
nikhilNavanikhilc-microsoftCopilot
authored
Fix initialization (#97)
* ensure exporter is added if reusing the tracer provider * Mak te A365exporter internal and final so its not instantiated outside the core sdk * add tests * Fix __init__.py filename and add copyright header in exporters module (#99) * Initial plan * Fix filename and add copyright header to exporters __init__.py Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> * fix PR comments and tests * fix PR comments --------- Co-authored-by: Nikhil Chitlur Navakiran (from Dev Box) <nikhilc@microsoft.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com>
1 parent 802060f commit b42d522

10 files changed

Lines changed: 201 additions & 35 deletions

File tree

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from opentelemetry.sdk.trace import TracerProvider
1111
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
1212

13-
from .exporters.agent365_exporter import Agent365Exporter
13+
from .exporters.agent365_exporter import _Agent365Exporter
1414
from .exporters.agent365_exporter_options import Agent365ExporterOptions
1515
from .exporters.utils import is_agent365_exporter_enabled
1616
from .trace_processor.span_processor import SpanProcessor
@@ -96,6 +96,13 @@ def _configure_internal(
9696
) -> bool:
9797
"""Internal configuration method - not thread-safe, must be called with lock."""
9898

99+
# Check if a365 observability is already configured
100+
if self._tracer_provider is not None:
101+
self._logger.warning(
102+
"a365 observability already configured. Ignoring repeated configure() call."
103+
)
104+
return True
105+
99106
# Create resource with service information
100107
resource = Resource.create(
101108
{
@@ -104,23 +111,24 @@ def _configure_internal(
104111
}
105112
)
106113

107-
# Get existing tracer provider or create new one
108-
try:
109-
tracer_provider = trace.get_tracer_provider()
110-
# Check if it's already configured
111-
if hasattr(tracer_provider, "resource") and tracer_provider.resource:
112-
# Already configured, just add our span processor
113-
agent_processor = SpanProcessor()
114-
tracer_provider.add_span_processor(agent_processor)
115-
self._tracer_provider = tracer_provider
116-
self._span_processors["agent"] = agent_processor
117-
return True
118-
except Exception:
119-
pass
120-
121-
# Configure tracer provider
122-
tracer_provider = TracerProvider(resource=resource)
123-
trace.set_tracer_provider(tracer_provider)
114+
# Check if there's an existing TracerProvider (from app's OTEL setup)
115+
tracer_provider = trace.get_tracer_provider()
116+
117+
# Determine if we should use existing provider or create new one
118+
# Check if it's a real TracerProvider with a resource (not a proxy/no-op)
119+
if getattr(tracer_provider, "resource", None):
120+
# Use existing provider from application's OTEL setup
121+
self._logger.info(
122+
"Detected existing TracerProvider with resource. "
123+
"Adding a365 observability processors to it."
124+
)
125+
else:
126+
# Create new TracerProvider with our resource
127+
self._logger.info("Creating new TracerProvider for a365 observability.")
128+
tracer_provider = TracerProvider(resource=resource)
129+
trace.set_tracer_provider(tracer_provider)
130+
131+
# Store reference
124132
self._tracer_provider = tracer_provider
125133

126134
# Use exporter_options if provided, otherwise create default options with legacy parameters
@@ -139,7 +147,7 @@ def _configure_internal(
139147
}
140148

141149
if is_agent365_exporter_enabled() and exporter_options.token_resolver is not None:
142-
exporter = Agent365Exporter(
150+
exporter = _Agent365Exporter(
143151
token_resolver=exporter_options.token_resolver,
144152
cluster_category=exporter_options.cluster_category,
145153
use_s2s_endpoint=exporter_options.use_s2s_endpoint,
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from .agent365_exporter_options import Agent365ExporterOptions
5+
6+
# Agent365Exporter is not exported intentionally.
7+
# It should only be used internally by the observability core module.
8+
__all__ = ["Agent365ExporterOptions"]

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/__init__py

Whitespace-only changes.

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import threading
1010
import time
1111
from collections.abc import Callable, Sequence
12-
from typing import Any
12+
from typing import Any, final
1313

1414
import requests
1515
from microsoft_agents_a365.runtime.power_platform_api_discovery import PowerPlatformApiDiscovery
@@ -36,7 +36,8 @@
3636
logger = logging.getLogger(__name__)
3737

3838

39-
class Agent365Exporter(SpanExporter):
39+
@final
40+
class _Agent365Exporter(SpanExporter):
4041
"""
4142
Agent 365 span exporter for Agent 365:
4243
* Partitions spans by (tenantId, agentId)

tests/observability/core/test_agent365.py

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,38 @@
99
Agent365ExporterOptions,
1010
)
1111
from microsoft_agents_a365.observability.core.trace_processor import SpanProcessor
12+
from opentelemetry.sdk.resources import Resource
13+
from opentelemetry.sdk.trace import TracerProvider
1214

1315

1416
class TestAgent365Configure(unittest.TestCase):
1517
"""Test suite for Agent365 configuration functionality."""
1618

1719
def setUp(self):
1820
"""Set up test fixtures."""
21+
# Reset TelemetryManager state before each test
22+
from microsoft_agents_a365.observability.core.config import _telemetry_manager
23+
from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope
24+
25+
_telemetry_manager._tracer_provider = None
26+
_telemetry_manager._span_processors = {}
27+
OpenTelemetryScope._tracer = None
28+
1929
self.mock_token_resolver = Mock()
2030
self.mock_token_resolver.return_value = "test_token_123"
2131

32+
def tearDown(self):
33+
"""Clean up after each test."""
34+
# Reset the telemetry manager singleton state
35+
from microsoft_agents_a365.observability.core.config import _telemetry_manager
36+
from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope
37+
38+
_telemetry_manager._tracer_provider = None
39+
_telemetry_manager._span_processors = {}
40+
OpenTelemetryScope._tracer = None
41+
42+
# Do NOT reset otel_trace._TRACER_PROVIDER to None to avoid NonRecordingSpan issues in other tests
43+
2244
def test_configure_basic_functionality(self):
2345
"""Test configure function with basic parameters and legacy parameters."""
2446
# Test basic configuration without exporter_options
@@ -61,13 +83,13 @@ def test_configure_with_exporter_options_and_parameter_precedence(self, mock_is_
6183
)
6284
self.assertTrue(result, "configure() should return True with exporter_options")
6385

64-
@patch("microsoft_agents_a365.observability.core.config.Agent365Exporter")
86+
@patch("microsoft_agents_a365.observability.core.config._Agent365Exporter")
6587
@patch("microsoft_agents_a365.observability.core.config.BatchSpanProcessor")
6688
@patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled")
6789
def test_batch_span_processor_and_exporter_called_with_correct_values(
6890
self, mock_is_enabled, mock_batch_processor, mock_exporter
6991
):
70-
"""Test that BatchSpanProcessor and Agent365Exporter are called with correct values from exporter_options."""
92+
"""Test that BatchSpanProcessor and _Agent365Exporter are called with correct values from exporter_options."""
7193
# Enable Agent365 exporter for this test
7294
mock_is_enabled.return_value = True
7395

@@ -112,6 +134,72 @@ def test_span_processor_creation(self):
112134
processor = SpanProcessor()
113135
self.assertIsNotNone(processor, "SpanProcessor should be created successfully")
114136

137+
def test_configure_prevents_duplicate_initialization(self):
138+
"""Test that calling configure() multiple times doesn't reinitialize."""
139+
result1 = configure(
140+
service_name="test-service-1",
141+
service_namespace="test-namespace-1",
142+
)
143+
self.assertTrue(result1)
144+
145+
with patch(
146+
"microsoft_agents_a365.observability.core.config._telemetry_manager._logger"
147+
) as mock_logger:
148+
result2 = configure(
149+
service_name="test-service-2",
150+
service_namespace="test-namespace-2",
151+
)
152+
self.assertTrue(result2)
153+
mock_logger.warning.assert_called_once()
154+
self.assertIn("already configured", mock_logger.warning.call_args[0][0].lower())
155+
156+
@patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled")
157+
@patch("microsoft_agents_a365.observability.core.config.trace.get_tracer_provider")
158+
def test_configure_uses_existing_tracer_provider(self, mock_get_provider, mock_is_enabled):
159+
"""Test configure() uses existing TracerProvider and adds processors without calling set_tracer_provider."""
160+
mock_is_enabled.return_value = False
161+
162+
existing_provider = TracerProvider(
163+
resource=Resource.create({"service.name": "existing-service"})
164+
)
165+
mock_get_provider.return_value = existing_provider
166+
167+
with patch(
168+
"microsoft_agents_a365.observability.core.config._telemetry_manager._logger"
169+
) as mock_logger:
170+
with patch(
171+
"microsoft_agents_a365.observability.core.config.trace.set_tracer_provider"
172+
) as mock_set:
173+
result = configure(service_name="new-service", service_namespace="new-namespace")
174+
self.assertTrue(result)
175+
176+
# Verify existing provider was detected
177+
info_calls = [call[0][0] for call in mock_logger.info.call_args_list]
178+
self.assertTrue(
179+
any("Detected existing TracerProvider" in msg for msg in info_calls)
180+
)
181+
182+
# Verify didn't call set_tracer_provider
183+
mock_set.assert_not_called()
184+
185+
# Verify both processors were added by inspecting the MultiSpanProcessor
186+
187+
active_processor = existing_provider._active_span_processor
188+
self.assertIsNotNone(active_processor)
189+
190+
# MultiSpanProcessor has a _span_processors list
191+
processors = active_processor._span_processors
192+
self.assertEqual(
193+
len(processors),
194+
2,
195+
"Should have 2 processors: BatchSpanProcessor and SpanProcessor",
196+
)
197+
198+
# Verify types of processors
199+
processor_types = [type(p).__name__ for p in processors]
200+
self.assertIn("BatchSpanProcessor", processor_types)
201+
self.assertIn("SpanProcessor", processor_types)
202+
115203

116204
if __name__ == "__main__":
117205
unittest.main()

tests/observability/core/test_agent365_exporter.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from microsoft_agents_a365.observability.core.constants import GEN_AI_AGENT_ID_KEY, TENANT_ID_KEY
88
from microsoft_agents_a365.observability.core.exporters.agent365_exporter import (
9-
Agent365Exporter,
9+
_Agent365Exporter,
1010
)
1111
from opentelemetry.sdk.trace import ReadableSpan
1212
from opentelemetry.sdk.trace.export import SpanExportResult
@@ -21,7 +21,7 @@ def setUp(self):
2121
self.mock_token_resolver.return_value = "test_token_123"
2222

2323
# Don't patch the class in setUp, do it per test
24-
self.exporter = Agent365Exporter(
24+
self.exporter = _Agent365Exporter(
2525
token_resolver=self.mock_token_resolver, cluster_category="test"
2626
)
2727

@@ -216,7 +216,7 @@ def test_partitioning_by_scope(self):
216216
def test_s2s_endpoint_path_when_enabled(self):
217217
"""Test 4: Test that S2S endpoint path is used when use_s2s_endpoint is True."""
218218
# Arrange - Create exporter with S2S endpoint enabled
219-
s2s_exporter = Agent365Exporter(
219+
s2s_exporter = _Agent365Exporter(
220220
token_resolver=self.mock_token_resolver, cluster_category="test", use_s2s_endpoint=True
221221
)
222222

@@ -252,7 +252,7 @@ def test_s2s_endpoint_path_when_enabled(self):
252252
def test_default_endpoint_path_when_s2s_disabled(self):
253253
"""Test 5: Test that default endpoint path is used when use_s2s_endpoint is False."""
254254
# Arrange - Create exporter with S2S endpoint disabled (default behavior)
255-
default_exporter = Agent365Exporter(
255+
default_exporter = _Agent365Exporter(
256256
token_resolver=self.mock_token_resolver, cluster_category="test", use_s2s_endpoint=False
257257
)
258258

@@ -372,6 +372,18 @@ def test_export_error_logging(self, mock_logger):
372372
"No spans with tenant/agent identity found; nothing exported."
373373
)
374374

375+
def test_exporter_is_internal(self):
376+
"""Test that _Agent365Exporter is marked as internal/private.
377+
378+
The underscore prefix convention indicates this class is internal to the SDK
379+
and should not be instantiated directly by developers.
380+
"""
381+
382+
self.assertTrue(
383+
_Agent365Exporter.__name__.startswith("_"),
384+
"Exporter class should be prefixed with underscore to indicate it's private/internal",
385+
)
386+
375387

376388
if __name__ == "__main__":
377389
unittest.main()

tests/observability/core/test_execute_tool_scope.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,28 @@
22
# Licensed under the MIT License.
33

44
import os
5-
from pathlib import Path
65
import sys
76
import unittest
8-
import pytest
7+
from pathlib import Path
98

9+
import pytest
1010
from microsoft_agents_a365.observability.core import (
1111
AgentDetails,
12-
ExecutionType,
1312
ExecuteToolScope,
13+
ExecutionType,
1414
Request,
1515
SourceMetadata,
1616
TenantDetails,
1717
ToolCallDetails,
1818
configure,
1919
get_tracer_provider,
2020
)
21+
from microsoft_agents_a365.observability.core.config import _telemetry_manager
2122
from microsoft_agents_a365.observability.core.constants import (
2223
GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY,
2324
GEN_AI_EXECUTION_SOURCE_NAME_KEY,
2425
)
26+
from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope
2527
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
2628
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
2729

@@ -56,6 +58,17 @@ def setUpClass(cls):
5658
def setUp(self):
5759
super().setUp()
5860

61+
# Reset TelemetryManager state to ensure fresh configuration
62+
_telemetry_manager._tracer_provider = None
63+
_telemetry_manager._span_processors = {}
64+
OpenTelemetryScope._tracer = None
65+
66+
# Reconfigure to get a fresh TracerProvider
67+
configure(
68+
service_name="test-execute-tool-service",
69+
service_namespace="test-namespace",
70+
)
71+
5972
# Set up tracer to capture spans
6073
self.span_exporter = InMemorySpanExporter()
6174
tracer_provider = get_tracer_provider()

tests/observability/core/test_inference_scope.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
# Licensed under the MIT License.
33

44
import os
5-
from pathlib import Path
65
import sys
76
import unittest
8-
import pytest
7+
from pathlib import Path
98

9+
import pytest
1010
from microsoft_agents_a365.observability.core import (
1111
ExecutionType,
1212
InferenceCallDetails,
@@ -19,10 +19,12 @@
1919
get_tracer_provider,
2020
)
2121
from microsoft_agents_a365.observability.core.agent_details import AgentDetails
22+
from microsoft_agents_a365.observability.core.config import _telemetry_manager
2223
from microsoft_agents_a365.observability.core.constants import (
2324
GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY,
2425
GEN_AI_EXECUTION_SOURCE_NAME_KEY,
2526
)
27+
from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope
2628
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
2729
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
2830

@@ -47,11 +49,21 @@ def setUpClass(cls):
4749
def setUp(self):
4850
super().setUp()
4951

52+
# Reset TelemetryManager state to ensure fresh configuration
53+
_telemetry_manager._tracer_provider = None
54+
_telemetry_manager._span_processors = {}
55+
OpenTelemetryScope._tracer = None
56+
57+
# Reconfigure to get a fresh TracerProvider
58+
configure(
59+
service_name="test-inference-service",
60+
service_namespace="test-namespace",
61+
)
62+
5063
# Set up tracer to capture spans
5164
self.span_exporter = InMemorySpanExporter()
5265
tracer_provider = get_tracer_provider()
5366
tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter))
54-
# trace.set_tracer_provider(tracer_provider)
5567

5668
def tearDown(self):
5769
super().tearDown()

0 commit comments

Comments
 (0)