Skip to content

Commit e6474dc

Browse files
committed
feat: initialized meter
1 parent 66b1f22 commit e6474dc

File tree

8 files changed

+324
-15
lines changed

8 files changed

+324
-15
lines changed

src/strands/agent/agent.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from ..handlers.tool_handler import AgentToolHandler
2727
from ..models.bedrock import BedrockModel
2828
from ..telemetry.metrics import EventLoopMetrics
29+
from ..telemetry.metrics_client import MetricsClient
2930
from ..telemetry.tracer import get_tracer
3031
from ..tools.registry import ToolRegistry
3132
from ..tools.thread_pool_executor import ThreadPoolExecutorWrapper
@@ -308,7 +309,7 @@ def __init__(
308309
# Initialize tracer instance (no-op if not configured)
309310
self.tracer = get_tracer()
310311
self.trace_span: Optional[trace.Span] = None
311-
312+
self.metrics_client = MetricsClient()
312313
self.tool_caller = Agent.ToolCaller(self)
313314

314315
@property

src/strands/telemetry/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
This module provides metrics and tracing functionality.
44
"""
55

6-
from .metrics import EventLoopMetrics, Trace, metrics_to_string
6+
from .metrics import EventLoopMetrics, Meter, Trace, metrics_to_string
7+
from .metrics_client import MetricsClient
78
from .tracer import Tracer, get_tracer
89

910
__all__ = [
@@ -12,4 +13,6 @@
1213
"metrics_to_string",
1314
"Tracer",
1415
"get_tracer",
16+
"Meter",
17+
"MetricsClient",
1518
]

src/strands/telemetry/metrics.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
"""Utilities for collecting and reporting performance metrics in the SDK."""
22

33
import logging
4+
import os
45
import time
56
import uuid
67
from dataclasses import dataclass, field
7-
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
8+
from importlib.metadata import version
9+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
10+
11+
import opentelemetry.metrics as metrics_api
12+
import opentelemetry.sdk.metrics as metrics_sdk
13+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
14+
from opentelemetry.sdk.metrics.export import (
15+
AggregationTemporality,
16+
MetricReader,
17+
PeriodicExportingMetricReader,
18+
)
19+
from opentelemetry.sdk.resources import Resource
820

921
from ..types.content import Message
1022
from ..types.streaming import Metrics, Usage
@@ -355,3 +367,80 @@ def metrics_to_string(event_loop_metrics: EventLoopMetrics, allowed_names: Optio
355367
A formatted string representation of the metrics.
356368
"""
357369
return "\n".join(_metrics_summary_to_lines(event_loop_metrics, allowed_names or set()))
370+
371+
372+
class Meter:
373+
"""Handles OpenTelemetry metrics.
374+
375+
This class provides a simple interface for creating and managing metrics,
376+
with support for sending to OTLP endpoints.
377+
378+
When the OTEL_EXPORTER_OTLP_ENDPOINT environment variable is set, metrics
379+
are sent to the OTLP endpoint.
380+
"""
381+
382+
def __init__(
383+
self,
384+
service_name: str = "strands-agents",
385+
) -> None:
386+
"""Initialize the meter with the given service name.
387+
388+
Args:
389+
service_name: The name of the service for which metrics are being collected.
390+
"""
391+
self.service_name = service_name
392+
self.meter_provider: Optional[metrics_api.MeterProvider] = None
393+
self.meter: Optional[metrics_api.Meter] = None
394+
env_metrics_exporter = os.environ.get("OTEL_METRICS_EXPORTER")
395+
if env_metrics_exporter and "none" == env_metrics_exporter:
396+
logger.info("OTEL_METRICS_EXPORTER set to 'none', using NoOpMeterProvider")
397+
self.meter_provider = metrics_api.NoOpMeterProvider()
398+
self.meter = self.meter_provider.get_meter(self.service_name)
399+
return
400+
if self._is_meter_initialized():
401+
self.meter_provider = metrics_api.get_meter_provider()
402+
self.meter = self.meter_provider.get_meter(self.service_name)
403+
return
404+
self._initialize_meter()
405+
406+
def _initialize_meter(self) -> None:
407+
"""Initialize the OpenTelemetry meter."""
408+
logger.info("initializing meter")
409+
410+
# Create resource with service information
411+
resource = Resource.create(
412+
{
413+
"service.name": self.service_name,
414+
"service.version": version("strands-agents"),
415+
"telemetry.sdk.name": "opentelemetry",
416+
"telemetry.sdk.language": "python",
417+
}
418+
)
419+
420+
# note: it is a concrete implementation from `opentelemetry.sdk`
421+
metric_readers = self._create_metric_readers()
422+
self.meter_provider = metrics_sdk.MeterProvider(resource=resource, metric_readers=metric_readers)
423+
424+
# initialize global meter provider
425+
metrics_api.set_meter_provider(self.meter_provider)
426+
self.meter = metrics_api.get_meter(self.service_name)
427+
428+
def _is_meter_initialized(self) -> bool:
429+
meter_provider = metrics_api.get_meter_provider()
430+
return isinstance(meter_provider, metrics_sdk.MeterProvider)
431+
432+
def _create_metric_readers(self) -> Sequence[MetricReader]:
433+
"""MetricReaders define how metrics are emitted by OTEL."""
434+
# export metrics in batches at designated intervals
435+
periodic_metric_reader = PeriodicExportingMetricReader(
436+
exporter=OTLPMetricExporter(
437+
preferred_temporality={
438+
# Prefer 'DELTA' temporality to avoid storing previous measurements
439+
# https://opentelemetry.io/docs/specs/otel/metrics/data-model/#temporality
440+
metrics_sdk.Counter: AggregationTemporality.DELTA,
441+
metrics_sdk.UpDownCounter: AggregationTemporality.DELTA,
442+
metrics_sdk.Histogram: AggregationTemporality.DELTA,
443+
}
444+
),
445+
)
446+
return [periodic_metric_reader]
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""MetricsClient for OpenTelemetry integration.
2+
3+
This module provides meter capabilities using OpenTelemetry,
4+
enabling metrics data to be sent to OTLP endpoints.
5+
"""
6+
7+
import threading
8+
from logging import getLogger
9+
10+
from opentelemetry.metrics import Counter, Meter
11+
12+
from ..telemetry import metrics_constants as constants
13+
from ..telemetry.metrics import Meter as StrandsMeter
14+
15+
logger = getLogger(__name__)
16+
17+
18+
class MetricsClient:
19+
"""Creates a new instance of the MetricsClient class if it doesn't exist, otherwise returns the existing instance.
20+
21+
:return: The instance of the MetricsClient class.
22+
"""
23+
24+
_instance = None
25+
_lock = threading.Lock()
26+
27+
meter: Meter
28+
strands_agent_invocation_count: Counter
29+
30+
def __init__(self) -> None:
31+
"""Initialize a MetricsClient instance.
32+
33+
Note: Initialization logic is intentionally placed in __new__ rather than __init__
34+
to ensure it only runs once when the singleton instance is created, not every
35+
time the class is instantiated.
36+
"""
37+
pass
38+
39+
def __new__(cls):
40+
"""Create and initialize a new MetricsClient instance if none exists.
41+
42+
Implements the singleton pattern by ensuring only one instance exists.
43+
The initialization logic (meter setup and instrument creation) is performed
44+
here rather than in __init__ to avoid reinitializing the singleton instance
45+
on subsequent instantiations.
46+
47+
Returns:
48+
MetricsClient: The singleton instance of the MetricsClient class.
49+
"""
50+
if cls._instance is None:
51+
with cls._lock:
52+
if cls._instance is None:
53+
logger.info("Creating Strands MetricsClient")
54+
cls._instance = super(MetricsClient, cls).__new__(cls)
55+
meter = StrandsMeter()
56+
cls._instance.meter = meter.meter
57+
cls._instance.create_instruments()
58+
return cls._instance
59+
60+
def create_instruments(self):
61+
"""Creates the OpenTelemetry Counter instruments."""
62+
if not self.meter:
63+
logger.warning("Meter is not initialized")
64+
return
65+
self.strands_agent_invocation_count = self.meter.create_counter(
66+
name=constants.STRANDS_AGENT_INVOCATION_COUNT, unit="Count"
67+
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Metrics that are emitted in Strands-Agent."""
2+
3+
STRANDS_AGENT_INVOCATION_COUNT = "strands.agent.invocation_count"

src/strands/telemetry/tracer.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,6 @@ def __init__(
139139
self.otlp_headers = otlp_headers or {}
140140
self.tracer_provider: Optional[trace_api.TracerProvider] = None
141141
self.tracer: Optional[trace_api.Tracer] = None
142-
143142
propagate.set_global_textmap(
144143
CompositePropagator(
145144
[
@@ -153,8 +152,8 @@ def __init__(
153152
self._initialize_tracer()
154153

155154
def _initialize_tracer(self) -> None:
156-
"""Initialize the OpenTelemetry tracer."""
157-
logger.info("initializing tracer")
155+
"""Initialize the OpenTelemetry tracer and meter."""
156+
logger.info("initializing tracer and meter")
158157

159158
if self._is_initialized():
160159
self.tracer_provider = trace_api.get_tracer_provider()
@@ -175,13 +174,13 @@ def _initialize_tracer(self) -> None:
175174
self.tracer_provider = SDKTracerProvider(resource=resource)
176175

177176
# Add console exporter if enabled
178-
if self.enable_console_export and self.tracer_provider:
177+
if self.enable_console_export:
179178
logger.info("enabling console export")
180179
console_processor = SimpleSpanProcessor(ConsoleSpanExporter())
181180
self.tracer_provider.add_span_processor(console_processor)
182181

183182
# Add OTLP exporter if endpoint is provided
184-
if self.otlp_endpoint and self.tracer_provider:
183+
if self.otlp_endpoint:
185184
try:
186185
# Ensure endpoint has the right format
187186
endpoint = self.otlp_endpoint
@@ -204,6 +203,7 @@ def _initialize_tracer(self) -> None:
204203
batch_processor = BatchSpanProcessor(otlp_exporter)
205204
self.tracer_provider.add_span_processor(batch_processor)
206205
logger.info("endpoint=<%s> | OTLP exporter configured with endpoint", endpoint)
206+
207207
except Exception as e:
208208
logger.exception("error=<%s> | Failed to configure OTLP exporter", e)
209209

@@ -294,7 +294,7 @@ def _end_span(
294294
finally:
295295
span.end()
296296
# Force flush to ensure spans are exported
297-
if self.tracer_provider and hasattr(self.tracer_provider, 'force_flush'):
297+
if self.tracer_provider and hasattr(self.tracer_provider, "force_flush"):
298298
try:
299299
self.tracer_provider.force_flush()
300300
except Exception as e:

tests/strands/agent/test_agent.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,8 +1010,14 @@ def test_agent_init_with_trace_attributes():
10101010
assert "invalid_nested_list" not in agent.trace_attributes
10111011

10121012

1013+
@pytest.fixture
1014+
def mock_metrics_client():
1015+
with unittest.mock.patch("strands.agent.agent.MetricsClient") as mock_metrics_client:
1016+
yield mock_metrics_client
1017+
1018+
10131019
@unittest.mock.patch("strands.agent.agent.get_tracer")
1014-
def test_agent_init_initializes_tracer(mock_get_tracer):
1020+
def test_agent_init_initializes_tracer(mock_get_tracer, mock_metrics_client):
10151021
"""Test that the tracer is initialized when creating an Agent."""
10161022
mock_tracer = unittest.mock.MagicMock()
10171023
mock_get_tracer.return_value = mock_tracer
@@ -1025,7 +1031,7 @@ def test_agent_init_initializes_tracer(mock_get_tracer):
10251031

10261032

10271033
@unittest.mock.patch("strands.agent.agent.get_tracer")
1028-
def test_agent_call_creates_and_ends_span_on_success(mock_get_tracer, mock_model):
1034+
def test_agent_call_creates_and_ends_span_on_success(mock_get_tracer, mock_metrics_client, mock_model):
10291035
"""Test that __call__ creates and ends a span when the call succeeds."""
10301036
# Setup mock tracer and span
10311037
mock_tracer = unittest.mock.MagicMock()
@@ -1060,7 +1066,9 @@ def test_agent_call_creates_and_ends_span_on_success(mock_get_tracer, mock_model
10601066

10611067
@pytest.mark.asyncio
10621068
@unittest.mock.patch("strands.agent.agent.get_tracer")
1063-
async def test_agent_stream_async_creates_and_ends_span_on_success(mock_get_tracer, mock_event_loop_cycle):
1069+
async def test_agent_stream_async_creates_and_ends_span_on_success(
1070+
mock_get_tracer, mock_metrics_client, mock_event_loop_cycle
1071+
):
10641072
"""Test that stream_async creates and ends a span when the call succeeds."""
10651073
# Setup mock tracer and span
10661074
mock_tracer = unittest.mock.MagicMock()
@@ -1105,7 +1113,7 @@ def call_callback_handler(*args, **kwargs):
11051113

11061114

11071115
@unittest.mock.patch("strands.agent.agent.get_tracer")
1108-
def test_agent_call_creates_and_ends_span_on_exception(mock_get_tracer, mock_model):
1116+
def test_agent_call_creates_and_ends_span_on_exception(mock_get_tracer, mock_metrics_client, mock_model):
11091117
"""Test that __call__ creates and ends a span when an exception occurs."""
11101118
# Setup mock tracer and span
11111119
mock_tracer = unittest.mock.MagicMock()
@@ -1139,7 +1147,7 @@ def test_agent_call_creates_and_ends_span_on_exception(mock_get_tracer, mock_mod
11391147

11401148
@pytest.mark.asyncio
11411149
@unittest.mock.patch("strands.agent.agent.get_tracer")
1142-
async def test_agent_stream_async_creates_and_ends_span_on_exception(mock_get_tracer, mock_model):
1150+
async def test_agent_stream_async_creates_and_ends_span_on_exception(mock_get_tracer, mock_metrics_client, mock_model):
11431151
"""Test that stream_async creates and ends a span when the call succeeds."""
11441152
# Setup mock tracer and span
11451153
mock_tracer = unittest.mock.MagicMock()
@@ -1174,7 +1182,7 @@ async def test_agent_stream_async_creates_and_ends_span_on_exception(mock_get_tr
11741182

11751183

11761184
@unittest.mock.patch("strands.agent.agent.get_tracer")
1177-
def test_event_loop_cycle_includes_parent_span(mock_get_tracer, mock_event_loop_cycle, mock_model):
1185+
def test_event_loop_cycle_includes_parent_span(mock_get_tracer, mock_metrics_client, mock_event_loop_cycle, mock_model):
11781186
"""Test that event_loop_cycle is called with the parent span."""
11791187
# Setup mock tracer and span
11801188
mock_tracer = unittest.mock.MagicMock()

0 commit comments

Comments
 (0)