From 37071faa8512bc8a3921f28d06276c0564c4fbf6 Mon Sep 17 00:00:00 2001 From: Diego Hurtado Date: Thu, 3 Jun 2021 15:03:55 -0600 Subject: [PATCH] Adds metric prototype Fixes #1835 --- CHANGELOG.md | 2 + docs/examples/metrics/grocery/README.rst | 10 + docs/examples/metrics/grocery/grocery.py | 94 +++++ docs/getting_started/otlpcollector_example.py | 5 +- docs/sdk/sdk.rst | 1 + .../exporter/otlp/proto/grpc/exporter.py | 2 +- .../src/opentelemetry/metrics/meter.py | 216 +++++++++++ .../sdk/environment_variables/__init__.py | 40 +- .../opentelemetry/sdk/metrics/aggregator.py | 288 +++++++++++++++ .../src/opentelemetry/sdk/metrics/export.py | 171 +++++++++ .../opentelemetry/sdk/metrics/instrument.py | 148 ++++++++ .../src/opentelemetry/sdk/metrics/meter.py | 209 +++++++++++ .../opentelemetry/sdk/metrics/processor.py | 36 ++ .../tests/metrics/test_aggregator.py | 346 ++++++++++++++++++ .../tests/metrics/test_instrument.py | 144 ++++++++ opentelemetry-sdk/tests/metrics/test_meter.py | 32 ++ tests/util/src/opentelemetry/test/__init__.py | 47 +++ .../util/src/opentelemetry/test/test_base.py | 2 + tests/util/tests/__init__.py | 13 + tests/util/tests/test_util.py | 91 +++++ tox.ini | 6 +- 21 files changed, 1898 insertions(+), 5 deletions(-) create mode 100644 docs/examples/metrics/grocery/README.rst create mode 100644 docs/examples/metrics/grocery/grocery.py create mode 100644 opentelemetry-api/src/opentelemetry/metrics/meter.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/metrics/aggregator.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/metrics/export.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/metrics/instrument.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/metrics/meter.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/metrics/processor.py create mode 100644 opentelemetry-sdk/tests/metrics/test_aggregator.py create mode 100644 opentelemetry-sdk/tests/metrics/test_instrument.py create mode 100644 opentelemetry-sdk/tests/metrics/test_meter.py create mode 100644 tests/util/src/opentelemetry/test/__init__.py create mode 100644 tests/util/tests/__init__.py create mode 100644 tests/util/tests/test_util.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4843749c3a..5e8be1470b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#1877](https://github.com/open-telemetry/opentelemetry-python/pull/1877)) - Added support for CreateKey functionality. ([#1853](https://github.com/open-telemetry/opentelemetry-python/pull/1853)) +- Add metrics + ([#1887](https://github.com/open-telemetry/opentelemetry-python/pull/1887)) ### Changed - Updated get_tracer to return an empty string when passed an invalid name diff --git a/docs/examples/metrics/grocery/README.rst b/docs/examples/metrics/grocery/README.rst new file mode 100644 index 0000000000..f8faaa52b7 --- /dev/null +++ b/docs/examples/metrics/grocery/README.rst @@ -0,0 +1,10 @@ +Grocery +======= + +This is the implementation of the grocery scenario. + + +Useful links +------------ + +.. _Grocery: https://github.com/open-telemetry/oteps/blob/main/text/metrics/0146-metrics-prototype-scenarios.md#scenario-1-grocery diff --git a/docs/examples/metrics/grocery/grocery.py b/docs/examples/metrics/grocery/grocery.py new file mode 100644 index 0000000000..dce7f52240 --- /dev/null +++ b/docs/examples/metrics/grocery/grocery.py @@ -0,0 +1,94 @@ +# 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. + +from opentelemetry.metrics import get_meter_provider +from opentelemetry.sdk.metrics.export import ConsoleExporter + +exporter = ConsoleExporter() + +get_meter_provider().start_pipeline_equivalent(exporter, 5) + +meter = get_meter_provider().get_meter() + + +order_counter = meter.create_counter( + name="orders", + description="number of orders", + unit="order", + value_type=int, +) + +amount_counter = meter.create_counter( + name="amount", + description="amount paid", + unit="dollar", + value_type=int, +) + +sold_items_counter = meter.create_counter( + name="sold items", + description="number of sold items", + unit="item", + value_type=int, +) + +customers_in_store = meter.create_up_down_counter( + name="customers in store", + description="amount of customers present in store", + unit="customer", + value_type=int, +) + + +class GroceryStore: + def __init__(self, name): + self._name = name + + def process_order(self, customer_name, potatoes=0, tomatoes=0): + order_counter.add(1, {"store": self._name, "customer": customer_name}) + + amount_counter.add( + (potatoes * 1) + (tomatoes * 3), + {"store": self._name, "customer": customer_name}, + ) + + sold_items_counter.add( + potatoes, + { + "store": self._name, + "customer": customer_name, + "item": "potato", + }, + ) + + sold_items_counter.add( + tomatoes, + { + "store": self._name, + "customer": customer_name, + "item": "tomato", + }, + ) + + def enter_customer(self, customer_name, account_type): + + customers_in_store.add( + 1, {"store": self._name, "account_type": account_type} + ) + + def exit_customer(self, customer_name, account_type): + + customers_in_store.add( + -1, {"store": self._name, "account_type": account_type} + ) diff --git a/docs/getting_started/otlpcollector_example.py b/docs/getting_started/otlpcollector_example.py index 11b3b12d4b..0fd524dc48 100644 --- a/docs/getting_started/otlpcollector_example.py +++ b/docs/getting_started/otlpcollector_example.py @@ -14,10 +14,13 @@ # otcollector.py -from opentelemetry import trace +from opentelemetry import metrics, trace +from opentelemetry.exporter.otlp.metrics_exporter import OTLPMetricsExporter from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( OTLPSpanExporter, ) +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PushController from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor diff --git a/docs/sdk/sdk.rst b/docs/sdk/sdk.rst index 333da1820b..4c1cf22fff 100644 --- a/docs/sdk/sdk.rst +++ b/docs/sdk/sdk.rst @@ -8,5 +8,6 @@ OpenTelemetry Python SDK resources trace + metrics error_handler environment_variables diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py index 15596ebc9f..4a8ddfd653 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py @@ -126,7 +126,7 @@ def _translate_key_values(key: Text, value: Any) -> KeyValue: return KeyValue(key=key, value=_translate_value(value)) -def get_resource_data( +def _get_resource_data( sdk_resource_instrumentation_library_data: Dict[ SDKResource, ResourceDataT ], diff --git a/opentelemetry-api/src/opentelemetry/metrics/meter.py b/opentelemetry-api/src/opentelemetry/metrics/meter.py new file mode 100644 index 0000000000..45c6f91b5f --- /dev/null +++ b/opentelemetry-api/src/opentelemetry/metrics/meter.py @@ -0,0 +1,216 @@ +# 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=abstract-class-instantiated +# pylint: disable=too-many-ancestors +# pylint: disable=useless-super-delegation + + +from abc import ABC, abstractmethod +from functools import wraps +from logging import getLogger +from typing import 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 +from opentelemetry.util.types import Attributes + +_logger = getLogger(__name__) + + +class Measurement(ABC): + @abstractmethod + def __init__(self, value, **attributes: Attributes): + pass + + +class DefaultMeasurement(Measurement): + def __init__(self, value, **attributes: Attributes): + super().__init__(value, **attributes) + + +class Meter(ABC): + + # FIXME make unit and description be "" if unit or description are None + @abstractmethod + def create_counter(self, name, unit=None, description=None) -> Counter: + pass + + @abstractmethod + def create_up_down_counter( + self, name, unit=None, description=None + ) -> UpDownCounter: + pass + + @abstractmethod + def create_observable_counter( + self, name, callback, unit=None, description=None + ) -> ObservableCounter: + pass + + @abstractmethod + def create_histogram(self, name, unit=None, description=None) -> Histogram: + pass + + @abstractmethod + def create_observable_gauge( + self, name, callback, unit=None, description=None + ) -> ObservableGauge: + pass + + @abstractmethod + def create_observable_up_down_counter( + self, name, callback, unit=None, description=None + ) -> ObservableUpDownCounter: + pass + + @staticmethod + def check_unique_name(checker): + def wrapper_0(method): + @wraps(method) + def wrapper_1(self, name, unit=None, description=None): + checker(self, name) + return method(self, name, unit=unit, description=description) + + return wrapper_1 + + return wrapper_0 + + +class DefaultMeter(Meter): + def __init__(self): + self._instrument_names = set() + + def _instrument_name_checker(self, name): + + if name in self._instrument_names: + raise Exception("Instrument name {} has been used already") + + self._instrument_names.add(name) + + @Meter.check_unique_name(_instrument_name_checker) + def create_counter(self, name, unit=None, description=None) -> Counter: + return DefaultCounter(name, unit=unit, description=description) + + @Meter.check_unique_name(_instrument_name_checker) + def create_up_down_counter( + self, name, unit=None, description=None + ) -> UpDownCounter: + return DefaultUpDownCounter(name, unit=unit, description=description) + + @Meter.check_unique_name(_instrument_name_checker) + def create_observable_counter( + self, name, callback, unit=None, description=None + ) -> ObservableCounter: + return DefaultObservableCounter( + name, + callback, + unit=unit, + description=description, + ) + + @Meter.check_unique_name(_instrument_name_checker) + def create_histogram(self, name, unit=None, description=None) -> Histogram: + return DefaultHistogram(name, unit=unit, description=description) + + @Meter.check_unique_name(_instrument_name_checker) + def create_observable_gauge( + self, name, callback, unit=None, description=None + ) -> ObservableGauge: + return DefaultObservableGauge( # pylint: disable=abstract-class-instantiated + name, + callback, + unit=unit, + description=description, + ) + + @Meter.check_unique_name(_instrument_name_checker) + def create_observable_up_down_counter( + self, name, callback, unit=None, description=None + ) -> ObservableUpDownCounter: + return DefaultObservableUpDownCounter( # pylint: disable=abstract-class-instantiated + name, + callback, + unit=unit, + description=description, + ) + + +class MeterProvider(ABC): + """ + var + """ + + @abstractmethod + def get_meter( + self, + name, + version=None, + schema_url=None, + ) -> Meter: + """ + vpas + """ + pass + + +class DefaultMeterProvider(MeterProvider): + def get_meter( + self, + name, + version=None, + schema_url=None, + ) -> Meter: + return DefaultMeter() + + +_METER_PROVIDER = None + + +def set_meter_provider(meter_provider: MeterProvider) -> None: + """Sets the current global :class:`~.MeterProvider` object.""" + 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.""" + global _METER_PROVIDER # pylint: disable=global-statement + + if _METER_PROVIDER is None: + _METER_PROVIDER = cast( + "MeterProvider", + _load_provider(OTEL_PYTHON_METER_PROVIDER, "meter_provider"), + ) + + return _METER_PROVIDER diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py index 8b3d4abbf8..543b19148e 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -357,8 +357,8 @@ """ .. envvar:: OTEL_SERVICE_NAME -Convenience environment variable for setting the service name resource attribute. -The following two environment variables have the same effect +Convenience environment variable for setting the service name resource +attribute. The following two environment variables have the same effect .. code-block:: console @@ -369,3 +369,39 @@ If both are set, :envvar:`OTEL_SERVICE_NAME` takes precedence. """ + +OTEL_EXPORTER_OTLP_METRIC_CERTIFICATE = "OTEL_EXPORTER_OTLP_METRIC_CERTIFICATE" +""" +.. envvar:: OTEL_EXPORTER_OTLP_METRIC_CERTIFICATE + +""" + +OTEL_EXPORTER_OTLP_METRIC_ENDPOINT = "OTEL_EXPORTER_OTLP_METRIC_ENDPOINT" +""" +.. envvar:: OTEL_EXPORTER_OTLP_METRIC_ENDPOINT + +""" + +OTEL_EXPORTER_OTLP_METRIC_CERTIFICATE = "OTEL_EXPORTER_OTLP_METRIC_CERTIFICATE" +""" +.. envvar:: OTEL_EXPORTER_OTLP_METRIC_CERTIFICATE + +""" + +OTEL_EXPORTER_OTLP_METRIC_INSECURE = "OTEL_EXPORTER_OTLP_METRIC_INSECURE" +""" +.. envvar:: OTEL_EXPORTER_OTLP_METRIC_INSECURE + +""" + +OTEL_EXPORTER_OTLP_METRIC_TIMEOUT = "OTEL_EXPORTER_OTLP_METRIC_TIMEOUT" +""" +.. envvar:: OTEL_EXPORTER_OTLP_METRIC_TIMEOUT + +""" + +OTEL_EXPORTER_OTLP_METRIC_HEADERS = "OTEL_EXPORTER_OTLP_METRIC_HEADERS" +""" +.. envvar:: OTEL_EXPORTER_OTLP_METRIC_HEADERS + +""" diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/aggregator.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/aggregator.py new file mode 100644 index 0000000000..460982b1e9 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/aggregator.py @@ -0,0 +1,288 @@ +# 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. + +from abc import ABC, abstractmethod +from logging import getLogger +from math import inf +from types import SimpleNamespace + +_logger = getLogger(__name__) + + +class Aggregator(ABC): + @classmethod + @abstractmethod + def _get_value_name(cls): + pass + + @abstractmethod + def _get_initial_value(self): + pass + + def aggregate(self, value): + if self.__class__.__bases__[0] is Aggregator: + self._value = self._aggregate(value) + + else: + for parent_class in self.__class__.__bases__: + getattr(self, parent_class._get_value_name()).aggregate(value) + + @abstractmethod + def _aggregate(self, new_value): + pass + + +class MinAggregator(Aggregator): + def __init__(self, *args, **kwargs): + + if self.__class__ == MinAggregator: + self._value = self._get_initial_value() + setattr( + self.__class__, "value", property(lambda self: self._value) + ) + + else: + setattr(self, MinAggregator._get_value_name(), MinAggregator()) + super().__init__(*args, **kwargs) + + @classmethod + def _get_value_name(cls): + return "min" + + def _get_initial_value(self): + return inf + + def _aggregate(self, value): + + return min(self._value, value) + + +class MaxAggregator(Aggregator): + def __init__(self, *args, **kwargs): + + if self.__class__ == MaxAggregator: + self._value = self._get_initial_value() + setattr( + self.__class__, "value", property(lambda self: self._value) + ) + + else: + setattr(self, MaxAggregator._get_value_name(), MaxAggregator()) + super().__init__(*args, **kwargs) + + @classmethod + def _get_value_name(cls): + return "max" + + def _get_initial_value(self): + return -inf + + def _aggregate(self, value): + + return max(self._value, value) + + +class SumAggregator(Aggregator): + def __init__(self, *args, **kwargs): + + if self.__class__ == SumAggregator: + self._value = self._get_initial_value() + setattr( + self.__class__, "value", property(lambda self: self._value) + ) + + else: + setattr(self, SumAggregator._get_value_name(), SumAggregator()) + super().__init__(*args, **kwargs) + + @classmethod + def _get_value_name(cls): + return "sum" + + def _get_initial_value(self): + return 0 + + def _aggregate(self, value): + + return sum([self._value, value]) + + +class CountAggregator(Aggregator): + def __init__(self, *args, **kwargs): + + if self.__class__ == CountAggregator: + self._value = self._get_initial_value() + setattr( + self.__class__, "value", property(lambda self: self._value) + ) + + else: + setattr(self, CountAggregator._get_value_name(), CountAggregator()) + super().__init__(*args, **kwargs) + + @classmethod + def _get_value_name(cls): + return "count" + + def _get_initial_value(self): + return 0 + + def _aggregate(self, value): + + return self._value + 1 + + +class LastAggregator(Aggregator): + def __init__(self, *args, **kwargs): + + if self.__class__ == LastAggregator: + self._value = self._get_initial_value() + setattr( + self.__class__, "value", property(lambda self: self._value) + ) + + else: + setattr(self, LastAggregator._get_value_name(), LastAggregator()) + super().__init__(*args, **kwargs) + + @classmethod + def _get_value_name(cls): + return "last" + + def _get_initial_value(self): + return None + + def _aggregate(self, value): + + return value + + +class HistogramAggregator(Aggregator): + """ + The rightmost bin is [], the other ones are [[. + """ + + def __init__(self, buckets, *args, **kwargs): + + if self.__class__ == HistogramAggregator: + self._buckets = buckets + self._value = self._get_initial_value() + setattr( + self.__class__, "value", property(lambda self: self._value) + ) + + else: + setattr( + self, + HistogramAggregator._get_value_name(), + HistogramAggregator(buckets), + ) + super().__init__(*args, **kwargs) + + @classmethod + def _get_value_name(cls): + return "histogram" + + def _get_initial_value(self): + + return [ + SimpleNamespace( + lower=SimpleNamespace(inclusive=True, value=lower), + upper=SimpleNamespace(inclusive=False, value=upper), + count=0, + ) + if index < len(self._buckets) - 2 + else SimpleNamespace( + lower=SimpleNamespace(inclusive=True, value=lower), + upper=SimpleNamespace(inclusive=True, value=upper), + count=0, + ) + for index, (lower, upper) in enumerate( + zip(self._buckets, self._buckets[1:]) + ) + ] + + def _aggregate(self, value): + + for bucket in self._value: + if value < bucket.lower.value: + _logger.warning("Value %s below lower histogram bound" % value) + break + + if (bucket.upper.inclusive and value <= bucket.upper.value) or ( + value < bucket.upper.value + ): + bucket.count = bucket.count + 1 + break + + else: + + _logger.warning("Value %s over upper histogram bound" % value) + + return self._value + + +class BoundSetAggregator(Aggregator): + def __init__(self, lower_bound, upper_bound, *args, **kwargs): + + if self.__class__ == BoundSetAggregator: + self._lower_bound = lower_bound + self._upper_bound = upper_bound + self._value = self._get_initial_value() + setattr( + self.__class__, "value", property(lambda self: self._value) + ) + + else: + setattr( + self, + BoundSetAggregator._get_value_name(), + BoundSetAggregator(lower_bound, upper_bound), + ) + super().__init__(*args, **kwargs) + + @classmethod + def _get_value_name(cls): + return "boundset" + + def _get_initial_value(self): + + return set() + + def _aggregate(self, value): + + if value < self._lower_bound: + _logger.warning("Value %s below lower set bound" % value) + + elif value > self._upper_bound: + _logger.warning("Value %s over upper set bound" % value) + + else: + self._value.add(value) + + return self._value + + +class MinMaxSumAggregator(MinAggregator, MaxAggregator, SumAggregator): + @classmethod + def _get_value_name(cls): + return "minmaxsum" + + +class MinMaxSumHistogramAggregator( + MinAggregator, MaxAggregator, SumAggregator, HistogramAggregator +): + @classmethod + def _get_value_name(cls): + return "minmaxsum" diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export.py new file mode 100644 index 0000000000..8baf81eedf --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export.py @@ -0,0 +1,171 @@ +# 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. + +from abc import ABC, abstractmethod +from enum import Enum +from threading import Event, Lock, Thread +from typing import Sequence, Tuple + +from opentelemetry.metrics.instrument import Instrument +from opentelemetry.metrics.meter import Meter +from opentelemetry.sdk.metrics.aggregator import Aggregator +from opentelemetry.sdk.resources import Resource + + +class Result(Enum): + SUCCESS = 0 + FAILURE = 1 + + +class Record: + def __init__( + self, + instrument: Instrument, + labels: Tuple[Tuple[str, str]], + aggregator: Aggregator, + resource: Resource, + ): + self.instrument = instrument + self.labels = labels + self.aggregator = aggregator + self.resource = resource + + +class Exporter(ABC): + """Interface for exporting metrics. + + Interface to be implemented by services that want to export recorded + metrics in its own format. + """ + + @abstractmethod + def export(self, records: Sequence[Record]) -> "Result": + """Exports a batch of telemetry data. + + Args: + records: A sequence of `Record` s. A `Record` + contains the metric to be exported, the labels associated + with that metric, as well as the aggregator used to export the + current checkpointed value. + + Returns: + The result of the export + """ + + @abstractmethod + def shutdown(self) -> None: + """Shuts down the exporter. + + Called when the SDK is shut down. + """ + + +class ConsoleExporter(Exporter): + """Implementation of `Exporter` that prints metrics to the console. + + This class can be used for diagnostic purposes. It prints the exported + metrics to the console STDOUT. + """ + + def export(self, records: Sequence[Record]) -> "Result": + for record in records: + print( + '{}(data="{}", labels="{}", value={}, resource={})'.format( + type(self).__name__, + record.instrument, + record.labels, + record.aggregator.checkpoint, + record.resource.attributes, + ) + ) + return Result.SUCCESS + + def shutdown(self): + pass + + +class MemoryExporter(Exporter): + """Implementation of `Exporter` that stores metrics in memory. + + This class can be used for testing purposes. It stores exported metrics + in a list in memory that can be retrieved using the + :func:`.exported_metrics` property. + """ + + def __init__(self): + self._exported_metrics = [] + self._shutdown = False + self._lock = Lock() + + def clear(self): + """Clear list of exported metrics.""" + with self._lock: + self._exported_metrics.clear() + + def export(self, records: Sequence[Record]) -> Result: + if self._shutdown: + return Result.FAILURE + + with self._lock: + self._exported_metrics.extend(records) + return Result.SUCCESS + + @property + def exported_metrics(self): + """Get list of exported metrics.""" + with self._lock: + return self._exported_metrics.copy() + + def shutdown(self) -> None: + """Shuts down the exporter. + + Called when the SDK is shut down. + """ + self._shutdown = True + + +class PushExporter(Thread): + """A push based controller, used for collecting and exporting. + + Uses a worker thread that periodically collects metrics for exporting, + exports them and performs some post-processing. + + Args: + meter: The meter used to get records. + exporter: The exporter used to export records. + interval: The collect/export interval in seconds. + """ + + daemon = True + + def __init__(self, meter: Meter, exporter: Exporter, interval: float): + super().__init__() + self._meter = meter + self._exporter = exporter + self._interval = interval + self._finished = Event() + self.start() + + def run(self): + while not self._finished.wait(self._interval): + self._tick() + + def shutdown(self): + self._finished.set() + # Run one more collection pass to flush metrics batched in the meter + self._tick() + + def _tick(self): + with self._meter.get_records() as records: + self._exporter.export(records) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/instrument.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/instrument.py new file mode 100644 index 0000000000..61d1f2db62 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/instrument.py @@ -0,0 +1,148 @@ +# 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=function-redefined,too-many-ancestors + +from typing import Generator + +from opentelemetry.metrics.instrument import ( + Adding, + Asynchronous, + Counter, + Grouping, + Histogram, + Instrument, + Monotonic, + NonMonotonic, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + Synchronous, + UpDownCounter, +) +from opentelemetry.sdk.metrics.aggregator import SumAggregator + + +class _Instrument(Instrument): + def __init__(self, name, unit=None, description=None): + + super().__init__(name, unit=unit, description=description) + + aggregator_class = None + + if isinstance(self, Adding): + if isinstance(self, Synchronous): + aggregator_class = SumAggregator + + elif isinstance(self, Asynchronous): + aggregator_class = None + + elif isinstance(self, Histogram): + aggregator_class = None + + elif isinstance(self, ObservableGauge): + aggregator_class = None + + self._aggregator_class = aggregator_class + self._name = name + self._unit = unit + self._description = description + + @property + def aggregator_class(self): + return self._aggregator_class + + @property + def name(self): + return self._name + + @property + def unit(self): + return self._unit + + @property + def description(self): + return self._description + + +class _Synchronous(Synchronous, _Instrument): + def __init__(self, name, unit=None, description=None): + super().__init__(name, unit=unit, description=description) + + +class _Asynchronous(Asynchronous, _Instrument): + def __init__(self, name, callback, unit=None, description=None): + super().__init__( + name, + callback, + unit=unit, + description=description, + ) + if not isinstance(callback, Generator): + raise TypeError("callback must be a generator") + + self._callback = callback + + def observe(self): + # FIXME make this limited by a timeout + return next(self._callback) + + +class _Adding(Adding, _Instrument): + pass + + +class _Grouping(Grouping, _Instrument): + pass + + +class _Monotonic(Monotonic, _Adding): + pass + + +class _NonMonotonic(NonMonotonic, _Adding): + pass + + +class Counter(Counter, _Monotonic, _Synchronous): + def add(self, amount, **attributes): + with self._datas_lock: + for view_data in self._view_datas: + view_data.record(amount) + + +class UpDownCounter(UpDownCounter, _NonMonotonic, _Synchronous): + def add(self, amount, **attributes): + print("add") + return super().add(amount, **attributes) + + +class ObservableCounter(ObservableCounter, _Monotonic, _Asynchronous): + pass + + +class ObservableUpDownCounter( + ObservableUpDownCounter, _NonMonotonic, _Asynchronous +): + pass + + +class Histogram(Histogram, _Grouping, _Synchronous): + def record(self, amount, **attributes): + print("record") + return super().record(amount, **attributes) + + +class ObservableGauge(ObservableGauge, _Grouping, _Asynchronous): + pass diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/meter.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/meter.py new file mode 100644 index 0000000000..14cf3745ab --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/meter.py @@ -0,0 +1,209 @@ +# 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=function-redefined,too-many-ancestors + +from contextlib import contextmanager +from threading import Lock +from typing import Sequence + +from opentelemetry.context import attach, detach, set_value +from opentelemetry.metrics.meter import Measurement, Meter, MeterProvider +from opentelemetry.sdk.metrics.export import Record +from opentelemetry.sdk.metrics.instrument import ( + Asynchronous, + Counter, + Histogram, + Instrument, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + UpDownCounter, +) +from opentelemetry.sdk.util import get_dict_as_key +from opentelemetry.util.types import Attributes + + +class Measurement(Measurement): + def __init__(self, value, **attributes: Attributes): + self._value = value + self._attributes = attributes + super().__init__(value, **attributes) + + @property + def value(self): + return self._value + + @property + def attributes(self): + return self._attributes + + +class Meter(Meter): + + # pylint: disable=no-self-use + def __init__(self): + + self._batch_map = {} + self._lock = Lock() + self._instruments = [] + self._views = [] + self._stateful = False + + def create_counter(self, name, unit=None, description=None) -> Counter: + return Counter(name, unit=unit, description=description) + + def create_up_down_counter( + self, name, unit=None, description=None + ) -> UpDownCounter: + return UpDownCounter(name, unit=unit, description=description) + + def create_observable_counter( + self, name, callback, unit=None, description=None + ) -> ObservableCounter: + return ObservableCounter( + name, callback, unit=unit, description=description + ) + + def create_histogram(self, name, unit=None, description=None) -> Histogram: + return Histogram(name, unit=unit, description=description) + + def create_observable_gauge( + self, name, callback, unit=None, description=None + ) -> ObservableGauge: + return ObservableGauge( + name, callback, unit=unit, description=description + ) + + def create_observable_up_down_counter( + self, name, callback, unit=None, description=None + ) -> ObservableUpDownCounter: + return ObservableUpDownCounter( + name, callback, unit=unit, description=description + ) + + @contextmanager + def get_records(self) -> Sequence[Record]: + """Gets all the records created with this `Meter` for export. + + Creates checkpoints of the current values in + each aggregator belonging to the metrics that were created with this + meter instance. + """ + # pylint: disable=too-many-branches + try: + with self._lock: + for instrument in self._instruments: + if not instrument.enabled: + continue + if isinstance(instrument, Instrument): + to_remove = [] + for ( + labels, + bound_instrument, + ) in instrument.bound_instruments.items(): + for view_data in bound_instrument.view_datas: + self._populate_batch_map( + instrument, + view_data.labels, + view_data.aggregator, + ) + + if bound_instrument.ref_count() == 0: + to_remove.append(labels) + + # Remove handles that were released + for labels in to_remove: + del instrument.bound_instruments[labels] + elif isinstance(instrument, Asynchronous): + if not instrument.run(): + continue + + for ( + labels, + aggregator, + ) in instrument.aggregators.items(): + self._populate_batch_map( + instrument, labels, aggregator + ) + + token = attach(set_value("suppress_instrumentation", True)) + records = [] + # pylint: disable=W0612 + for ( + (instrument, aggregator_type, _, labels), + aggregator, + ) in self._batch_map.items(): + records.append( + Record(instrument, labels, aggregator, self._resource) + ) + + yield records + + finally: + + detach(token) + + if not self._stateful: + self._batch_map = {} + + def _populate_batch_map(self, instrument, labels, aggregator) -> None: + """Stores record information to be ready for exporting.""" + # Checkpoints the current aggregator value to be collected for export + aggregator.take_checkpoint() + + # The uniqueness of a batch record is defined by a specific metric + # using an aggregator type with a specific set of labels. + # If two aggregators are the same but with different configs, they are still two valid unique records + # (for example, two histogram views with different buckets) + key = ( + instrument, + aggregator.__class__, + get_dict_as_key(aggregator.config), + labels, + ) + + batch_value = self._batch_map.get(key) + + if batch_value: + # Update the stored checkpointed value if exists. The call to merge + # here combines only identical records (same key). + batch_value.merge(aggregator) + return + + # create a copy of the aggregator and update + # it with the current checkpointed value for long-term storage + aggregator = aggregator.__class__(config=aggregator.config) + aggregator.merge(aggregator) + + self._batch_map[key] = aggregator + + +class MeterProvider(MeterProvider): + def add_pipeline(self): + pass + + def configure_views(self): + pass + + def configure_timint(self): + pass + + def get_meter( + self, + name, + version=None, + schema_url=None, + ) -> Meter: + return Meter() diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/processor.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/processor.py new file mode 100644 index 0000000000..43979e5eb2 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/processor.py @@ -0,0 +1,36 @@ +# 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. + + +from abc import ABC, abstractmethod + + +class Processor(ABC): + @abstractmethod + def process(self): + """ + This is to be called when a metric FIXME complete this + """ + + +class MeasurementProcessor(Processor): + """ + FIXME + """ + + +class MetricProcessor(Processor): + """ + FIXME + """ diff --git a/opentelemetry-sdk/tests/metrics/test_aggregator.py b/opentelemetry-sdk/tests/metrics/test_aggregator.py new file mode 100644 index 0000000000..0c7d82bf50 --- /dev/null +++ b/opentelemetry-sdk/tests/metrics/test_aggregator.py @@ -0,0 +1,346 @@ +# 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. + + +from logging import WARNING +from math import inf +from unittest import TestCase + +from opentelemetry.sdk.metrics.aggregator import ( + BoundSetAggregator, + CountAggregator, + HistogramAggregator, + LastAggregator, + MaxAggregator, + MinAggregator, + MinMaxSumAggregator, + MinMaxSumHistogramAggregator, + SumAggregator, + _logger, +) + + +class TestAggregator(TestCase): + # pylint: disable=no-member + + def test_min_aggregator(self): + + min_aggregator = MinAggregator() + + self.assertEqual(min_aggregator.value, inf) + + min_aggregator.aggregate(3) + self.assertEqual(min_aggregator.value, 3) + + min_aggregator.aggregate(2) + self.assertEqual(min_aggregator.value, 2) + + min_aggregator.aggregate(3) + self.assertEqual(min_aggregator.value, 2) + + def test_max_aggregator(self): + + max_aggregator = MaxAggregator() + + self.assertEqual(max_aggregator.value, -inf) + + max_aggregator.aggregate(2) + self.assertEqual(max_aggregator.value, 2) + + max_aggregator.aggregate(3) + self.assertEqual(max_aggregator.value, 3) + + max_aggregator.aggregate(1) + self.assertEqual(max_aggregator.value, 3) + + def test_sum_aggregator(self): + + sum_aggregator = SumAggregator() + + self.assertEqual(sum_aggregator.value, 0) + + sum_aggregator.aggregate(2) + self.assertEqual(sum_aggregator.value, 2) + + sum_aggregator.aggregate(3) + self.assertEqual(sum_aggregator.value, 5) + + sum_aggregator.aggregate(1) + self.assertEqual(sum_aggregator.value, 6) + + def test_count_aggregator(self): + + count_aggregator = CountAggregator() + + self.assertEqual(count_aggregator.value, 0) + + count_aggregator.aggregate(2) + self.assertEqual(count_aggregator.value, 1) + + count_aggregator.aggregate(3) + self.assertEqual(count_aggregator.value, 2) + + count_aggregator.aggregate(1) + self.assertEqual(count_aggregator.value, 3) + + def test_last_aggregator(self): + + last_aggregator = LastAggregator() + + self.assertEqual(last_aggregator.value, None) + + last_aggregator.aggregate(2) + self.assertEqual(last_aggregator.value, 2) + + last_aggregator.aggregate(3) + self.assertEqual(last_aggregator.value, 3) + + last_aggregator.aggregate(2) + self.assertEqual(last_aggregator.value, 2) + + def test_histogram_aggregator(self): + + histogram_aggregator = HistogramAggregator([-3, 1, 3, 7, 12]) + + bucket_0 = histogram_aggregator.value[0] + bucket_1 = histogram_aggregator.value[1] + bucket_2 = histogram_aggregator.value[2] + bucket_3 = histogram_aggregator.value[3] + + self.assertTrue(bucket_0.lower.inclusive) + self.assertEqual(bucket_0.lower.value, -3) + self.assertFalse(bucket_0.upper.inclusive) + self.assertEqual(bucket_0.upper.value, 1) + self.assertEqual(bucket_0.count, 0) + + self.assertTrue(bucket_1.lower.inclusive) + self.assertEqual(bucket_1.lower.value, 1) + self.assertFalse(bucket_1.upper.inclusive) + self.assertEqual(bucket_1.upper.value, 3) + self.assertEqual(bucket_1.count, 0) + + self.assertTrue(bucket_2.lower.inclusive) + self.assertEqual(bucket_2.lower.value, 3) + self.assertFalse(bucket_2.upper.inclusive) + self.assertEqual(bucket_2.upper.value, 7) + self.assertEqual(bucket_2.count, 0) + + self.assertTrue(bucket_3.lower.inclusive) + self.assertEqual(bucket_3.lower.value, 7) + self.assertTrue(bucket_3.upper.inclusive) + self.assertEqual(bucket_3.upper.value, 12) + self.assertEqual(bucket_3.count, 0) + + histogram_aggregator.aggregate(2) + + self.assertEqual(bucket_0.count, 0) + self.assertEqual(bucket_1.count, 1) + self.assertEqual(bucket_2.count, 0) + self.assertEqual(bucket_3.count, 0) + + histogram_aggregator.aggregate(9) + + self.assertEqual(bucket_0.count, 0) + self.assertEqual(bucket_1.count, 1) + self.assertEqual(bucket_2.count, 0) + self.assertEqual(bucket_3.count, 1) + + histogram_aggregator.aggregate(10) + + self.assertEqual(bucket_0.count, 0) + self.assertEqual(bucket_1.count, 1) + self.assertEqual(bucket_2.count, 0) + self.assertEqual(bucket_3.count, 2) + + histogram_aggregator.aggregate(2) + + self.assertEqual(bucket_0.count, 0) + self.assertEqual(bucket_1.count, 2) + self.assertEqual(bucket_2.count, 0) + self.assertEqual(bucket_3.count, 2) + + histogram_aggregator.aggregate(3) + + self.assertEqual(bucket_0.count, 0) + self.assertEqual(bucket_1.count, 2) + self.assertEqual(bucket_2.count, 1) + self.assertEqual(bucket_3.count, 2) + + histogram_aggregator.aggregate(-3) + + self.assertEqual(bucket_0.count, 1) + self.assertEqual(bucket_1.count, 2) + self.assertEqual(bucket_2.count, 1) + self.assertEqual(bucket_3.count, 2) + + histogram_aggregator.aggregate(12) + + self.assertEqual(bucket_0.count, 1) + self.assertEqual(bucket_1.count, 2) + self.assertEqual(bucket_2.count, 1) + self.assertEqual(bucket_3.count, 3) + + with self.assertLogs(_logger, WARNING) as log: + print(log.output) + histogram_aggregator.aggregate(-5) + self.assertEqual( + log.output, + [ + "WARNING:opentelemetry.sdk.metrics.aggregator:" + "Value -5 below lower histogram bound" + ], + ) + + with self.assertLogs(_logger, WARNING) as log: + histogram_aggregator.aggregate(13) + self.assertEqual( + log.output, + [ + "WARNING:opentelemetry.sdk.metrics.aggregator:" + "Value 13 over upper histogram bound" + ], + ) + + def test_min_max_sum_aggregator(self): + + min_max_sum_aggregator = MinMaxSumAggregator() + + self.assertEqual(min_max_sum_aggregator.min.value, inf) + self.assertEqual(min_max_sum_aggregator.max.value, -inf) + self.assertEqual(min_max_sum_aggregator.sum.value, 0) + + min_max_sum_aggregator.aggregate(1) + min_max_sum_aggregator.aggregate(2) + min_max_sum_aggregator.aggregate(3) + + self.assertEqual(min_max_sum_aggregator.min.value, 1) + self.assertEqual(min_max_sum_aggregator.max.value, 3) + self.assertEqual(min_max_sum_aggregator.sum.value, 6) + + def test_min_max_sum_histogram_aggregator(self): + + min_max_sum_histogram_aggregator = MinMaxSumHistogramAggregator( + [-3, 1, 3, 7, 12] + ) + + self.assertEqual(min_max_sum_histogram_aggregator.min.value, inf) + self.assertEqual(min_max_sum_histogram_aggregator.max.value, -inf) + self.assertEqual(min_max_sum_histogram_aggregator.sum.value, 0) + + min_max_sum_histogram_aggregator.aggregate(1) + min_max_sum_histogram_aggregator.aggregate(2) + min_max_sum_histogram_aggregator.aggregate(3) + + self.assertEqual(min_max_sum_histogram_aggregator.min.value, 1) + self.assertEqual(min_max_sum_histogram_aggregator.max.value, 3) + self.assertEqual(min_max_sum_histogram_aggregator.sum.value, 6) + + bucket_0 = min_max_sum_histogram_aggregator.histogram.value[0] + bucket_1 = min_max_sum_histogram_aggregator.histogram.value[1] + bucket_2 = min_max_sum_histogram_aggregator.histogram.value[2] + bucket_3 = min_max_sum_histogram_aggregator.histogram.value[3] + + self.assertTrue(bucket_0.lower.inclusive) + self.assertEqual(bucket_0.lower.value, -3) + self.assertFalse(bucket_0.upper.inclusive) + self.assertEqual(bucket_0.upper.value, 1) + self.assertEqual(bucket_0.count, 0) + + self.assertTrue(bucket_1.lower.inclusive) + self.assertEqual(bucket_1.lower.value, 1) + self.assertFalse(bucket_1.upper.inclusive) + self.assertEqual(bucket_1.upper.value, 3) + self.assertEqual(bucket_1.count, 2) + + self.assertTrue(bucket_2.lower.inclusive) + self.assertEqual(bucket_2.lower.value, 3) + self.assertFalse(bucket_2.upper.inclusive) + self.assertEqual(bucket_2.upper.value, 7) + self.assertEqual(bucket_2.count, 1) + + self.assertTrue(bucket_3.lower.inclusive) + self.assertEqual(bucket_3.lower.value, 7) + self.assertTrue(bucket_3.upper.inclusive) + self.assertEqual(bucket_3.upper.value, 12) + self.assertEqual(bucket_3.count, 0) + + def test_boundset_histogram_aggregator(self): + class BoundSetHistogramAggregator( + BoundSetAggregator, HistogramAggregator + ): + @classmethod + def _get_value_name(cls): + return "boundsethistogram" + + bound_set_histogram_aggregator = BoundSetHistogramAggregator( + 1, 4, [6, 9, 11] + ) + + bound_set_histogram_aggregator.aggregate(2) + bound_set_histogram_aggregator.aggregate(5) + bound_set_histogram_aggregator.aggregate(10) + + self.assertEqual( + bound_set_histogram_aggregator.boundset.value, set([2]) + ) + + bucket_0 = bound_set_histogram_aggregator.histogram.value[0] + bucket_1 = bound_set_histogram_aggregator.histogram.value[1] + + self.assertTrue(bucket_0.lower.inclusive) + self.assertEqual(bucket_0.lower.value, 6) + self.assertFalse(bucket_0.upper.inclusive) + self.assertEqual(bucket_0.upper.value, 9) + self.assertEqual(bucket_0.count, 0) + + self.assertTrue(bucket_1.lower.inclusive) + self.assertEqual(bucket_1.lower.value, 9) + self.assertTrue(bucket_1.upper.inclusive) + self.assertEqual(bucket_1.upper.value, 11) + self.assertEqual(bucket_1.count, 1) + + def test_histogram_boundset_aggregator(self): + class HistogramBoundSetAggregator( + HistogramAggregator, BoundSetAggregator + ): + @classmethod + def _get_value_name(cls): + return "histogramboundset" + + histogram_bound_set_aggregator = HistogramBoundSetAggregator( + [6, 9, 11], 1, 4 + ) + + histogram_bound_set_aggregator.aggregate(2) + histogram_bound_set_aggregator.aggregate(5) + histogram_bound_set_aggregator.aggregate(10) + + self.assertEqual( + histogram_bound_set_aggregator.boundset.value, set([2]) + ) + + bucket_0 = histogram_bound_set_aggregator.histogram.value[0] + bucket_1 = histogram_bound_set_aggregator.histogram.value[1] + + self.assertTrue(bucket_0.lower.inclusive) + self.assertEqual(bucket_0.lower.value, 6) + self.assertFalse(bucket_0.upper.inclusive) + self.assertEqual(bucket_0.upper.value, 9) + self.assertEqual(bucket_0.count, 0) + + self.assertTrue(bucket_1.lower.inclusive) + self.assertEqual(bucket_1.lower.value, 9) + self.assertTrue(bucket_1.upper.inclusive) + self.assertEqual(bucket_1.upper.value, 11) + self.assertEqual(bucket_1.count, 1) diff --git a/opentelemetry-sdk/tests/metrics/test_instrument.py b/opentelemetry-sdk/tests/metrics/test_instrument.py new file mode 100644 index 0000000000..7b5f34544f --- /dev/null +++ b/opentelemetry-sdk/tests/metrics/test_instrument.py @@ -0,0 +1,144 @@ +# 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. + + +from unittest import TestCase +from unittest.mock import Mock + +from opentelemetry.metrics.instrument import ( + Adding, + Asynchronous, + Counter, + Grouping, + Histogram, + Instrument, + Monotonic, + NonMonotonic, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + Synchronous, + UpDownCounter, +) +from opentelemetry.sdk.metrics.instrument import Counter as SDKCounter +from opentelemetry.sdk.metrics.instrument import Histogram as SDKHistogram +from opentelemetry.sdk.metrics.instrument import ( + ObservableCounter as SDKObservableCounter, +) +from opentelemetry.sdk.metrics.instrument import ( + ObservableGauge as SDKObservableGauge, +) +from opentelemetry.sdk.metrics.instrument import ( + ObservableUpDownCounter as SDKObservableUpDownCounter, +) +from opentelemetry.sdk.metrics.instrument import ( + UpDownCounter as SDKUpDownCounter, +) +from opentelemetry.sdk.metrics.meter import Meter as SDKMeter + + +class TestInstrument(TestCase): + def test_create_counter(self): + counter = SDKMeter().create_counter( + "name", unit="unit", description="description" + ) + self.assertEqual(counter.name, "name") + self.assertEqual(counter.unit, "unit") + self.assertEqual(counter.description, "description") + self.assertIsInstance(counter, SDKCounter) + self.assertIsInstance(counter, Counter) + self.assertIsInstance(counter, Monotonic) + self.assertIsInstance(counter, Adding) + self.assertIsInstance(counter, Synchronous) + self.assertIsInstance(counter, Instrument) + + def test_create_up_down_counter(self): + up_down_counter = SDKMeter().create_up_down_counter( + "name", unit="unit", description="description" + ) + self.assertEqual(up_down_counter.name, "name") + self.assertEqual(up_down_counter.unit, "unit") + self.assertEqual(up_down_counter.description, "description") + self.assertIsInstance(up_down_counter, SDKUpDownCounter) + self.assertIsInstance(up_down_counter, UpDownCounter) + self.assertIsInstance(up_down_counter, NonMonotonic) + self.assertIsInstance(up_down_counter, Adding) + self.assertIsInstance(up_down_counter, Synchronous) + self.assertIsInstance(up_down_counter, Instrument) + + def test_create_observable_counter(self): + callback = Mock() + observable_counter = SDKMeter().create_observable_counter( + "name", callback, unit="unit", description="description" + ) + self.assertEqual(observable_counter.name, "name") + self.assertIs(observable_counter.callback, callback) + self.assertEqual(observable_counter.unit, "unit") + self.assertEqual(observable_counter.description, "description") + self.assertIsInstance(observable_counter, SDKObservableCounter) + self.assertIsInstance(observable_counter, ObservableCounter) + self.assertIsInstance(observable_counter, Monotonic) + self.assertIsInstance(observable_counter, Adding) + self.assertIsInstance(observable_counter, Asynchronous) + self.assertIsInstance(observable_counter, Instrument) + + def test_create_observable_up_down_counter(self): + callback = Mock() + observable_up_down_counter = ( + SDKMeter().create_observable_up_down_counter( + "name", callback, unit="unit", description="description" + ) + ) + self.assertEqual(observable_up_down_counter.name, "name") + self.assertIs(observable_up_down_counter.callback, callback) + self.assertEqual(observable_up_down_counter.unit, "unit") + self.assertEqual(observable_up_down_counter.description, "description") + self.assertIsInstance( + observable_up_down_counter, SDKObservableUpDownCounter + ) + self.assertIsInstance( + observable_up_down_counter, ObservableUpDownCounter + ) + self.assertIsInstance(observable_up_down_counter, NonMonotonic) + self.assertIsInstance(observable_up_down_counter, Adding) + self.assertIsInstance(observable_up_down_counter, Asynchronous) + self.assertIsInstance(observable_up_down_counter, Instrument) + + def test_create_histogram(self): + histogram = SDKMeter().create_histogram( + "name", unit="unit", description="description" + ) + self.assertEqual(histogram.name, "name") + self.assertEqual(histogram.unit, "unit") + self.assertEqual(histogram.description, "description") + self.assertIsInstance(histogram, SDKHistogram) + self.assertIsInstance(histogram, Histogram) + self.assertIsInstance(histogram, Grouping) + self.assertIsInstance(histogram, Synchronous) + self.assertIsInstance(histogram, Instrument) + + def test_create_observable_gauge(self): + callback = Mock() + observable_gauge = SDKMeter().create_observable_gauge( + "name", callback, unit="unit", description="description" + ) + self.assertEqual(observable_gauge.name, "name") + self.assertIs(observable_gauge.callback, callback) + self.assertEqual(observable_gauge.unit, "unit") + self.assertEqual(observable_gauge.description, "description") + self.assertIsInstance(observable_gauge, SDKObservableGauge) + self.assertIsInstance(observable_gauge, ObservableGauge) + self.assertIsInstance(observable_gauge, Grouping) + self.assertIsInstance(observable_gauge, Asynchronous) + self.assertIsInstance(observable_gauge, Instrument) diff --git a/opentelemetry-sdk/tests/metrics/test_meter.py b/opentelemetry-sdk/tests/metrics/test_meter.py new file mode 100644 index 0000000000..bf817826d0 --- /dev/null +++ b/opentelemetry-sdk/tests/metrics/test_meter.py @@ -0,0 +1,32 @@ +# 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. + + +from unittest import TestCase + +from opentelemetry.metrics.meter import Meter, MeterProvider +from opentelemetry.sdk.metrics.meter import Meter as SDKMeter +from opentelemetry.sdk.metrics.meter import MeterProvider as SDKMeterProvider + + +class TestMeter(TestCase): + def test_meter_provider(self): + meter_provider = SDKMeterProvider() + self.assertIsInstance(meter_provider, SDKMeterProvider) + self.assertIsInstance(meter_provider, MeterProvider) + + def test_meter(self): + meter = SDKMeterProvider().get_meter(__name__) + self.assertIsInstance(meter, SDKMeter) + self.assertIsInstance(meter, Meter) diff --git a/tests/util/src/opentelemetry/test/__init__.py b/tests/util/src/opentelemetry/test/__init__.py new file mode 100644 index 0000000000..ebce7268d4 --- /dev/null +++ b/tests/util/src/opentelemetry/test/__init__.py @@ -0,0 +1,47 @@ +# 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. + + +from traceback import format_tb + + +class AssertNotRaisesMixin: + class _AssertNotRaises: + def __init__(self, test_case): + self._test_case = test_case + + def __enter__(self): + return self + + def __exit__(self, type_, value, tb): # pylint: disable=invalid-name + if value is not None and type_ in self._exception_types: + + self._test_case.fail( + "Unexpected exception was raised:\n{}".format( + "\n".join(format_tb(tb)) + ) + ) + + return True + + def __call__(self, exception, *exceptions): + # pylint: disable=attribute-defined-outside-init + self._exception_types = (exception, *exceptions) + return self + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + # pylint: disable=invalid-name + self.assertNotRaises = self._AssertNotRaises(self) diff --git a/tests/util/src/opentelemetry/test/test_base.py b/tests/util/src/opentelemetry/test/test_base.py index 9762a08010..25df3a4d6a 100644 --- a/tests/util/src/opentelemetry/test/test_base.py +++ b/tests/util/src/opentelemetry/test/test_base.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +# pylint: disable=abstract-class-instantiated + import logging import unittest from contextlib import contextmanager diff --git a/tests/util/tests/__init__.py b/tests/util/tests/__init__.py new file mode 100644 index 0000000000..b0a6f42841 --- /dev/null +++ b/tests/util/tests/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/tests/util/tests/test_util.py b/tests/util/tests/test_util.py new file mode 100644 index 0000000000..a4837e313a --- /dev/null +++ b/tests/util/tests/test_util.py @@ -0,0 +1,91 @@ +# 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. + +from unittest import TestCase + +from opentelemetry.test import AssertNotRaisesMixin + + +class TestAssertNotRaises(TestCase): + class TestCaseAssertNotRaises(AssertNotRaisesMixin, TestCase): + pass + + @classmethod + def setUpClass(cls): + cls.test_case = cls.TestCaseAssertNotRaises() + + def test_no_exception(self): + + try: + + with self.test_case.assertNotRaises(Exception): + pass + + except Exception as error: # pylint: disable=broad-except + + self._test_case.fail( # pylint: disable=no-member + "Unexpected exception {} was raised".format(error) + ) + + def test_no_specified_exception_single(self): + + try: + + with self.test_case.assertNotRaises(KeyError): + 1 / 0 # pylint: disable=pointless-statement + + except Exception as error: # pylint: disable=broad-except + + self._test_case.fail( # pylint: disable=no-member + "Unexpected exception {} was raised".format(error) + ) + + def test_no_specified_exception_multiple(self): + + try: + + with self.test_case.assertNotRaises(KeyError, IndexError): + 1 / 0 # pylint: disable=pointless-statement + + except Exception as error: # pylint: disable=broad-except + + self._test_case.fail( # pylint: disable=no-member + "Unexpected exception was raised:\n{}".format(error) + ) + + def test_exception(self): + + with self.assertRaises(AssertionError): + + with self.test_case.assertNotRaises(ZeroDivisionError): + 1 / 0 # pylint: disable=pointless-statement + + def test_missing_exception(self): + + with self.assertRaises(AssertionError) as error: + + with self.test_case.assertNotRaises(ZeroDivisionError): + + def raise_zero_division_error(): + raise ZeroDivisionError() + + raise_zero_division_error() + + error_lines = error.exception.args[0].split("\n") + + self.assertEqual( + error_lines[0].strip(), "Unexpected exception was raised:" + ) + self.assertEqual(error_lines[2].strip(), "raise_zero_division_error()") + self.assertEqual(error_lines[5].strip(), "raise ZeroDivisionError()") diff --git a/tox.ini b/tox.ini index cba6f08ab8..262eede260 100644 --- a/tox.ini +++ b/tox.ini @@ -64,6 +64,10 @@ envlist = py3{6,7,8,9}-opentelemetry-propagator-jaeger pypy3-opentelemetry-propagator-jaeger + ; opentelemetry-test + py3{6,7,8,9}-test-txst + pypy3-test-txst + lint tracecontext mypy,mypyinstalled @@ -218,7 +222,7 @@ commands_pre = python -m pip install -e {toxinidir}/exporter/opentelemetry-exporter-otlp[test] python -m pip install -e {toxinidir}/exporter/opentelemetry-exporter-zipkin-json[test] python -m pip install -e {toxinidir}/exporter/opentelemetry-exporter-zipkin-proto-http[test] - python -m pip install -e {toxinidir}/exporter/opentelemetry-exporter-zipkin[test] + python -m pip install -e {toxinidir}/exporter/opentelemetry-exporter-prometheus[test] python -m pip install -e {toxinidir}/propagator/opentelemetry-propagator-b3[test] python -m pip install -e {toxinidir}/propagator/opentelemetry-propagator-jaeger[test] python -m pip install -e {toxinidir}/opentelemetry-distro[test]