Skip to content

Commit 12872cb

Browse files
committed
feat: initialized meter
1 parent cc5be12 commit 12872cb

File tree

8 files changed

+153
-30
lines changed

8 files changed

+153
-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 .config import get_otel_resource
7+
from .metrics import EventLoopMetrics, MetricsClient, Trace, metrics_to_string
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/config.py

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() -> 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": __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/metrics.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
from dataclasses import dataclass, field
77
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
88

9+
import opentelemetry.metrics as metrics_api
10+
from opentelemetry.metrics import Counter, Meter
11+
12+
from ..telemetry import metrics_constants as constants
913
from ..types.content import Message
1014
from ..types.streaming import Metrics, Usage
1115
from ..types.tools import ToolUse
@@ -355,3 +359,45 @@ def metrics_to_string(event_loop_metrics: EventLoopMetrics, allowed_names: Optio
355359
A formatted string representation of the metrics.
356360
"""
357361
return "\n".join(_metrics_summary_to_lines(event_loop_metrics, allowed_names or set()))
362+
363+
364+
class MetricsClient:
365+
"""Singleton client for managing OpenTelemetry metrics instruments.
366+
367+
The actual metrics export destination (console, OTLP endpoint, etc.) is configured
368+
through OpenTelemetry SDK configuration by users, not by this client.
369+
"""
370+
371+
_instance: Optional["MetricsClient"] = None
372+
meter: Meter
373+
strands_agent_invocation_count: Counter
374+
375+
def __new__(cls) -> "MetricsClient":
376+
"""Create or return the singleton instance of MetricsClient.
377+
378+
Returns:
379+
The single MetricsClient instance.
380+
"""
381+
if cls._instance is None:
382+
cls._instance = super().__new__(cls)
383+
return cls._instance
384+
385+
def __init__(self) -> None:
386+
"""Initialize the MetricsClient.
387+
388+
This method only runs once due to the singleton pattern.
389+
Sets up the OpenTelemetry meter and creates metric instruments.
390+
"""
391+
if hasattr(self, "meter"):
392+
return
393+
394+
logger.info("Creating Strands MetricsClient")
395+
meter_provider: metrics_api.MeterProvider = metrics_api.get_meter_provider()
396+
self.meter = meter_provider.get_meter(__name__)
397+
self.create_instruments()
398+
399+
def create_instruments(self) -> None:
400+
"""Create and initialize all OpenTelemetry metric instruments."""
401+
self.strands_agent_invocation_count = self.meter.create_counter(
402+
name=constants.STRANDS_AGENT_INVOCATION_COUNT, unit="Count"
403+
)
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: 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()
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: 56 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 import MetricsClient
711
from strands.types.streaming import Metrics, Usage
812

913

@@ -117,6 +121,30 @@ def test_trace_end(mock_time, end_time, trace):
117121
assert tru_end_time == exp_end_time
118122

119123

124+
@pytest.fixture
125+
def mock_get_meter_provider():
126+
with mock.patch("strands.telemetry.metrics.metrics_api.get_meter_provider") as mock_get_meter_provider:
127+
meter_provider_mock = mock.MagicMock(spec=MeterProvider)
128+
mock_get_meter_provider.return_value = meter_provider_mock
129+
130+
mock_meter = mock.MagicMock()
131+
meter_provider_mock.get_meter.return_value = mock_meter
132+
133+
yield mock_get_meter_provider
134+
135+
136+
@pytest.fixture
137+
def mock_sdk_meter_provider():
138+
with mock.patch("strands.telemetry.metrics.metrics_sdk.MeterProvider") as mock_meter_provider:
139+
yield mock_meter_provider
140+
141+
142+
@pytest.fixture
143+
def mock_resource():
144+
with mock.patch("opentelemetry.sdk.resources.Resource") as mock_resource:
145+
yield mock_resource
146+
147+
120148
def test_trace_add_child(child_trace, trace):
121149
trace.add_child(child_trace)
122150

@@ -379,3 +407,31 @@ def test_metrics_to_string(trace, child_trace, tool_metrics, exp_str, event_loop
379407
tru_str = strands.telemetry.metrics.metrics_to_string(event_loop_metrics)
380408

381409
assert tru_str == exp_str
410+
411+
412+
def test_setup_meter_if_meter_provider_is_set(
413+
mock_get_meter_provider,
414+
mock_resource,
415+
):
416+
"""Test global meter_provider and meter are used"""
417+
mock_resource_instance = mock.MagicMock()
418+
mock_resource.create.return_value = mock_resource_instance
419+
420+
metrics_client = MetricsClient()
421+
422+
mock_get_meter_provider.assert_called()
423+
mock_get_meter_provider.return_value.get_meter.assert_called()
424+
425+
assert metrics_client is not None
426+
427+
428+
def test_use_ProxyMeter_if_no_global_meter_provider():
429+
"""Return _ProxyMeter"""
430+
# Reset the singleton instance
431+
strands.telemetry.metrics.MetricsClient._instance = None
432+
433+
# Create a new instance which should use the real _ProxyMeter
434+
metrics_client = MetricsClient()
435+
436+
# Verify it's using a _ProxyMeter
437+
assert isinstance(metrics_client.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)