diff --git a/docs/opentelemetry.metrics.handle.rst b/docs/opentelemetry.metrics.handle.rst deleted file mode 100644 index 826a0e4e5a9..00000000000 --- a/docs/opentelemetry.metrics.handle.rst +++ /dev/null @@ -1,5 +0,0 @@ -opentelemetry.metrics.handle module -========================================== - -.. automodule:: opentelemetry.metrics.handle - diff --git a/docs/opentelemetry.metrics.rst b/docs/opentelemetry.metrics.rst index 289f1842ba1..358d5491a69 100644 --- a/docs/opentelemetry.metrics.rst +++ b/docs/opentelemetry.metrics.rst @@ -1,13 +1,6 @@ opentelemetry.metrics package ============================= -Submodules ----------- - -.. toctree:: - - opentelemetry.metrics.handle - Module contents --------------- diff --git a/opentelemetry-api/src/opentelemetry/metrics/examples/raw.py b/examples/opentelemetry-example-app/src/opentelemetry_example_app/metrics_example.py similarity index 52% rename from opentelemetry-api/src/opentelemetry/metrics/examples/raw.py rename to examples/opentelemetry-example-app/src/opentelemetry_example_app/metrics_example.py index 501b2e5b617..1c9db3def7a 100644 --- a/opentelemetry-api/src/opentelemetry/metrics/examples/raw.py +++ b/examples/opentelemetry-example-app/src/opentelemetry_example_app/metrics_example.py @@ -11,26 +11,32 @@ # 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. +# +""" +This module serves as an example for a simple application using metrics +""" -# pylint: skip-file from opentelemetry import metrics +from opentelemetry.sdk.metrics import Meter -METER = metrics.Meter() -MEASURE = metrics.create_measure( - "idle_cpu_percentage", - "cpu idle over time", - "percentage", - metrics.ValueType.FLOAT, - ["environment"], +metrics.set_preferred_meter_implementation(lambda _: Meter()) +meter = metrics.meter() +counter = meter.create_metric( + "available memory", + "available memory", + "bytes", + int, + metrics.MetricKind.COUNTER, + ("environment"), ) -# Metrics sent to some exporter -MEASURE_METRIC_TESTING = MEASURE.get_handle("Testing") -MEASURE_METRIC_STAGING = MEASURE.get_handle("Staging") +label_values = "staging" + +counter_handle = counter.get_handle(label_values) + +counter_handle.add(100) +meter.record_batch(label_values, [(counter, 50)]) -# record individual measures -STATISTIC = 100 -MEASURE_METRIC_STAGING.record(STATISTIC) +print(counter_handle.data) -# Batch recording -METER.record_batch([tuple(MEASURE_METRIC_STAGING, STATISTIC)]) +# TODO: exporters diff --git a/ext/opentelemetry-ext-azure-monitor/examples/metrics.py b/ext/opentelemetry-ext-azure-monitor/examples/metrics.py new file mode 100644 index 00000000000..7d4a6ed7278 --- /dev/null +++ b/ext/opentelemetry-ext-azure-monitor/examples/metrics.py @@ -0,0 +1,42 @@ +# Copyright 2019, OpenCensus 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. + +import logging + +from opentelemetry import metrics +from opentelemetry.ext.azure_monitor import AzureMonitorMetricsExporter +from opentelemetry.sdk.metrics import Counter, Meter +from opentelemetry.sdk.metrics.export import ConsoleMetricsExporter + +logging.basicConfig(level=logging.INFO) + +metrics.set_preferred_meter_implementation(lambda T: Meter()) +meter = metrics.meter() +exporter = AzureMonitorMetricsExporter() +console_exporter = ConsoleMetricsExporter() + +counter = meter.create_metric( + "available memory", + "available memory", + "bytes", + int, + Counter, + ("environment",), +) +label_values = ("staging",) +counter_handle = counter.get_handle(label_values) +counter_handle.add(100) + +console_exporter.export([(counter, label_values)]) +exporter.export([(counter, label_values)]) diff --git a/ext/opentelemetry-ext-azure-monitor/src/opentelemetry/ext/azure_monitor/__init__.py b/ext/opentelemetry-ext-azure-monitor/src/opentelemetry/ext/azure_monitor/__init__.py index 81222c546eb..7dc8decf653 100644 --- a/ext/opentelemetry-ext-azure-monitor/src/opentelemetry/ext/azure_monitor/__init__.py +++ b/ext/opentelemetry-ext-azure-monitor/src/opentelemetry/ext/azure_monitor/__init__.py @@ -16,8 +16,8 @@ The opentelemetry-ext-azure-monitor package provides integration with Microsoft Azure Monitor. """ - +from opentelemetry.ext.azure_monitor.metrics import AzureMonitorMetricsExporter from opentelemetry.ext.azure_monitor.trace import AzureMonitorSpanExporter from opentelemetry.ext.azure_monitor.version import __version__ # noqa -__all__ = ["AzureMonitorSpanExporter"] +__all__ = ["AzureMonitorMetricsExporter", "AzureMonitorSpanExporter"] diff --git a/ext/opentelemetry-ext-azure-monitor/src/opentelemetry/ext/azure_monitor/metrics.py b/ext/opentelemetry-ext-azure-monitor/src/opentelemetry/ext/azure_monitor/metrics.py new file mode 100644 index 00000000000..60fa25c2802 --- /dev/null +++ b/ext/opentelemetry-ext-azure-monitor/src/opentelemetry/ext/azure_monitor/metrics.py @@ -0,0 +1,98 @@ +# Copyright 2019, 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. + +import json +import logging + +import requests + +from opentelemetry.ext.azure_monitor import protocol, util +from opentelemetry.sdk.metrics.export import MetricsExporter,\ + MetricsExportResult + + +logger = logging.getLogger(__name__) + + +class AzureMonitorMetricsExporter(MetricsExporter): + def __init__(self, **options): + self.options = util.Options(**options) + if not self.options.instrumentation_key: + raise ValueError("The instrumentation_key is not provided.") + + def export(self, metric_tuples): + envelopes = tuple(map(self.metric_tuple_to_envelope, metric_tuples)) + + try: + response = requests.post( + url=self.options.endpoint, + data=json.dumps(envelopes), + headers={ + "Accept": "application/json", + "Content-Type": "application/json; charset=utf-8", + }, + timeout=self.options.timeout, + ) + except requests.RequestException as ex: + logger.warning("Transient client side error %s.", ex) + return MetricsExportResult.FAILED_RETRYABLE + + text = "N/A" + data = None # noqa pylint: disable=unused-variable + try: + text = response.text + except Exception as ex: # noqa pylint: disable=broad-except + logger.warning("Error while reading response body %s.", ex) + else: + try: + data = json.loads(text) # noqa pylint: disable=unused-variable + except Exception: # noqa pylint: disable=broad-except + pass + + if response.status_code == 200: + logger.info("Transmission succeeded: %s.", text) + return MetricsExportResult.SUCCESS + + if response.status_code in ( + 206, # Partial Content + 429, # Too Many Requests + 500, # Internal Server Error + 503, # Service Unavailable + ): + return MetricsExportResult.FAILED_RETRYABLE + + return MetricsExportResult.FAILED_NOT_RETRYABLE + + def metric_tuple_to_envelope(self, metric_tuple): + metric = metric_tuple[0] + label_values = metric_tuple[1] + handle = metric.get_handle(label_values) + envelope = protocol.Envelope( + iKey=self.options.instrumentation_key, + tags=dict(util.azure_monitor_context), + time=handle.time_stamp.isoformat(), + ) + envelope.name = "Microsoft.ApplicationInsights.Metric" + # label_keys and label_values assumed to have the same length + properties = {metric.label_keys[idx]: label_values[idx] + for idx, value in enumerate(label_values, start=0)} + data_point = protocol.DataPoint(ns=metric.name, + name=metric.name, + value=handle.data) + data = protocol.MetricData( + metrics=[data_point], + properties=properties + ) + envelope.data = protocol.Data(baseData=data, baseType="MetricData") + return envelope diff --git a/opentelemetry-api/src/opentelemetry/metrics/__init__.py b/opentelemetry-api/src/opentelemetry/metrics/__init__.py index 668c61cbc35..bfb7ae95b5d 100644 --- a/opentelemetry-api/src/opentelemetry/metrics/__init__.py +++ b/opentelemetry-api/src/opentelemetry/metrics/__init__.py @@ -27,147 +27,44 @@ """ from abc import ABC, abstractmethod -from typing import Dict, List, Tuple, Type, Union +from typing import Callable, Optional, Sequence, Tuple, Type, TypeVar -from opentelemetry.metrics.handle import ( - CounterHandle, - GaugeHandle, - MeasureHandle, -) -from opentelemetry.trace import SpanContext +from opentelemetry.util import loader +ValueT = TypeVar("ValueT", int, float) -# pylint: disable=unused-argument -class Meter: - """An interface to allow the recording of metrics. - - `Metric` s are used for recording pre-defined aggregation (gauge and - counter), or raw values (measure) in which the aggregation and labels - for the exported metric are deferred. - """ - - def record_batch( - self, - label_tuples: Dict[str, str], - record_tuples: List[Tuple["Metric", Union[float, int]]], - ) -> None: - """Atomically records a batch of `Metric` and value pairs. - - Allows the functionality of acting upon multiple metrics with - a single API call. Implementations should find handles that match - the key-value pairs in the label tuples. - - Args: - label_tuples: A collection of key value pairs that will be matched - against to record for the metric-handle that has those labels. - record_tuples: A list of pairs of `Metric` s and the - corresponding value to record for that metric. - """ - - -def create_counter( - name: str, - description: str, - unit: str, - value_type: Union[Type[float], Type[int]], - is_bidirectional: bool = False, - label_keys: List[str] = None, - span_context: SpanContext = None, -) -> Union["FloatCounter", "IntCounter"]: - """Creates a counter metric with type value_type. - By default, counter values can only go up (unidirectional). The API - should reject negative inputs to unidirectional counter metrics. - Counter metrics have a bidirectional option to allow for negative - inputs. +class DefaultMetricHandle: + """The default MetricHandle. - Args: - name: The name of the counter. - description: Human readable description of the metric. - unit: Unit of the metric values. - value_type: The type of values being recorded by the metric. - is_bidirectional: Set to true to allow negative inputs. - label_keys: list of keys for the labels with dynamic values. - Order of the list is important as the same order must be used - on recording when suppling values for these labels. - span_context: The `SpanContext` that identifies the `Span` - that the metric is associated with. - - Returns: A new counter metric for values of the given value_type. + Used when no MetricHandle implementation is available. """ -def create_gauge( - name: str, - description: str, - unit: str, - value_type: Union[Type[float], Type[int]], - is_unidirectional: bool = False, - label_keys: List[str] = None, - span_context: SpanContext = None, -) -> Union["FloatGauge", "IntGauge"]: - """Creates a gauge metric with type value_type. +class CounterHandle: + def add(self, value: ValueT) -> None: + """Increases the value of the handle by ``value``""" - By default, gauge values can go both up and down (bidirectional). The API - allows for an optional unidirectional flag, in which when set will reject - descending update values. - - Args: - name: The name of the gauge. - description: Human readable description of the metric. - unit: Unit of the metric values. - value_type: The type of values being recorded by the metric. - is_unidirectional: Set to true to reject negative inputs. - label_keys: list of keys for the labels with dynamic values. - Order of the list is important as the same order must be used - on recording when suppling values for these labels. - span_context: The `SpanContext` that identifies the `Span` - that the metric is associated with. - - Returns: A new gauge metric for values of the given value_type. - """ +class GaugeHandle: + def set(self, value: ValueT) -> None: + """Sets the current value of the handle to ``value``.""" -def create_measure( - name: str, - description: str, - unit: str, - value_type: Union[Type[float], Type[int]], - is_non_negative: bool = False, - label_keys: List[str] = None, - span_context: SpanContext = None, -) -> Union["FloatMeasure", "IntMeasure"]: - """Creates a measure metric with type value_type. - Measure metrics represent raw statistics that are recorded. As an option, - measure metrics can be declared as non-negative. The API will reject - negative metric events for non-negative measures. - - Args: - name: The name of the measure. - description: Human readable description of the metric. - unit: Unit of the metric values. - value_type: The type of values being recorded by the metric. - is_non_negative: Set to true to reject negative inputs. - label_keys: list of keys for the labels with dynamic values. - Order of the list is important as the same order must be used - on recording when suppling values for these labels. - span_context: The `SpanContext` that identifies the `Span` - that the metric is associated with. - - Returns: A new measure metric for values of the given value_type. - """ +class MeasureHandle: + def record(self, value: ValueT) -> None: + """Records the given ``value`` to this handle.""" class Metric(ABC): """Base class for various types of metrics. Metric class that inherit from this class are specialized with the type of - time series that the metric holds. + handle that the metric holds. """ @abstractmethod - def get_handle(self, label_values: List[str]) -> "object": + def get_handle(self, label_values: Sequence[str]) -> "object": """Gets a handle, used for repeated-use of metrics instruments. Handles are useful to reduce the cost of repeatedly recording a metric @@ -178,60 +75,150 @@ def get_handle(self, label_values: List[str]) -> "object": a value was not provided are permitted. Args: - label_values: A list of label values that will be associated - with the return handle. + label_values: Values to associate with the returned handle. """ - def remove_handle(self, label_values: List[str]) -> None: - """Removes the handle from the Metric, if present. - The handle with matching label values will be removed. +class DefaultMetric(Metric): + """The default Metric used when no Metric implementation is available.""" - args: - label_values: The list of label values to match against. - """ + def get_handle(self, label_values: Sequence[str]) -> "DefaultMetricHandle": + return DefaultMetricHandle() - def clear(self) -> None: - """Removes all handles from the `Metric`.""" +class Counter(Metric): + """A counter type metric that expresses the computation of a sum.""" -class FloatCounter(Metric): - """A counter type metric that holds float values.""" + def get_handle(self, label_values: Sequence[str]) -> "CounterHandle": + """Gets a `CounterHandle`.""" + return CounterHandle() - def get_handle(self, label_values: List[str]) -> "CounterHandle": - """Gets a `CounterHandle` with a float value.""" +class Gauge(Metric): + """A gauge type metric that expresses a pre-calculated value. -class IntCounter(Metric): - """A counter type metric that holds int values.""" + Gauge metrics have a value that is either ``Set`` by explicit + instrumentation or observed through a callback. This kind of metric + should be used when the metric cannot be expressed as a sum or because + the measurement interval is arbitrary. + """ - def get_handle(self, label_values: List[str]) -> "CounterHandle": - """Gets a `CounterHandle` with an int value.""" + def get_handle(self, label_values: Sequence[str]) -> "GaugeHandle": + """Gets a `GaugeHandle`.""" + return GaugeHandle() -class FloatGauge(Metric): - """A gauge type metric that holds float values.""" +class Measure(Metric): + """A measure type metric that represent raw stats that are recorded. - def get_handle(self, label_values: List[str]) -> "GaugeHandle": - """Gets a `GaugeHandle` with a float value.""" + Measure metrics represent raw statistics that are recorded. By + default, measure metrics can accept both positive and negatives. + Negative inputs will be discarded when monotonic is True. + """ + def get_handle(self, label_values: Sequence[str]) -> "MeasureHandle": + """Gets a `MeasureHandle` with a float value.""" + return MeasureHandle() -class IntGauge(Metric): - """A gauge type metric that holds int values.""" - def get_handle(self, label_values: List[str]) -> "GaugeHandle": - """Gets a `GaugeHandle` with an int value.""" +MetricT = TypeVar("MetricT", Counter, Gauge, Measure) -class FloatMeasure(Metric): - """A measure type metric that holds float values.""" +# pylint: disable=unused-argument +class Meter: + """An interface to allow the recording of metrics. - def get_handle(self, label_values: List[str]) -> "MeasureHandle": - """Gets a `MeasureHandle` with a float value.""" + `Metric` s are used for recording pre-defined aggregation (gauge and + counter), or raw values (measure) in which the aggregation and labels + for the exported metric are deferred. + """ + + def record_batch( + self, + label_values: Sequence[str], + record_tuples: Sequence[Tuple["Metric", ValueT]], + ) -> None: + """Atomically records a batch of `Metric` and value pairs. + Allows the functionality of acting upon multiple metrics with + a single API call. Implementations should find metric and handles that + match the key-value pairs in the label tuples. + + Args: + label_values: The values that will be matched against to record for + the handles under each metric that has those labels. + record_tuples: A sequence of pairs of `Metric` s and the + corresponding value to record for that metric. + """ + + def create_metric( + self, + name: str, + description: str, + unit: str, + value_type: Type[ValueT], + metric_type: Type[MetricT], + label_keys: Sequence[str] = None, + enabled: bool = True, + monotonic: bool = False, + ) -> "Metric": + """Creates a ``metric_kind`` metric with type ``value_type``. + + Args: + name: The name of the counter. + description: Human-readable description of the metric. + unit: Unit of the metric values. + value_type: The type of values being recorded by the metric. + metric_type: The type of metric being created. + label_keys: The keys for the labels with dynamic values. + Order of the sequence is important as the same order must be + used on recording when suppling values for these labels. + enabled: Whether to report the metric by default. + monotonic: Whether to only allow non-negative values. + + Returns: A new ``metric_type`` metric with values of ``value_type``. + """ + # pylint: disable=no-self-use + return DefaultMetric() + + +# Once https://github.com/python/mypy/issues/7092 is resolved, +# the following type definition should be replaced with +# from opentelemetry.util.loader import ImplementationFactory +ImplementationFactory = Callable[[Type[Meter]], Optional[Meter]] + +_METER = None +_METER_FACTORY = None + + +def meter() -> Meter: + """Gets the current global :class:`~.Meter` object. + + If there isn't one set yet, a default will be loaded. + """ + global _METER, _METER_FACTORY # pylint:disable=global-statement + + if _METER is None: + # pylint:disable=protected-access + _METER = loader._load_impl(Meter, _METER_FACTORY) + del _METER_FACTORY + + return _METER + + +def set_preferred_meter_implementation(factory: ImplementationFactory) -> None: + """Set the factory to be used to create the meter. + + See :mod:`opentelemetry.util.loader` for details. + + This function may not be called after a meter is already loaded. + + Args: + factory: Callback that should create a new :class:`Meter` instance. + """ + global _METER, _METER_FACTORY # pylint:disable=global-statement -class IntMeasure(Metric): - """A measure type metric that holds int values.""" + if _METER: + raise RuntimeError("Meter already loaded.") - def get_handle(self, label_values: List[str]) -> "MeasureHandle": - """Gets a `MeasureHandle` with an int value.""" + _METER_FACTORY = factory diff --git a/opentelemetry-api/src/opentelemetry/metrics/examples/pre_aggregated.py b/opentelemetry-api/tests/metrics/__init__.py similarity index 59% rename from opentelemetry-api/src/opentelemetry/metrics/examples/pre_aggregated.py rename to opentelemetry-api/tests/metrics/__init__.py index 3cbf4d3d20c..d853a7bcf65 100644 --- a/opentelemetry-api/src/opentelemetry/metrics/examples/pre_aggregated.py +++ b/opentelemetry-api/tests/metrics/__init__.py @@ -11,22 +11,3 @@ # 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: skip-file -from opentelemetry import metrics - -METER = metrics.Meter() -COUNTER = METER.create_counter( - "sum numbers", - "sum numbers over time", - "number", - metrics.ValueType.FLOAT, - ["environment"], -) - -# Metrics sent to some exporter -METRIC_TESTING = COUNTER.get_handle("Testing") -METRIC_STAGING = COUNTER.get_handle("Staging") - -for i in range(100): - METRIC_STAGING.add(i) diff --git a/opentelemetry-api/tests/metrics/test_metrics.py b/opentelemetry-api/tests/metrics/test_metrics.py new file mode 100644 index 00000000000..14667f62eaa --- /dev/null +++ b/opentelemetry-api/tests/metrics/test_metrics.py @@ -0,0 +1,68 @@ +# Copyright 2019, 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. + +import unittest + +from opentelemetry import metrics + + +# pylint: disable=no-self-use +class TestMeter(unittest.TestCase): + def setUp(self): + self.meter = metrics.Meter() + + def test_record_batch(self): + counter = metrics.Counter() + self.meter.record_batch(("values"), ((counter, 1))) + + def test_create_metric(self): + metric = self.meter.create_metric("", "", "", float, metrics.Counter) + self.assertIsInstance(metric, metrics.DefaultMetric) + + +class TestMetrics(unittest.TestCase): + def test_default(self): + default = metrics.DefaultMetric() + handle = default.get_handle(("test", "test1")) + self.assertIsInstance(handle, metrics.DefaultMetricHandle) + + def test_counter(self): + counter = metrics.Counter() + handle = counter.get_handle(("test", "test1")) + self.assertIsInstance(handle, metrics.CounterHandle) + + def test_gauge(self): + gauge = metrics.Gauge() + handle = gauge.get_handle(("test", "test1")) + self.assertIsInstance(handle, metrics.GaugeHandle) + + def test_measure(self): + measure = metrics.Measure() + handle = measure.get_handle(("test", "test1")) + self.assertIsInstance(handle, metrics.MeasureHandle) + + def test_default_handle(self): + metrics.DefaultMetricHandle() + + def test_counter_handle(self): + handle = metrics.CounterHandle() + handle.add(1) + + def test_gauge_handle(self): + handle = metrics.GaugeHandle() + handle.set(1) + + def test_measure_handle(self): + handle = metrics.MeasureHandle() + handle.record(1) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/__init__.py index 81366d9d47d..0f3bff4571e 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/__init__.py @@ -12,6 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from . import trace, util +from . import metrics, trace, util -__all__ = ["trace", "util"] +__all__ = ["metrics", "trace", "util"] diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py new file mode 100644 index 00000000000..c1a815b750c --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py @@ -0,0 +1,268 @@ +# Copyright 2019, 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. + +import logging +from datetime import datetime + +from typing import Sequence, Tuple, Type + +from opentelemetry import metrics as metrics_api + +logger = logging.getLogger(__name__) + + +class MetricHandle: + def __init__( + self, + value_type: Type[metrics_api.ValueT], + enabled: bool, + monotonic: bool, + ): + self.data = 0 + self.value_type = value_type + self.enabled = enabled + self.monotonic = monotonic + self.time_stamp = datetime.utcnow() + + def _validate_update(self, value: metrics_api.ValueT): + if not self.enabled: + return False + if self.monotonic and value < 0: + logger.warning("Monotonic metric cannot descend.") + return False + if not isinstance(value, self.value_type): + logger.warning( + "Invalid value passed for %s.", self.value_type.__name__ + ) + return False + return True + + def __repr__(self): + return '{}(data="{}", time_stamp={})'.format( + type(self).__name__, self.data, self.time_stamp + ) + + def __str__(self): + return '{}(data="{}", time_stamp={})'.format( + type(self).__name__, self.data, self.time_stamp + ) + + +class CounterHandle(metrics_api.CounterHandle, MetricHandle): + def update(self, value: metrics_api.ValueT) -> None: + if self._validate_update(value): + self.data += value + self.time_stamp = datetime.utcnow() + + def add(self, value: metrics_api.ValueT) -> None: + """See `opentelemetry.metrics.CounterHandle._add`.""" + self.update(value) + + +class GaugeHandle(metrics_api.GaugeHandle, MetricHandle): + def update(self, value: metrics_api.ValueT) -> None: + if self._validate_update(value): + self.data = value + self.time_stamp = datetime.utcnow() + + def set(self, value: metrics_api.ValueT) -> None: + """See `opentelemetry.metrics.GaugeHandle._set`.""" + self.update(value) + + +class MeasureHandle(metrics_api.MeasureHandle, MetricHandle): + def update(self, value: metrics_api.ValueT) -> None: + if self._validate_update(value): + # TODO: record + self.time_stamp = datetime.utcnow() + + def record(self, value: metrics_api.ValueT) -> None: + """See `opentelemetry.metrics.MeasureHandle._record`.""" + self.update(value) + + +class Metric(metrics_api.Metric): + """See `opentelemetry.metrics.Metric`.""" + + HANDLE_TYPE = MetricHandle + + def __init__( + self, + name: str, + description: str, + unit: str, + value_type: Type[metrics_api.ValueT], + label_keys: Sequence[str] = None, + enabled: bool = True, + monotonic: bool = False, + ): + self.name = name + self.description = description + self.unit = unit + self.value_type = value_type + self.label_keys = label_keys + self.enabled = enabled + self.monotonic = monotonic + self.handles = {} + + def get_handle(self, label_values: Sequence[str]) -> MetricHandle: + """See `opentelemetry.metrics.Metric.get_handle`.""" + if len(label_values) != len(self.label_keys): + raise ValueError("Label values must match label keys.") + handle = self.handles.get(label_values) + if not handle: + handle = self.__class__.HANDLE_TYPE( + self.value_type, self.enabled, self.monotonic + ) + self.handles[label_values] = handle + return handle + + def __repr__(self): + return '{}(name="{}", description={})'.format( + type(self).__name__, self.name, self.description + ) + + def __str__(self): + return '{}(name="{}", description={})'.format( + type(self).__name__, self.name, self.description + ) + + +class Counter(Metric): + """See `opentelemetry.metrics.Counter`. + + By default, counter values can only go up (monotonic). Negative inputs + will be discarded for monotonic counter metrics. Counter metrics that + have a monotonic option set to False allows negative inputs. + """ + + HANDLE_TYPE = CounterHandle + + def __init__( + self, + name: str, + description: str, + unit: str, + value_type: Type[metrics_api.ValueT], + label_keys: Sequence[str] = None, + enabled: bool = True, + monotonic: bool = True, + ): + super().__init__( + name, + description, + unit, + value_type, + label_keys=label_keys, + enabled=enabled, + monotonic=monotonic, + ) + + +class Gauge(Metric): + """See `opentelemetry.metrics.Gauge`. + + By default, gauge values can go both up and down (non-monotonic). + Negative inputs will be discarded for monotonic gauge metrics. + """ + + HANDLE_TYPE = GaugeHandle + + def __init__( + self, + name: str, + description: str, + unit: str, + value_type: Type[metrics_api.ValueT], + label_keys: Sequence[str] = None, + enabled: bool = True, + monotonic: bool = False, + ): + super().__init__( + name, + description, + unit, + value_type, + label_keys=label_keys, + enabled=enabled, + monotonic=monotonic, + ) + + +class Measure(Metric): + """See `opentelemetry.metrics.Measure`. + + By default, measure metrics can accept both positive and negatives. + Negative inputs will be discarded when monotonic is True. + """ + + HANDLE_TYPE = MeasureHandle + + def __init__( + self, + name: str, + description: str, + unit: str, + value_type: Type[metrics_api.ValueT], + label_keys: Sequence[str] = None, + enabled: bool = False, + monotonic: bool = False, + ): + super().__init__( + name, + description, + unit, + value_type, + label_keys=label_keys, + enabled=enabled, + monotonic=monotonic, + ) + + +class Meter(metrics_api.Meter): + """See `opentelemetry.metrics.Meter`.""" + + def record_batch( + self, + label_values: Sequence[str], + record_tuples: Sequence[Tuple[metrics_api.Metric, metrics_api.ValueT]], + ) -> None: + """See `opentelemetry.metrics.Meter.record_batch`.""" + for metric, value in record_tuples: + metric.get_handle(label_values).update(value) + + def create_metric( + self, + name: str, + description: str, + unit: str, + value_type: Type[metrics_api.ValueT], + metric_type: Type[metrics_api.MetricT], + label_keys: Sequence[str] = None, + enabled: bool = True, + monotonic: bool = False, + ) -> "Metric": + """See `opentelemetry.metrics.Meter.create_metric`.""" + return metric_type( + name, + description, + unit, + value_type, + label_keys=label_keys, + enabled=enabled, + monotonic=monotonic, + ) + + +meter = Meter() diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/__init__.py new file mode 100644 index 00000000000..c655551a82f --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/__init__.py @@ -0,0 +1,73 @@ +# Copyright 2019, 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 enum import Enum +from typing import Sequence, Tuple + +from .. import Metric + + +class MetricsExportResult(Enum): + SUCCESS = 0 + FAILED_RETRYABLE = 1 + FAILED_NOT_RETRYABLE = 2 + + +class MetricsExporter: + """Interface for exporting metrics. + + Interface to be implemented by services that want to export recorded in + its own format. + """ + + def export( + self, + metric_tuples: Sequence[Tuple[Metric, Sequence[str]]] + ) -> "MetricsExportResult": + """Exports a batch of telemetry data. + + Args: + metric_tuples: A sequence of metric pairs. A metric pair consists + of a `Metric` and a sequence of strings. The sequence of + strings will be used to get the corresponding `MetricHandle` + from the `Metric` to import. + + Returns: + The result of the export + """ + + def shutdown(self) -> None: + """Shuts down the exporter. + + Called when the SDK is shut down. + """ + + +class ConsoleMetricsExporter(MetricsExporter): + """Implementation of :class:`MetricsExporter` that prints metric handles + to the console. + + This class can be used for diagnostic purposes. It prints the exported + metric handles to the console STDOUT. + """ + + def export( + self, + metric_tuples: Sequence[Tuple[Metric, Sequence[str]]] + ) -> "MetricsExportResult": + for metric_tuple in metric_tuples: + handle = metric_tuple[0].get_handle(metric_tuple[1]) + print(str(metric_tuple[0]) + ", LabelValues: " + + str(metric_tuple[1]) + ", " + str(handle)) + return MetricsExportResult.SUCCESS diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 544906e6fb4..dc3efb03687 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -17,132 +17,21 @@ import random import threading import typing -from collections import OrderedDict, deque from contextlib import contextmanager from opentelemetry import trace as trace_api from opentelemetry.context import Context from opentelemetry.sdk import util +from opentelemetry.sdk.util import BoundedDict, BoundedList from opentelemetry.util import types logger = logging.getLogger(__name__) -try: - # pylint: disable=ungrouped-imports - from collections.abc import MutableMapping - from collections.abc import Sequence -except ImportError: - # pylint: disable=no-name-in-module,ungrouped-imports - from collections import MutableMapping - from collections import Sequence - MAX_NUM_ATTRIBUTES = 32 MAX_NUM_EVENTS = 128 MAX_NUM_LINKS = 32 -class BoundedList(Sequence): - """An append only list with a fixed max size.""" - - def __init__(self, maxlen): - self.dropped = 0 - self._dq = deque(maxlen=maxlen) - self._lock = threading.Lock() - - def __repr__(self): - return "{}({}, maxlen={})".format( - type(self).__name__, list(self._dq), self._dq.maxlen - ) - - def __getitem__(self, index): - return self._dq[index] - - def __len__(self): - return len(self._dq) - - def __iter__(self): - with self._lock: - return iter(self._dq.copy()) - - def append(self, item): - with self._lock: - if len(self._dq) == self._dq.maxlen: - self.dropped += 1 - self._dq.append(item) - - def extend(self, seq): - with self._lock: - to_drop = len(seq) + len(self._dq) - self._dq.maxlen - if to_drop > 0: - self.dropped += to_drop - self._dq.extend(seq) - - @classmethod - def from_seq(cls, maxlen, seq): - seq = tuple(seq) - if len(seq) > maxlen: - raise ValueError - bounded_list = cls(maxlen) - # pylint: disable=protected-access - bounded_list._dq = deque(seq, maxlen=maxlen) - return bounded_list - - -class BoundedDict(MutableMapping): - """A dict with a fixed max capacity.""" - - def __init__(self, maxlen): - if not isinstance(maxlen, int): - raise ValueError - if maxlen < 0: - raise ValueError - self.maxlen = maxlen - self.dropped = 0 - self._dict = OrderedDict() - self._lock = threading.Lock() - - def __repr__(self): - return "{}({}, maxlen={})".format( - type(self).__name__, dict(self._dict), self.maxlen - ) - - def __getitem__(self, key): - return self._dict[key] - - def __setitem__(self, key, value): - with self._lock: - if self.maxlen == 0: - self.dropped += 1 - return - - if key in self._dict: - del self._dict[key] - elif len(self._dict) == self.maxlen: - del self._dict[next(iter(self._dict.keys()))] - self.dropped += 1 - self._dict[key] = value - - def __delitem__(self, key): - del self._dict[key] - - def __iter__(self): - with self._lock: - return iter(self._dict.copy()) - - def __len__(self): - return len(self._dict) - - @classmethod - def from_map(cls, maxlen, mapping): - mapping = OrderedDict(mapping) - if len(mapping) > maxlen: - raise ValueError - bounded_dict = cls(maxlen) - # pylint: disable=protected-access - bounded_dict._dict = mapping - return bounded_dict - - class SpanProcessor: """Interface which allows hooks for SDK's `Span`s start and end method invocations. diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/util.py b/opentelemetry-sdk/src/opentelemetry/sdk/util.py index d855e851794..b4e9b83d832 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/util.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util.py @@ -13,7 +13,18 @@ # limitations under the License. import datetime +import threading import time +from collections import OrderedDict, deque + +try: + # pylint: disable=ungrouped-imports + from collections.abc import MutableMapping + from collections.abc import Sequence +except ImportError: + # pylint: disable=no-name-in-module,ungrouped-imports + from collections import MutableMapping + from collections import Sequence try: time_ns = time.time_ns @@ -28,3 +39,105 @@ def ns_to_iso_str(nanoseconds): """Get an ISO 8601 string from time_ns value.""" ts = datetime.datetime.fromtimestamp(nanoseconds / 1e9) return ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + +class BoundedList(Sequence): + """An append only list with a fixed max size.""" + + def __init__(self, maxlen): + self.dropped = 0 + self._dq = deque(maxlen=maxlen) + self._lock = threading.Lock() + + def __repr__(self): + return "{}({}, maxlen={})".format( + type(self).__name__, list(self._dq), self._dq.maxlen + ) + + def __getitem__(self, index): + return self._dq[index] + + def __len__(self): + return len(self._dq) + + def __iter__(self): + with self._lock: + return iter(self._dq.copy()) + + def append(self, item): + with self._lock: + if len(self._dq) == self._dq.maxlen: + self.dropped += 1 + self._dq.append(item) + + def extend(self, seq): + with self._lock: + to_drop = len(seq) + len(self._dq) - self._dq.maxlen + if to_drop > 0: + self.dropped += to_drop + self._dq.extend(seq) + + @classmethod + def from_seq(cls, maxlen, seq): + seq = tuple(seq) + if len(seq) > maxlen: + raise ValueError + bounded_list = cls(maxlen) + # pylint: disable=protected-access + bounded_list._dq = deque(seq, maxlen=maxlen) + return bounded_list + + +class BoundedDict(MutableMapping): + """A dict with a fixed max capacity.""" + + def __init__(self, maxlen): + if not isinstance(maxlen, int): + raise ValueError + if maxlen < 0: + raise ValueError + self.maxlen = maxlen + self.dropped = 0 + self._dict = OrderedDict() + self._lock = threading.Lock() + + def __repr__(self): + return "{}({}, maxlen={})".format( + type(self).__name__, dict(self._dict), self.maxlen + ) + + def __getitem__(self, key): + return self._dict[key] + + def __setitem__(self, key, value): + with self._lock: + if self.maxlen == 0: + self.dropped += 1 + return + + if key in self._dict: + del self._dict[key] + elif len(self._dict) == self.maxlen: + del self._dict[next(iter(self._dict.keys()))] + self.dropped += 1 + self._dict[key] = value + + def __delitem__(self, key): + del self._dict[key] + + def __iter__(self): + with self._lock: + return iter(self._dict.copy()) + + def __len__(self): + return len(self._dict) + + @classmethod + def from_map(cls, maxlen, mapping): + mapping = OrderedDict(mapping) + if len(mapping) > maxlen: + raise ValueError + bounded_dict = cls(maxlen) + # pylint: disable=protected-access + bounded_dict._dict = mapping + return bounded_dict diff --git a/opentelemetry-api/src/opentelemetry/metrics/handle.py b/opentelemetry-sdk/tests/metrics/__init__.py similarity index 52% rename from opentelemetry-api/src/opentelemetry/metrics/handle.py rename to opentelemetry-sdk/tests/metrics/__init__.py index 5da3cf43e7e..d853a7bcf65 100644 --- a/opentelemetry-api/src/opentelemetry/metrics/handle.py +++ b/opentelemetry-sdk/tests/metrics/__init__.py @@ -11,23 +11,3 @@ # 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. - -import typing - - -class CounterHandle: - def add(self, value: typing.Union[float, int]) -> None: - """Adds the given value to the current value. - - The input value cannot be negative if not bidirectional. - """ - - -class GaugeHandle: - def set(self, value: typing.Union[float, int]) -> None: - """Sets the current value to the given value. Can be negative.""" - - -class MeasureHandle: - def record(self, value: typing.Union[float, int]) -> None: - """Records the given value to this measure.""" diff --git a/opentelemetry-sdk/tests/metrics/test_metrics.py b/opentelemetry-sdk/tests/metrics/test_metrics.py new file mode 100644 index 00000000000..4cfab9aaa09 --- /dev/null +++ b/opentelemetry-sdk/tests/metrics/test_metrics.py @@ -0,0 +1,189 @@ +# Copyright 2019, 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. + +import unittest +from unittest import mock + +from opentelemetry import metrics as metrics_api +from opentelemetry.sdk import metrics + + +class TestMeter(unittest.TestCase): + def test_extends_api(self): + meter = metrics.Meter() + self.assertIsInstance(meter, metrics_api.Meter) + + def test_record_batch(self): + meter = metrics.Meter() + label_keys = ("key1",) + label_values = ("value1",) + counter = metrics.Counter("name", "desc", "unit", float, label_keys) + record_tuples = [(counter, 1.0)] + meter.record_batch(label_values, record_tuples) + self.assertEqual(counter.get_handle(label_values).data, 1.0) + + def test_record_batch_multiple(self): + meter = metrics.Meter() + label_keys = ("key1", "key2", "key3") + label_values = ("value1", "value2", "value3") + counter = metrics.Counter("name", "desc", "unit", float, label_keys) + gauge = metrics.Gauge("name", "desc", "unit", int, label_keys) + measure = metrics.Measure("name", "desc", "unit", float, label_keys) + record_tuples = [(counter, 1.0), (gauge, 5), (measure, 3.0)] + meter.record_batch(label_values, record_tuples) + self.assertEqual(counter.get_handle(label_values).data, 1.0) + self.assertEqual(gauge.get_handle(label_values).data, 5) + self.assertEqual(measure.get_handle(label_values).data, 0) + + def test_record_batch_exists(self): + meter = metrics.Meter() + label_keys = ("key1",) + label_values = ("value1",) + counter = metrics.Counter("name", "desc", "unit", float, label_keys) + handle = counter.get_handle(label_values) + handle.update(1.0) + record_tuples = [(counter, 1.0)] + meter.record_batch(label_values, record_tuples) + self.assertEqual(counter.get_handle(label_values), handle) + self.assertEqual(handle.data, 2.0) + + def test_create_metric(self): + meter = metrics.Meter() + counter = meter.create_metric( + "name", "desc", "unit", int, metrics.Counter, () + ) + self.assertTrue(isinstance(counter, metrics.Counter)) + self.assertEqual(counter.value_type, int) + self.assertEqual(counter.name, "name") + + def test_create_gauge(self): + meter = metrics.Meter() + gauge = meter.create_metric( + "name", "desc", "unit", float, metrics.Gauge, () + ) + self.assertTrue(isinstance(gauge, metrics.Gauge)) + self.assertEqual(gauge.value_type, float) + self.assertEqual(gauge.name, "name") + + def test_create_measure(self): + meter = metrics.Meter() + measure = meter.create_metric( + "name", "desc", "unit", float, metrics.Measure, () + ) + self.assertTrue(isinstance(measure, metrics.Measure)) + self.assertEqual(measure.value_type, float) + self.assertEqual(measure.name, "name") + + +class TestMetric(unittest.TestCase): + def test_get_handle(self): + metric_types = [metrics.Counter, metrics.Gauge, metrics.Measure] + for _type in metric_types: + metric = _type("name", "desc", "unit", int, ("key")) + label_values = ("value",) + handle = metric.get_handle(label_values) + self.assertEqual(metric.handles.get(label_values), handle) + + +class TestCounterHandle(unittest.TestCase): + def test_update(self): + handle = metrics.CounterHandle(float, True, False) + handle.update(2.0) + self.assertEqual(handle.data, 2.0) + + def test_add(self): + handle = metrics.CounterHandle(int, True, False) + handle.add(3) + self.assertEqual(handle.data, 3) + + def test_add_disabled(self): + handle = metrics.CounterHandle(int, False, False) + handle.add(3) + self.assertEqual(handle.data, 0) + + @mock.patch("opentelemetry.sdk.metrics.logger") + def test_add_monotonic(self, logger_mock): + handle = metrics.CounterHandle(int, True, True) + handle.add(-3) + self.assertEqual(handle.data, 0) + self.assertTrue(logger_mock.warning.called) + + @mock.patch("opentelemetry.sdk.metrics.logger") + def test_add_incorrect_type(self, logger_mock): + handle = metrics.CounterHandle(int, True, False) + handle.add(3.0) + self.assertEqual(handle.data, 0) + self.assertTrue(logger_mock.warning.called) + + +class TestGaugeHandle(unittest.TestCase): + def test_update(self): + handle = metrics.GaugeHandle(float, True, False) + handle.update(2.0) + self.assertEqual(handle.data, 2.0) + + def test_set(self): + handle = metrics.GaugeHandle(int, True, False) + handle.set(3) + self.assertEqual(handle.data, 3) + + def test_set_disabled(self): + handle = metrics.GaugeHandle(int, False, False) + handle.set(3) + self.assertEqual(handle.data, 0) + + @mock.patch("opentelemetry.sdk.metrics.logger") + def test_set_monotonic(self, logger_mock): + handle = metrics.GaugeHandle(int, True, True) + handle.set(-3) + self.assertEqual(handle.data, 0) + self.assertTrue(logger_mock.warning.called) + + @mock.patch("opentelemetry.sdk.metrics.logger") + def test_set_incorrect_type(self, logger_mock): + handle = metrics.GaugeHandle(int, True, False) + handle.set(3.0) + self.assertEqual(handle.data, 0) + self.assertTrue(logger_mock.warning.called) + + +class TestMeasureHandle(unittest.TestCase): + def test_update(self): + handle = metrics.MeasureHandle(float, False, False) + handle.update(2.0) + self.assertEqual(handle.data, 0) + + def test_record(self): + handle = metrics.MeasureHandle(int, False, False) + handle.record(3) + self.assertEqual(handle.data, 0) + + def test_record_disabled(self): + handle = metrics.MeasureHandle(int, False, False) + handle.record(3) + self.assertEqual(handle.data, 0) + + @mock.patch("opentelemetry.sdk.metrics.logger") + def test_record_monotonic(self, logger_mock): + handle = metrics.MeasureHandle(int, True, True) + handle.record(-3) + self.assertEqual(handle.data, 0) + self.assertTrue(logger_mock.warning.called) + + @mock.patch("opentelemetry.sdk.metrics.logger") + def test_record_incorrect_type(self, logger_mock): + handle = metrics.MeasureHandle(int, True, False) + handle.record(3.0) + self.assertEqual(handle.data, 0) + self.assertTrue(logger_mock.warning.called)