diff --git a/src/strands/telemetry/__init__.py b/src/strands/telemetry/__init__.py index 21dd6ebf2..79906d20a 100644 --- a/src/strands/telemetry/__init__.py +++ b/src/strands/telemetry/__init__.py @@ -3,16 +3,21 @@ This module provides metrics and tracing functionality. """ -from .config import get_otel_resource +from .config import StrandsTelemetry, get_otel_resource from .metrics import EventLoopMetrics, MetricsClient, Trace, metrics_to_string from .tracer import Tracer, get_tracer __all__ = [ + # Metrics "EventLoopMetrics", "Trace", "metrics_to_string", + "MetricsClient", + # Tracer "Tracer", "get_tracer", - "MetricsClient", + # Resource "get_otel_resource", + # Telemetry Setup + "StrandsTelemetry", ] diff --git a/src/strands/telemetry/config.py b/src/strands/telemetry/config.py index 9f5a05fda..b5a93b749 100644 --- a/src/strands/telemetry/config.py +++ b/src/strands/telemetry/config.py @@ -4,26 +4,31 @@ for OpenTelemetry components and other telemetry infrastructure shared across Strands applications. """ +import logging from importlib.metadata import version +import opentelemetry.trace as trace_api +from opentelemetry import propagate +from opentelemetry.baggage.propagation import W3CBaggagePropagator +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.propagators.composite import CompositePropagator from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor +from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator + +logger = logging.getLogger(__name__) def get_otel_resource() -> Resource: """Create a standard OpenTelemetry resource with service information. - This function implements a singleton pattern - it will return the same - Resource object for the same service_name parameter. - - Args: - service_name: Name of the service for OpenTelemetry. - Returns: Resource object with standard service information. """ resource = Resource.create( { - "service.name": __name__, + "service.name": "strands-agents", "service.version": version("strands-agents"), "telemetry.sdk.name": "opentelemetry", "telemetry.sdk.language": "python", @@ -31,3 +36,87 @@ def get_otel_resource() -> Resource: ) return resource + + +class StrandsTelemetry: + """OpenTelemetry configuration and setup for Strands applications. + + It automatically initializes a tracer provider with text map propagators. + Trace exporters (console, OTLP) can be set up individually using dedicated methods. + + Environment Variables: + Environment variables are handled by the underlying OpenTelemetry SDK: + - OTEL_EXPORTER_OTLP_ENDPOINT: OTLP endpoint URL + - OTEL_EXPORTER_OTLP_HEADERS: Headers for OTLP requests + + Example: + Basic setup with console exporter: + >>> telemetry = StrandsTelemetry() + >>> telemetry.setup_console_exporter() + + Setup with OTLP exporter: + >>> telemetry = StrandsTelemetry() + >>> telemetry.setup_otlp_exporter() + + Setup with both exporters: + >>> telemetry = StrandsTelemetry() + >>> telemetry.setup_console_exporter() + >>> telemetry.setup_otlp_exporter() + + Note: + The tracer provider is automatically initialized upon instantiation. + Exporters must be explicitly configured using the setup methods. + Failed exporter configurations are logged but do not raise exceptions. + """ + + def __init__( + self, + ) -> None: + """Initialize the StrandsTelemetry instance. + + Automatically sets up the OpenTelemetry infrastructure. + + The instance is ready to use immediately after initialization, though + trace exporters must be configured separately using the setup methods. + """ + self.resource = get_otel_resource() + self._initialize_tracer() + + def _initialize_tracer(self) -> None: + """Initialize the OpenTelemetry tracer.""" + logger.info("initializing tracer") + + # Create tracer provider + self.tracer_provider = SDKTracerProvider(resource=self.resource) + + # Set as global tracer provider + trace_api.set_tracer_provider(self.tracer_provider) + + # Set up propagators + propagate.set_global_textmap( + CompositePropagator( + [ + W3CBaggagePropagator(), + TraceContextTextMapPropagator(), + ] + ) + ) + + def setup_console_exporter(self) -> None: + """Set up console exporter for the tracer provider.""" + try: + logger.info("enabling console export") + console_processor = SimpleSpanProcessor(ConsoleSpanExporter()) + self.tracer_provider.add_span_processor(console_processor) + except Exception as e: + logger.exception("error=<%s> | Failed to configure console exporter", e) + + def setup_otlp_exporter(self) -> None: + """Set up OTLP exporter for the tracer provider.""" + try: + otlp_exporter = OTLPSpanExporter() + batch_processor = BatchSpanProcessor(otlp_exporter) + self.tracer_provider.add_span_processor(batch_processor) + logger.info("OTLP exporter configured") + except Exception as e: + logger.exception("error=<%s> | Failed to configure OTLP exporter", e) diff --git a/src/strands/telemetry/tracer.py b/src/strands/telemetry/tracer.py index cb0e518a2..0fbe56c36 100644 --- a/src/strands/telemetry/tracer.py +++ b/src/strands/telemetry/tracer.py @@ -6,21 +6,13 @@ import json import logging -import os from datetime import date, datetime, timezone from typing import Any, Dict, Mapping, Optional import opentelemetry.trace as trace_api -from opentelemetry import propagate -from opentelemetry.baggage.propagation import W3CBaggagePropagator -from opentelemetry.propagators.composite import CompositePropagator -from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor from opentelemetry.trace import Span, StatusCode -from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator from ..agent.agent_result import AgentResult -from ..telemetry import get_otel_resource from ..types.content import Message, Messages from ..types.streaming import Usage from ..types.tools import ToolResult, ToolUse @@ -28,19 +20,6 @@ logger = logging.getLogger(__name__) -HAS_OTEL_EXPORTER_MODULE = False -OTEL_EXPORTER_MODULE_ERROR = ( - "opentelemetry-exporter-otlp-proto-http not detected;" - "please install strands-agents with the optional 'otel' target" - "otel http exporting is currently DISABLED" -) -try: - from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter - - HAS_OTEL_EXPORTER_MODULE = True -except ImportError: - pass - class JSONEncoder(json.JSONEncoder): """Custom JSON encoder that handles non-serializable types.""" @@ -106,119 +85,18 @@ class Tracer: def __init__( self, service_name: str = "strands-agents", - otlp_endpoint: Optional[str] = None, - otlp_headers: Optional[Dict[str, str]] = None, - enable_console_export: Optional[bool] = None, ): """Initialize the tracer. Args: service_name: Name of the service for OpenTelemetry. - otlp_endpoint: OTLP endpoint URL for sending traces. - otlp_headers: Headers to include with OTLP requests. - enable_console_export: Whether to also export traces to console. """ - # Check environment variables first - env_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") - env_console_export_str = os.environ.get("STRANDS_OTEL_ENABLE_CONSOLE_EXPORT") - - # Constructor parameters take precedence over environment variables - self.otlp_endpoint = otlp_endpoint or env_endpoint - - if enable_console_export is not None: - self.enable_console_export = enable_console_export - elif env_console_export_str: - self.enable_console_export = env_console_export_str.lower() in ("true", "1", "yes") - else: - self.enable_console_export = False - - # Parse headers from environment if available - env_headers = os.environ.get("OTEL_EXPORTER_OTLP_HEADERS") - if env_headers: - try: - headers_dict = {} - # Parse comma-separated key-value pairs (format: "key1=value1,key2=value2") - for pair in env_headers.split(","): - if "=" in pair: - key, value = pair.split("=", 1) - headers_dict[key.strip()] = value.strip() - otlp_headers = headers_dict - except Exception as e: - logger.warning("error=<%s> | failed to parse OTEL_EXPORTER_OTLP_HEADERS", e) - self.service_name = service_name - self.otlp_headers = otlp_headers or {} self.tracer_provider: Optional[trace_api.TracerProvider] = None self.tracer: Optional[trace_api.Tracer] = None - propagate.set_global_textmap( - CompositePropagator( - [ - W3CBaggagePropagator(), - TraceContextTextMapPropagator(), - ] - ) - ) - if self.otlp_endpoint or self.enable_console_export: - # Create our own tracer provider - self._initialize_tracer() - - def _initialize_tracer(self) -> None: - """Initialize the OpenTelemetry tracer.""" - logger.info("initializing tracer") - if self._is_initialized(): - self.tracer_provider = trace_api.get_tracer_provider() - self.tracer = self.tracer_provider.get_tracer(self.service_name) - return - - resource = get_otel_resource() - - # Create tracer provider - self.tracer_provider = SDKTracerProvider(resource=resource) - - # Add console exporter if enabled - if self.enable_console_export and self.tracer_provider: - logger.info("enabling console export") - console_processor = SimpleSpanProcessor(ConsoleSpanExporter()) - self.tracer_provider.add_span_processor(console_processor) - - # Add OTLP exporter if endpoint is provided - if HAS_OTEL_EXPORTER_MODULE and self.otlp_endpoint and self.tracer_provider: - try: - # Ensure endpoint has the right format - endpoint = self.otlp_endpoint - if not endpoint.endswith("/v1/traces") and not endpoint.endswith("/traces"): - if not endpoint.endswith("/"): - endpoint += "/" - endpoint += "v1/traces" - - # Set default content type header if not provided - headers = self.otlp_headers.copy() - if "Content-Type" not in headers: - headers["Content-Type"] = "application/x-protobuf" - - # Create OTLP exporter and processor - otlp_exporter = OTLPSpanExporter( - endpoint=endpoint, - headers=headers, - ) - - batch_processor = BatchSpanProcessor(otlp_exporter) - self.tracer_provider.add_span_processor(batch_processor) - logger.info("endpoint=<%s> | OTLP exporter configured with endpoint", endpoint) - - except Exception as e: - logger.exception("error=<%s> | Failed to configure OTLP exporter", e) - elif self.otlp_endpoint and self.tracer_provider: - raise ModuleNotFoundError(OTEL_EXPORTER_MODULE_ERROR) - - # Set as global tracer provider - trace_api.set_tracer_provider(self.tracer_provider) - self.tracer = trace_api.get_tracer(self.service_name) - - def _is_initialized(self) -> bool: - tracer_provider = trace_api.get_tracer_provider() - return isinstance(tracer_provider, SDKTracerProvider) + self.tracer_provider = trace_api.get_tracer_provider() + self.tracer = self.tracer_provider.get_tracer(self.service_name) def _start_span( self, @@ -571,31 +449,20 @@ def end_agent_span( def get_tracer( service_name: str = "strands-agents", - otlp_endpoint: Optional[str] = None, - otlp_headers: Optional[Dict[str, str]] = None, - enable_console_export: Optional[bool] = None, ) -> Tracer: """Get or create the global tracer. Args: service_name: Name of the service for OpenTelemetry. - otlp_endpoint: OTLP endpoint URL for sending traces. - otlp_headers: Headers to include with OTLP requests. - enable_console_export: Whether to also export traces to console. Returns: The global tracer instance. """ global _tracer_instance - if ( - _tracer_instance is None or (otlp_endpoint and _tracer_instance.otlp_endpoint != otlp_endpoint) # type: ignore[unreachable] - ): + if not _tracer_instance: _tracer_instance = Tracer( service_name=service_name, - otlp_endpoint=otlp_endpoint, - otlp_headers=otlp_headers, - enable_console_export=enable_console_export, ) return _tracer_instance diff --git a/tests/strands/agent/test_agent.py b/tests/strands/agent/test_agent.py index 7fd1bea6f..2e7639796 100644 --- a/tests/strands/agent/test_agent.py +++ b/tests/strands/agent/test_agent.py @@ -632,10 +632,10 @@ def test_agent__call__callback(mock_model, agent, callback_handler): current_tool_use={"toolUseId": "123", "name": "test", "input": {}}, delta={"toolUse": {"input": '{"value"}'}}, event_loop_cycle_id=unittest.mock.ANY, - event_loop_cycle_span=None, + event_loop_cycle_span=unittest.mock.ANY, event_loop_cycle_trace=unittest.mock.ANY, event_loop_metrics=unittest.mock.ANY, - event_loop_parent_span=None, + event_loop_parent_span=unittest.mock.ANY, request_state={}, ), unittest.mock.call(event={"contentBlockStop": {}}), @@ -645,10 +645,10 @@ def test_agent__call__callback(mock_model, agent, callback_handler): agent=agent, delta={"reasoningContent": {"text": "value"}}, event_loop_cycle_id=unittest.mock.ANY, - event_loop_cycle_span=None, + event_loop_cycle_span=unittest.mock.ANY, event_loop_cycle_trace=unittest.mock.ANY, event_loop_metrics=unittest.mock.ANY, - event_loop_parent_span=None, + event_loop_parent_span=unittest.mock.ANY, reasoning=True, reasoningText="value", request_state={}, @@ -658,10 +658,10 @@ def test_agent__call__callback(mock_model, agent, callback_handler): agent=agent, delta={"reasoningContent": {"signature": "value"}}, event_loop_cycle_id=unittest.mock.ANY, - event_loop_cycle_span=None, + event_loop_cycle_span=unittest.mock.ANY, event_loop_cycle_trace=unittest.mock.ANY, event_loop_metrics=unittest.mock.ANY, - event_loop_parent_span=None, + event_loop_parent_span=unittest.mock.ANY, reasoning=True, reasoning_signature="value", request_state={}, @@ -674,10 +674,10 @@ def test_agent__call__callback(mock_model, agent, callback_handler): data="value", delta={"text": "value"}, event_loop_cycle_id=unittest.mock.ANY, - event_loop_cycle_span=None, + event_loop_cycle_span=unittest.mock.ANY, event_loop_cycle_trace=unittest.mock.ANY, event_loop_metrics=unittest.mock.ANY, - event_loop_parent_span=None, + event_loop_parent_span=unittest.mock.ANY, request_state={}, ), unittest.mock.call(event={"contentBlockStop": {}}), diff --git a/tests/strands/telemetry/test_config.py b/tests/strands/telemetry/test_config.py new file mode 100644 index 000000000..f63afe51e --- /dev/null +++ b/tests/strands/telemetry/test_config.py @@ -0,0 +1,140 @@ +from unittest import mock + +import pytest + +from strands.telemetry import StrandsTelemetry + + +@pytest.fixture +def mock_tracer_provider(): + with mock.patch("strands.telemetry.config.SDKTracerProvider") as mock_provider: + yield mock_provider + + +@pytest.fixture +def mock_get_tracer_provider(): + with mock.patch("strands.telemetry.config.trace_api.get_tracer_provider") as mock_get_tracer_provider: + mock_provider = mock.MagicMock() + mock_get_tracer_provider.return_value = mock_provider + yield mock_provider + + +@pytest.fixture +def mock_tracer(): + with mock.patch("strands.telemetry.config.trace_api.get_tracer") as mock_get_tracer: + mock_tracer = mock.MagicMock() + mock_get_tracer.return_value = mock_tracer + yield mock_tracer + + +@pytest.fixture +def mock_set_tracer_provider(): + with mock.patch("strands.telemetry.config.trace_api.set_tracer_provider") as mock_set: + yield mock_set + + +@pytest.fixture +def mock_set_global_textmap(): + with mock.patch("strands.telemetry.config.propagate.set_global_textmap") as mock_set_global_textmap: + yield mock_set_global_textmap + + +@pytest.fixture +def mock_console_exporter(): + with mock.patch("strands.telemetry.config.ConsoleSpanExporter") as mock_console_exporter: + yield mock_console_exporter + + +@pytest.fixture +def mock_otlp_exporter(): + with mock.patch("strands.telemetry.config.OTLPSpanExporter") as mock_otlp_exporter: + yield mock_otlp_exporter + + +@pytest.fixture +def mock_batch_processor(): + with mock.patch("strands.telemetry.config.BatchSpanProcessor") as mock_batch_processor: + yield mock_batch_processor + + +@pytest.fixture +def mock_simple_processor(): + with mock.patch("strands.telemetry.config.SimpleSpanProcessor") as mock_simple_processor: + yield mock_simple_processor + + +@pytest.fixture +def mock_resource(): + with mock.patch("strands.telemetry.config.get_otel_resource") as mock_resource: + mock_resource_instance = mock.MagicMock() + mock_resource.return_value = mock_resource_instance + yield mock_resource + + +@pytest.fixture +def mock_initialize_tracer(): + with mock.patch("strands.telemetry.StrandsTelemetry._initialize_tracer") as mock_initialize_tracer: + yield mock_initialize_tracer + + +def test_init_default(mock_resource, mock_tracer_provider, mock_set_tracer_provider, mock_set_global_textmap): + """Test initializing the Tracer.""" + + StrandsTelemetry() + + mock_resource.assert_called() + mock_tracer_provider.assert_called_with(resource=mock_resource.return_value) + mock_set_tracer_provider.assert_called_with(mock_tracer_provider.return_value) + mock_set_global_textmap.assert_called() + + +def test_setup_console_exporter(mock_resource, mock_tracer_provider, mock_console_exporter, mock_simple_processor): + """Test add console exporter""" + + telemetry = StrandsTelemetry() + # Set the tracer_provider directly + telemetry.tracer_provider = mock_tracer_provider.return_value + telemetry.setup_console_exporter() + + mock_console_exporter.assert_called_once() + mock_simple_processor.assert_called_once_with(mock_console_exporter.return_value) + + mock_tracer_provider.return_value.add_span_processor.assert_called() + + +def test_setup_otlp_exporter(mock_resource, mock_tracer_provider, mock_otlp_exporter, mock_batch_processor): + """Test add otlp exporter.""" + + telemetry = StrandsTelemetry() + # Set the tracer_provider directly + telemetry.tracer_provider = mock_tracer_provider.return_value + telemetry.setup_otlp_exporter() + + mock_otlp_exporter.assert_called_once() + mock_batch_processor.assert_called_once_with(mock_otlp_exporter.return_value) + + mock_tracer_provider.return_value.add_span_processor.assert_called() + + +def test_setup_console_exporter_exception(mock_resource, mock_tracer_provider, mock_console_exporter): + """Test console exporter with exception.""" + mock_console_exporter.side_effect = Exception("Test exception") + + telemetry = StrandsTelemetry() + telemetry.tracer_provider = mock_tracer_provider.return_value + # This should not raise an exception + telemetry.setup_console_exporter() + + mock_console_exporter.assert_called_once() + + +def test_setup_otlp_exporter_exception(mock_resource, mock_tracer_provider, mock_otlp_exporter): + """Test otlp exporter with exception.""" + mock_otlp_exporter.side_effect = Exception("Test exception") + + telemetry = StrandsTelemetry() + telemetry.tracer_provider = mock_tracer_provider.return_value + # This should not raise an exception + telemetry.setup_otlp_exporter() + + mock_otlp_exporter.assert_called_once() diff --git a/tests/strands/telemetry/test_tracer.py b/tests/strands/telemetry/test_tracer.py index 6ae3e1ad0..78682c496 100644 --- a/tests/strands/telemetry/test_tracer.py +++ b/tests/strands/telemetry/test_tracer.py @@ -18,21 +18,11 @@ def moto_autouse(moto_env, moto_mock_aws): _ = moto_mock_aws -@pytest.fixture -def mock_tracer_provider(): - with mock.patch("strands.telemetry.tracer.SDKTracerProvider") as mock_provider: - yield mock_provider - - -@pytest.fixture -def mock_is_initialized(): - with mock.patch("strands.telemetry.tracer.Tracer._is_initialized") as mock_is_initialized: - yield mock_is_initialized - - @pytest.fixture def mock_get_tracer_provider(): with mock.patch("strands.telemetry.tracer.trace_api.get_tracer_provider") as mock_get_tracer_provider: + mock_tracer = mock.MagicMock() + mock_get_tracer_provider.get_tracer.return_value = mock_tracer yield mock_get_tracer_provider @@ -50,35 +40,6 @@ def mock_span(): return mock_span -@pytest.fixture -def mock_set_tracer_provider(): - with mock.patch("strands.telemetry.tracer.trace_api.set_tracer_provider") as mock_set: - yield mock_set - - -@pytest.fixture -def mock_otlp_exporter(): - with ( - mock.patch("strands.telemetry.tracer.HAS_OTEL_EXPORTER_MODULE", True), - mock.patch("opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter") as mock_exporter, - ): - yield mock_exporter - - -@pytest.fixture -def mock_console_exporter(): - with mock.patch("strands.telemetry.tracer.ConsoleSpanExporter") as mock_exporter: - yield mock_exporter - - -@pytest.fixture -def mock_resource(): - with mock.patch("strands.telemetry.tracer.get_otel_resource") as mock_resource: - mock_resource_instance = mock.MagicMock() - mock_resource.return_value = mock_resource_instance - yield mock_resource - - @pytest.fixture def clean_env(): """Fixture to provide a clean environment for each test.""" @@ -86,135 +47,13 @@ def clean_env(): yield -@pytest.fixture -def env_with_otlp(): - """Fixture with OTLP environment variables.""" - with mock.patch.dict( - os.environ, - { - "OTEL_EXPORTER_OTLP_ENDPOINT": "http://env-endpoint", - }, - ): - yield - - -@pytest.fixture -def env_with_console(): - """Fixture with console export environment variables.""" - with mock.patch.dict( - os.environ, - { - "STRANDS_OTEL_ENABLE_CONSOLE_EXPORT": "true", - }, - ): - yield - - -@pytest.fixture -def env_with_both(): - """Fixture with both OTLP and console export environment variables.""" - with mock.patch.dict( - os.environ, - { - "OTEL_EXPORTER_OTLP_ENDPOINT": "http://env-endpoint", - "STRANDS_OTEL_ENABLE_CONSOLE_EXPORT": "true", - }, - ): - yield - - -@pytest.fixture -def mock_initialize(): - with mock.patch("strands.telemetry.tracer.Tracer._initialize_tracer") as mock_initialize: - yield mock_initialize - - -def test_init_default(mock_is_initialized, mock_get_tracer_provider): +def test_init_default(): """Test initializing the Tracer with default parameters.""" - mock_is_initialized.return_value = False - mock_get_tracer_provider.return_value = None - tracer = Tracer() assert tracer.service_name == "strands-agents" - assert tracer.otlp_endpoint is None - assert tracer.otlp_headers == {} - assert tracer.enable_console_export is False - assert tracer.tracer_provider is None - assert tracer.tracer is None - - -def test_init_with_env_endpoint(): - """Test initializing the Tracer with endpoint from environment variable.""" - with mock.patch.dict(os.environ, {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://test-endpoint"}): - tracer = Tracer() - - assert tracer.otlp_endpoint == "http://test-endpoint" - - -def test_init_with_env_console_export(): - """Test initializing the Tracer with console export from environment variable.""" - with mock.patch.dict(os.environ, {"STRANDS_OTEL_ENABLE_CONSOLE_EXPORT": "true"}): - tracer = Tracer() - - assert tracer.enable_console_export is True - - -def test_init_with_env_headers(): - """Test initializing the Tracer with headers from environment variable.""" - with mock.patch.dict(os.environ, {"OTEL_EXPORTER_OTLP_HEADERS": "key1=value1,key2=value2"}): - tracer = Tracer() - - assert tracer.otlp_headers == {"key1": "value1", "key2": "value2"} - - -def test_initialize_tracer_with_console( - mock_is_initialized, - mock_tracer_provider, - mock_set_tracer_provider, - mock_console_exporter, - mock_resource, -): - """Test initializing the tracer with console exporter.""" - mock_is_initialized.return_value = False - - # Initialize Tracer - Tracer(enable_console_export=True) - - # Verify the tracer provider was created with correct resource - mock_tracer_provider.assert_called_once_with(resource=mock_resource.return_value) - - # Verify console exporter was added - mock_console_exporter.assert_called_once() - mock_tracer_provider.return_value.add_span_processor.assert_called_once() - - # Verify set_tracer_provider was called - mock_set_tracer_provider.assert_called_once_with(mock_tracer_provider.return_value) - - -def test_initialize_tracer_with_otlp( - mock_is_initialized, mock_tracer_provider, mock_set_tracer_provider, mock_otlp_exporter, mock_resource -): - """Test initializing the tracer with OTLP exporter.""" - mock_is_initialized.return_value = False - - # Initialize Tracer - with ( - mock.patch("strands.telemetry.tracer.HAS_OTEL_EXPORTER_MODULE", True), - mock.patch("strands.telemetry.tracer.OTLPSpanExporter", mock_otlp_exporter), - ): - Tracer(otlp_endpoint="http://test-endpoint") - - # Verify the tracer provider was created with correct resource - mock_tracer_provider.assert_called_once_with(resource=mock_resource.return_value) - - # Verify OTLP exporter was added with correct endpoint - mock_otlp_exporter.assert_called_once() - assert "endpoint" in mock_otlp_exporter.call_args.kwargs - assert "headers" in mock_otlp_exporter.call_args.kwargs - - # Verify set_tracer_provider was called - mock_set_tracer_provider.assert_called_once_with(mock_tracer_provider.return_value) + assert tracer.tracer_provider is not None + assert tracer.tracer is not None def test_start_span_no_tracer(): @@ -222,13 +61,13 @@ def test_start_span_no_tracer(): tracer = Tracer() span = tracer._start_span("test_span") - assert span is None + assert span is not None def test_start_span(mock_tracer): """Test starting a span with attributes.""" with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): - tracer = Tracer(enable_console_export=True) + tracer = Tracer() tracer.tracer = mock_tracer mock_span = mock.MagicMock() @@ -299,7 +138,7 @@ def test_end_span_with_error_message(mock_span): def test_start_model_invoke_span(mock_tracer): """Test starting a model invoke span.""" with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): - tracer = Tracer(enable_console_export=True) + tracer = Tracer() tracer.tracer = mock_tracer mock_span = mock.MagicMock() @@ -337,7 +176,7 @@ def test_end_model_invoke_span(mock_span): def test_start_tool_call_span(mock_tracer): """Test starting a tool call span.""" with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): - tracer = Tracer(enable_console_export=True) + tracer = Tracer() tracer.tracer = mock_tracer mock_span = mock.MagicMock() @@ -375,7 +214,7 @@ def test_end_tool_call_span(mock_span): def test_start_event_loop_cycle_span(mock_tracer): """Test starting an event loop cycle span.""" with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): - tracer = Tracer(enable_console_export=True) + tracer = Tracer() tracer.tracer = mock_tracer mock_span = mock.MagicMock() @@ -410,7 +249,7 @@ def test_end_event_loop_cycle_span(mock_span): def test_start_agent_span(mock_tracer): """Test starting an agent span.""" with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): - tracer = Tracer(enable_console_export=True) + tracer = Tracer() tracer.tracer = mock_tracer mock_span = mock.MagicMock() @@ -476,10 +315,9 @@ def test_get_tracer_new_endpoint(): # Reset the singleton first with mock.patch("strands.telemetry.tracer._tracer_instance", None): tracer1 = get_tracer() - tracer2 = get_tracer(otlp_endpoint="http://new-endpoint") + tracer2 = get_tracer() - assert tracer1 is not tracer2 - assert tracer2.otlp_endpoint == "http://new-endpoint" + assert tracer1 is tracer2 def test_get_tracer_parameters(): @@ -488,75 +326,16 @@ def test_get_tracer_parameters(): with mock.patch("strands.telemetry.tracer._tracer_instance", None): tracer = get_tracer( service_name="test-service", - otlp_endpoint="http://test-endpoint", - otlp_headers={"key": "value"}, - enable_console_export=True, ) assert tracer.service_name == "test-service" - assert tracer.otlp_endpoint == "http://test-endpoint" - assert tracer.otlp_headers == {"key": "value"} - assert tracer.enable_console_export is True - - -def test_initialize_tracer_with_invalid_otlp_endpoint( - mock_is_initialized, mock_tracer_provider, mock_set_tracer_provider, mock_otlp_exporter, mock_resource -): - """Test initializing the tracer with an invalid OTLP endpoint.""" - mock_is_initialized.return_value = False - - mock_otlp_exporter.side_effect = Exception("Connection error") - - # This should not raise an exception, but should log an error - - # Initialize Tracer - with ( - mock.patch("strands.telemetry.tracer.HAS_OTEL_EXPORTER_MODULE", True), - mock.patch("strands.telemetry.tracer.OTLPSpanExporter", mock_otlp_exporter), - ): - Tracer(otlp_endpoint="http://invalid-endpoint") - - # Verify the tracer provider was created with correct resource - mock_tracer_provider.assert_called_once_with(resource=mock_resource.return_value) - - # Verify OTLP exporter was attempted - mock_otlp_exporter.assert_called_once() - - # Verify set_tracer_provider was still called - mock_set_tracer_provider.assert_called_once_with(mock_tracer_provider.return_value) -def test_initialize_tracer_with_missing_module( - mock_is_initialized, mock_tracer_provider, mock_set_tracer_provider, mock_resource -): - """Test initializing the tracer when the OTLP exporter module is missing.""" - mock_is_initialized.return_value = False - - # Initialize Tracer with OTLP endpoint but missing module - with ( - mock.patch("strands.telemetry.tracer.HAS_OTEL_EXPORTER_MODULE", False), - pytest.raises(ModuleNotFoundError) as excinfo, - ): - Tracer(otlp_endpoint="http://test-endpoint") - - # Verify the error message - assert "opentelemetry-exporter-otlp-proto-http not detected" in str(excinfo.value) - assert "otel http exporting is currently DISABLED" in str(excinfo.value) - - # Verify the tracer provider was created with correct resource - mock_tracer_provider.assert_called_once_with(resource=mock_resource.return_value) - - # Verify set_tracer_provider was not called since an exception was raised - mock_set_tracer_provider.assert_not_called() - - -def test_initialize_tracer_with_custom_tracer_provider(mock_is_initialized, mock_get_tracer_provider, mock_resource): +def test_initialize_tracer_with_custom_tracer_provider(mock_get_tracer_provider): """Test initializing the tracer with NoOpTracerProvider.""" - mock_is_initialized.return_value = True - tracer = Tracer(otlp_endpoint="http://invalid-endpoint") + tracer = Tracer() mock_get_tracer_provider.assert_called() - mock_resource.assert_not_called() assert tracer.tracer_provider is not None assert tracer.tracer is not None @@ -579,11 +358,12 @@ def test_end_span_with_exception_handling(mock_span): pytest.fail("_end_span should not raise exceptions") -def test_force_flush_with_error(mock_span, mock_tracer_provider): +def test_force_flush_with_error(mock_span, mock_get_tracer_provider): """Test force flush with error handling.""" # Setup the tracer with a provider that raises an exception on force_flush tracer = Tracer() - tracer.tracer_provider = mock_tracer_provider + + mock_tracer_provider = mock_get_tracer_provider.return_value mock_tracer_provider.force_flush.side_effect = Exception("Force flush error") # Should not raise an exception @@ -607,7 +387,7 @@ def test_end_tool_call_span_with_none(mock_span): def test_start_model_invoke_span_with_parent(mock_tracer): """Test starting a model invoke span with a parent span.""" with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): - tracer = Tracer(enable_console_export=True) + tracer = Tracer() tracer.tracer = mock_tracer mock_span = mock.MagicMock() @@ -801,50 +581,3 @@ def test_serialize_vs_json_dumps(): custom_result = serialize({"text": japanese_text}) assert japanese_text in custom_result assert "\\u" not in custom_result - - -def test_init_with_no_env_or_param(clean_env): - """Test initializing with neither environment variable nor constructor parameter.""" - tracer = Tracer() - assert tracer.otlp_endpoint is None - assert tracer.enable_console_export is False - - tracer = Tracer(otlp_endpoint="http://param-endpoint") - assert tracer.otlp_endpoint == "http://param-endpoint" - - tracer = Tracer(enable_console_export=True) - assert tracer.enable_console_export is True - - -def test_constructor_params_with_otlp_env(env_with_otlp): - """Test constructor parameters precedence over OTLP environment variable.""" - # Constructor parameter should take precedence - tracer = Tracer(otlp_endpoint="http://constructor-endpoint") - assert tracer.otlp_endpoint == "http://constructor-endpoint" - - # Without constructor parameter, should use env var - tracer = Tracer() - assert tracer.otlp_endpoint == "http://env-endpoint" - - -def test_constructor_params_with_console_env(env_with_console): - """Test constructor parameters precedence over console environment variable.""" - # Constructor parameter should take precedence - tracer = Tracer(enable_console_export=False) - assert tracer.enable_console_export is False - - # Without explicit constructor parameter, should use env var - tracer = Tracer() - assert tracer.enable_console_export is True - - -def test_fallback_to_env_vars(env_with_both): - """Test fallback to environment variables when no constructor parameters.""" - tracer = Tracer() - assert tracer.otlp_endpoint == "http://env-endpoint" - assert tracer.enable_console_export is True - - # Constructor parameters should still take precedence - tracer = Tracer(otlp_endpoint="http://constructor-endpoint", enable_console_export=False) - assert tracer.otlp_endpoint == "http://constructor-endpoint" - assert tracer.enable_console_export is False