Skip to content
Open
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
62 changes: 59 additions & 3 deletions src/strands/telemetry/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand All @@ -68,41 +72,74 @@ 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
- When no tracer_provider is provided, the instance sets itself as the global provider
- 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")
Expand Down Expand Up @@ -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))
Expand All @@ -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:
Expand All @@ -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:
Expand Down
97 changes: 97 additions & 0 deletions tests/strands/telemetry/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions tests/strands/tools/test_decorator_pep563.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down