Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
{
Expand All @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
92 changes: 90 additions & 2 deletions tests/observability/core/test_agent365.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,38 @@
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):
"""Test suite for Agent365 configuration functionality."""

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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
20 changes: 16 additions & 4 deletions tests/observability/core/test_agent365_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
)

Expand Down Expand Up @@ -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
)

Expand Down Expand Up @@ -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
)

Expand Down Expand Up @@ -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()
19 changes: 16 additions & 3 deletions tests/observability/core/test_execute_tool_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,28 @@
# 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,
ToolCallDetails,
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

Expand Down Expand Up @@ -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()
Expand Down
18 changes: 15 additions & 3 deletions tests/observability/core/test_inference_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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()
Expand Down
Loading
Loading