diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py index 0ea6776a..a3915b6b 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py @@ -12,12 +12,14 @@ ) from .execute_tool_scope import ExecuteToolScope from .execution_type import ExecutionType +from .exporters.agent365_exporter_options import Agent365ExporterOptions from .exporters.enriched_span import EnrichedReadableSpan from .exporters.enriching_span_processor import ( get_span_enricher, register_span_enricher, unregister_span_enricher, ) +from .exporters.spectra_exporter_options import SpectraExporterOptions from .inference_call_details import InferenceCallDetails, ServiceEndpoint from .inference_operation_type import InferenceOperationType from .inference_scope import InferenceScope @@ -38,6 +40,9 @@ "is_configured", "get_tracer", "get_tracer_provider", + # Exporter options + "Agent365ExporterOptions", + "SpectraExporterOptions", # Span enrichment "register_span_enricher", "unregister_span_enricher", 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 68acc389..ce451328 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 @@ -8,6 +8,9 @@ from typing import Any, Optional from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as GrpcOTLPSpanExporter, +) from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.resources import SERVICE_NAME, SERVICE_NAMESPACE, Resource from opentelemetry.sdk.trace import TracerProvider @@ -18,6 +21,7 @@ from .exporters.enriching_span_processor import ( _EnrichingBatchSpanProcessor, ) +from .exporters.spectra_exporter_options import SpectraExporterOptions from .exporters.utils import is_agent365_exporter_enabled from .trace_processor.span_processor import SpanProcessor @@ -58,7 +62,7 @@ def configure( logger_name: str = DEFAULT_LOGGER_NAME, token_resolver: Callable[[str, str], str | None] | None = None, cluster_category: str = "prod", - exporter_options: Optional[Agent365ExporterOptions] = None, + exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None = None, suppress_invoke_agent_input: bool = False, **kwargs: Any, ) -> bool: @@ -72,9 +76,10 @@ def configure( Use exporter_options instead. :param cluster_category: (Deprecated) Environment / cluster category (e.g. "prod"). Use exporter_options instead. - :param exporter_options: Agent365ExporterOptions instance for configuring the exporter. - If provided, exporter_options takes precedence. If exporter_options is None, the token_resolver and cluster_category parameters are used as fallback/legacy support to construct a default Agent365ExporterOptions instance. - :param suppress_invoke_agent_input: If True, suppress input messages for spans that are children of InvokeAgent spans. + :param exporter_options: Exporter configuration. Pass Agent365ExporterOptions for A365 API + export, SpectraExporterOptions for Spectra Collector sidecar export, or None (default) + to construct Agent365ExporterOptions from legacy parameters. + :param suppress_invoke_agent_input: If True, suppress input messages for InvokeAgent spans. :return: True if configuration succeeded, False otherwise. """ try: @@ -100,7 +105,7 @@ def _configure_internal( logger_name: str, token_resolver: Callable[[str, str], str | None] | None = None, cluster_category: str = "prod", - exporter_options: Optional[Agent365ExporterOptions] = None, + exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None = None, suppress_invoke_agent_input: bool = False, **kwargs: Any, ) -> bool: @@ -156,25 +161,43 @@ def _configure_internal( "max_export_batch_size": exporter_options.max_export_batch_size, } - if is_agent365_exporter_enabled() and exporter_options.token_resolver is not None: + # Type-based exporter dispatch + if isinstance(exporter_options, SpectraExporterOptions): + # Spectra path — OTLP exporter to sidecar + # ENABLE_A365_OBSERVABILITY_EXPORTER is intentionally ignored. + if exporter_options.protocol == "grpc": + exporter = GrpcOTLPSpanExporter( + endpoint=exporter_options.endpoint, + insecure=exporter_options.insecure, + ) + else: + exporter = OTLPSpanExporter( + endpoint=exporter_options.endpoint, + ) + + elif is_agent365_exporter_enabled() and exporter_options.token_resolver is not None: exporter = _Agent365Exporter( token_resolver=exporter_options.token_resolver, cluster_category=exporter_options.cluster_category, use_s2s_endpoint=exporter_options.use_s2s_endpoint, - suppress_invoke_agent_input=suppress_invoke_agent_input, ) else: exporter = ConsoleSpanExporter() self._logger.warning( - "is_agent365_exporter_enabled() not enabled or token_resolver not set. Falling back to console exporter." + "is_agent365_exporter_enabled() not enabled or token_resolver not set." + " Falling back to console exporter." ) # Add span processors # Create _EnrichingBatchSpanProcessor with optimized settings # This allows extensions to enrich spans before export - batch_processor = _EnrichingBatchSpanProcessor(exporter, **batch_processor_kwargs) + batch_processor = _EnrichingBatchSpanProcessor( + exporter, + suppress_invoke_agent_input=suppress_invoke_agent_input, + **batch_processor_kwargs, + ) agent_processor = SpanProcessor() tracer_provider.add_span_processor(batch_processor) @@ -248,7 +271,8 @@ def configure( logger_name: str = DEFAULT_LOGGER_NAME, token_resolver: Callable[[str, str], str | None] | None = None, cluster_category: str = "prod", - exporter_options: Optional[Agent365ExporterOptions] = None, + exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None = None, + suppress_invoke_agent_input: bool = False, **kwargs: Any, ) -> bool: """ @@ -261,8 +285,10 @@ def configure( Use exporter_options instead. :param cluster_category: (Deprecated) Environment / cluster category (e.g. "prod"). Use exporter_options instead. - :param exporter_options: Agent365ExporterOptions instance for configuring the exporter. - If provided, exporter_options takes precedence. If exporter_options is None, the token_resolver and cluster_category parameters are used as fallback/legacy support to construct a default Agent365ExporterOptions instance. + :param exporter_options: Exporter configuration. Pass Agent365ExporterOptions for A365 API + export, SpectraExporterOptions for Spectra Collector sidecar export, or None (default) + to construct Agent365ExporterOptions from legacy parameters. + :param suppress_invoke_agent_input: If True, suppress input messages for InvokeAgent spans. :return: True if configuration succeeded, False otherwise. """ return _telemetry_manager.configure( @@ -272,6 +298,7 @@ def configure( token_resolver, cluster_category, exporter_options, + suppress_invoke_agent_input, **kwargs, ) 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 index b0e584f5..9b0ab065 100644 --- 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 @@ -2,7 +2,8 @@ # Licensed under the MIT License. from .agent365_exporter_options import Agent365ExporterOptions +from .spectra_exporter_options import SpectraExporterOptions # Agent365Exporter is not exported intentionally. # It should only be used internally by the observability core module. -__all__ = ["Agent365ExporterOptions"] +__all__ = ["Agent365ExporterOptions", "SpectraExporterOptions"] 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 27b54e23..dd04e285 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 @@ -17,11 +17,6 @@ from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult from opentelemetry.trace import StatusCode -from ..constants import ( - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OPERATION_NAME_KEY, - INVOKE_AGENT_OPERATION_NAME, -) from .utils import ( build_export_url, get_validated_domain_override, @@ -59,7 +54,6 @@ def __init__( token_resolver: Callable[[str, str], str | None], cluster_category: str = "prod", use_s2s_endpoint: bool = False, - suppress_invoke_agent_input: bool = False, ): if token_resolver is None: raise ValueError("token_resolver must be provided.") @@ -69,7 +63,6 @@ def __init__( self._token_resolver = token_resolver self._cluster_category = cluster_category self._use_s2s_endpoint = use_s2s_endpoint - self._suppress_invoke_agent_input = suppress_invoke_agent_input # Read domain override once at initialization self._domain_override = get_validated_domain_override() @@ -270,19 +263,6 @@ def _map_span(self, sp: ReadableSpan) -> dict[str, Any]: # attributes attrs = dict(sp.attributes or {}) - # Suppress input messages if configured and current span is an InvokeAgent span - if self._suppress_invoke_agent_input: - # Check if current span is an InvokeAgent span by: - # 1. Span name starts with "invoke_agent" - # 2. Has attribute gen_ai.operation.name set to INVOKE_AGENT_OPERATION_NAME - operation_name = attrs.get(GEN_AI_OPERATION_NAME_KEY) - if ( - sp.name.startswith(INVOKE_AGENT_OPERATION_NAME) - and operation_name == INVOKE_AGENT_OPERATION_NAME - ): - # Remove input messages attribute - attrs.pop(GEN_AI_INPUT_MESSAGES_KEY, None) - # events events = [] for ev in sp.events: diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriched_span.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriched_span.py index b57bd4e7..a6b4e8c1 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriched_span.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriched_span.py @@ -19,22 +19,31 @@ class EnrichedReadableSpan(ReadableSpan): the original span. """ - def __init__(self, span: ReadableSpan, extra_attributes: dict): + def __init__( + self, + span: ReadableSpan, + extra_attributes: dict, + excluded_attribute_keys: set[str] | None = None, + ): """ Initialize the enriched span wrapper. Args: span: The original ReadableSpan to wrap. extra_attributes: Additional attributes to merge with the original. + excluded_attribute_keys: Attribute keys to remove after merging. """ self._span = span self._extra_attributes = extra_attributes + self._excluded_attribute_keys = excluded_attribute_keys or set() @property def attributes(self) -> types.Attributes: """Return merged attributes from original span and extra attributes.""" original = dict(self._span.attributes or {}) original.update(self._extra_attributes) + for key in self._excluded_attribute_keys: + original.pop(key, None) return original @property diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriching_span_processor.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriching_span_processor.py index 03c54775..b324b0d8 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriching_span_processor.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriching_span_processor.py @@ -10,6 +10,13 @@ from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.sdk.trace.export import BatchSpanProcessor +from ..constants import ( + GEN_AI_INPUT_MESSAGES_KEY, + GEN_AI_OPERATION_NAME_KEY, + INVOKE_AGENT_OPERATION_NAME, +) +from .enriched_span import EnrichedReadableSpan + logger = logging.getLogger(__name__) # Single span enricher - only one platform instrumentor should be active at a time @@ -65,6 +72,15 @@ def get_span_enricher() -> Callable[[ReadableSpan], ReadableSpan] | None: class _EnrichingBatchSpanProcessor(BatchSpanProcessor): """BatchSpanProcessor that applies the registered enricher before batching.""" + def __init__( + self, + *args: object, + suppress_invoke_agent_input: bool = False, + **kwargs: object, + ): + super().__init__(*args, **kwargs) + self._suppress_invoke_agent_input = suppress_invoke_agent_input + def on_end(self, span: ReadableSpan) -> None: """Apply the span enricher and pass to parent for batching. @@ -83,4 +99,18 @@ def on_end(self, span: ReadableSpan) -> None: enricher.__name__, ) + # Apply input message suppression for InvokeAgent spans + if self._suppress_invoke_agent_input: + attrs = enriched_span.attributes or {} + operation_name = attrs.get(GEN_AI_OPERATION_NAME_KEY) + if ( + enriched_span.name.startswith(INVOKE_AGENT_OPERATION_NAME) + and operation_name == INVOKE_AGENT_OPERATION_NAME + ): + enriched_span = EnrichedReadableSpan( + enriched_span, + extra_attributes={}, + excluded_attribute_keys={GEN_AI_INPUT_MESSAGES_KEY}, + ) + super().on_end(enriched_span) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py new file mode 100644 index 00000000..826076a3 --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Literal + + +class SpectraExporterOptions: + """ + Configuration for exporting traces to a Spectra Collector sidecar via OTLP. + + Spectra Collector is deployed as a Kubernetes sidecar that accepts + standard OTLP telemetry on localhost. Defaults are tuned for this + deployment topology — most consumers should not need to override them. + + Note: Batch processor fields (max_queue_size, scheduled_delay_ms, etc.) + are duplicated from Agent365ExporterOptions intentionally — these two + options classes have no shared base class per design decision C4. + """ + + _DEFAULT_GRPC_ENDPOINT = "http://localhost:4317" + _DEFAULT_HTTP_ENDPOINT = "http://localhost:4318" + + def __init__( + self, + endpoint: str | None = None, + protocol: Literal["grpc", "http"] = "grpc", + insecure: bool = True, + max_queue_size: int = 2048, + scheduled_delay_ms: int = 5000, + exporter_timeout_ms: int = 30000, + max_export_batch_size: int = 512, + ): + """ + Args: + endpoint: Spectra sidecar OTLP endpoint. Defaults to + http://localhost:4317 for gRPC or http://localhost:4318 for HTTP. + protocol: OTLP protocol — "grpc" or "http". Default: grpc. + insecure: Use insecure (no TLS) connection. Default: True (localhost sidecar). + max_queue_size: Batch processor queue size. Default: 2048. + scheduled_delay_ms: Export interval in milliseconds. Default: 5000. + exporter_timeout_ms: Export timeout in milliseconds. Default: 30000. + max_export_batch_size: Max spans per export batch. Default: 512. + """ + if protocol not in ("grpc", "http"): + raise ValueError(f"protocol must be 'grpc' or 'http', got '{protocol}'") + if endpoint is None: + endpoint = ( + self._DEFAULT_GRPC_ENDPOINT if protocol == "grpc" else self._DEFAULT_HTTP_ENDPOINT + ) + self.endpoint = endpoint + self.protocol = protocol + self.insecure = insecure + self.max_queue_size = max_queue_size + self.scheduled_delay_ms = scheduled_delay_ms + self.exporter_timeout_ms = exporter_timeout_ms + self.max_export_batch_size = max_export_batch_size diff --git a/tests/observability/core/test_agent365.py b/tests/observability/core/test_agent365.py index f8c4869c..ac8adaed 100644 --- a/tests/observability/core/test_agent365.py +++ b/tests/observability/core/test_agent365.py @@ -115,11 +115,11 @@ def test_batch_span_processor_and_exporter_called_with_correct_values( self.assertTrue(result, "configure() should return True") # Verify Agent365Exporter was called with correct parameters + # (suppress_invoke_agent_input is now handled by _EnrichingBatchSpanProcessor) mock_exporter.assert_called_once_with( token_resolver=self.mock_token_resolver, cluster_category="staging", use_s2s_endpoint=True, - suppress_invoke_agent_input=False, ) # Verify BatchSpanProcessor was called with correct parameters from exporter_options diff --git a/tests/observability/core/test_spectra_exporter.py b/tests/observability/core/test_spectra_exporter.py new file mode 100644 index 00000000..e4d19603 --- /dev/null +++ b/tests/observability/core/test_spectra_exporter.py @@ -0,0 +1,293 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import unittest +from unittest.mock import Mock, patch + +from microsoft_agents_a365.observability.core import configure +from microsoft_agents_a365.observability.core.constants import ( + GEN_AI_INPUT_MESSAGES_KEY, + GEN_AI_OPERATION_NAME_KEY, + INVOKE_AGENT_OPERATION_NAME, +) +from microsoft_agents_a365.observability.core.exporters.agent365_exporter_options import ( + Agent365ExporterOptions, +) +from microsoft_agents_a365.observability.core.exporters.enriched_span import ( + EnrichedReadableSpan, +) +from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import ( + _EnrichingBatchSpanProcessor, +) +from microsoft_agents_a365.observability.core.exporters.spectra_exporter_options import ( + SpectraExporterOptions, +) +from opentelemetry.sdk.trace import ReadableSpan + + +class TestSpectraExporterOptions(unittest.TestCase): + """Tests for SpectraExporterOptions class.""" + + def test_spectra_exporter_options_defaults(self): + """All default values are correct.""" + opts = SpectraExporterOptions() + self.assertEqual(opts.endpoint, "http://localhost:4317") + self.assertEqual(opts.protocol, "grpc") + self.assertTrue(opts.insecure) + self.assertEqual(opts.max_queue_size, 2048) + self.assertEqual(opts.scheduled_delay_ms, 5000) + self.assertEqual(opts.exporter_timeout_ms, 30000) + self.assertEqual(opts.max_export_batch_size, 512) + + def test_spectra_exporter_options_http_default_endpoint(self): + """HTTP protocol defaults to port 4318.""" + opts = SpectraExporterOptions(protocol="http") + self.assertEqual(opts.endpoint, "http://localhost:4318") + + def test_spectra_exporter_options_explicit_endpoint_overrides_default(self): + """Explicit endpoint overrides protocol-based default.""" + opts = SpectraExporterOptions(protocol="http", endpoint="http://custom:9999") + self.assertEqual(opts.endpoint, "http://custom:9999") + + def test_spectra_options_invalid_protocol_raises(self): + """ValueError for invalid protocol.""" + with self.assertRaises(ValueError) as ctx: + SpectraExporterOptions(protocol="websocket") + self.assertIn("websocket", str(ctx.exception)) + + +class TestConfigureWithSpectraOptions(unittest.TestCase): + """Tests for configure() with SpectraExporterOptions.""" + + def setUp(self): + 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 + + def tearDown(self): + 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 + + def test_configure_with_spectra_options_default(self): + """configure() succeeds with SpectraExporterOptions() defaults.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions(), + ) + self.assertTrue(result) + + @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") + def test_configure_with_spectra_options_creates_grpc_exporter(self, mock_grpc): + """gRPC OTLPSpanExporter created with correct endpoint and insecure args.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions(), + ) + self.assertTrue(result) + mock_grpc.assert_called_once_with( + endpoint="http://localhost:4317", + insecure=True, + ) + + @patch("microsoft_agents_a365.observability.core.config.OTLPSpanExporter") + def test_configure_with_spectra_options_creates_http_exporter(self, mock_http): + """HTTP OTLPSpanExporter created when protocol='http'.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions(protocol="http"), + ) + self.assertTrue(result) + mock_http.assert_called_once_with( + endpoint="http://localhost:4318", + ) + + @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") + def test_configure_with_spectra_options_custom_endpoint(self, mock_grpc): + """Custom endpoint passed through to exporter.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions(endpoint="http://spectra-sidecar:4317"), + ) + self.assertTrue(result) + mock_grpc.assert_called_once_with( + endpoint="http://spectra-sidecar:4317", + insecure=True, + ) + + @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") + @patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled") + @patch.dict("os.environ", {"ENABLE_A365_OBSERVABILITY_EXPORTER": "true"}) + def test_configure_with_spectra_options_ignores_a365_env_var(self, mock_is_enabled, mock_grpc): + """ENABLE_A365_OBSERVABILITY_EXPORTER=true doesn't create _Agent365Exporter.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions(), + ) + self.assertTrue(result) + mock_grpc.assert_called_once() + # is_agent365_exporter_enabled should not be called when Spectra path is taken + mock_is_enabled.assert_not_called() + + @patch("microsoft_agents_a365.observability.core.config._EnrichingBatchSpanProcessor") + @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") + def test_configure_with_spectra_options_batch_settings(self, mock_grpc, mock_batch): + """Batch processor kwargs extracted from SpectraExporterOptions.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions( + max_queue_size=1024, + scheduled_delay_ms=2000, + exporter_timeout_ms=15000, + max_export_batch_size=256, + ), + ) + self.assertTrue(result) + mock_batch.assert_called_once() + call_kwargs = mock_batch.call_args.kwargs + self.assertEqual(call_kwargs["max_queue_size"], 1024) + self.assertEqual(call_kwargs["schedule_delay_millis"], 2000) + self.assertEqual(call_kwargs["export_timeout_millis"], 15000) + self.assertEqual(call_kwargs["max_export_batch_size"], 256) + + @patch("microsoft_agents_a365.observability.core.config._Agent365Exporter") + @patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled") + def test_configure_with_agent365_options_unchanged(self, mock_is_enabled, mock_exporter): + """A365 regression test — existing path still works identically.""" + mock_is_enabled.return_value = True + mock_token_resolver = Mock() + mock_token_resolver.return_value = "token" + + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=Agent365ExporterOptions( + cluster_category="staging", + token_resolver=mock_token_resolver, + use_s2s_endpoint=True, + ), + ) + self.assertTrue(result) + mock_exporter.assert_called_once_with( + token_resolver=mock_token_resolver, + cluster_category="staging", + use_s2s_endpoint=True, + ) + + @patch("microsoft_agents_a365.observability.core.config.OTLPSpanExporter") + @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") + @patch.dict("os.environ", {"ENABLE_OTLP_EXPORTER": "true"}) + def test_configure_spectra_with_otlp_bolt_on(self, mock_grpc, mock_http_otlp): + """Spectra + ENABLE_OTLP_EXPORTER=true creates two exporters.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions(), + ) + self.assertTrue(result) + # gRPC for Spectra + mock_grpc.assert_called_once() + # HTTP OTLP for bolt-on + mock_http_otlp.assert_called_once() + + @patch("microsoft_agents_a365.observability.core.config._EnrichingBatchSpanProcessor") + @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") + def test_configure_spectra_with_suppress_invoke_agent_input(self, mock_grpc, mock_batch): + """suppress_invoke_agent_input=True passed to batch processor.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions(), + suppress_invoke_agent_input=True, + ) + self.assertTrue(result) + mock_batch.assert_called_once() + call_kwargs = mock_batch.call_args.kwargs + self.assertTrue(call_kwargs["suppress_invoke_agent_input"]) + + +class TestEnrichedSpanExcludedAttributes(unittest.TestCase): + """Tests for EnrichedReadableSpan excluded_attribute_keys.""" + + def test_enriched_span_excluded_attribute_keys(self): + """EnrichedReadableSpan with exclusions removes specified attributes.""" + mock_span = Mock(spec=ReadableSpan) + mock_span.attributes = { + "key1": "value1", + "key2": "value2", + "key3": "value3", + } + + enriched = EnrichedReadableSpan( + mock_span, + extra_attributes={"key4": "value4"}, + excluded_attribute_keys={"key2"}, + ) + + attrs = enriched.attributes + self.assertEqual(attrs["key1"], "value1") + self.assertNotIn("key2", attrs) + self.assertEqual(attrs["key3"], "value3") + self.assertEqual(attrs["key4"], "value4") + + +class TestSuppressInvokeAgentInputInProcessor(unittest.TestCase): + """Tests for suppress_invoke_agent_input in _EnrichingBatchSpanProcessor.""" + + def test_suppress_invoke_agent_input_strips_attribute_in_enriching_processor(self): + """Processor strips gen_ai.input.messages from InvokeAgent spans.""" + mock_exporter = Mock() + + processor = _EnrichingBatchSpanProcessor( + mock_exporter, + suppress_invoke_agent_input=True, + ) + + mock_span = Mock(spec=ReadableSpan) + mock_span.name = "invoke_agent test-agent" + mock_span.attributes = { + GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME, + GEN_AI_INPUT_MESSAGES_KEY: '[{"role": "user", "content": "hello"}]', + "other_key": "other_value", + } + + with patch.object(_EnrichingBatchSpanProcessor, "on_end", wraps=processor.on_end): + # Call on_end directly — the parent's on_end will queue the span + # We patch super().on_end to capture what gets queued + with patch( + "microsoft_agents_a365.observability.core.exporters" + ".enriching_span_processor.BatchSpanProcessor.on_end" + ) as mock_super_on_end: + processor.on_end(mock_span) + + # Verify super().on_end was called with an EnrichedReadableSpan + mock_super_on_end.assert_called_once() + enriched_span = mock_super_on_end.call_args[0][0] + self.assertIsInstance(enriched_span, EnrichedReadableSpan) + + # Verify input messages were stripped + attrs = enriched_span.attributes + self.assertNotIn(GEN_AI_INPUT_MESSAGES_KEY, attrs) + self.assertEqual(attrs["other_key"], "other_value") + + processor.shutdown() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/observability/extensions/openai/test_prompt_suppression.py b/tests/observability/extensions/openai/test_prompt_suppression.py index 2c8e0ffe..1d198885 100644 --- a/tests/observability/extensions/openai/test_prompt_suppression.py +++ b/tests/observability/extensions/openai/test_prompt_suppression.py @@ -2,37 +2,42 @@ # Licensed under the MIT License. import unittest +from unittest.mock import Mock -from microsoft_agents_a365.observability.core.exporters.agent365_exporter import _Agent365Exporter +from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import ( + _EnrichingBatchSpanProcessor, +) class TestPromptSuppressionConfiguration(unittest.TestCase): """Unit tests for prompt suppression configuration in the core SDK.""" - def test_exporter_default_suppression_is_false(self): - """Test that the default value for suppress_invoke_agent_input is False in exporter.""" - exporter = _Agent365Exporter(token_resolver=lambda x, y: "test") + def test_processor_default_suppression_is_false(self): + """Test that the default value for suppress_invoke_agent_input is False in processor.""" + mock_exporter = Mock() + processor = _EnrichingBatchSpanProcessor(mock_exporter) self.assertFalse( - exporter._suppress_invoke_agent_input, + processor._suppress_invoke_agent_input, "Default value for suppress_invoke_agent_input should be False", ) + processor.shutdown() - def test_exporter_can_enable_suppression(self): - """Test that suppression can be enabled via exporter constructor.""" - exporter = _Agent365Exporter( - token_resolver=lambda x, y: "test", suppress_invoke_agent_input=True - ) + def test_processor_can_enable_suppression(self): + """Test that suppression can be enabled via processor constructor.""" + mock_exporter = Mock() + processor = _EnrichingBatchSpanProcessor(mock_exporter, suppress_invoke_agent_input=True) self.assertTrue( - exporter._suppress_invoke_agent_input, + processor._suppress_invoke_agent_input, "suppress_invoke_agent_input should be True when explicitly set", ) + processor.shutdown() def run_tests(): """Run all prompt suppression configuration tests.""" - print("🧪 Running prompt suppression configuration tests...") + print("Running prompt suppression configuration tests...") print("=" * 80) loader = unittest.TestLoader() @@ -42,16 +47,16 @@ def run_tests(): result = runner.run(suite) print("\n" + "=" * 80) - print("🏁 Test Summary:") + print("Test Summary:") print(f"Tests run: {result.testsRun}") print(f"Failures: {len(result.failures)}") print(f"Errors: {len(result.errors)}") if result.wasSuccessful(): - print("🎉 All tests passed!") + print("All tests passed!") return True else: - print("🔧 Some tests failed. Check output above.") + print("Some tests failed. Check output above.") return False