Skip to content

Commit b7d3eca

Browse files
committed
feat: initialized meter
1 parent e12bc2f commit b7d3eca

File tree

9 files changed

+202
-36
lines changed

9 files changed

+202
-36
lines changed

src/strands/agent/agent.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from ..handlers.callback_handler import CompositeCallbackHandler, PrintingCallbackHandler, null_callback_handler
2626
from ..handlers.tool_handler import AgentToolHandler
2727
from ..models.bedrock import BedrockModel
28+
from ..telemetry import MetricsClient
2829
from ..telemetry.metrics import EventLoopMetrics
2930
from ..telemetry.tracer import get_tracer
3031
from ..tools.registry import ToolRegistry
@@ -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
@@ -372,6 +373,7 @@ def __call__(self, prompt: str, **kwargs: Any) -> AgentResult:
372373
- metrics: Performance metrics from the event loop
373374
- state: The final state of the event loop
374375
"""
376+
self.metrics_client.strands_agent_invocation_count.add(1)
375377
self._start_agent_trace_span(prompt)
376378

377379
try:

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, MetricsClient, Trace, metrics_to_string
7+
from .strands_config import get_otel_resource
78
from .tracer import Tracer, get_tracer
89

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

src/strands/telemetry/metrics.py

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

33
import logging
4+
import threading
45
import time
56
import uuid
67
from dataclasses import dataclass, field
78
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
89

10+
import opentelemetry.metrics as metrics_api
11+
from opentelemetry.metrics import Counter, Meter
12+
13+
from ..telemetry import metrics_constants as constants
914
from ..types.content import Message
1015
from ..types.streaming import Metrics, Usage
1116
from ..types.tools import ToolUse
@@ -355,3 +360,53 @@ def metrics_to_string(event_loop_metrics: EventLoopMetrics, allowed_names: Optio
355360
A formatted string representation of the metrics.
356361
"""
357362
return "\n".join(_metrics_summary_to_lines(event_loop_metrics, allowed_names or set()))
363+
364+
365+
class MetricsClient:
366+
"""Singleton client for managing OpenTelemetry metrics instruments.
367+
368+
The actual metrics export destination (console, OTLP endpoint, etc.) is configured
369+
through OpenTelemetry SDK configuration by users, not by this client.
370+
"""
371+
372+
_instance = None
373+
_lock = threading.Lock()
374+
375+
meter: Meter
376+
strands_agent_invocation_count: Counter
377+
378+
def __init__(self) -> None:
379+
"""Initialize a MetricsClient instance.
380+
381+
Note: Initialization logic is intentionally placed in __new__ rather than __init__
382+
to ensure it only runs once when the singleton instance is created, not every
383+
time the class is instantiated.
384+
"""
385+
pass
386+
387+
def __new__(cls) -> "MetricsClient":
388+
"""Create and initialize a new MetricsClient instance if none exists.
389+
390+
Implements the singleton pattern by ensuring only one instance exists.
391+
The initialization logic (meter setup and instrument creation) is performed
392+
here rather than in __init__ to avoid reinitializing the singleton instance
393+
on subsequent instantiations.
394+
395+
Returns:
396+
MetricsClient: The singleton instance of the MetricsClient class.
397+
"""
398+
if cls._instance is None:
399+
with cls._lock:
400+
if cls._instance is None:
401+
logger.info("Creating Strands MetricsClient")
402+
cls._instance = super(MetricsClient, cls).__new__(cls)
403+
meter_provider: metrics_api.MeterProvider = metrics_api.get_meter_provider()
404+
cls._instance.meter = meter_provider.get_meter("strands-agents")
405+
cls._instance.create_instruments()
406+
return cls._instance
407+
408+
def create_instruments(self) -> None:
409+
"""Create and initialize all OpenTelemetry metric instruments."""
410+
self.strands_agent_invocation_count = self.meter.create_counter(
411+
name=constants.STRANDS_AGENT_INVOCATION_COUNT, unit="Count"
412+
)
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"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""OpenTelemetry configuration and setup utilities for Strands agents.
2+
3+
This module provides centralized configuration and initialization functionality
4+
for OpenTelemetry components and other telemetry infrastructure shared across Strands applications.
5+
"""
6+
7+
from importlib.metadata import version
8+
from typing import Dict
9+
10+
from opentelemetry.sdk.resources import Resource
11+
12+
# Singleton cache for resources
13+
_resource_cache: Dict[str, Resource] = {}
14+
15+
16+
def get_otel_resource(service_name: str = "strands-agents") -> Resource:
17+
"""Create a standard OpenTelemetry resource with service information.
18+
19+
This function implements a singleton pattern - it will return the same
20+
Resource object for the same service_name parameter.
21+
22+
Args:
23+
service_name: Name of the service for OpenTelemetry.
24+
25+
Returns:
26+
Resource object with standard service information.
27+
"""
28+
# Check if we already have a resource for this service name
29+
if service_name in _resource_cache:
30+
return _resource_cache[service_name]
31+
32+
# Create a new resource
33+
resource = Resource.create(
34+
{
35+
"service.name": service_name,
36+
"service.version": version("strands-agents"),
37+
"telemetry.sdk.name": "opentelemetry",
38+
"telemetry.sdk.language": "python",
39+
}
40+
)
41+
42+
# Cache the resource
43+
_resource_cache[service_name] = resource
44+
45+
return resource

src/strands/telemetry/tracer.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,19 @@
88
import logging
99
import os
1010
from datetime import date, datetime, timezone
11-
from importlib.metadata import version
1211
from typing import Any, Dict, Mapping, Optional
1312

1413
import opentelemetry.trace as trace_api
1514
from opentelemetry import propagate
1615
from opentelemetry.baggage.propagation import W3CBaggagePropagator
1716
from opentelemetry.propagators.composite import CompositePropagator
18-
from opentelemetry.sdk.resources import Resource
1917
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
2018
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor
2119
from opentelemetry.trace import Span, StatusCode
2220
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
2321

2422
from ..agent.agent_result import AgentResult
23+
from ..telemetry import get_otel_resource
2524
from ..types.content import Message, Messages
2625
from ..types.streaming import Usage
2726
from ..types.tools import ToolResult, ToolUse
@@ -151,7 +150,6 @@ def __init__(
151150
self.otlp_headers = otlp_headers or {}
152151
self.tracer_provider: Optional[trace_api.TracerProvider] = None
153152
self.tracer: Optional[trace_api.Tracer] = None
154-
155153
propagate.set_global_textmap(
156154
CompositePropagator(
157155
[
@@ -173,15 +171,7 @@ def _initialize_tracer(self) -> None:
173171
self.tracer = self.tracer_provider.get_tracer(self.service_name)
174172
return
175173

176-
# Create resource with service information
177-
resource = Resource.create(
178-
{
179-
"service.name": self.service_name,
180-
"service.version": version("strands-agents"),
181-
"telemetry.sdk.name": "opentelemetry",
182-
"telemetry.sdk.language": "python",
183-
}
184-
)
174+
resource = get_otel_resource(self.service_name)
185175

186176
# Create tracer provider
187177
self.tracer_provider = SDKTracerProvider(resource=resource)
@@ -216,6 +206,7 @@ def _initialize_tracer(self) -> None:
216206
batch_processor = BatchSpanProcessor(otlp_exporter)
217207
self.tracer_provider.add_span_processor(batch_processor)
218208
logger.info("endpoint=<%s> | OTLP exporter configured with endpoint", endpoint)
209+
219210
except Exception as e:
220211
logger.exception("error=<%s> | Failed to configure OTLP exporter", e)
221212
elif self.otlp_endpoint and self.tracer_provider:

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()

tests/strands/telemetry/test_metrics.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import dataclasses
22
import unittest
3+
from unittest import mock
34

45
import pytest
6+
from opentelemetry.metrics._internal import _ProxyMeter
7+
from opentelemetry.sdk.metrics import MeterProvider
58

69
import strands
10+
from strands.telemetry.metrics import MetricsClient
711
from strands.types.streaming import Metrics, Usage
812

913

@@ -13,6 +17,14 @@ def moto_autouse(moto_env, moto_mock_aws):
1317
_ = moto_mock_aws
1418

1519

20+
@pytest.fixture(autouse=True)
21+
def reset_metrics_client_singleton():
22+
"""Reset MetricsClient singleton between tests."""
23+
yield
24+
# Reset the singleton after each test
25+
MetricsClient._instance = None
26+
27+
1628
@pytest.fixture
1729
def trace(request):
1830
params = {
@@ -117,6 +129,38 @@ def test_trace_end(mock_time, end_time, trace):
117129
assert tru_end_time == exp_end_time
118130

119131

132+
@pytest.fixture
133+
def mock_get_meter_provider():
134+
with mock.patch("strands.telemetry.metrics.metrics_api.get_meter_provider") as mock_get_meter_provider:
135+
meter_provider_mock = mock.MagicMock(spec=MeterProvider)
136+
mock_get_meter_provider.return_value = meter_provider_mock
137+
138+
mock_meter = mock.MagicMock()
139+
meter_provider_mock.get_meter.return_value = mock_meter
140+
141+
yield mock_get_meter_provider
142+
143+
144+
@pytest.fixture
145+
def mock_sdk_meter_provider():
146+
with mock.patch("strands.telemetry.metrics.metrics_sdk.MeterProvider") as mock_meter_provider:
147+
yield mock_meter_provider
148+
149+
150+
@pytest.fixture
151+
def mock_noop_meter_provider():
152+
with mock.patch("opentelemetry.metrics.NoOpMeterProvider") as mock_noop_meter_provider:
153+
mock_meter = mock.MagicMock()
154+
mock_noop_meter_provider.get_meter.return_value = mock_meter
155+
yield mock_noop_meter_provider
156+
157+
158+
@pytest.fixture
159+
def mock_resource():
160+
with mock.patch("opentelemetry.sdk.resources.Resource") as mock_resource:
161+
yield mock_resource
162+
163+
120164
def test_trace_add_child(child_trace, trace):
121165
trace.add_child(child_trace)
122166

@@ -379,3 +423,26 @@ def test_metrics_to_string(trace, child_trace, tool_metrics, exp_str, event_loop
379423
tru_str = strands.telemetry.metrics.metrics_to_string(event_loop_metrics)
380424

381425
assert tru_str == exp_str
426+
427+
428+
def test_setup_meter_if_meter_provider_is_set(
429+
mock_get_meter_provider,
430+
mock_resource,
431+
):
432+
"""Test global meter_provider and meter are used"""
433+
mock_resource_instance = mock.MagicMock()
434+
mock_resource.create.return_value = mock_resource_instance
435+
436+
metricsClient = MetricsClient()
437+
438+
mock_get_meter_provider.assert_called()
439+
mock_get_meter_provider.return_value.get_meter.assert_called_with("strands-agents")
440+
441+
assert metricsClient is not None
442+
443+
444+
def test_use_ProxyMeter_if_no_global_meter_provider():
445+
"""Return _ProxyMeter"""
446+
metricsClient = MetricsClient()
447+
448+
assert isinstance(metricsClient.meter, _ProxyMeter)

0 commit comments

Comments
 (0)