Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/5450.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-api`: implement metrics bind API
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@

from abc import ABC, abstractmethod
from collections.abc import Callable, Generator, Iterable, Sequence
from copy import deepcopy
from dataclasses import dataclass
from logging import getLogger
from re import compile as re_compile
from typing import (
Generic,
TypeVar,
cast,
)

# pylint: disable=unused-import; needed for typing and sphinx
Expand Down Expand Up @@ -46,6 +48,7 @@ class CallbackOptions:


InstrumentT = TypeVar("InstrumentT", bound="Instrument")
SyncInstrumentT = TypeVar("SyncInstrumentT", bound="Synchronous")
# pylint: disable=invalid-name
CallbackT = (
Callable[[CallbackOptions], Iterable[Observation]]
Expand Down Expand Up @@ -159,6 +162,9 @@ def __init__(
class Counter(Synchronous):
"""A Counter is a synchronous `Instrument` which supports non-negative increments."""

def _bind(self, attributes: Attributes) -> "_BoundCounter":
return _BoundCounter(self, attributes)

Comment thread
herin049 marked this conversation as resolved.
@abstractmethod
def add(
self,
Expand Down Expand Up @@ -217,6 +223,9 @@ def _create_real_instrument(self, meter: "metrics.Meter") -> Counter:
class UpDownCounter(Synchronous):
"""An UpDownCounter is a synchronous `Instrument` which supports increments and decrements."""

def _bind(self, attributes: Attributes) -> "_BoundUpDownCounter":
return _BoundUpDownCounter(self, attributes)

Comment thread
herin049 marked this conversation as resolved.
@abstractmethod
def add(
self,
Expand Down Expand Up @@ -370,6 +379,9 @@ def __init__(
) -> None:
pass

def _bind(self, attributes: Attributes) -> "_BoundHistogram":
return _BoundHistogram(self, attributes)

Comment thread
herin049 marked this conversation as resolved.
@abstractmethod
def record(
self,
Expand Down Expand Up @@ -493,6 +505,9 @@ def _create_real_instrument(
class Gauge(Synchronous):
"""A Gauge is a synchronous `Instrument` which can be used to record non-additive values as they occur."""

def _bind(self, attributes: Attributes) -> "_BoundGauge":
return _BoundGauge(self, attributes)

Comment thread
herin049 marked this conversation as resolved.
@abstractmethod
def set(
self,
Expand Down Expand Up @@ -553,3 +568,76 @@ def _create_real_instrument(self, meter: "metrics.Meter") -> Gauge:
self._unit,
self._description,
)


class _BoundInstrument(Generic[SyncInstrumentT]):
def __init__(
self, instrument: SyncInstrumentT, attributes: Attributes
) -> None:
if isinstance(instrument, _BoundInstrument):
attributes = {**instrument._attributes, **(attributes or {})}
instrument = cast(SyncInstrumentT, instrument._instrument)
self._instrument: SyncInstrumentT = instrument
self._attributes: dict = (
{} if attributes is None else deepcopy(dict(attributes))
)


class _BoundCounter(_BoundInstrument["Counter"], Counter):
def add(
self,
amount: int | float,
attributes: Attributes | None = None,
context: Context | None = None,
) -> None:
if not attributes:
self._instrument.add(amount, self._attributes, context)
return
self._instrument.add(
amount, {**self._attributes, **attributes}, context
)


class _BoundUpDownCounter(_BoundInstrument["UpDownCounter"], UpDownCounter):
def add(
self,
amount: int | float,
attributes: Attributes | None = None,
context: Context | None = None,
) -> None:
if not attributes:
self._instrument.add(amount, self._attributes, context)
return
self._instrument.add(
amount, {**self._attributes, **attributes}, context
)


class _BoundHistogram(_BoundInstrument["Histogram"], Histogram):
def record(
self,
amount: int | float,
attributes: Attributes | None = None,
context: Context | None = None,
) -> None:
if not attributes:
self._instrument.record(amount, self._attributes, context)
return
self._instrument.record(
amount, {**self._attributes, **attributes}, context
)


class _BoundGauge(_BoundInstrument["Gauge"], Gauge):
def set(
self,
amount: int | float,
attributes: Attributes | None = None,
context: Context | None = None,
) -> None:
if not attributes:
self._instrument.set(amount, self._attributes, context)
return
self._instrument.set(
amount, {**self._attributes, **attributes}, context
)
72 changes: 72 additions & 0 deletions opentelemetry-api/tests/metrics/test_instruments.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
# SPDX-License-Identifier: Apache-2.0
# type: ignore

# pylint: disable=protected-access

from inspect import Signature, isabstract, signature
from unittest import TestCase
from unittest.mock import Mock, patch

from opentelemetry.metrics import (
Counter,
Expand All @@ -20,6 +23,14 @@
UpDownCounter,
_Gauge,
)
from opentelemetry.metrics._internal.instrument import (
NoOpGauge,
_BoundCounter,
_BoundGauge,
_BoundHistogram,
_BoundUpDownCounter,
_ProxyCounter,
)

# FIXME Test that the instrument methods can be called concurrently safely.

Expand Down Expand Up @@ -713,3 +724,64 @@ def test_description_check(self):
],
"",
)


class TestBind(TestCase):
_sync_instruments = [
(NoOpCounter, _BoundCounter, Counter, "add"),
(NoOpUpDownCounter, _BoundUpDownCounter, UpDownCounter, "add"),
(NoOpHistogram, _BoundHistogram, Histogram, "record"),
(NoOpGauge, _BoundGauge, _Gauge, "set"),
]

def test_record_delegates_with_merged_attributes(self):
cases = [
({"key": "value"}, None, {"key": "value"}),
(None, None, {}),
(
{"key": "value", "keep": 1},
{"key": "override", "new": 2},
{"key": "override", "keep": 1, "new": 2},
),
]
for noop_cls, bound_cls, api_cls, method in self._sync_instruments:
for bound_attrs, call_attrs, expected in cases:
with self.subTest(
bound=bound_cls.__name__, bound_attrs=bound_attrs
):
instrument = noop_cls("name")
bound = instrument._bind(bound_attrs)
self.assertIsInstance(bound, bound_cls)
self.assertIsInstance(bound, api_cls)
self.assertIs(bound._instrument, instrument)
with patch.object(instrument, method) as record:
getattr(bound, method)(7, call_attrs)
record.assert_called_once_with(7, expected, None)

def test_bind_deepcopies_attributes(self):
counter = NoOpCounter("name")
attributes = {"nested": {"a": 1}}
bound = counter._bind(attributes)
attributes["nested"]["a"] = 999
self.assertEqual(bound._attributes, {"nested": {"a": 1}})

def test_rebind_flattens(self):
counter = NoOpCounter("name")
bound = counter._bind({"a": 1})._bind({"b": 2, "a": 3})
self.assertIsInstance(bound, _BoundCounter)
self.assertIs(bound._instrument, counter)
self.assertEqual(bound._attributes, {"a": 3, "b": 2})

def test_proxy_bind(self):
proxy = _ProxyCounter("name")
bound = proxy._bind({"key": "value"})
self.assertIsInstance(bound, _BoundCounter)
self.assertIs(bound._instrument, proxy)

bound.add(1)

meter = Mock()
proxy.on_meter_set(meter)
real = proxy._real_instrument
bound.add(2, {"extra": 1})
real.add.assert_called_once_with(2, {"key": "value", "extra": 1}, None)
43 changes: 42 additions & 1 deletion opentelemetry-sdk/tests/metrics/test_instrument.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0

# pylint: disable=no-self-use
# pylint: disable=no-self-use,protected-access

from logging import WARNING

Expand Down Expand Up @@ -577,3 +577,44 @@ def test_disallow_direct_histogram_creation(self):
with self.assertRaises(TypeError):
# pylint: disable=abstract-class-instantiated
Histogram("name", Mock(), Mock())


class TestBind(TestCase):
_sync_instruments = [
(_Counter, "add"),
(_UpDownCounter, "add"),
(_Histogram, "record"),
(_Gauge, "set"),
]

def test_bound_record_flows_through_consumer(self):
cases = [
({"key": "value"}, None, {"key": "value"}),
(None, None, {}),
(
{"key": "value", "keep": 1},
{"key": "override", "new": 2},
{"key": "override", "keep": 1, "new": 2},
),
]
for cls, method in self._sync_instruments:
for bound_attrs, call_attrs, expected in cases:
with self.subTest(
instrument=cls.__name__, bound_attrs=bound_attrs
):
mc = Mock()
instrument = cls("name", Mock(), mc)
bound = instrument._bind(bound_attrs)
getattr(bound, method)(1.0, call_attrs)
mc.consume_measurement.assert_called_once()
measurement = mc.consume_measurement.call_args.args[0]
self.assertEqual(measurement.value, 1.0)
self.assertEqual(measurement.attributes, expected)

def test_bound_record_invalid(self):
mc = Mock()
counter = _Counter("name", Mock(), mc)
bound = counter._bind({"key": "value"})
with self.assertLogs(level=WARNING):
bound.add(-1.0)
mc.consume_measurement.assert_not_called()
Loading