From a114a0af281f67f763e5a549aa3e02c38a5071df Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Mon, 20 Jul 2026 23:45:49 -0400 Subject: [PATCH 1/2] opentelemetry-api: implement metrics bind API --- .../metrics/_internal/instrument.py | 88 +++++++++++++++++++ .../tests/metrics/test_instruments.py | 72 +++++++++++++++ .../tests/metrics/test_instrument.py | 43 ++++++++- 3 files changed, 202 insertions(+), 1 deletion(-) diff --git a/opentelemetry-api/src/opentelemetry/metrics/_internal/instrument.py b/opentelemetry-api/src/opentelemetry/metrics/_internal/instrument.py index 79bdfa4e673..fd14b3f268f 100644 --- a/opentelemetry-api/src/opentelemetry/metrics/_internal/instrument.py +++ b/opentelemetry-api/src/opentelemetry/metrics/_internal/instrument.py @@ -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 @@ -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]] @@ -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) + @abstractmethod def add( self, @@ -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) + @abstractmethod def add( self, @@ -370,6 +379,9 @@ def __init__( ) -> None: pass + def _bind(self, attributes: Attributes) -> "_BoundHistogram": + return _BoundHistogram(self, attributes) + @abstractmethod def record( self, @@ -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) + @abstractmethod def set( self, @@ -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 + ) diff --git a/opentelemetry-api/tests/metrics/test_instruments.py b/opentelemetry-api/tests/metrics/test_instruments.py index 0f844b11050..45f014042e3 100644 --- a/opentelemetry-api/tests/metrics/test_instruments.py +++ b/opentelemetry-api/tests/metrics/test_instruments.py @@ -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, @@ -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. @@ -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) diff --git a/opentelemetry-sdk/tests/metrics/test_instrument.py b/opentelemetry-sdk/tests/metrics/test_instrument.py index d8fd813e0a7..79b8be790d4 100644 --- a/opentelemetry-sdk/tests/metrics/test_instrument.py +++ b/opentelemetry-sdk/tests/metrics/test_instrument.py @@ -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 @@ -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() From 3bb5db96148c0ca6ac4ff4d95ece05de409994b2 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Mon, 20 Jul 2026 23:48:53 -0400 Subject: [PATCH 2/2] add changelog fragment --- .changelog/5450.added | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changelog/5450.added diff --git a/.changelog/5450.added b/.changelog/5450.added new file mode 100644 index 00000000000..665ccec1141 --- /dev/null +++ b/.changelog/5450.added @@ -0,0 +1 @@ +`opentelemetry-api`: implement metrics bind API