Skip to content

Commit 6a1ccea

Browse files
authored
chore: refactor tracer (#286)
1 parent 2ba7459 commit 6a1ccea

File tree

6 files changed

+273
-439
lines changed

6 files changed

+273
-439
lines changed

src/strands/telemetry/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,21 @@
33
This module provides metrics and tracing functionality.
44
"""
55

6-
from .config import get_otel_resource
6+
from .config import StrandsTelemetry, get_otel_resource
77
from .metrics import EventLoopMetrics, MetricsClient, Trace, metrics_to_string
88
from .tracer import Tracer, get_tracer
99

1010
__all__ = [
11+
# Metrics
1112
"EventLoopMetrics",
1213
"Trace",
1314
"metrics_to_string",
15+
"MetricsClient",
16+
# Tracer
1417
"Tracer",
1518
"get_tracer",
16-
"MetricsClient",
19+
# Resource
1720
"get_otel_resource",
21+
# Telemetry Setup
22+
"StrandsTelemetry",
1823
]

src/strands/telemetry/config.py

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,119 @@
44
for OpenTelemetry components and other telemetry infrastructure shared across Strands applications.
55
"""
66

7+
import logging
78
from importlib.metadata import version
89

10+
import opentelemetry.trace as trace_api
11+
from opentelemetry import propagate
12+
from opentelemetry.baggage.propagation import W3CBaggagePropagator
13+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
14+
from opentelemetry.propagators.composite import CompositePropagator
915
from opentelemetry.sdk.resources import Resource
16+
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
17+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor
18+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
19+
20+
logger = logging.getLogger(__name__)
1021

1122

1223
def get_otel_resource() -> Resource:
1324
"""Create a standard OpenTelemetry resource with service information.
1425
15-
This function implements a singleton pattern - it will return the same
16-
Resource object for the same service_name parameter.
17-
18-
Args:
19-
service_name: Name of the service for OpenTelemetry.
20-
2126
Returns:
2227
Resource object with standard service information.
2328
"""
2429
resource = Resource.create(
2530
{
26-
"service.name": __name__,
31+
"service.name": "strands-agents",
2732
"service.version": version("strands-agents"),
2833
"telemetry.sdk.name": "opentelemetry",
2934
"telemetry.sdk.language": "python",
3035
}
3136
)
3237

3338
return resource
39+
40+
41+
class StrandsTelemetry:
42+
"""OpenTelemetry configuration and setup for Strands applications.
43+
44+
It automatically initializes a tracer provider with text map propagators.
45+
Trace exporters (console, OTLP) can be set up individually using dedicated methods.
46+
47+
Environment Variables:
48+
Environment variables are handled by the underlying OpenTelemetry SDK:
49+
- OTEL_EXPORTER_OTLP_ENDPOINT: OTLP endpoint URL
50+
- OTEL_EXPORTER_OTLP_HEADERS: Headers for OTLP requests
51+
52+
Example:
53+
Basic setup with console exporter:
54+
>>> telemetry = StrandsTelemetry()
55+
>>> telemetry.setup_console_exporter()
56+
57+
Setup with OTLP exporter:
58+
>>> telemetry = StrandsTelemetry()
59+
>>> telemetry.setup_otlp_exporter()
60+
61+
Setup with both exporters:
62+
>>> telemetry = StrandsTelemetry()
63+
>>> telemetry.setup_console_exporter()
64+
>>> telemetry.setup_otlp_exporter()
65+
66+
Note:
67+
The tracer provider is automatically initialized upon instantiation.
68+
Exporters must be explicitly configured using the setup methods.
69+
Failed exporter configurations are logged but do not raise exceptions.
70+
"""
71+
72+
def __init__(
73+
self,
74+
) -> None:
75+
"""Initialize the StrandsTelemetry instance.
76+
77+
Automatically sets up the OpenTelemetry infrastructure.
78+
79+
The instance is ready to use immediately after initialization, though
80+
trace exporters must be configured separately using the setup methods.
81+
"""
82+
self.resource = get_otel_resource()
83+
self._initialize_tracer()
84+
85+
def _initialize_tracer(self) -> None:
86+
"""Initialize the OpenTelemetry tracer."""
87+
logger.info("initializing tracer")
88+
89+
# Create tracer provider
90+
self.tracer_provider = SDKTracerProvider(resource=self.resource)
91+
92+
# Set as global tracer provider
93+
trace_api.set_tracer_provider(self.tracer_provider)
94+
95+
# Set up propagators
96+
propagate.set_global_textmap(
97+
CompositePropagator(
98+
[
99+
W3CBaggagePropagator(),
100+
TraceContextTextMapPropagator(),
101+
]
102+
)
103+
)
104+
105+
def setup_console_exporter(self) -> None:
106+
"""Set up console exporter for the tracer provider."""
107+
try:
108+
logger.info("enabling console export")
109+
console_processor = SimpleSpanProcessor(ConsoleSpanExporter())
110+
self.tracer_provider.add_span_processor(console_processor)
111+
except Exception as e:
112+
logger.exception("error=<%s> | Failed to configure console exporter", e)
113+
114+
def setup_otlp_exporter(self) -> None:
115+
"""Set up OTLP exporter for the tracer provider."""
116+
try:
117+
otlp_exporter = OTLPSpanExporter()
118+
batch_processor = BatchSpanProcessor(otlp_exporter)
119+
self.tracer_provider.add_span_processor(batch_processor)
120+
logger.info("OTLP exporter configured")
121+
except Exception as e:
122+
logger.exception("error=<%s> | Failed to configure OTLP exporter", e)

src/strands/telemetry/tracer.py

Lines changed: 3 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -6,41 +6,20 @@
66

77
import json
88
import logging
9-
import os
109
from datetime import date, datetime, timezone
1110
from typing import Any, Dict, Mapping, Optional
1211

1312
import opentelemetry.trace as trace_api
14-
from opentelemetry import propagate
15-
from opentelemetry.baggage.propagation import W3CBaggagePropagator
16-
from opentelemetry.propagators.composite import CompositePropagator
17-
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
18-
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor
1913
from opentelemetry.trace import Span, StatusCode
20-
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
2114

2215
from ..agent.agent_result import AgentResult
23-
from ..telemetry import get_otel_resource
2416
from ..types.content import Message, Messages
2517
from ..types.streaming import Usage
2618
from ..types.tools import ToolResult, ToolUse
2719
from ..types.traces import AttributeValue
2820

2921
logger = logging.getLogger(__name__)
3022

31-
HAS_OTEL_EXPORTER_MODULE = False
32-
OTEL_EXPORTER_MODULE_ERROR = (
33-
"opentelemetry-exporter-otlp-proto-http not detected;"
34-
"please install strands-agents with the optional 'otel' target"
35-
"otel http exporting is currently DISABLED"
36-
)
37-
try:
38-
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
39-
40-
HAS_OTEL_EXPORTER_MODULE = True
41-
except ImportError:
42-
pass
43-
4423

4524
class JSONEncoder(json.JSONEncoder):
4625
"""Custom JSON encoder that handles non-serializable types."""
@@ -106,119 +85,18 @@ class Tracer:
10685
def __init__(
10786
self,
10887
service_name: str = "strands-agents",
109-
otlp_endpoint: Optional[str] = None,
110-
otlp_headers: Optional[Dict[str, str]] = None,
111-
enable_console_export: Optional[bool] = None,
11288
):
11389
"""Initialize the tracer.
11490
11591
Args:
11692
service_name: Name of the service for OpenTelemetry.
117-
otlp_endpoint: OTLP endpoint URL for sending traces.
118-
otlp_headers: Headers to include with OTLP requests.
119-
enable_console_export: Whether to also export traces to console.
12093
"""
121-
# Check environment variables first
122-
env_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
123-
env_console_export_str = os.environ.get("STRANDS_OTEL_ENABLE_CONSOLE_EXPORT")
124-
125-
# Constructor parameters take precedence over environment variables
126-
self.otlp_endpoint = otlp_endpoint or env_endpoint
127-
128-
if enable_console_export is not None:
129-
self.enable_console_export = enable_console_export
130-
elif env_console_export_str:
131-
self.enable_console_export = env_console_export_str.lower() in ("true", "1", "yes")
132-
else:
133-
self.enable_console_export = False
134-
135-
# Parse headers from environment if available
136-
env_headers = os.environ.get("OTEL_EXPORTER_OTLP_HEADERS")
137-
if env_headers:
138-
try:
139-
headers_dict = {}
140-
# Parse comma-separated key-value pairs (format: "key1=value1,key2=value2")
141-
for pair in env_headers.split(","):
142-
if "=" in pair:
143-
key, value = pair.split("=", 1)
144-
headers_dict[key.strip()] = value.strip()
145-
otlp_headers = headers_dict
146-
except Exception as e:
147-
logger.warning("error=<%s> | failed to parse OTEL_EXPORTER_OTLP_HEADERS", e)
148-
14994
self.service_name = service_name
150-
self.otlp_headers = otlp_headers or {}
15195
self.tracer_provider: Optional[trace_api.TracerProvider] = None
15296
self.tracer: Optional[trace_api.Tracer] = None
153-
propagate.set_global_textmap(
154-
CompositePropagator(
155-
[
156-
W3CBaggagePropagator(),
157-
TraceContextTextMapPropagator(),
158-
]
159-
)
160-
)
161-
if self.otlp_endpoint or self.enable_console_export:
162-
# Create our own tracer provider
163-
self._initialize_tracer()
164-
165-
def _initialize_tracer(self) -> None:
166-
"""Initialize the OpenTelemetry tracer."""
167-
logger.info("initializing tracer")
16897

169-
if self._is_initialized():
170-
self.tracer_provider = trace_api.get_tracer_provider()
171-
self.tracer = self.tracer_provider.get_tracer(self.service_name)
172-
return
173-
174-
resource = get_otel_resource()
175-
176-
# Create tracer provider
177-
self.tracer_provider = SDKTracerProvider(resource=resource)
178-
179-
# Add console exporter if enabled
180-
if self.enable_console_export and self.tracer_provider:
181-
logger.info("enabling console export")
182-
console_processor = SimpleSpanProcessor(ConsoleSpanExporter())
183-
self.tracer_provider.add_span_processor(console_processor)
184-
185-
# Add OTLP exporter if endpoint is provided
186-
if HAS_OTEL_EXPORTER_MODULE and self.otlp_endpoint and self.tracer_provider:
187-
try:
188-
# Ensure endpoint has the right format
189-
endpoint = self.otlp_endpoint
190-
if not endpoint.endswith("/v1/traces") and not endpoint.endswith("/traces"):
191-
if not endpoint.endswith("/"):
192-
endpoint += "/"
193-
endpoint += "v1/traces"
194-
195-
# Set default content type header if not provided
196-
headers = self.otlp_headers.copy()
197-
if "Content-Type" not in headers:
198-
headers["Content-Type"] = "application/x-protobuf"
199-
200-
# Create OTLP exporter and processor
201-
otlp_exporter = OTLPSpanExporter(
202-
endpoint=endpoint,
203-
headers=headers,
204-
)
205-
206-
batch_processor = BatchSpanProcessor(otlp_exporter)
207-
self.tracer_provider.add_span_processor(batch_processor)
208-
logger.info("endpoint=<%s> | OTLP exporter configured with endpoint", endpoint)
209-
210-
except Exception as e:
211-
logger.exception("error=<%s> | Failed to configure OTLP exporter", e)
212-
elif self.otlp_endpoint and self.tracer_provider:
213-
raise ModuleNotFoundError(OTEL_EXPORTER_MODULE_ERROR)
214-
215-
# Set as global tracer provider
216-
trace_api.set_tracer_provider(self.tracer_provider)
217-
self.tracer = trace_api.get_tracer(self.service_name)
218-
219-
def _is_initialized(self) -> bool:
220-
tracer_provider = trace_api.get_tracer_provider()
221-
return isinstance(tracer_provider, SDKTracerProvider)
98+
self.tracer_provider = trace_api.get_tracer_provider()
99+
self.tracer = self.tracer_provider.get_tracer(self.service_name)
222100

223101
def _start_span(
224102
self,
@@ -571,31 +449,20 @@ def end_agent_span(
571449

572450
def get_tracer(
573451
service_name: str = "strands-agents",
574-
otlp_endpoint: Optional[str] = None,
575-
otlp_headers: Optional[Dict[str, str]] = None,
576-
enable_console_export: Optional[bool] = None,
577452
) -> Tracer:
578453
"""Get or create the global tracer.
579454
580455
Args:
581456
service_name: Name of the service for OpenTelemetry.
582-
otlp_endpoint: OTLP endpoint URL for sending traces.
583-
otlp_headers: Headers to include with OTLP requests.
584-
enable_console_export: Whether to also export traces to console.
585457
586458
Returns:
587459
The global tracer instance.
588460
"""
589461
global _tracer_instance
590462

591-
if (
592-
_tracer_instance is None or (otlp_endpoint and _tracer_instance.otlp_endpoint != otlp_endpoint) # type: ignore[unreachable]
593-
):
463+
if not _tracer_instance:
594464
_tracer_instance = Tracer(
595465
service_name=service_name,
596-
otlp_endpoint=otlp_endpoint,
597-
otlp_headers=otlp_headers,
598-
enable_console_export=enable_console_export,
599466
)
600467

601468
return _tracer_instance

tests/strands/agent/test_agent.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -632,10 +632,10 @@ def test_agent__call__callback(mock_model, agent, callback_handler):
632632
current_tool_use={"toolUseId": "123", "name": "test", "input": {}},
633633
delta={"toolUse": {"input": '{"value"}'}},
634634
event_loop_cycle_id=unittest.mock.ANY,
635-
event_loop_cycle_span=None,
635+
event_loop_cycle_span=unittest.mock.ANY,
636636
event_loop_cycle_trace=unittest.mock.ANY,
637637
event_loop_metrics=unittest.mock.ANY,
638-
event_loop_parent_span=None,
638+
event_loop_parent_span=unittest.mock.ANY,
639639
request_state={},
640640
),
641641
unittest.mock.call(event={"contentBlockStop": {}}),
@@ -645,10 +645,10 @@ def test_agent__call__callback(mock_model, agent, callback_handler):
645645
agent=agent,
646646
delta={"reasoningContent": {"text": "value"}},
647647
event_loop_cycle_id=unittest.mock.ANY,
648-
event_loop_cycle_span=None,
648+
event_loop_cycle_span=unittest.mock.ANY,
649649
event_loop_cycle_trace=unittest.mock.ANY,
650650
event_loop_metrics=unittest.mock.ANY,
651-
event_loop_parent_span=None,
651+
event_loop_parent_span=unittest.mock.ANY,
652652
reasoning=True,
653653
reasoningText="value",
654654
request_state={},
@@ -658,10 +658,10 @@ def test_agent__call__callback(mock_model, agent, callback_handler):
658658
agent=agent,
659659
delta={"reasoningContent": {"signature": "value"}},
660660
event_loop_cycle_id=unittest.mock.ANY,
661-
event_loop_cycle_span=None,
661+
event_loop_cycle_span=unittest.mock.ANY,
662662
event_loop_cycle_trace=unittest.mock.ANY,
663663
event_loop_metrics=unittest.mock.ANY,
664-
event_loop_parent_span=None,
664+
event_loop_parent_span=unittest.mock.ANY,
665665
reasoning=True,
666666
reasoning_signature="value",
667667
request_state={},
@@ -674,10 +674,10 @@ def test_agent__call__callback(mock_model, agent, callback_handler):
674674
data="value",
675675
delta={"text": "value"},
676676
event_loop_cycle_id=unittest.mock.ANY,
677-
event_loop_cycle_span=None,
677+
event_loop_cycle_span=unittest.mock.ANY,
678678
event_loop_cycle_trace=unittest.mock.ANY,
679679
event_loop_metrics=unittest.mock.ANY,
680-
event_loop_parent_span=None,
680+
event_loop_parent_span=unittest.mock.ANY,
681681
request_state={},
682682
),
683683
unittest.mock.call(event={"contentBlockStop": {}}),

0 commit comments

Comments
 (0)