Skip to content

Commit 13f90b7

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

File tree

6 files changed

+174
-20
lines changed

6 files changed

+174
-20
lines changed

src/strands/agent/agent.py

Lines changed: 3 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 get_metrics_client
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 = get_metrics_client()
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: 5 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, get_metrics_client
78
from .tracer import Tracer, get_tracer
89

910
__all__ = [
@@ -12,4 +13,7 @@
1213
"metrics_to_string",
1314
"Tracer",
1415
"get_tracer",
16+
"Meter",
17+
"MetricsClient",
18+
"get_metrics_client",
1519
]

src/strands/telemetry/metrics.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,18 @@
44
import time
55
import uuid
66
from dataclasses import dataclass, field
7-
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
7+
from importlib.metadata import version
8+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
9+
10+
import opentelemetry.metrics as metrics_api
11+
import opentelemetry.sdk.metrics as metrics_sdk
12+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
13+
from opentelemetry.sdk.metrics.export import (
14+
AggregationTemporality,
15+
MetricReader,
16+
PeriodicExportingMetricReader,
17+
)
18+
from opentelemetry.sdk.resources import Resource
819

920
from ..types.content import Message
1021
from ..types.streaming import Metrics, Usage
@@ -355,3 +366,74 @@ def metrics_to_string(event_loop_metrics: EventLoopMetrics, allowed_names: Optio
355366
A formatted string representation of the metrics.
356367
"""
357368
return "\n".join(_metrics_summary_to_lines(event_loop_metrics, allowed_names or set()))
369+
370+
371+
class Meter:
372+
"""Handles OpenTelemetry metrics.
373+
374+
This class provides a simple interface for creating and managing metrics,
375+
with support for sending to OTLP endpoints.
376+
377+
When the OTEL_EXPORTER_OTLP_ENDPOINT environment variable is set, metrics
378+
are sent to the OTLP endpoint.
379+
"""
380+
381+
def __init__(
382+
self,
383+
service_name: str = "strands-agents",
384+
) -> None:
385+
"""Initialize the meter with the given service name.
386+
387+
Args:
388+
service_name: The name of the service for which metrics are being collected.
389+
"""
390+
self.service_name = service_name
391+
self.meter_provider: Optional[metrics_api.MeterProvider] = None
392+
self.meter: Optional[metrics_api.Meter] = None
393+
if self._is_meter_initialized():
394+
self.meter_provider = metrics_api.get_meter_provider()
395+
self.meter = self.meter_provider.get_meter(self.service_name)
396+
return
397+
self._initialize_meter()
398+
399+
def _initialize_meter(self) -> None:
400+
"""Initialize the OpenTelemetry meter."""
401+
logger.info("initializing meter")
402+
403+
# Create resource with service information
404+
resource = Resource.create(
405+
{
406+
"service.name": self.service_name,
407+
"service.version": version("strands-agents"),
408+
"telemetry.sdk.name": "opentelemetry",
409+
"telemetry.sdk.language": "python",
410+
}
411+
)
412+
413+
# note: it is a concrete implementation from `opentelemetry.sdk`
414+
metric_readers = self._create_metric_readers()
415+
self.meter_provider = metrics_sdk.MeterProvider(resource=resource, metric_readers=metric_readers)
416+
417+
# initialize global meter provider
418+
metrics_api.set_meter_provider(self.meter_provider)
419+
self.meter = metrics_api.get_meter(self.service_name)
420+
421+
def _is_meter_initialized(self) -> bool:
422+
meter_provider = metrics_api.get_meter_provider()
423+
return isinstance(meter_provider, metrics_sdk.MeterProvider)
424+
425+
def _create_metric_readers(self) -> Sequence[MetricReader]:
426+
"""MetricReaders define how metrics are emitted by OTEL."""
427+
# export metrics in batches at designated intervals
428+
periodic_metric_reader = PeriodicExportingMetricReader(
429+
exporter=OTLPMetricExporter(
430+
preferred_temporality={
431+
# Prefer 'DELTA' temporality to avoid storing previous measurements
432+
# https://opentelemetry.io/docs/specs/otel/metrics/data-model/#temporality
433+
metrics_sdk.Counter: AggregationTemporality.DELTA,
434+
metrics_sdk.UpDownCounter: AggregationTemporality.DELTA,
435+
metrics_sdk.Histogram: AggregationTemporality.DELTA,
436+
}
437+
),
438+
)
439+
return [periodic_metric_reader]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
)
68+
69+
70+
def get_metrics_client() -> MetricsClient:
71+
"""Get the singleton instance of MetricsClient."""
72+
return MetricsClient()
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: 8 additions & 17 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,10 +152,10 @@ 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

159-
if self._is_initialized():
158+
if self._is_tracer_initialized():
160159
self.tracer_provider = trace_api.get_tracer_provider()
161160
self.tracer = self.tracer_provider.get_tracer(self.service_name)
162161
return
@@ -175,43 +174,35 @@ 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:
186-
# Ensure endpoint has the right format
187-
endpoint = self.otlp_endpoint
188-
if not endpoint.endswith("/v1/traces") and not endpoint.endswith("/traces"):
189-
if not endpoint.endswith("/"):
190-
endpoint += "/"
191-
endpoint += "v1/traces"
192-
193185
# Set default content type header if not provided
194186
headers = self.otlp_headers.copy()
195187
if "Content-Type" not in headers:
196188
headers["Content-Type"] = "application/x-protobuf"
197189

198190
# Create OTLP exporter and processor
199191
otlp_exporter = OTLPSpanExporter(
200-
endpoint=endpoint,
201192
headers=headers,
202193
)
203194

204195
batch_processor = BatchSpanProcessor(otlp_exporter)
205196
self.tracer_provider.add_span_processor(batch_processor)
206-
logger.info("endpoint=<%s> | OTLP exporter configured with endpoint", endpoint)
197+
207198
except Exception as e:
208199
logger.exception("error=<%s> | Failed to configure OTLP exporter", e)
209200

210201
# Set as global tracer provider
211202
trace_api.set_tracer_provider(self.tracer_provider)
212203
self.tracer = trace_api.get_tracer(self.service_name)
213204

214-
def _is_initialized(self) -> bool:
205+
def _is_tracer_initialized(self) -> bool:
215206
tracer_provider = trace_api.get_tracer_provider()
216207
return isinstance(tracer_provider, SDKTracerProvider)
217208

@@ -294,7 +285,7 @@ def _end_span(
294285
finally:
295286
span.end()
296287
# Force flush to ensure spans are exported
297-
if self.tracer_provider and hasattr(self.tracer_provider, 'force_flush'):
288+
if self.tracer_provider and hasattr(self.tracer_provider, "force_flush"):
298289
try:
299290
self.tracer_provider.force_flush()
300291
except Exception as e:

0 commit comments

Comments
 (0)