diff --git a/src/strands/telemetry/config.py b/src/strands/telemetry/config.py index 93225335d..0f475b4b8 100644 --- a/src/strands/telemetry/config.py +++ b/src/strands/telemetry/config.py @@ -19,6 +19,7 @@ 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 import NoOpTracerProvider from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator logger = logging.getLogger(__name__) @@ -54,9 +55,12 @@ class StrandsTelemetry: Args: tracer_provider: Optional pre-configured SDKTracerProvider. If None, a new one will be created and set as the global tracer provider. + enabled: Whether to enable OpenTelemetry instrumentation. Defaults to True. + Can also be disabled via STRANDS_OTEL_ENABLED=false environment variable. + When disabled, uses NoOpTracerProvider and all setup methods become no-ops. Environment Variables: - Environment variables are handled by the underlying OpenTelemetry SDK: + - STRANDS_OTEL_ENABLED: Set to "false" to disable instrumentation (default: true) - OTEL_EXPORTER_OTLP_ENDPOINT: OTLP endpoint URL - OTEL_EXPORTER_OTLP_HEADERS: Headers for OTLP requests - OTEL_SERVICE_NAME: Overrides resource service name @@ -68,13 +72,20 @@ class StrandsTelemetry: Using a custom tracer provider: >>> StrandsTelemetry(tracer_provider=my_provider).setup_console_exporter() + Disable instrumentation programmatically: + >>> StrandsTelemetry(enabled=False) + + Disable instrumentation via environment variable: + >>> # export STRANDS_OTEL_ENABLED=false + >>> StrandsTelemetry() # Will use NoOpTracerProvider + Step-by-step configuration: >>> telemetry = StrandsTelemetry() >>> telemetry.setup_console_exporter() >>> telemetry.setup_otlp_exporter() To setup global meter provider - >>> telemetry.setup_meter(enable_console_exporter=True, enable_otlp_exporter=True) # default are False + >>> telemetry.setup_meter(enable_console_exporter=True, enable_otlp_exporter=True) Note: - The tracer provider is automatically initialized upon instantiation @@ -82,27 +93,53 @@ class StrandsTelemetry: - Exporters must be explicitly configured using the setup methods - Failed exporter configurations are logged but do not raise exceptions - All setup methods return self to enable method chaining + - When disabled, all methods are no-ops and return self for chaining compatibility """ def __init__( self, tracer_provider: SDKTracerProvider | None = None, + enabled: bool | None = None, ) -> None: """Initialize the StrandsTelemetry instance. Args: tracer_provider: Optional pre-configured tracer provider. If None, a new one will be created and set as global. + enabled: Whether to enable telemetry. Defaults to True unless + STRANDS_OTEL_ENABLED environment variable is set to "false". The instance is ready to use immediately after initialization, though trace exporters must be configured separately using the setup methods. """ + # Determine if telemetry is enabled + if enabled is None: + env_value = os.environ.get("STRANDS_OTEL_ENABLED", "true").lower() + self._enabled = env_value not in ("false", "0", "no", "off") + else: + self._enabled = enabled + self.resource = get_otel_resource() + + if not self._enabled: + logger.info("OpenTelemetry instrumentation disabled") + self.tracer_provider = NoOpTracerProvider() # type: ignore[assignment] + return + if tracer_provider: self.tracer_provider = tracer_provider else: self._initialize_tracer() + @property + def enabled(self) -> bool: + """Check if telemetry is enabled. + + Returns: + True if telemetry is enabled, False otherwise. + """ + return self._enabled + def _initialize_tracer(self) -> None: """Initialize the OpenTelemetry tracer.""" logger.info("Initializing tracer") @@ -136,7 +173,13 @@ def setup_console_exporter(self, **kwargs: Any) -> "StrandsTelemetry": This method configures a SimpleSpanProcessor with a ConsoleSpanExporter, allowing trace data to be output to the console. Any additional keyword arguments provided will be forwarded to the ConsoleSpanExporter. + + Note: + This is a no-op if telemetry is disabled. """ + if not self._enabled: + return self + try: logger.info("Enabling console export") console_processor = SimpleSpanProcessor(ConsoleSpanExporter(**kwargs)) @@ -158,7 +201,13 @@ def setup_otlp_exporter(self, **kwargs: Any) -> "StrandsTelemetry": This method configures a BatchSpanProcessor with an OTLPSpanExporter, allowing trace data to be exported to an OTLP endpoint. Any additional keyword arguments provided will be forwarded to the OTLPSpanExporter. + + Note: + This is a no-op if telemetry is disabled. """ + if not self._enabled: + return self + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter try: @@ -173,7 +222,14 @@ def setup_otlp_exporter(self, **kwargs: Any) -> "StrandsTelemetry": def setup_meter( self, enable_console_exporter: bool = False, enable_otlp_exporter: bool = False ) -> "StrandsTelemetry": - """Initialize the OpenTelemetry Meter.""" + """Initialize the OpenTelemetry Meter. + + Note: + This is a no-op if telemetry is disabled. + """ + if not self._enabled: + return self + logger.info("Initializing meter") metrics_readers = [] try: diff --git a/tests/strands/telemetry/test_config.py b/tests/strands/telemetry/test_config.py index cc08c295c..5e9977a20 100644 --- a/tests/strands/telemetry/test_config.py +++ b/tests/strands/telemetry/test_config.py @@ -231,3 +231,100 @@ def test_get_otel_resource_respects_otel_service_name(monkeypatch): resource = telemetry_config.get_otel_resource() assert resource.attributes.get("service.name") == "my-service" + + +def test_init_disabled_programmatically(mock_resource): + """Test initializing telemetry with enabled=False.""" + from opentelemetry.trace import NoOpTracerProvider + + telemetry = StrandsTelemetry(enabled=False) + + assert telemetry.enabled is False + assert isinstance(telemetry.tracer_provider, NoOpTracerProvider) + + +def test_init_disabled_via_env_var_false(mock_resource, monkeypatch): + """Test disabling telemetry via STRANDS_OTEL_ENABLED=false.""" + from opentelemetry.trace import NoOpTracerProvider + + monkeypatch.setenv("STRANDS_OTEL_ENABLED", "false") + telemetry = StrandsTelemetry() + + assert telemetry.enabled is False + assert isinstance(telemetry.tracer_provider, NoOpTracerProvider) + + +def test_init_disabled_via_env_var_0(mock_resource, monkeypatch): + """Test disabling telemetry via STRANDS_OTEL_ENABLED=0.""" + from opentelemetry.trace import NoOpTracerProvider + + monkeypatch.setenv("STRANDS_OTEL_ENABLED", "0") + telemetry = StrandsTelemetry() + + assert telemetry.enabled is False + assert isinstance(telemetry.tracer_provider, NoOpTracerProvider) + + +def test_init_disabled_via_env_var_off(mock_resource, monkeypatch): + """Test disabling telemetry via STRANDS_OTEL_ENABLED=off.""" + from opentelemetry.trace import NoOpTracerProvider + + monkeypatch.setenv("STRANDS_OTEL_ENABLED", "off") + telemetry = StrandsTelemetry() + + assert telemetry.enabled is False + assert isinstance(telemetry.tracer_provider, NoOpTracerProvider) + + +def test_init_enabled_explicit(mock_resource, mock_tracer_provider, mock_set_tracer_provider, mock_set_global_textmap): + """Test that enabled=True explicitly enables telemetry.""" + telemetry = StrandsTelemetry(enabled=True) + + assert telemetry.enabled is True + mock_tracer_provider.assert_called() + + +def test_init_enabled_overrides_env_var( + mock_resource, mock_tracer_provider, mock_set_tracer_provider, mock_set_global_textmap, monkeypatch +): + """Test that explicit enabled=True overrides STRANDS_OTEL_ENABLED=false.""" + monkeypatch.setenv("STRANDS_OTEL_ENABLED", "false") + telemetry = StrandsTelemetry(enabled=True) + + assert telemetry.enabled is True + mock_tracer_provider.assert_called() + + +def test_setup_console_exporter_noop_when_disabled(mock_resource, mock_console_exporter): + """Test that setup_console_exporter is a no-op when disabled.""" + telemetry = StrandsTelemetry(enabled=False) + result = telemetry.setup_console_exporter() + + mock_console_exporter.assert_not_called() + assert result is telemetry # Should still return self for chaining + + +def test_setup_otlp_exporter_noop_when_disabled(mock_resource, mock_otlp_exporter): + """Test that setup_otlp_exporter is a no-op when disabled.""" + telemetry = StrandsTelemetry(enabled=False) + result = telemetry.setup_otlp_exporter() + + mock_otlp_exporter.assert_not_called() + assert result is telemetry # Should still return self for chaining + + +def test_setup_meter_noop_when_disabled(mock_resource, mock_meter_provider, mock_metrics_api): + """Test that setup_meter is a no-op when disabled.""" + telemetry = StrandsTelemetry(enabled=False) + result = telemetry.setup_meter(enable_console_exporter=True, enable_otlp_exporter=True) + + mock_meter_provider.assert_not_called() + assert result is telemetry # Should still return self for chaining + + +def test_method_chaining_when_disabled(mock_resource): + """Test that method chaining still works when disabled.""" + telemetry = StrandsTelemetry(enabled=False) + result = telemetry.setup_console_exporter().setup_otlp_exporter().setup_meter() + + assert result is telemetry diff --git a/tests/strands/tools/test_decorator_pep563.py b/tests/strands/tools/test_decorator_pep563.py index 07ec8f2ba..44d9a626a 100644 --- a/tests/strands/tools/test_decorator_pep563.py +++ b/tests/strands/tools/test_decorator_pep563.py @@ -10,10 +10,10 @@ from __future__ import annotations -from typing import Any +from typing import Any, Literal import pytest -from typing_extensions import Literal, TypedDict +from typing_extensions import TypedDict from strands import tool