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
9 changes: 7 additions & 2 deletions src/strands/telemetry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
103 changes: 96 additions & 7 deletions src/strands/telemetry/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,119 @@
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",
}
)

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)
139 changes: 3 additions & 136 deletions src/strands/telemetry/tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,41 +6,20 @@

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
from ..types.traces import AttributeValue

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."""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions tests/strands/agent/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {}}),
Expand All @@ -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={},
Expand All @@ -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={},
Expand All @@ -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": {}}),
Expand Down
Loading