diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d9a92b2e4..59be905581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/open-telemetry/opentelemetry-python/compare/v1.5.0-0.24b0...HEAD) +- Add metrics API + ([#1887](https://github.com/open-telemetry/opentelemetry-python/pull/1887)) - `opentelemetry-semantic-conventions` Update to semantic conventions v1.6.1 ([#2077](https://github.com/open-telemetry/opentelemetry-python/pull/2077)) - Do not count invalid attributes for dropped diff --git a/docs/api/api.rst b/docs/api/api.rst index e531d1419e..a13c9e698b 100644 --- a/docs/api/api.rst +++ b/docs/api/api.rst @@ -9,4 +9,5 @@ OpenTelemetry Python API baggage context trace + metrics environment_variables diff --git a/docs/api/metrics.instrument.rst b/docs/api/metrics.instrument.rst new file mode 100644 index 0000000000..efceaf74c6 --- /dev/null +++ b/docs/api/metrics.instrument.rst @@ -0,0 +1,8 @@ +opentelemetry.metrics.instrument +================================ + +.. automodule:: opentelemetry.metrics.instrument + :members: + :private-members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/metrics.measurement.rst b/docs/api/metrics.measurement.rst new file mode 100644 index 0000000000..4674169c13 --- /dev/null +++ b/docs/api/metrics.measurement.rst @@ -0,0 +1,7 @@ +opentelemetry.metrics.measurement +================================= + +.. automodule:: opentelemetry.metrics.measurement + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/metrics.rst b/docs/api/metrics.rst new file mode 100644 index 0000000000..e445a5015e --- /dev/null +++ b/docs/api/metrics.rst @@ -0,0 +1,15 @@ +opentelemetry.metrics package +============================= + +Submodules +---------- + +.. toctree:: + + metrics.instrument + metrics.measurement + +Module contents +--------------- + +.. automodule:: opentelemetry.metrics diff --git a/opentelemetry-api/src/opentelemetry/environment_variables/__init__.py b/opentelemetry-api/src/opentelemetry/environment_variables/__init__.py index 6077115b01..16e0b7ea7e 100644 --- a/opentelemetry-api/src/opentelemetry/environment_variables/__init__.py +++ b/opentelemetry-api/src/opentelemetry/environment_variables/__init__.py @@ -41,3 +41,14 @@ """ .. envvar:: OTEL_PYTHON_TRACER_PROVIDER """ + +OTEL_PYTHON_METER_PROVIDER = "OTEL_PYTHON_METER_PROVIDER" +""" +.. envvar:: OTEL_PYTHON_METER_PROVIDER +""" + +OTEL_METRICS_EXPORTER = "OTEL_METRICS_EXPORTER" +""" +.. envvar:: OTEL_METRICS_EXPORTER + +""" diff --git a/opentelemetry-api/src/opentelemetry/metrics/__init__.py b/opentelemetry-api/src/opentelemetry/metrics/__init__.py new file mode 100644 index 0000000000..83d210e063 --- /dev/null +++ b/opentelemetry-api/src/opentelemetry/metrics/__init__.py @@ -0,0 +1,384 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=too-many-ancestors +# type: ignore + +# FIXME enhance the documentation of this module +""" +This module provides abstract and concrete (but noop) classes that can be used +to generate metrics. +""" + + +from abc import ABC, abstractmethod +from logging import getLogger +from os import environ +from typing import Optional, cast + +from opentelemetry.environment_variables import OTEL_PYTHON_METER_PROVIDER +from opentelemetry.metrics.instrument import ( + Counter, + DefaultCounter, + DefaultHistogram, + DefaultObservableCounter, + DefaultObservableGauge, + DefaultObservableUpDownCounter, + DefaultUpDownCounter, + Histogram, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + UpDownCounter, +) +from opentelemetry.util._providers import _load_provider + +_logger = getLogger(__name__) + + +class MeterProvider(ABC): + @abstractmethod + def get_meter( + self, + name, + version=None, + schema_url=None, + ) -> "Meter": + if name is None or name == "": + _logger.warning("Invalid name: %s", name) + + +class _DefaultMeterProvider(MeterProvider): + def get_meter( + self, + name, + version=None, + schema_url=None, + ) -> "Meter": + super().get_meter(name, version=version, schema_url=schema_url) + return _DefaultMeter(name, version=version, schema_url=schema_url) + + +class ProxyMeterProvider(MeterProvider): + def get_meter( + self, + name, + version=None, + schema_url=None, + ) -> "Meter": + if _METER_PROVIDER: + return _METER_PROVIDER.get_meter( + name, version=version, schema_url=schema_url + ) + return ProxyMeter(name, version=version, schema_url=schema_url) + + +class Meter(ABC): + def __init__(self, name, version=None, schema_url=None): + super().__init__() + self._name = name + self._version = version + self._schema_url = schema_url + self._instrument_names = set() + + @property + def name(self): + return self._name + + @property + def version(self): + return self._version + + @property + def schema_url(self): + return self._schema_url + + def _secure_instrument_name(self, name): + name = name.lower() + + if name in self._instrument_names: + _logger.error("Instrument name %s has been used already", name) + + else: + self._instrument_names.add(name) + + @abstractmethod + def create_counter(self, name, unit="", description="") -> Counter: + self._secure_instrument_name(name) + + @abstractmethod + def create_up_down_counter( + self, name, unit="", description="" + ) -> UpDownCounter: + self._secure_instrument_name(name) + + @abstractmethod + def create_observable_counter( + self, name, callback, unit="", description="" + ) -> ObservableCounter: + """Creates an observable counter instrument + + An observable counter observes a monotonically increasing count by + calling a provided callback which returns multiple + :class:`~opentelemetry.metrics.measurement.Measurement`. + + For example, an observable counter could be used to report system CPU + time periodically. Here is a basic implementation:: + + def cpu_time_callback() -> Iterable[Measurement]: + measurements = [] + with open("/proc/stat") as procstat: + procstat.readline() # skip the first line + for line in procstat: + if not line.startswith("cpu"): break + cpu, *states = line.split() + measurements.append(Measurement(int(states[0]) // 100, {"cpu": cpu, "state": "user"})) + measurements.append(Measurement(int(states[1]) // 100, {"cpu": cpu, "state": "nice"})) + measurements.append(Measurement(int(states[2]) // 100, {"cpu": cpu, "state": "system"})) + # ... other states + return measurements + + meter.create_observable_counter( + "system.cpu.time", + callback=cpu_time_callback, + unit="s", + description="CPU time" + ) + + To reduce memory usage, you can use generator callbacks instead of + building the full list:: + + def cpu_time_callback() -> Iterable[Measurement]: + with open("/proc/stat") as procstat: + procstat.readline() # skip the first line + for line in procstat: + if not line.startswith("cpu"): break + cpu, *states = line.split() + yield Measurement(int(states[0]) // 100, {"cpu": cpu, "state": "user"}) + yield Measurement(int(states[1]) // 100, {"cpu": cpu, "state": "nice"}) + # ... other states + + Alternatively, you can pass a generator directly instead of a callback, + which should return iterables of + :class:`~opentelemetry.metrics.measurement.Measurement`:: + + def cpu_time_callback(states_to_include: set[str]) -> Iterable[Iterable[Measurement]]: + while True: + measurements = [] + with open("/proc/stat") as procstat: + procstat.readline() # skip the first line + for line in procstat: + if not line.startswith("cpu"): break + cpu, *states = line.split() + if "user" in states_to_include: + measurements.append(Measurement(int(states[0]) // 100, {"cpu": cpu, "state": "user"})) + if "nice" in states_to_include: + measurements.append(Measurement(int(states[1]) // 100, {"cpu": cpu, "state": "nice"})) + # ... other states + yield measurements + + meter.create_observable_counter( + "system.cpu.time", + callback=cpu_time_callback({"user", "system"}), + unit="s", + description="CPU time" + ) + + Args: + name: The name of the instrument to be created + callback: A callback that returns an iterable of + :class:`~opentelemetry.metrics.measurement.Measurement`. + Alternatively, can be a generator that yields iterables of + :class:`~opentelemetry.metrics.measurement.Measurement`. + unit: The unit for measurements this instrument reports. For + example, ``By`` for bytes. UCUM units are recommended. + description: A description for this instrument and what it measures. + """ + + self._secure_instrument_name(name) + + @abstractmethod + def create_histogram(self, name, unit="", description="") -> Histogram: + self._secure_instrument_name(name) + + @abstractmethod + def create_observable_gauge( + self, name, callback, unit="", description="" + ) -> ObservableGauge: + self._secure_instrument_name(name) + + @abstractmethod + def create_observable_up_down_counter( + self, name, callback, unit="", description="" + ) -> ObservableUpDownCounter: + self._secure_instrument_name(name) + + +class ProxyMeter(Meter): + def __init__( + self, + name, + version=None, + schema_url=None, + ): + super().__init__(name, version=version, schema_url=schema_url) + self._real_meter: Optional[Meter] = None + self._noop_meter = _DefaultMeter( + name, version=version, schema_url=schema_url + ) + + @property + def _meter(self) -> Meter: + if self._real_meter is not None: + return self._real_meter + + if _METER_PROVIDER: + self._real_meter = _METER_PROVIDER.get_meter( + self._name, + self._version, + ) + return self._real_meter + return self._noop_meter + + def create_counter(self, *args, **kwargs) -> Counter: + return self._meter.create_counter(*args, **kwargs) + + def create_up_down_counter(self, *args, **kwargs) -> UpDownCounter: + return self._meter.create_up_down_counter(*args, **kwargs) + + def create_observable_counter(self, *args, **kwargs) -> ObservableCounter: + return self._meter.create_observable_counter(*args, **kwargs) + + def create_histogram(self, *args, **kwargs) -> Histogram: + return self._meter.create_histogram(*args, **kwargs) + + def create_observable_gauge(self, *args, **kwargs) -> ObservableGauge: + return self._meter.create_observable_gauge(*args, **kwargs) + + def create_observable_up_down_counter( + self, *args, **kwargs + ) -> ObservableUpDownCounter: + return self._meter.create_observable_up_down_counter(*args, **kwargs) + + +class _DefaultMeter(Meter): + def create_counter(self, name, unit="", description="") -> Counter: + super().create_counter(name, unit=unit, description=description) + return DefaultCounter(name, unit=unit, description=description) + + def create_up_down_counter( + self, name, unit="", description="" + ) -> UpDownCounter: + super().create_up_down_counter( + name, unit=unit, description=description + ) + return DefaultUpDownCounter(name, unit=unit, description=description) + + def create_observable_counter( + self, name, callback, unit="", description="" + ) -> ObservableCounter: + super().create_observable_counter( + name, callback, unit=unit, description=description + ) + return DefaultObservableCounter( + name, + callback, + unit=unit, + description=description, + ) + + def create_histogram(self, name, unit="", description="") -> Histogram: + super().create_histogram(name, unit=unit, description=description) + return DefaultHistogram(name, unit=unit, description=description) + + def create_observable_gauge( + self, name, callback, unit="", description="" + ) -> ObservableGauge: + super().create_observable_gauge( + name, callback, unit=unit, description=description + ) + return DefaultObservableGauge( + name, + callback, + unit=unit, + description=description, + ) + + def create_observable_up_down_counter( + self, name, callback, unit="", description="" + ) -> ObservableUpDownCounter: + super().create_observable_up_down_counter( + name, callback, unit=unit, description=description + ) + return DefaultObservableUpDownCounter( + name, + callback, + unit=unit, + description=description, + ) + + +_METER_PROVIDER = None +_PROXY_METER_PROVIDER = None + + +def get_meter( + name: str, + version: str = "", + meter_provider: Optional[MeterProvider] = None, +) -> "Meter": + """Returns a `Meter` for use by the given instrumentation library. + + This function is a convenience wrapper for + opentelemetry.trace.MeterProvider.get_meter. + + If meter_provider is omitted the current configured one is used. + """ + if meter_provider is None: + meter_provider = get_meter_provider() + return meter_provider.get_meter(name, version) + + +def set_meter_provider(meter_provider: MeterProvider) -> None: + """Sets the current global :class:`~.MeterProvider` object. + + This can only be done once, a warning will be logged if any furter attempt + is made. + """ + global _METER_PROVIDER # pylint: disable=global-statement + + if _METER_PROVIDER is not None: + _logger.warning("Overriding of current MeterProvider is not allowed") + return + + _METER_PROVIDER = meter_provider + + +def get_meter_provider() -> MeterProvider: + """Gets the current global :class:`~.MeterProvider` object.""" + # pylint: disable=global-statement + global _METER_PROVIDER + global _PROXY_METER_PROVIDER + + if _METER_PROVIDER is None: + if OTEL_PYTHON_METER_PROVIDER not in environ.keys(): + if _PROXY_METER_PROVIDER is None: + _PROXY_METER_PROVIDER = ProxyMeterProvider() + return _PROXY_METER_PROVIDER + + _METER_PROVIDER = cast( + "MeterProvider", + _load_provider(OTEL_PYTHON_METER_PROVIDER, "meter_provider"), + ) + return _METER_PROVIDER diff --git a/opentelemetry-api/src/opentelemetry/metrics/instrument.py b/opentelemetry-api/src/opentelemetry/metrics/instrument.py new file mode 100644 index 0000000000..5d38205640 --- /dev/null +++ b/opentelemetry-api/src/opentelemetry/metrics/instrument.py @@ -0,0 +1,234 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=too-many-ancestors + +# type: ignore + +from abc import ABC, abstractmethod +from collections import abc as collections_abc +from logging import getLogger +from re import compile as compile_ +from typing import Callable, Generator, Iterable, Union + +from opentelemetry.metrics.measurement import Measurement + +_TInstrumentCallback = Callable[[], Iterable[Measurement]] +_TInstrumentCallbackGenerator = Generator[Iterable[Measurement], None, None] +TCallback = Union[_TInstrumentCallback, _TInstrumentCallbackGenerator] + + +_logger = getLogger(__name__) + + +class Instrument(ABC): + + _name_regex = compile_(r"[a-zA-Z][-.\w]{0,62}") + + @property + def name(self): + return self._name + + @property + def unit(self): + return self._unit + + @property + def description(self): + return self._description + + @abstractmethod + def __init__(self, name, unit="", description=""): + + if name is None or self._name_regex.fullmatch(name) is None: + _logger.error("Invalid instrument name %s", name) + + else: + self._name = name + + if unit is None: + self._unit = "" + elif len(unit) > 63: + _logger.error("unit must be 63 characters or shorter") + + elif any(ord(character) > 127 for character in unit): + _logger.error("unit must only contain ASCII characters") + else: + self._unit = unit + + if description is None: + description = "" + + self._description = description + + +class Synchronous(Instrument): + pass + + +class Asynchronous(Instrument): + @abstractmethod + def __init__( + self, + name, + callback: TCallback, + *args, + unit="", + description="", + **kwargs + ): + super().__init__( + name, *args, unit=unit, description=description, **kwargs + ) + + if isinstance(callback, collections_abc.Callable): + self._callback = callback + elif isinstance(callback, collections_abc.Generator): + self._callback = self._wrap_generator_callback(callback) + else: + _logger.error("callback must be a callable or generator") + + def _wrap_generator_callback( + self, + generator_callback: _TInstrumentCallbackGenerator, + ) -> _TInstrumentCallback: + """Wraps a generator style callback into a callable one""" + has_items = True + + def inner() -> Iterable[Measurement]: + nonlocal has_items + if not has_items: + return [] + + try: + return next(generator_callback) + except StopIteration: + has_items = False + _logger.error( + "callback generator for instrument %s ran out of measurements", + self._name, + ) + return [] + + return inner + + def callback(self): + measurements = self._callback() + if not isinstance(measurements, collections_abc.Iterable): + _logger.error( + "Callback must return an iterable of Measurement, got %s", + type(measurements), + ) + return + for measurement in measurements: + if not isinstance(measurement, Measurement): + _logger.error( + "Callback must return an iterable of Measurement, " + "iterable contained type %s", + type(measurement), + ) + yield measurement + + +class _Adding(Instrument): + pass + + +class _Grouping(Instrument): + pass + + +class _Monotonic(_Adding): + pass + + +class _NonMonotonic(_Adding): + pass + + +class Counter(_Monotonic, Synchronous): + @abstractmethod + def add(self, amount, attributes=None): + if amount < 0: + _logger.error("Amount must be non-negative") + + +class DefaultCounter(Counter): + def __init__(self, name, unit="", description=""): + super().__init__(name, unit=unit, description=description) + + def add(self, amount, attributes=None): + return super().add(amount, attributes=attributes) + + +class UpDownCounter(_NonMonotonic, Synchronous): + @abstractmethod + def add(self, amount, attributes=None): + pass + + +class DefaultUpDownCounter(UpDownCounter): + def __init__(self, name, unit="", description=""): + super().__init__(name, unit=unit, description=description) + + def add(self, amount, attributes=None): + return super().add(amount, attributes=attributes) + + +class ObservableCounter(_Monotonic, Asynchronous): + def callback(self): + measurements = super().callback() + + for measurement in measurements: + if measurement.value < 0: + _logger.error("Amount must be non-negative") + yield measurement + + +class DefaultObservableCounter(ObservableCounter): + def __init__(self, name, callback, unit="", description=""): + super().__init__(name, callback, unit=unit, description=description) + + +class ObservableUpDownCounter(_NonMonotonic, Asynchronous): + + pass + + +class DefaultObservableUpDownCounter(ObservableUpDownCounter): + def __init__(self, name, callback, unit="", description=""): + super().__init__(name, callback, unit=unit, description=description) + + +class Histogram(_Grouping, Synchronous): + @abstractmethod + def record(self, amount, attributes=None): + pass + + +class DefaultHistogram(Histogram): + def __init__(self, name, unit="", description=""): + super().__init__(name, unit=unit, description=description) + + def record(self, amount, attributes=None): + return super().record(amount, attributes=attributes) + + +class ObservableGauge(_Grouping, Asynchronous): + pass + + +class DefaultObservableGauge(ObservableGauge): + def __init__(self, name, callback, unit="", description=""): + super().__init__(name, callback, unit=unit, description=description) diff --git a/opentelemetry-api/src/opentelemetry/metrics/measurement.py b/opentelemetry-api/src/opentelemetry/metrics/measurement.py new file mode 100644 index 0000000000..6b5b081c26 --- /dev/null +++ b/opentelemetry-api/src/opentelemetry/metrics/measurement.py @@ -0,0 +1,39 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=too-many-ancestors +# type:ignore + + +from abc import ABC, abstractmethod + + +class Measurement(ABC): + @property + def value(self): + return self._value + + @property + def attributes(self): + return self._attributes + + @abstractmethod + def __init__(self, value, attributes=None): + self._value = value + self._attributes = attributes + + +class DefaultMeasurement(Measurement): + def __init__(self, value, attributes=None): + super().__init__(value, attributes=attributes) diff --git a/opentelemetry-api/tests/metrics/integration_test/test_cpu_time.py b/opentelemetry-api/tests/metrics/integration_test/test_cpu_time.py new file mode 100644 index 0000000000..347f6c4dc4 --- /dev/null +++ b/opentelemetry-api/tests/metrics/integration_test/test_cpu_time.py @@ -0,0 +1,194 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# type: ignore + +import io +from typing import Generator, Iterable +from unittest import TestCase + +from opentelemetry.metrics import _DefaultMeter +from opentelemetry.metrics.measurement import Measurement + +# FIXME Test that the instrument methods can be called concurrently safely. + + +class ChildMeasurement(Measurement): + def __init__(self, value, attributes=None): + super().__init__(value, attributes=attributes) + + def __eq__(self, o: Measurement) -> bool: + return self.value == o.value and self.attributes == o.attributes + + +class TestCpuTimeIntegration(TestCase): + """Integration test of scraping CPU time from proc stat with an observable + counter""" + + procstat_str = """\ +cpu 8549517 4919096 9165935 1430260740 1641349 0 1646147 623279 0 0 +cpu0 615029 317746 594601 89126459 129629 0 834346 42137 0 0 +cpu1 588232 349185 640492 89156411 124485 0 241004 41862 0 0 +intr 4370168813 38 9 0 0 1639 0 0 0 0 0 2865202 0 152 0 0 0 0 0 0 0 0 0 0 0 0 7236812 5966240 4501046 6467792 7289114 6048205 5299600 5178254 4642580 6826812 6880917 6230308 6307699 4699637 6119330 4905094 5644039 4700633 10539029 5365438 6086908 2227906 5094323 9685701 10137610 7739951 7143508 8123281 4968458 5683103 9890878 4466603 0 0 0 8929628 0 5 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +ctxt 6877594077 +btime 1631501040 +processes 2557351 +procs_running 2 +procs_blocked 0 +softirq 1644603067 0 166540056 208 309152755 8936439 0 1354908 935642970 13 222975718\n""" + + measurements_expected = [ + ChildMeasurement(6150, {"cpu": "cpu0", "state": "user"}), + ChildMeasurement(3177, {"cpu": "cpu0", "state": "nice"}), + ChildMeasurement(5946, {"cpu": "cpu0", "state": "system"}), + ChildMeasurement(891264, {"cpu": "cpu0", "state": "idle"}), + ChildMeasurement(1296, {"cpu": "cpu0", "state": "iowait"}), + ChildMeasurement(0, {"cpu": "cpu0", "state": "irq"}), + ChildMeasurement(8343, {"cpu": "cpu0", "state": "softirq"}), + ChildMeasurement(421, {"cpu": "cpu0", "state": "guest"}), + ChildMeasurement(0, {"cpu": "cpu0", "state": "guest_nice"}), + ChildMeasurement(5882, {"cpu": "cpu1", "state": "user"}), + ChildMeasurement(3491, {"cpu": "cpu1", "state": "nice"}), + ChildMeasurement(6404, {"cpu": "cpu1", "state": "system"}), + ChildMeasurement(891564, {"cpu": "cpu1", "state": "idle"}), + ChildMeasurement(1244, {"cpu": "cpu1", "state": "iowait"}), + ChildMeasurement(0, {"cpu": "cpu1", "state": "irq"}), + ChildMeasurement(2410, {"cpu": "cpu1", "state": "softirq"}), + ChildMeasurement(418, {"cpu": "cpu1", "state": "guest"}), + ChildMeasurement(0, {"cpu": "cpu1", "state": "guest_nice"}), + ] + + def test_cpu_time_callback(self): + meter = _DefaultMeter("foo") + + def cpu_time_callback() -> Iterable[Measurement]: + procstat = io.StringIO(self.procstat_str) + procstat.readline() # skip the first line + for line in procstat: + if not line.startswith("cpu"): + break + cpu, *states = line.split() + yield ChildMeasurement( + int(states[0]) // 100, {"cpu": cpu, "state": "user"} + ) + yield ChildMeasurement( + int(states[1]) // 100, {"cpu": cpu, "state": "nice"} + ) + yield ChildMeasurement( + int(states[2]) // 100, {"cpu": cpu, "state": "system"} + ) + yield ChildMeasurement( + int(states[3]) // 100, {"cpu": cpu, "state": "idle"} + ) + yield ChildMeasurement( + int(states[4]) // 100, {"cpu": cpu, "state": "iowait"} + ) + yield ChildMeasurement( + int(states[5]) // 100, {"cpu": cpu, "state": "irq"} + ) + yield ChildMeasurement( + int(states[6]) // 100, {"cpu": cpu, "state": "softirq"} + ) + yield ChildMeasurement( + int(states[7]) // 100, {"cpu": cpu, "state": "guest"} + ) + yield ChildMeasurement( + int(states[8]) // 100, {"cpu": cpu, "state": "guest_nice"} + ) + + observable_counter = meter.create_observable_counter( + "system.cpu.time", + callback=cpu_time_callback, + unit="s", + description="CPU time", + ) + measurements = list(observable_counter.callback()) + self.assertEqual(measurements, self.measurements_expected) + + def test_cpu_time_generator(self): + meter = _DefaultMeter("foo") + + def cpu_time_generator() -> Generator[ + Iterable[Measurement], None, None + ]: + while True: + measurements = [] + procstat = io.StringIO(self.procstat_str) + procstat.readline() # skip the first line + for line in procstat: + if not line.startswith("cpu"): + break + cpu, *states = line.split() + measurements.append( + ChildMeasurement( + int(states[0]) // 100, + {"cpu": cpu, "state": "user"}, + ) + ) + measurements.append( + ChildMeasurement( + int(states[1]) // 100, + {"cpu": cpu, "state": "nice"}, + ) + ) + measurements.append( + ChildMeasurement( + int(states[2]) // 100, + {"cpu": cpu, "state": "system"}, + ) + ) + measurements.append( + ChildMeasurement( + int(states[3]) // 100, + {"cpu": cpu, "state": "idle"}, + ) + ) + measurements.append( + ChildMeasurement( + int(states[4]) // 100, + {"cpu": cpu, "state": "iowait"}, + ) + ) + measurements.append( + ChildMeasurement( + int(states[5]) // 100, {"cpu": cpu, "state": "irq"} + ) + ) + measurements.append( + ChildMeasurement( + int(states[6]) // 100, + {"cpu": cpu, "state": "softirq"}, + ) + ) + measurements.append( + ChildMeasurement( + int(states[7]) // 100, + {"cpu": cpu, "state": "guest"}, + ) + ) + measurements.append( + ChildMeasurement( + int(states[8]) // 100, + {"cpu": cpu, "state": "guest_nice"}, + ) + ) + yield measurements + + observable_counter = meter.create_observable_counter( + "system.cpu.time", + callback=cpu_time_generator(), + unit="s", + description="CPU time", + ) + measurements = list(observable_counter.callback()) + self.assertEqual(measurements, self.measurements_expected) diff --git a/opentelemetry-api/tests/metrics/test_instruments.py b/opentelemetry-api/tests/metrics/test_instruments.py new file mode 100644 index 0000000000..2dd100c9ed --- /dev/null +++ b/opentelemetry-api/tests/metrics/test_instruments.py @@ -0,0 +1,804 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# type: ignore + +from inspect import Signature, isabstract, signature +from logging import ERROR +from unittest import TestCase + +from opentelemetry.metrics import Meter, _DefaultMeter +from opentelemetry.metrics.instrument import ( + Counter, + DefaultCounter, + DefaultHistogram, + DefaultObservableCounter, + DefaultObservableGauge, + DefaultObservableUpDownCounter, + DefaultUpDownCounter, + Histogram, + Instrument, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + UpDownCounter, +) +from opentelemetry.metrics.measurement import Measurement + +# FIXME Test that the instrument methods can be called concurrently safely. + + +class ChildInstrument(Instrument): + def __init__(self, name, *args, unit="", description="", **kwargs): + super().__init__( + name, *args, unit=unit, description=description, **kwargs + ) + + +class ChildMeasurement(Measurement): + def __init__(self, value, attributes=None): + super().__init__(value, attributes=attributes) + + +class TestInstrument(TestCase): + def test_instrument_has_name(self): + """ + Test that the instrument has name. + """ + + init_signature = signature(Instrument.__init__) + self.assertIn("name", init_signature.parameters.keys()) + self.assertIs( + init_signature.parameters["name"].default, Signature.empty + ) + + self.assertTrue(hasattr(Instrument, "name")) + + def test_instrument_has_unit(self): + """ + Test that the instrument has unit. + """ + + init_signature = signature(Instrument.__init__) + self.assertIn("unit", init_signature.parameters.keys()) + self.assertIs(init_signature.parameters["unit"].default, "") + + self.assertTrue(hasattr(Instrument, "unit")) + + def test_instrument_has_description(self): + """ + Test that the instrument has description. + """ + + init_signature = signature(Instrument.__init__) + self.assertIn("description", init_signature.parameters.keys()) + self.assertIs(init_signature.parameters["description"].default, "") + + self.assertTrue(hasattr(Instrument, "description")) + + def test_instrument_name_syntax(self): + """ + Test that instrument names conform to the specified syntax. + """ + + with self.assertLogs(level=ERROR): + ChildInstrument("") + + with self.assertLogs(level=ERROR): + ChildInstrument(None) + + with self.assertLogs(level=ERROR): + ChildInstrument("1a") + + with self.assertLogs(level=ERROR): + ChildInstrument("_a") + + with self.assertLogs(level=ERROR): + ChildInstrument("!a ") + + with self.assertLogs(level=ERROR): + ChildInstrument("a ") + + with self.assertLogs(level=ERROR): + ChildInstrument("a%") + + with self.assertLogs(level=ERROR): + ChildInstrument("a" * 64) + + with self.assertRaises(AssertionError): + with self.assertLogs(level=ERROR): + ChildInstrument("abc_def_ghi") + + def test_instrument_unit_syntax(self): + """ + Test that instrument unit conform to the specified syntax. + """ + + with self.assertLogs(level=ERROR): + ChildInstrument("name", unit="a" * 64) + + with self.assertLogs(level=ERROR): + ChildInstrument("name", unit="ñ") + + child_instrument = ChildInstrument("name", unit="a") + self.assertEqual(child_instrument.unit, "a") + + child_instrument = ChildInstrument("name", unit="A") + self.assertEqual(child_instrument.unit, "A") + + child_instrument = ChildInstrument("name") + self.assertEqual(child_instrument.unit, "") + + child_instrument = ChildInstrument("name", unit=None) + self.assertEqual(child_instrument.unit, "") + + def test_instrument_description_syntax(self): + """ + Test that instrument description conform to the specified syntax. + """ + + child_instrument = ChildInstrument("name", description="a") + self.assertEqual(child_instrument.description, "a") + + with self.assertRaises(AssertionError): + with self.assertLogs(level=ERROR): + ChildInstrument("name", description="a" * 1024) + + child_instrument = ChildInstrument("name") + self.assertEqual(child_instrument.description, "") + + child_instrument = ChildInstrument("name", description=None) + self.assertEqual(child_instrument.description, "") + + +class TestCounter(TestCase): + def test_create_counter(self): + """ + Test that the Counter can be created with create_counter. + """ + + self.assertTrue( + isinstance(_DefaultMeter("name").create_counter("name"), Counter) + ) + + def test_api_counter_abstract(self): + """ + Test that the API Counter is an abstract class. + """ + + self.assertTrue(isabstract(Counter)) + + def test_create_counter_api(self): + """ + Test that the API for creating a counter accepts the name of the instrument. + Test that the API for creating a counter accepts the unit of the instrument. + Test that the API for creating a counter accepts the description of the + """ + + create_counter_signature = signature(Meter.create_counter) + self.assertIn("name", create_counter_signature.parameters.keys()) + self.assertIs( + create_counter_signature.parameters["name"].default, + Signature.empty, + ) + + create_counter_signature = signature(Meter.create_counter) + self.assertIn("unit", create_counter_signature.parameters.keys()) + self.assertIs(create_counter_signature.parameters["unit"].default, "") + + create_counter_signature = signature(Meter.create_counter) + self.assertIn( + "description", create_counter_signature.parameters.keys() + ) + self.assertIs( + create_counter_signature.parameters["description"].default, "" + ) + + def test_counter_add_method(self): + """ + Test that the counter has an add method. + Test that the add method returns None. + Test that the add method accepts optional attributes. + Test that the add method accepts the increment amount. + Test that the add method accepts only positive amounts. + """ + + self.assertTrue(hasattr(Counter, "add")) + + self.assertIsNone(DefaultCounter("name").add(1)) + + add_signature = signature(Counter.add) + self.assertIn("attributes", add_signature.parameters.keys()) + self.assertIs(add_signature.parameters["attributes"].default, None) + + self.assertIn("amount", add_signature.parameters.keys()) + self.assertIs( + add_signature.parameters["amount"].default, Signature.empty + ) + + with self.assertLogs(level=ERROR): + DefaultCounter("name").add(-1) + + +class TestObservableCounter(TestCase): + def test_create_observable_counter(self): + """ + Test that the ObservableCounter can be created with create_observable_counter. + """ + + def callback(): + yield + + self.assertTrue( + isinstance( + _DefaultMeter("name").create_observable_counter( + "name", callback() + ), + ObservableCounter, + ) + ) + + def test_api_observable_counter_abstract(self): + """ + Test that the API ObservableCounter is an abstract class. + """ + + self.assertTrue(isabstract(ObservableCounter)) + + def test_create_observable_counter_api(self): + """ + Test that the API for creating a observable_counter accepts the name of the instrument. + Test that the API for creating a observable_counter accepts a callback. + Test that the API for creating a observable_counter accepts the unit of the instrument. + Test that the API for creating a observable_counter accepts the description of the instrument + """ + + create_observable_counter_signature = signature( + Meter.create_observable_counter + ) + self.assertIn( + "name", create_observable_counter_signature.parameters.keys() + ) + self.assertIs( + create_observable_counter_signature.parameters["name"].default, + Signature.empty, + ) + create_observable_counter_signature = signature( + Meter.create_observable_counter + ) + self.assertIn( + "callback", create_observable_counter_signature.parameters.keys() + ) + self.assertIs( + create_observable_counter_signature.parameters["callback"].default, + Signature.empty, + ) + create_observable_counter_signature = signature( + Meter.create_observable_counter + ) + self.assertIn( + "unit", create_observable_counter_signature.parameters.keys() + ) + self.assertIs( + create_observable_counter_signature.parameters["unit"].default, "" + ) + + create_observable_counter_signature = signature( + Meter.create_observable_counter + ) + self.assertIn( + "description", + create_observable_counter_signature.parameters.keys(), + ) + self.assertIs( + create_observable_counter_signature.parameters[ + "description" + ].default, + "", + ) + + def test_observable_counter_generator(self): + """ + Test that the API for creating a asynchronous counter accepts a generator. + Test that the generator function reports iterable of measurements. + Test that there is a way to pass state to the generator. + Test that the instrument accepts positive measurements. + Test that the instrument does not accept negative measurements. + """ + + create_observable_counter_signature = signature( + Meter.create_observable_counter + ) + self.assertIn( + "callback", create_observable_counter_signature.parameters.keys() + ) + self.assertIs( + create_observable_counter_signature.parameters["name"].default, + Signature.empty, + ) + + def callback(): + yield 1 + + with self.assertRaises(AssertionError): + with self.assertLogs(level=ERROR): + observable_counter = DefaultObservableCounter( + "name", callback() + ) + + with self.assertLogs(level=ERROR): + # use list() to consume the whole generator returned by callback() + list(observable_counter.callback()) + + def callback(): + yield [ChildMeasurement(1), ChildMeasurement(2)] + yield [ChildMeasurement(-1)] + + observable_counter = DefaultObservableCounter("name", callback()) + + with self.assertRaises(AssertionError): + with self.assertLogs(level=ERROR): + list(observable_counter.callback()) + + with self.assertLogs(level=ERROR): + list(observable_counter.callback()) + + # out of items in generator, should log once + with self.assertLogs(level=ERROR): + list(observable_counter.callback()) + + # but log only once + with self.assertRaises(AssertionError): + with self.assertLogs(level=ERROR): + list(observable_counter.callback()) + + def test_observable_counter_callback(self): + """ + Equivalent to test_observable_counter_generator but uses the callback + form. + """ + + def callback_invalid_return(): + return 1 + + with self.assertRaises(AssertionError): + with self.assertLogs(level=ERROR): + observable_counter = DefaultObservableCounter( + "name", callback_invalid_return + ) + + with self.assertLogs(level=ERROR): + # use list() to consume the whole generator returned by callback() + list(observable_counter.callback()) + + def callback_valid(): + return [ChildMeasurement(1), ChildMeasurement(2)] + + observable_counter = DefaultObservableCounter("name", callback_valid) + + with self.assertRaises(AssertionError): + with self.assertLogs(level=ERROR): + list(observable_counter.callback()) + + def callback_one_invalid(): + return [ChildMeasurement(1), ChildMeasurement(-2)] + + observable_counter = DefaultObservableCounter( + "name", callback_one_invalid + ) + + with self.assertLogs(level=ERROR): + list(observable_counter.callback()) + + +class TestHistogram(TestCase): + def test_create_histogram(self): + """ + Test that the Histogram can be created with create_histogram. + """ + + self.assertTrue( + isinstance( + _DefaultMeter("name").create_histogram("name"), Histogram + ) + ) + + def test_api_histogram_abstract(self): + """ + Test that the API Histogram is an abstract class. + """ + + self.assertTrue(isabstract(Histogram)) + + def test_create_histogram_api(self): + """ + Test that the API for creating a histogram accepts the name of the instrument. + Test that the API for creating a histogram accepts the unit of the instrument. + Test that the API for creating a histogram accepts the description of the + """ + + create_histogram_signature = signature(Meter.create_histogram) + self.assertIn("name", create_histogram_signature.parameters.keys()) + self.assertIs( + create_histogram_signature.parameters["name"].default, + Signature.empty, + ) + + create_histogram_signature = signature(Meter.create_histogram) + self.assertIn("unit", create_histogram_signature.parameters.keys()) + self.assertIs( + create_histogram_signature.parameters["unit"].default, "" + ) + + create_histogram_signature = signature(Meter.create_histogram) + self.assertIn( + "description", create_histogram_signature.parameters.keys() + ) + self.assertIs( + create_histogram_signature.parameters["description"].default, "" + ) + + def test_histogram_record_method(self): + """ + Test that the histogram has an record method. + Test that the record method returns None. + Test that the record method accepts optional attributes. + Test that the record method accepts the increment amount. + Test that the record method returns None. + """ + + self.assertTrue(hasattr(Histogram, "record")) + + self.assertIsNone(DefaultHistogram("name").record(1)) + + record_signature = signature(Histogram.record) + self.assertIn("attributes", record_signature.parameters.keys()) + self.assertIs(record_signature.parameters["attributes"].default, None) + + self.assertIn("amount", record_signature.parameters.keys()) + self.assertIs( + record_signature.parameters["amount"].default, Signature.empty + ) + + self.assertIsNone(DefaultHistogram("name").record(1)) + + +class TestObservableGauge(TestCase): + def test_create_observable_gauge(self): + """ + Test that the ObservableGauge can be created with create_observable_gauge. + """ + + def callback(): + yield + + self.assertTrue( + isinstance( + _DefaultMeter("name").create_observable_gauge( + "name", callback() + ), + ObservableGauge, + ) + ) + + def test_api_observable_gauge_abstract(self): + """ + Test that the API ObservableGauge is an abstract class. + """ + + self.assertTrue(isabstract(ObservableGauge)) + + def test_create_observable_gauge_api(self): + """ + Test that the API for creating a observable_gauge accepts the name of the instrument. + Test that the API for creating a observable_gauge accepts a callback. + Test that the API for creating a observable_gauge accepts the unit of the instrument. + Test that the API for creating a observable_gauge accepts the description of the instrument + """ + + create_observable_gauge_signature = signature( + Meter.create_observable_gauge + ) + self.assertIn( + "name", create_observable_gauge_signature.parameters.keys() + ) + self.assertIs( + create_observable_gauge_signature.parameters["name"].default, + Signature.empty, + ) + create_observable_gauge_signature = signature( + Meter.create_observable_gauge + ) + self.assertIn( + "callback", create_observable_gauge_signature.parameters.keys() + ) + self.assertIs( + create_observable_gauge_signature.parameters["callback"].default, + Signature.empty, + ) + create_observable_gauge_signature = signature( + Meter.create_observable_gauge + ) + self.assertIn( + "unit", create_observable_gauge_signature.parameters.keys() + ) + self.assertIs( + create_observable_gauge_signature.parameters["unit"].default, "" + ) + + create_observable_gauge_signature = signature( + Meter.create_observable_gauge + ) + self.assertIn( + "description", create_observable_gauge_signature.parameters.keys() + ) + self.assertIs( + create_observable_gauge_signature.parameters[ + "description" + ].default, + "", + ) + + def test_observable_gauge_callback(self): + """ + Test that the API for creating a asynchronous gauge accepts a callback. + Test that the callback function reports measurements. + Test that there is a way to pass state to the callback. + """ + + create_observable_gauge_signature = signature( + Meter.create_observable_gauge + ) + self.assertIn( + "callback", create_observable_gauge_signature.parameters.keys() + ) + self.assertIs( + create_observable_gauge_signature.parameters["name"].default, + Signature.empty, + ) + + def callback(): + yield + + with self.assertRaises(AssertionError): + with self.assertLogs(level=ERROR): + observable_gauge = DefaultObservableGauge("name", callback()) + + with self.assertLogs(level=ERROR): + list(observable_gauge.callback()) + + def callback(): + yield [ChildMeasurement(1), ChildMeasurement(-1)] + + observable_gauge = DefaultObservableGauge("name", callback()) + with self.assertRaises(AssertionError): + with self.assertLogs(level=ERROR): + list(observable_gauge.callback()) + + +class TestUpDownCounter(TestCase): + def test_create_up_down_counter(self): + """ + Test that the UpDownCounter can be created with create_up_down_counter. + """ + + self.assertTrue( + isinstance( + _DefaultMeter("name").create_up_down_counter("name"), + UpDownCounter, + ) + ) + + def test_api_up_down_counter_abstract(self): + """ + Test that the API UpDownCounter is an abstract class. + """ + + self.assertTrue(isabstract(UpDownCounter)) + + def test_create_up_down_counter_api(self): + """ + Test that the API for creating a up_down_counter accepts the name of the instrument. + Test that the API for creating a up_down_counter accepts the unit of the instrument. + Test that the API for creating a up_down_counter accepts the description of the + """ + + create_up_down_counter_signature = signature( + Meter.create_up_down_counter + ) + self.assertIn( + "name", create_up_down_counter_signature.parameters.keys() + ) + self.assertIs( + create_up_down_counter_signature.parameters["name"].default, + Signature.empty, + ) + + create_up_down_counter_signature = signature( + Meter.create_up_down_counter + ) + self.assertIn( + "unit", create_up_down_counter_signature.parameters.keys() + ) + self.assertIs( + create_up_down_counter_signature.parameters["unit"].default, "" + ) + + create_up_down_counter_signature = signature( + Meter.create_up_down_counter + ) + self.assertIn( + "description", create_up_down_counter_signature.parameters.keys() + ) + self.assertIs( + create_up_down_counter_signature.parameters["description"].default, + "", + ) + + def test_up_down_counter_add_method(self): + """ + Test that the up_down_counter has an add method. + Test that the add method returns None. + Test that the add method accepts optional attributes. + Test that the add method accepts the increment or decrement amount. + Test that the add method accepts positive and negative amounts. + """ + + self.assertTrue(hasattr(UpDownCounter, "add")) + + self.assertIsNone(DefaultUpDownCounter("name").add(1)) + + add_signature = signature(UpDownCounter.add) + self.assertIn("attributes", add_signature.parameters.keys()) + self.assertIs(add_signature.parameters["attributes"].default, None) + + self.assertIn("amount", add_signature.parameters.keys()) + self.assertIs( + add_signature.parameters["amount"].default, Signature.empty + ) + + with self.assertRaises(AssertionError): + with self.assertLogs(level=ERROR): + DefaultUpDownCounter("name").add(-1) + + with self.assertRaises(AssertionError): + with self.assertLogs(level=ERROR): + DefaultUpDownCounter("name").add(1) + + +class TestObservableUpDownCounter(TestCase): + def test_create_observable_up_down_counter(self): + """ + Test that the ObservableUpDownCounter can be created with create_observable_up_down_counter. + """ + + def callback(): + yield + + self.assertTrue( + isinstance( + _DefaultMeter("name").create_observable_up_down_counter( + "name", callback() + ), + ObservableUpDownCounter, + ) + ) + + def test_api_observable_up_down_counter_abstract(self): + """ + Test that the API ObservableUpDownCounter is an abstract class. + """ + + self.assertTrue(isabstract(ObservableUpDownCounter)) + + def test_create_observable_up_down_counter_api(self): + """ + Test that the API for creating a observable_up_down_counter accepts the name of the instrument. + Test that the API for creating a observable_up_down_counter accepts a callback. + Test that the API for creating a observable_up_down_counter accepts the unit of the instrument. + Test that the API for creating a observable_up_down_counter accepts the description of the instrument + """ + + create_observable_up_down_counter_signature = signature( + Meter.create_observable_up_down_counter + ) + self.assertIn( + "name", + create_observable_up_down_counter_signature.parameters.keys(), + ) + self.assertIs( + create_observable_up_down_counter_signature.parameters[ + "name" + ].default, + Signature.empty, + ) + create_observable_up_down_counter_signature = signature( + Meter.create_observable_up_down_counter + ) + self.assertIn( + "callback", + create_observable_up_down_counter_signature.parameters.keys(), + ) + self.assertIs( + create_observable_up_down_counter_signature.parameters[ + "callback" + ].default, + Signature.empty, + ) + create_observable_up_down_counter_signature = signature( + Meter.create_observable_up_down_counter + ) + self.assertIn( + "unit", + create_observable_up_down_counter_signature.parameters.keys(), + ) + self.assertIs( + create_observable_up_down_counter_signature.parameters[ + "unit" + ].default, + "", + ) + + create_observable_up_down_counter_signature = signature( + Meter.create_observable_up_down_counter + ) + self.assertIn( + "description", + create_observable_up_down_counter_signature.parameters.keys(), + ) + self.assertIs( + create_observable_up_down_counter_signature.parameters[ + "description" + ].default, + "", + ) + + def test_observable_up_down_counter_callback(self): + """ + Test that the API for creating a asynchronous up_down_counter accepts a callback. + Test that the callback function reports measurements. + Test that there is a way to pass state to the callback. + Test that the instrument accepts positive and negative values. + """ + + create_observable_up_down_counter_signature = signature( + Meter.create_observable_up_down_counter + ) + self.assertIn( + "callback", + create_observable_up_down_counter_signature.parameters.keys(), + ) + self.assertIs( + create_observable_up_down_counter_signature.parameters[ + "name" + ].default, + Signature.empty, + ) + + def callback(): + yield ChildMeasurement(1) + yield ChildMeasurement(-1) + + with self.assertRaises(AssertionError): + with self.assertLogs(level=ERROR): + observable_up_down_counter = DefaultObservableUpDownCounter( + "name", callback() + ) + + with self.assertRaises(AssertionError): + with self.assertLogs(level=ERROR): + observable_up_down_counter.callback() + + with self.assertRaises(AssertionError): + with self.assertLogs(level=ERROR): + observable_up_down_counter.callback() diff --git a/opentelemetry-api/tests/metrics/test_meter.py b/opentelemetry-api/tests/metrics/test_meter.py new file mode 100644 index 0000000000..96543b69fe --- /dev/null +++ b/opentelemetry-api/tests/metrics/test_meter.py @@ -0,0 +1,179 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# type: ignore + +from logging import WARNING +from unittest import TestCase +from unittest.mock import Mock + +from opentelemetry.metrics import Meter + +# FIXME Test that the meter methods can be called concurrently safely. + + +class ChildMeter(Meter): + def create_counter(self, name, unit="", description=""): + super().create_counter(name, unit=unit, description=description) + + def create_up_down_counter(self, name, unit="", description=""): + super().create_up_down_counter( + name, unit=unit, description=description + ) + + def create_observable_counter( + self, name, callback, unit="", description="" + ): + super().create_observable_counter( + name, callback, unit=unit, description=description + ) + + def create_histogram(self, name, unit="", description=""): + super().create_histogram(name, unit=unit, description=description) + + def create_observable_gauge(self, name, callback, unit="", description=""): + super().create_observable_gauge( + name, callback, unit=unit, description=description + ) + + def create_observable_up_down_counter( + self, name, callback, unit="", description="" + ): + super().create_observable_up_down_counter( + name, callback, unit=unit, description=description + ) + + +class TestMeter(TestCase): + def test_create_counter(self): + """ + Test that the meter provides a function to create a new Counter + """ + + self.assertTrue(hasattr(Meter, "create_counter")) + self.assertTrue(Meter.create_counter.__isabstractmethod__) + + def test_create_up_down_counter(self): + """ + Test that the meter provides a function to create a new UpDownCounter + """ + + self.assertTrue(hasattr(Meter, "create_up_down_counter")) + self.assertTrue(Meter.create_up_down_counter.__isabstractmethod__) + + def test_create_observable_counter(self): + """ + Test that the meter provides a function to create a new ObservableCounter + """ + + self.assertTrue(hasattr(Meter, "create_observable_counter")) + self.assertTrue(Meter.create_observable_counter.__isabstractmethod__) + + def test_create_histogram(self): + """ + Test that the meter provides a function to create a new Histogram + """ + + self.assertTrue(hasattr(Meter, "create_histogram")) + self.assertTrue(Meter.create_histogram.__isabstractmethod__) + + def test_create_observable_gauge(self): + """ + Test that the meter provides a function to create a new ObservableGauge + """ + + self.assertTrue(hasattr(Meter, "create_observable_gauge")) + self.assertTrue(Meter.create_observable_gauge.__isabstractmethod__) + + def test_create_observable_up_down_counter(self): + """ + Test that the meter provides a function to create a new + ObservableUpDownCounter + """ + + self.assertTrue(hasattr(Meter, "create_observable_up_down_counter")) + self.assertTrue( + Meter.create_observable_up_down_counter.__isabstractmethod__ + ) + + def test_no_repeated_instrument_names(self): + """ + Test that the meter returns an error when multiple instruments are + registered under the same Meter using the same name. + """ + + meter = ChildMeter("name") + + meter.create_counter("name") + + with self.assertLogs(level=WARNING): + meter.create_counter("name") + + with self.assertLogs(level=WARNING): + meter.create_up_down_counter("name") + + with self.assertLogs(level=WARNING): + meter.create_observable_counter("name", Mock()) + + with self.assertLogs(level=WARNING): + meter.create_histogram("name") + + with self.assertLogs(level=WARNING): + meter.create_observable_gauge("name", Mock()) + + with self.assertLogs(level=WARNING): + meter.create_observable_up_down_counter("name", Mock()) + + def test_same_name_instrument_different_meter(self): + """ + Test that is possible to register two instruments with the same name + under different meters. + """ + + meter_0 = ChildMeter("meter_0") + meter_1 = ChildMeter("meter_1") + + meter_0.create_counter("counter") + meter_0.create_up_down_counter("up_down_counter") + meter_0.create_observable_counter("observable_counter", Mock()) + meter_0.create_histogram("histogram") + meter_0.create_observable_gauge("observable_gauge", Mock()) + meter_0.create_observable_up_down_counter( + "observable_up_down_counter", Mock() + ) + + with self.assertRaises(AssertionError): + with self.assertLogs(level=WARNING): + meter_1.create_counter("counter") + + with self.assertRaises(AssertionError): + with self.assertLogs(level=WARNING): + meter_1.create_up_down_counter("up_down_counter") + + with self.assertRaises(AssertionError): + with self.assertLogs(level=WARNING): + meter_1.create_observable_counter("observable_counter", Mock()) + + with self.assertRaises(AssertionError): + with self.assertLogs(level=WARNING): + meter_1.create_histogram("histogram") + + with self.assertRaises(AssertionError): + with self.assertLogs(level=WARNING): + meter_1.create_observable_gauge("observable_gauge", Mock()) + + with self.assertRaises(AssertionError): + with self.assertLogs(level=WARNING): + meter_1.create_observable_up_down_counter( + "observable_up_down_counter", Mock() + ) diff --git a/opentelemetry-api/tests/metrics/test_meter_provider.py b/opentelemetry-api/tests/metrics/test_meter_provider.py new file mode 100644 index 0000000000..9784e505e0 --- /dev/null +++ b/opentelemetry-api/tests/metrics/test_meter_provider.py @@ -0,0 +1,260 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# type: ignore + +from logging import WARNING +from unittest import TestCase +from unittest.mock import Mock, patch + +from pytest import fixture + +from opentelemetry import metrics +from opentelemetry.environment_variables import OTEL_PYTHON_METER_PROVIDER +from opentelemetry.metrics import ( + ProxyMeter, + ProxyMeterProvider, + _DefaultMeter, + _DefaultMeterProvider, + get_meter_provider, + set_meter_provider, +) +from opentelemetry.metrics.instrument import ( + DefaultCounter, + DefaultHistogram, + DefaultObservableCounter, + DefaultObservableGauge, + DefaultObservableUpDownCounter, + DefaultUpDownCounter, +) + +# FIXME Test that the instrument methods can be called concurrently safely. + + +@fixture +def reset_meter_provider(): + original_meter_provider_value = metrics._METER_PROVIDER + + yield + + metrics._METER_PROVIDER = original_meter_provider_value + + +def test_set_meter_provider(reset_meter_provider): + """ + Test that the API provides a way to set a global default MeterProvider + """ + + mock = Mock() + + assert metrics._METER_PROVIDER is None + + set_meter_provider(mock) + + assert metrics._METER_PROVIDER is mock + + +def test_get_meter_provider(reset_meter_provider): + """ + Test that the API provides a way to get a global default MeterProvider + """ + + assert metrics._METER_PROVIDER is None + + assert isinstance(get_meter_provider(), ProxyMeterProvider) + + metrics._METER_PROVIDER = None + + with patch.dict( + "os.environ", {OTEL_PYTHON_METER_PROVIDER: "test_meter_provider"} + ): + + with patch("opentelemetry.metrics._load_provider", Mock()): + with patch( + "opentelemetry.metrics.cast", + Mock(**{"return_value": "test_meter_provider"}), + ): + assert get_meter_provider() == "test_meter_provider" + + +class TestGetMeter(TestCase): + def test_get_meter_parameters(self): + """ + Test that get_meter accepts name, version and schema_url + """ + try: + _DefaultMeterProvider().get_meter( + "name", version="version", schema_url="schema_url" + ) + except Exception as error: + self.fail(f"Unexpected exception raised: {error}") + + def test_invalid_name(self): + """ + Test that when an invalid name is specified a working meter + implementation is returned as a fallback. + + Test that the fallback meter name property keeps its original invalid + value. + + Test that a message is logged reporting the specified value for the + fallback meter is invalid. + """ + with self.assertLogs(level=WARNING): + meter = _DefaultMeterProvider().get_meter("") + + self.assertTrue(isinstance(meter, _DefaultMeter)) + + self.assertEqual(meter.name, "") + + with self.assertLogs(level=WARNING): + meter = _DefaultMeterProvider().get_meter(None) + + self.assertTrue(isinstance(meter, _DefaultMeter)) + + self.assertEqual(meter.name, None) + + +class MockProvider(_DefaultMeterProvider): + def get_meter(self, name, version=None, schema_url=None): + return MockMeter(name, version=version, schema_url=schema_url) + + +class MockMeter(_DefaultMeter): + def create_counter(self, name, unit="", description=""): + return MockCounter("name") + + def create_up_down_counter(self, name, unit="", description=""): + return MockUpDownCounter("name") + + def create_observable_counter( + self, name, callback, unit="", description="" + ): + return MockObservableCounter("name", callback) + + def create_histogram(self, name, unit="", description=""): + return MockHistogram("name") + + def create_observable_gauge(self, name, callback, unit="", description=""): + return MockObservableGauge("name", callback) + + def create_observable_up_down_counter( + self, name, callback, unit="", description="" + ): + return MockObservableUpDownCounter("name", callback) + + +class MockCounter(DefaultCounter): + pass + + +class MockHistogram(DefaultHistogram): + pass + + +class MockObservableCounter(DefaultObservableCounter): + pass + + +class MockObservableGauge(DefaultObservableGauge): + pass + + +class MockObservableUpDownCounter(DefaultObservableUpDownCounter): + pass + + +class MockUpDownCounter(DefaultUpDownCounter): + pass + + +class TestProxy(TestCase): + def test_proxy_meter(self): + + """ + Test that the proxy meter provider and proxy meter automatically point + to updated objects. + """ + + original_provider = metrics._METER_PROVIDER + + provider = get_meter_provider() + self.assertIsInstance(provider, ProxyMeterProvider) + + meter = provider.get_meter("proxy-test") + self.assertIsInstance(meter, ProxyMeter) + + self.assertIsInstance(meter.create_counter("counter0"), DefaultCounter) + + self.assertIsInstance( + meter.create_histogram("histogram0"), DefaultHistogram + ) + + def callback(): + yield + + self.assertIsInstance( + meter.create_observable_counter("observable_counter0", callback()), + DefaultObservableCounter, + ) + + self.assertIsInstance( + meter.create_observable_gauge("observable_gauge0", callback()), + DefaultObservableGauge, + ) + + self.assertIsInstance( + meter.create_observable_up_down_counter( + "observable_up_down_counter0", callback() + ), + DefaultObservableUpDownCounter, + ) + + self.assertIsInstance( + meter.create_up_down_counter("up_down_counter0"), + DefaultUpDownCounter, + ) + + set_meter_provider(MockProvider()) + + self.assertIsInstance(get_meter_provider(), MockProvider) + self.assertIsInstance(provider.get_meter("proxy-test"), MockMeter) + + self.assertIsInstance(meter.create_counter("counter1"), MockCounter) + + self.assertIsInstance( + meter.create_histogram("histogram1"), MockHistogram + ) + + self.assertIsInstance( + meter.create_observable_counter("observable_counter1", callback()), + MockObservableCounter, + ) + + self.assertIsInstance( + meter.create_observable_gauge("observable_gauge1", callback()), + MockObservableGauge, + ) + + self.assertIsInstance( + meter.create_observable_up_down_counter( + "observable_up_down_counter1", callback() + ), + MockObservableUpDownCounter, + ) + + self.assertIsInstance( + meter.create_up_down_counter("up_down_counter1"), MockUpDownCounter + ) + + metrics._METER_PROVIDER = original_provider diff --git a/tests/util/src/opentelemetry/test/__init__.py b/tests/util/src/opentelemetry/test/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tox.ini b/tox.ini index 724a7f2018..cba6f08ab8 100644 --- a/tox.ini +++ b/tox.ini @@ -107,7 +107,7 @@ changedir = exporter-zipkin-combined: exporter/opentelemetry-exporter-zipkin/tests exporter-zipkin-proto-http: exporter/opentelemetry-exporter-zipkin-proto-http/tests exporter-zipkin-json: exporter/opentelemetry-exporter-zipkin-json/tests - + propagator-b3: propagator/opentelemetry-propagator-b3/tests propagator-jaeger: propagator/opentelemetry-propagator-jaeger/tests