Skip to content

Commit e3856a1

Browse files
committed
feat: initialized meter
1 parent eb50073 commit e3856a1

File tree

8 files changed

+173
-30
lines changed

8 files changed

+173
-30
lines changed

src/strands/agent/agent.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,6 @@ def __init__(
308308
# Initialize tracer instance (no-op if not configured)
309309
self.tracer = get_tracer()
310310
self.trace_span: Optional[trace.Span] = None
311-
312311
self.tool_caller = Agent.ToolCaller(self)
313312

314313
@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, 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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
9+
from opentelemetry.sdk.resources import Resource
10+
11+
12+
def get_otel_resource(service_name: str = "strands-agents") -> Resource:
13+
"""Create a standard OpenTelemetry resource with service information.
14+
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+
21+
Returns:
22+
Resource object with standard service information.
23+
"""
24+
resource = Resource.create(
25+
{
26+
"service.name": service_name,
27+
"service.version": version("strands-agents"),
28+
"telemetry.sdk.name": "opentelemetry",
29+
"telemetry.sdk.language": "python",
30+
}
31+
)
32+
33+
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/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)

tests/strands/telemetry/test_tracer.py

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ def mock_console_exporter():
7373

7474
@pytest.fixture
7575
def mock_resource():
76-
with mock.patch("strands.telemetry.tracer.Resource") as mock_resource:
76+
with mock.patch("strands.telemetry.tracer.get_otel_resource") as mock_resource:
77+
mock_resource_instance = mock.MagicMock()
78+
mock_resource.return_value = mock_resource_instance
7779
yield mock_resource
7880

7981

@@ -175,14 +177,12 @@ def test_initialize_tracer_with_console(
175177
):
176178
"""Test initializing the tracer with console exporter."""
177179
mock_is_initialized.return_value = False
178-
mock_resource_instance = mock.MagicMock()
179-
mock_resource.create.return_value = mock_resource_instance
180180

181181
# Initialize Tracer
182182
Tracer(enable_console_export=True)
183183

184184
# Verify the tracer provider was created with correct resource
185-
mock_tracer_provider.assert_called_once_with(resource=mock_resource_instance)
185+
mock_tracer_provider.assert_called_once_with(resource=mock_resource.return_value)
186186

187187
# Verify console exporter was added
188188
mock_console_exporter.assert_called_once()
@@ -198,9 +198,6 @@ def test_initialize_tracer_with_otlp(
198198
"""Test initializing the tracer with OTLP exporter."""
199199
mock_is_initialized.return_value = False
200200

201-
mock_resource_instance = mock.MagicMock()
202-
mock_resource.create.return_value = mock_resource_instance
203-
204201
# Initialize Tracer
205202
with (
206203
mock.patch("strands.telemetry.tracer.HAS_OTEL_EXPORTER_MODULE", True),
@@ -209,7 +206,7 @@ def test_initialize_tracer_with_otlp(
209206
Tracer(otlp_endpoint="http://test-endpoint")
210207

211208
# Verify the tracer provider was created with correct resource
212-
mock_tracer_provider.assert_called_once_with(resource=mock_resource_instance)
209+
mock_tracer_provider.assert_called_once_with(resource=mock_resource.return_value)
213210

214211
# Verify OTLP exporter was added with correct endpoint
215212
mock_otlp_exporter.assert_called_once()
@@ -508,8 +505,6 @@ def test_initialize_tracer_with_invalid_otlp_endpoint(
508505
"""Test initializing the tracer with an invalid OTLP endpoint."""
509506
mock_is_initialized.return_value = False
510507

511-
mock_resource_instance = mock.MagicMock()
512-
mock_resource.create.return_value = mock_resource_instance
513508
mock_otlp_exporter.side_effect = Exception("Connection error")
514509

515510
# This should not raise an exception, but should log an error
@@ -522,7 +517,7 @@ def test_initialize_tracer_with_invalid_otlp_endpoint(
522517
Tracer(otlp_endpoint="http://invalid-endpoint")
523518

524519
# Verify the tracer provider was created with correct resource
525-
mock_tracer_provider.assert_called_once_with(resource=mock_resource_instance)
520+
mock_tracer_provider.assert_called_once_with(resource=mock_resource.return_value)
526521

527522
# Verify OTLP exporter was attempted
528523
mock_otlp_exporter.assert_called_once()
@@ -537,9 +532,6 @@ def test_initialize_tracer_with_missing_module(
537532
"""Test initializing the tracer when the OTLP exporter module is missing."""
538533
mock_is_initialized.return_value = False
539534

540-
mock_resource_instance = mock.MagicMock()
541-
mock_resource.create.return_value = mock_resource_instance
542-
543535
# Initialize Tracer with OTLP endpoint but missing module
544536
with (
545537
mock.patch("strands.telemetry.tracer.HAS_OTEL_EXPORTER_MODULE", False),
@@ -552,13 +544,13 @@ def test_initialize_tracer_with_missing_module(
552544
assert "otel http exporting is currently DISABLED" in str(excinfo.value)
553545

554546
# Verify the tracer provider was created with correct resource
555-
mock_tracer_provider.assert_called_once_with(resource=mock_resource_instance)
547+
mock_tracer_provider.assert_called_once_with(resource=mock_resource.return_value)
556548

557549
# Verify set_tracer_provider was not called since an exception was raised
558550
mock_set_tracer_provider.assert_not_called()
559551

560552

561-
def test_initialize_tracer_with_custom_tracer_provider(mock_get_tracer_provider, mock_resource):
553+
def test_initialize_tracer_with_custom_tracer_provider(mock_is_initialized, mock_get_tracer_provider, mock_resource):
562554
"""Test initializing the tracer with NoOpTracerProvider."""
563555
mock_is_initialized.return_value = True
564556
tracer = Tracer(otlp_endpoint="http://invalid-endpoint")

0 commit comments

Comments
 (0)