diff --git a/docs/opentelemetry.sdk.metrics.export.aggregate.rst b/docs/opentelemetry.sdk.metrics.export.aggregate.rst new file mode 100644 index 00000000000..7c9306c6846 --- /dev/null +++ b/docs/opentelemetry.sdk.metrics.export.aggregate.rst @@ -0,0 +1,7 @@ +opentelemetry.sdk.metrics.export.aggregate +========================================== + +.. automodule:: opentelemetry.sdk.metrics.export.aggregate + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/opentelemetry.sdk.metrics.export.batcher.rst b/docs/opentelemetry.sdk.metrics.export.batcher.rst new file mode 100644 index 00000000000..5dbd1d6e582 --- /dev/null +++ b/docs/opentelemetry.sdk.metrics.export.batcher.rst @@ -0,0 +1,11 @@ +opentelemetry.sdk.metrics.export.batcher +========================================== + +.. toctree:: + + opentelemetry.sdk.metrics.export + +.. automodule:: opentelemetry.sdk.metrics.export.batcher + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/opentelemetry.sdk.metrics.export.rst b/docs/opentelemetry.sdk.metrics.export.rst new file mode 100644 index 00000000000..1ae51170e4f --- /dev/null +++ b/docs/opentelemetry.sdk.metrics.export.rst @@ -0,0 +1,7 @@ +opentelemetry.sdk.metrics.export +========================================== + +.. automodule:: opentelemetry.sdk.metrics.export + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/opentelemetry.sdk.metrics.rst b/docs/opentelemetry.sdk.metrics.rst index 6d646c3b15f..ec8687dd2dc 100644 --- a/docs/opentelemetry.sdk.metrics.rst +++ b/docs/opentelemetry.sdk.metrics.rst @@ -1,6 +1,14 @@ opentelemetry.sdk.metrics package ========================================== +Submodules +---------- + +.. toctree:: + + opentelemetry.sdk.metrics.export.aggregate + opentelemetry.sdk.metrics.export.batcher + .. automodule:: opentelemetry.sdk.metrics :members: :undoc-members: diff --git a/examples/metrics/record.py b/examples/metrics/record.py new file mode 100644 index 00000000000..be68c8083ff --- /dev/null +++ b/examples/metrics/record.py @@ -0,0 +1,69 @@ +# 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. +# +""" +This module serves as an example for a simple application using metrics. +It demonstrates the different ways you can record metrics via the meter. +""" +import time + +from opentelemetry import metrics +from opentelemetry.sdk.metrics import Counter, Meter +from opentelemetry.sdk.metrics.export import ConsoleMetricsExporter +from opentelemetry.sdk.metrics.export.controller import PushController + +# Meter is responsible for creating and recording metrics +metrics.set_preferred_meter_implementation(lambda _: Meter()) +meter = metrics.meter() +# exporter to export metrics to the console +exporter = ConsoleMetricsExporter() +# controller collects metrics created from meter and exports it via the +# exporter every interval +controller = PushController(meter, exporter, 5) + +# Example to show how to record using the meter +counter = meter.create_metric( + "requests", "number of requests", 1, int, Counter, ("environment",) +) + +counter2 = meter.create_metric( + "clicks", "number of clicks", 1, int, Counter, ("environment",) +) + +# Labelsets are used to identify key-values that are associated with a specific +# metric that you want to record. These are useful for pre-aggregation and can +# be used to store custom dimensions pertaining to a metric + +# The meter takes a dictionary of key value pairs +label_set = meter.get_label_set({"environment": "staging"}) + +# Handle usage +# You can record metrics with metric handles. Handles are created by passing in +# a labelset. A handle is essentially metric data that corresponds to a specific +# set of labels. Therefore, getting a handle using the same set of labels will +# yield the same metric handle. +counter_handle = counter.get_handle(label_set) +counter_handle.add(100) + +# Direct metric usage +# You can record metrics directly using the metric instrument. You pass in a +# labelset that you would like to record for. +counter.add(25, label_set) + +# Record batch usage +# You can record metrics in a batch by passing in a labelset and a sequence of +# (metric, value) pairs. The value would be recorded for each metric using the +# specified labelset for each. +meter.record_batch(label_set, [(counter, 50), (counter2, 70)]) +time.sleep(100) diff --git a/examples/metrics/stateful.py b/examples/metrics/stateful.py new file mode 100644 index 00000000000..c43f795e228 --- /dev/null +++ b/examples/metrics/stateful.py @@ -0,0 +1,72 @@ +# 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. +# +""" +This module serves as an example for a simple application using metrics +Examples show how to recording affects the collection of metrics to be exported +""" +import time + +from opentelemetry import metrics +from opentelemetry.sdk.metrics import Counter, Meter +from opentelemetry.sdk.metrics.export import ConsoleMetricsExporter +from opentelemetry.sdk.metrics.export.batcher import UngroupedBatcher +from opentelemetry.sdk.metrics.export.controller import PushController + +# Batcher used to collect all created metrics from meter ready for exporting +# Pass in true/false to indicate whether the batcher is stateful. True +# indicates the batcher computes checkpoints from over the process lifetime. +# False indicates the batcher computes checkpoints which describe the updates +# of a single collection period (deltas) +batcher = UngroupedBatcher(True) +# If a batcher is not provded, a default batcher is used +# Meter is responsible for creating and recording metrics +metrics.set_preferred_meter_implementation(lambda _: Meter(batcher)) +meter = metrics.meter() +# exporter to export metrics to the console +exporter = ConsoleMetricsExporter() +# controller collects metrics created from meter and exports it via the +# exporter every interval +controller = PushController(meter, exporter, 5) + +counter = meter.create_metric( + "requests", "number of requests", 1, int, Counter, ("environment",) +) + +counter2 = meter.create_metric( + "clicks", "number of clicks", 1, int, Counter, ("environment",) +) + +# Labelsets are used to identify key-values that are associated with a specific +# metric that you want to record. These are useful for pre-aggregation and can +# be used to store custom dimensions pertaining to a metric +label_set = meter.get_label_set({"environment": "staging"}) +label_set2 = meter.get_label_set({"environment": "testing"}) + +counter.add(25, label_set) +# We sleep for 5 seconds, exported value should be 25 +time.sleep(5) + +counter.add(50, label_set) +# exported value should be 75 +time.sleep(5) + +counter.add(35, label_set2) +# should be two exported values 75 and 35, one for each labelset +time.sleep(5) + +counter2.add(5, label_set) +# should be three exported values, labelsets can be reused for different +# metrics but will be recorded seperately, 75, 35 and 5 +time.sleep(5) diff --git a/examples/metrics/stateless.py b/examples/metrics/stateless.py new file mode 100644 index 00000000000..69213cbddd3 --- /dev/null +++ b/examples/metrics/stateless.py @@ -0,0 +1,57 @@ +# 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. +# +""" +This module serves as an example for a simple application using metrics +Examples show how to recording affects the collection of metrics to be exported +""" +import time + +from opentelemetry import metrics +from opentelemetry.sdk.metrics import Counter, Meter +from opentelemetry.sdk.metrics.export import ConsoleMetricsExporter +from opentelemetry.sdk.metrics.export.batcher import UngroupedBatcher +from opentelemetry.sdk.metrics.export.controller import PushController + +# Batcher used to collect all created metrics from meter ready for exporting +# Pass in false for non-stateful batcher. Indicates the batcher computes +# checkpoints which describe the updates of a single collection period (deltas) +batcher = UngroupedBatcher(False) +# Meter is responsible for creating and recording metrics +metrics.set_preferred_meter_implementation(lambda _: Meter(batcher)) +meter = metrics.meter() +# exporter to export metrics to the console +exporter = ConsoleMetricsExporter() +# controller collects metrics created from meter and exports it via the +# exporter every interval +controller = PushController(meter, exporter, 5) + +counter = meter.create_metric( + "requests", "number of requests", 1, int, Counter, ("environment",) +) + +# Labelsets are used to identify key-values that are associated with a specific +# metric that you want to record. These are useful for pre-aggregation and can +# be used to store custom dimensions pertaining to a metric +label_set = meter.get_label_set({"environment": "staging"}) + +counter.add(25, label_set) +# We sleep for 5 seconds, exported value should be 25 +time.sleep(5) + +counter.add(50, label_set) +# exported value should be 50 due to non-stateful batcher +time.sleep(20) + +# Following exported values would be 0 diff --git a/examples/opentelemetry-example-app/src/opentelemetry_example_app/metrics_example.py b/examples/opentelemetry-example-app/src/opentelemetry_example_app/metrics_example.py index 246d6c3507d..2f423619021 100644 --- a/examples/opentelemetry-example-app/src/opentelemetry_example_app/metrics_example.py +++ b/examples/opentelemetry-example-app/src/opentelemetry_example_app/metrics_example.py @@ -18,8 +18,12 @@ from opentelemetry import metrics from opentelemetry.sdk.metrics import Counter, Meter +from opentelemetry.sdk.metrics.export import ConsoleMetricsExporter +from opentelemetry.sdk.metrics.export.batcher import UngroupedBatcher +from opentelemetry.sdk.metrics.export.controller import PushController -metrics.set_preferred_meter_implementation(lambda _: Meter()) +batcher = UngroupedBatcher(True) +metrics.set_preferred_meter_implementation(lambda _: Meter(batcher)) meter = metrics.meter() counter = meter.create_metric( "available memory", @@ -33,7 +37,7 @@ label_set = meter.get_label_set({"environment": "staging"}) # Direct metric usage -counter.add(label_set, 25) +counter.add(25, label_set) # Handle usage counter_handle = counter.get_handle(label_set) @@ -41,6 +45,6 @@ # Record batch usage meter.record_batch(label_set, [(counter, 50)]) -print(counter_handle.data) -# TODO: exporters +exporter = ConsoleMetricsExporter() +controller = PushController(meter, exporter, 5) diff --git a/ext/opentelemetry-ext-jaeger/tests/test_jaeger_exporter.py b/ext/opentelemetry-ext-jaeger/tests/test_jaeger_exporter.py index 23fce98b79a..08c5a4adeda 100644 --- a/ext/opentelemetry-ext-jaeger/tests/test_jaeger_exporter.py +++ b/ext/opentelemetry-ext-jaeger/tests/test_jaeger_exporter.py @@ -163,7 +163,7 @@ def test_translate_to_jaeger(self): vLong=StatusCanonicalCode.OK.value, ), jaeger.Tag( - key="status.message", vType=jaeger.TagType.STRING, vStr=None, + key="status.message", vType=jaeger.TagType.STRING, vStr=None ), jaeger.Tag( key="span.kind", @@ -246,7 +246,7 @@ def test_translate_to_jaeger(self): vStr=trace_api.SpanKind.CLIENT.name, ), jaeger.Tag( - key="error", vType=jaeger.TagType.BOOL, vBool=True, + key="error", vType=jaeger.TagType.BOOL, vBool=True ), ], references=[ diff --git a/ext/opentelemetry-ext-zipkin/src/opentelemetry/ext/zipkin/__init__.py b/ext/opentelemetry-ext-zipkin/src/opentelemetry/ext/zipkin/__init__.py index e0b5791d1e1..fec4da8c3ed 100644 --- a/ext/opentelemetry-ext-zipkin/src/opentelemetry/ext/zipkin/__init__.py +++ b/ext/opentelemetry-ext-zipkin/src/opentelemetry/ext/zipkin/__init__.py @@ -101,10 +101,7 @@ def export(self, spans: Sequence[Span]) -> SpanExportResult: def _translate_to_zipkin(self, spans: Sequence[Span]): - local_endpoint = { - "serviceName": self.service_name, - "port": self.port, - } + local_endpoint = {"serviceName": self.service_name, "port": self.port} if self.ipv4 is not None: local_endpoint["ipv4"] = self.ipv4 diff --git a/ext/opentelemetry-ext-zipkin/tests/test_zipkin_exporter.py b/ext/opentelemetry-ext-zipkin/tests/test_zipkin_exporter.py index e2bdb413052..467bc610bd8 100644 --- a/ext/opentelemetry-ext-zipkin/tests/test_zipkin_exporter.py +++ b/ext/opentelemetry-ext-zipkin/tests/test_zipkin_exporter.py @@ -114,9 +114,7 @@ def test_export(self): ) span_context = trace_api.SpanContext( - trace_id, - span_id, - trace_options=TraceOptions(TraceOptions.SAMPLED), + trace_id, span_id, trace_options=TraceOptions(TraceOptions.SAMPLED) ) parent_context = trace_api.SpanContext(trace_id, parent_id) other_context = trace_api.SpanContext(trace_id, other_id) @@ -168,10 +166,7 @@ def test_export(self): otel_spans[2].end(end_time=end_times[2]) service_name = "test-service" - local_endpoint = { - "serviceName": service_name, - "port": 9411, - } + local_endpoint = {"serviceName": service_name, "port": 9411} exporter = ZipkinSpanExporter(service_name) expected = [ diff --git a/opentelemetry-api/src/opentelemetry/metrics/__init__.py b/opentelemetry-api/src/opentelemetry/metrics/__init__.py index c6b339be13c..3e04354d3c5 100644 --- a/opentelemetry-api/src/opentelemetry/metrics/__init__.py +++ b/opentelemetry-api/src/opentelemetry/metrics/__init__.py @@ -40,13 +40,34 @@ class DefaultMetricHandle: Used when no MetricHandle implementation is available. """ + def add(self, value: ValueT) -> None: + """No-op implementation of `CounterHandle` add. + + Args: + value: The value to add to the handle. + """ + + def set(self, value: ValueT) -> None: + """No-op implementation of `GaugeHandle` set. + + Args: + value: The value to set to the handle. + """ + + def record(self, value: ValueT) -> None: + """No-op implementation of `MeasureHandle` record. + + Args: + value: The value to record to the handle. + """ + class CounterHandle: def add(self, value: ValueT) -> None: """Increases the value of the handle by ``value``. Args: - value: The value to record to the handle. + value: The value to add to the handle. """ @@ -55,7 +76,7 @@ def set(self, value: ValueT) -> None: """Sets the current value of the handle to ``value``. Args: - value: The value to record to the handle. + value: The value to set to the handle. """ @@ -121,6 +142,30 @@ def get_handle(self, label_set: LabelSet) -> "DefaultMetricHandle": """ return DefaultMetricHandle() + def add(self, value: ValueT, label_set: LabelSet) -> None: + """No-op implementation of `Counter` add. + + Args: + value: The value to add to the counter metric. + label_set: `LabelSet` to associate with the returned handle. + """ + + def set(self, value: ValueT, label_set: LabelSet) -> None: + """No-op implementation of `Gauge` set. + + Args: + value: The value to set the gauge metric to. + label_set: `LabelSet` to associate with the returned handle. + """ + + def record(self, value: ValueT, label_set: LabelSet) -> None: + """No-op implementation of `Measure` record. + + Args: + value: The value to record to this measure metric. + label_set: `LabelSet` to associate with the returned handle. + """ + class Counter(Metric): """A counter type metric that expresses the computation of a sum.""" @@ -129,12 +174,12 @@ def get_handle(self, label_set: LabelSet) -> "CounterHandle": """Gets a `CounterHandle`.""" return CounterHandle() - def add(self, label_set: LabelSet, value: ValueT) -> None: + def add(self, value: ValueT, label_set: LabelSet) -> None: """Increases the value of the counter by ``value``. Args: - label_set: `LabelSet` to associate with the returned handle. value: The value to add to the counter metric. + label_set: `LabelSet` to associate with the returned handle. """ @@ -151,12 +196,12 @@ def get_handle(self, label_set: LabelSet) -> "GaugeHandle": """Gets a `GaugeHandle`.""" return GaugeHandle() - def set(self, label_set: LabelSet, value: ValueT) -> None: + def set(self, value: ValueT, label_set: LabelSet) -> None: """Sets the value of the gauge to ``value``. Args: - label_set: `LabelSet` to associate with the returned handle. value: The value to set the gauge metric to. + label_set: `LabelSet` to associate with the returned handle. """ @@ -172,12 +217,12 @@ def get_handle(self, label_set: LabelSet) -> "MeasureHandle": """Gets a `MeasureHandle` with a float value.""" return MeasureHandle() - def record(self, label_set: LabelSet, value: ValueT) -> None: + def record(self, value: ValueT, label_set: LabelSet) -> None: """Records the ``value`` to the measure. Args: - label_set: `LabelSet` to associate with the returned handle. value: The value to record to this measure metric. + label_set: `LabelSet` to associate with the returned handle. """ @@ -224,6 +269,7 @@ def create_metric( label_keys: Sequence[str] = (), enabled: bool = True, monotonic: bool = False, + absolute: bool = True, ) -> "Metric": """Creates a ``metric_kind`` metric with type ``value_type``. @@ -235,8 +281,10 @@ def create_metric( metric_type: The type of metric being created. label_keys: The keys for the labels with dynamic values. enabled: Whether to report the metric by default. - monotonic: Whether to only allow non-negative values. - + monotonic: Configure a counter or gauge that accepts only + monotonic/non-monotonic updates. + absolute: Configure a measure that does or does not accept negative + updates. Returns: A new ``metric_type`` metric with values of ``value_type``. """ @@ -271,6 +319,7 @@ def create_metric( label_keys: Sequence[str] = (), enabled: bool = True, monotonic: bool = False, + absolute: bool = True, ) -> "Metric": # pylint: disable=no-self-use return DefaultMetric() @@ -298,7 +347,12 @@ def meter() -> Meter: if _METER is None: # pylint:disable=protected-access - _METER = loader._load_impl(DefaultMeter, _METER_FACTORY) + try: + _METER = loader._load_impl(Meter, _METER_FACTORY) # type: ignore + except TypeError: + # if we raised an exception trying to instantiate an + # abstract class, default to no-op tracer impl + _METER = DefaultMeter() del _METER_FACTORY return _METER diff --git a/opentelemetry-api/tests/metrics/test_metrics.py b/opentelemetry-api/tests/metrics/test_metrics.py index a8959266b28..3ec0f81c718 100644 --- a/opentelemetry-api/tests/metrics/test_metrics.py +++ b/opentelemetry-api/tests/metrics/test_metrics.py @@ -13,6 +13,8 @@ # limitations under the License. import unittest +from contextlib import contextmanager +from unittest import mock from opentelemetry import metrics @@ -52,7 +54,7 @@ def test_counter(self): def test_counter_add(self): counter = metrics.Counter() label_set = metrics.LabelSet() - counter.add(label_set, 1) + counter.add(1, label_set) def test_gauge(self): gauge = metrics.Gauge() @@ -63,7 +65,7 @@ def test_gauge(self): def test_gauge_set(self): gauge = metrics.Gauge() label_set = metrics.LabelSet() - gauge.set(label_set, 1) + gauge.set(1, label_set) def test_measure(self): measure = metrics.Measure() @@ -74,7 +76,7 @@ def test_measure(self): def test_measure_record(self): measure = metrics.Measure() label_set = metrics.LabelSet() - measure.record(label_set, 1) + measure.record(1, label_set) def test_default_handle(self): metrics.DefaultMetricHandle() @@ -90,3 +92,34 @@ def test_gauge_handle(self): def test_measure_handle(self): handle = metrics.MeasureHandle() handle.record(1) + + +@contextmanager +# type: ignore +def patch_metrics_globals(meter=None, meter_factory=None): + """Mock metrics._METER and metrics._METER_FACTORY. + + This prevents previous changes to these values from affecting the code in + this scope, and prevents changes in this scope from leaking out and + affecting other tests. + """ + with mock.patch("opentelemetry.metrics._METER", meter): + with mock.patch("opentelemetry.metrics._METER_FACTORY", meter_factory): + yield + + +class TestGlobals(unittest.TestCase): + def test_meter_default_factory(self): + """Check that the default meter is a DefaultMeter.""" + with patch_metrics_globals(): + meter = metrics.meter() + self.assertIsInstance(meter, metrics.DefaultMeter) + # Check that we don't create a new instance on each call + self.assertIs(meter, metrics.meter()) + + def test_meter_custom_factory(self): + """Check that we use the provided factory for custom global meters.""" + mock_meter = mock.Mock(metrics.Meter) + with patch_metrics_globals(meter_factory=lambda _: mock_meter): + meter = metrics.meter() + self.assertIs(meter, mock_meter) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py index f0c3e0e6d33..ea16878a7bb 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py @@ -17,6 +17,8 @@ from typing import Dict, Sequence, Tuple, Type from opentelemetry import metrics as metrics_api +from opentelemetry.sdk.metrics.export.aggregate import Aggregator +from opentelemetry.sdk.metrics.export.batcher import Batcher, UngroupedBatcher from opentelemetry.util import time_ns logger = logging.getLogger(__name__) @@ -27,20 +29,52 @@ class LabelSet(metrics_api.LabelSet): """See `opentelemetry.metrics.LabelSet`.""" def __init__(self, labels: Dict[str, str] = None): - self.labels = labels + if labels is None: + labels = {} + # LabelSet properties used only in dictionaries for fast lookup + self._labels = tuple(labels.items()) + self._encoded = tuple(sorted(labels.items())) + + @property + def labels(self): + return self._labels + + def __hash__(self): + return hash(self._encoded) + + def __eq__(self, other): + return self._encoded == other._encoded class BaseHandle: + """The base handle class containing common behavior for all handles. + + Handles are responsible for operating on data for metric instruments for a + specific set of labels. + + Args: + value_type: The type of values this handle holds (int, float). + enabled: True if the originating instrument is enabled. + monotonic: Indicates acceptance of only monotonic/non-monotonic values + for updating counter and gauge handles. + absolute: Indicates acceptance of negative updates to measure handles. + aggregator: The aggregator for this handle. Will handle aggregation + upon updates and checkpointing of values for exporting. + """ + def __init__( self, value_type: Type[metrics_api.ValueT], enabled: bool, monotonic: bool, + absolute: bool, + aggregator: Aggregator, ): - self.data = value_type() self.value_type = value_type self.enabled = enabled self.monotonic = monotonic + self.absolute = absolute + self.aggregator = aggregator self.last_update_timestamp = time_ns() def _validate_update(self, value: metrics_api.ValueT) -> bool: @@ -53,9 +87,15 @@ def _validate_update(self, value: metrics_api.ValueT) -> bool: return False return True + def update(self, value: metrics_api.ValueT): + self.last_update_timestamp = time_ns() + self.aggregator.update(value) + def __repr__(self): return '{}(data="{}", last_update_timestamp={})'.format( - type(self).__name__, self.data, self.last_update_timestamp + type(self).__name__, + self.aggregator.current, + self.last_update_timestamp, ) @@ -66,34 +106,37 @@ def add(self, value: metrics_api.ValueT) -> None: if self.monotonic and value < 0: logger.warning("Monotonic counter cannot descend.") return - self.last_update_timestamp = time_ns() - self.data += value + self.update(value) class GaugeHandle(metrics_api.GaugeHandle, BaseHandle): def set(self, value: metrics_api.ValueT) -> None: """See `opentelemetry.metrics.GaugeHandle.set`.""" if self._validate_update(value): - if self.monotonic and value < self.data: + if self.monotonic and value < self.aggregator.current: logger.warning("Monotonic gauge cannot descend.") return - self.last_update_timestamp = time_ns() - self.data = value + self.update(value) class MeasureHandle(metrics_api.MeasureHandle, BaseHandle): def record(self, value: metrics_api.ValueT) -> None: """See `opentelemetry.metrics.MeasureHandle.record`.""" if self._validate_update(value): - if self.monotonic and value < 0: - logger.warning("Monotonic measure cannot accept negatives.") + if self.absolute and value < 0: + logger.warning("Absolute measure cannot accept negatives.") return - self.last_update_timestamp = time_ns() - # TODO: record + self.update(value) class Metric(metrics_api.Metric): - """See `opentelemetry.metrics.Metric`.""" + """Base class for all metric types. + + Also known as metric instrument. This is the class that is used to + represent a metric that is to be continuously recorded and tracked. Each + metric has a set of handles that are created from the metric. See + `BaseHandle` for information on handles. + """ HANDLE_TYPE = BaseHandle @@ -103,17 +146,21 @@ def __init__( description: str, unit: str, value_type: Type[metrics_api.ValueT], + meter: "Meter", label_keys: Sequence[str] = (), enabled: bool = True, monotonic: bool = False, + absolute: bool = True, ): self.name = name self.description = description self.unit = unit self.value_type = value_type + self.meter = meter self.label_keys = label_keys self.enabled = enabled self.monotonic = monotonic + self.absolute = absolute self.handles = {} def get_handle(self, label_set: LabelSet) -> BaseHandle: @@ -121,9 +168,14 @@ def get_handle(self, label_set: LabelSet) -> BaseHandle: handle = self.handles.get(label_set) if not handle: handle = self.HANDLE_TYPE( - self.value_type, self.enabled, self.monotonic + self.value_type, + self.enabled, + self.monotonic, + self.absolute, + # Aggregator will be created based off type of metric + self.meter.batcher.aggregator_for(self.__class__), ) - self.handles[label_set] = handle + self.handles[label_set] = handle return handle def __repr__(self): @@ -138,8 +190,8 @@ class Counter(Metric, metrics_api.Counter): """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. + will be rejected for monotonic counter metrics. Counter metrics that have a + monotonic option set to False allows negative inputs. """ HANDLE_TYPE = CounterHandle @@ -150,21 +202,25 @@ def __init__( description: str, unit: str, value_type: Type[metrics_api.ValueT], + meter: "Meter", label_keys: Sequence[str] = (), enabled: bool = True, monotonic: bool = True, + absolute: bool = False, ): super().__init__( name, description, unit, value_type, + meter, label_keys=label_keys, enabled=enabled, monotonic=monotonic, + absolute=absolute, ) - def add(self, label_set: LabelSet, value: metrics_api.ValueT) -> None: + def add(self, value: metrics_api.ValueT, label_set: LabelSet) -> None: """See `opentelemetry.metrics.Counter.add`.""" self.get_handle(label_set).add(value) @@ -175,7 +231,7 @@ class Gauge(Metric, metrics_api.Gauge): """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. + Negative inputs will be rejected for monotonic gauge metrics. """ HANDLE_TYPE = GaugeHandle @@ -186,21 +242,25 @@ def __init__( description: str, unit: str, value_type: Type[metrics_api.ValueT], + meter: "Meter", label_keys: Sequence[str] = (), enabled: bool = True, monotonic: bool = False, + absolute: bool = False, ): super().__init__( name, description, unit, value_type, + meter, label_keys=label_keys, enabled=enabled, monotonic=monotonic, + absolute=absolute, ) - def set(self, label_set: LabelSet, value: metrics_api.ValueT) -> None: + def set(self, value: metrics_api.ValueT, label_set: LabelSet) -> None: """See `opentelemetry.metrics.Gauge.set`.""" self.get_handle(label_set).set(value) @@ -208,50 +268,58 @@ def set(self, label_set: LabelSet, value: metrics_api.ValueT) -> None: class Measure(Metric, metrics_api.Measure): - """See `opentelemetry.metrics.Measure`. - - By default, measure metrics can accept both positive and negatives. - Negative inputs will be discarded when monotonic is True. - """ + """See `opentelemetry.metrics.Measure`.""" HANDLE_TYPE = MeasureHandle - def __init__( - self, - name: str, - description: str, - unit: str, - value_type: Type[metrics_api.ValueT], - label_keys: Sequence[str] = (), - enabled: bool = False, - monotonic: bool = False, - ): - super().__init__( - name, - description, - unit, - value_type, - label_keys=label_keys, - enabled=enabled, - monotonic=monotonic, - ) - - def record(self, label_set: LabelSet, value: metrics_api.ValueT) -> None: + def record(self, value: metrics_api.ValueT, label_set: LabelSet) -> None: """See `opentelemetry.metrics.Measure.record`.""" self.get_handle(label_set).record(value) UPDATE_FUNCTION = record +class Record: + """Container class used for processing in the `Batcher`""" + + def __init__( + self, metric: Metric, label_set: LabelSet, aggregator: Aggregator + ): + self.metric = metric + self.label_set = label_set + self.aggregator = aggregator + + # Used when getting a LabelSet with no key/values EMPTY_LABEL_SET = LabelSet() class Meter(metrics_api.Meter): - """See `opentelemetry.metrics.Meter`.""" + """See `opentelemetry.metrics.Meter`. + + Args: + batcher: The `Batcher` used for this meter. + """ - def __init__(self): - self.labels = {} + def __init__(self, batcher: Batcher = UngroupedBatcher(True)): + self.batcher = batcher + self.metrics = set() + + def collect(self) -> None: + """Collects all the metrics created with this `Meter` for export. + + Utilizes the batcher to create checkpoints of the current values in + each aggregator belonging to the metrics that were created with this + meter instance. + """ + for metric in self.metrics: + if metric.enabled: + for label_set, handle in metric.handles.items(): + # TODO: Consider storing records in memory? + record = Record(metric, label_set, handle.aggregator) + # Checkpoints the current aggregators + # Applies different batching logic based on type of batcher + self.batcher.process(record) def record_batch( self, @@ -260,7 +328,7 @@ def record_batch( ) -> None: """See `opentelemetry.metrics.Meter.record_batch`.""" for metric, value in record_tuples: - metric.UPDATE_FUNCTION(label_set, value) + metric.UPDATE_FUNCTION(value, label_set) def create_metric( self, @@ -272,18 +340,23 @@ def create_metric( label_keys: Sequence[str] = (), enabled: bool = True, monotonic: bool = False, + absolute: bool = True, ) -> metrics_api.MetricT: """See `opentelemetry.metrics.Meter.create_metric`.""" # Ignore type b/c of mypy bug in addition to missing annotations - return metric_type( # type: ignore + metric = metric_type( # type: ignore name, description, unit, value_type, + self, label_keys=label_keys, enabled=enabled, monotonic=monotonic, + absolute=absolute, ) + self.metrics.add(metric) + return metric def get_label_set(self, labels: Dict[str, str]): """See `opentelemetry.metrics.Meter.create_metric`. @@ -295,12 +368,4 @@ def get_label_set(self, labels: Dict[str, str]): """ if len(labels) == 0: return EMPTY_LABEL_SET - # Use simple encoding for now until encoding API is implemented - encoded = tuple(sorted(labels.items())) - # If LabelSet exists for this meter in memory, use existing one - if encoded not in self.labels: - self.labels[encoded] = LabelSet(labels=labels) - return self.labels[encoded] - - -meter = Meter() + return LabelSet(labels=labels) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/__init__.py index b6cb396331a..6901a4efe46 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/__init__.py @@ -15,8 +15,6 @@ from enum import Enum from typing import Sequence, Tuple -from .. import Metric - class MetricsExportResult(Enum): SUCCESS = 0 @@ -24,6 +22,13 @@ class MetricsExportResult(Enum): FAILED_NOT_RETRYABLE = 2 +class MetricRecord: + def __init__(self, aggregator, label_set, metric): + self.aggregator = aggregator + self.label_set = label_set + self.metric = metric + + class MetricsExporter: """Interface for exporting metrics. @@ -32,15 +37,15 @@ class MetricsExporter: """ def export( - self, metric_tuples: Sequence[Tuple[Metric, Sequence[str]]] + self, metric_records: Sequence[MetricRecord] ) -> "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 export. + metric_records: A sequence of `MetricRecord` s. A `MetricRecord` + contains the metric to be exported, the label set associated + with that metric, as well as the aggregator used to export the + current checkpointed value. Returns: The result of the export @@ -57,17 +62,19 @@ class ConsoleMetricsExporter(MetricsExporter): """Implementation of `MetricsExporter` that prints metrics to the console. This class can be used for diagnostic purposes. It prints the exported - metric handles to the console STDOUT. + metrics to the console STDOUT. """ def export( - self, metric_tuples: Sequence[Tuple[Metric, Sequence[str]]] + self, metric_records: Sequence[MetricRecord] ) -> "MetricsExportResult": - for metric, label_values in metric_tuples: - handle = metric.get_handle(label_values) + for record in metric_records: print( - '{}(data="{}", label_values="{}", metric_data={})'.format( - type(self).__name__, metric, label_values, handle + '{}(data="{}", label_set="{}", value={})'.format( + type(self).__name__, + record.metric, + record.label_set.labels, + record.aggregator.checkpoint, ) ) return MetricsExportResult.SUCCESS diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/aggregate.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/aggregate.py new file mode 100644 index 00000000000..642fe1cdfe4 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/aggregate.py @@ -0,0 +1,58 @@ +# 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 abc + + +class Aggregator(abc.ABC): + """Base class for aggregators. + + Aggregators are responsible for holding aggregated values and taking a + snapshot of these values upon export (checkpoint). + """ + + def __init__(self): + self.current = None + self.checkpoint = None + + @abc.abstractmethod + def update(self, value): + """Updates the current with the new value.""" + + @abc.abstractmethod + def take_checkpoint(self): + """Stores a snapshot of the current value.""" + + @abc.abstractmethod + def merge(self, other): + """Combines two aggregator values.""" + + +class CounterAggregator(Aggregator): + """Aggregator for Counter metrics.""" + + def __init__(self): + super().__init__() + self.current = 0 + self.checkpoint = 0 + + def update(self, value): + self.current += value + + def take_checkpoint(self): + self.checkpoint = self.current + self.current = 0 + + def merge(self, other): + self.checkpoint += other.checkpoint diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/batcher.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/batcher.py new file mode 100644 index 00000000000..c81db0fe740 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/batcher.py @@ -0,0 +1,100 @@ +# 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 abc +from typing import Sequence, Type + +from opentelemetry.metrics import Counter, MetricT +from opentelemetry.sdk.metrics.export import MetricRecord +from opentelemetry.sdk.metrics.export.aggregate import ( + Aggregator, + CounterAggregator, +) + + +class Batcher(abc.ABC): + """Base class for all batcher types. + + The batcher is responsible for storing the aggregators and aggregated + values received from updates from metrics in the meter. The stored values + will be sent to an exporter for exporting. + """ + + def __init__(self, stateful: bool): + self._batch_map = {} + # stateful=True indicates the batcher computes checkpoints from over + # the process lifetime. False indicates the batcher computes + # checkpoints which describe the updates of a single collection period + # (deltas) + self.stateful = stateful + + def aggregator_for(self, metric_type: Type[MetricT]) -> Aggregator: + """Returns an aggregator based on metric type. + + Aggregators keep track of and updates values when metrics get updated. + """ + # pylint:disable=R0201 + if metric_type == Counter: + return CounterAggregator() + # TODO: Add other aggregators + return CounterAggregator() + + def checkpoint_set(self) -> Sequence[MetricRecord]: + """Returns a list of MetricRecords used for exporting. + + The list of MetricRecords is a snapshot created from the current + data in all of the aggregators in this batcher. + """ + metric_records = [] + for (metric, label_set), aggregator in self._batch_map.items(): + metric_records.append(MetricRecord(aggregator, label_set, metric)) + return metric_records + + def finished_collection(self): + """Performs certain post-export logic. + + For batchers that are stateless, resets the batch map. + """ + if not self.stateful: + self._batch_map = {} + + @abc.abstractmethod + def process(self, record) -> None: + """Stores record information to be ready for exporting. + + Depending on type of batcher, performs pre-export logic, such as + filtering records based off of keys. + """ + + +class UngroupedBatcher(Batcher): + """Accepts all records and passes them for exporting""" + + def process(self, record): + # Checkpoints the current aggregator value to be collected for export + record.aggregator.take_checkpoint() + batch_key = (record.metric, record.label_set) + batch_value = self._batch_map.get(batch_key) + aggregator = record.aggregator + 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 + if self.stateful: + # if stateful batcher, create a copy of the aggregator and update + # it with the current checkpointed value for long-term storage + aggregator = self.aggregator_for(record.metric.__class__) + aggregator.merge(record.aggregator) + self._batch_map[batch_key] = aggregator diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/controller.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/controller.py new file mode 100644 index 00000000000..03c857f04d9 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/export/controller.py @@ -0,0 +1,56 @@ +# 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 atexit +import threading + + +class PushController(threading.Thread): + """A push based controller, used for exporting. + + Uses a worker thread that periodically collects metrics for exporting, + exports them and performs some post-processing. + """ + + daemon = True + + def __init__(self, meter, exporter, interval, shutdown_on_exit=True): + super().__init__() + self.meter = meter + self.exporter = exporter + self.interval = interval + self.finished = threading.Event() + self._atexit_handler = None + if shutdown_on_exit: + self._atexit_handler = atexit.register(self.shutdown) + self.start() + + def run(self): + while not self.finished.wait(self.interval): + self.tick() + + def shutdown(self): + self.finished.set() + self.exporter.shutdown() + if self._atexit_handler is not None: + atexit.unregister(self._atexit_handler) + self._atexit_handler = None + + def tick(self): + # Collect all of the meter's metrics to be exported + self.meter.collect() + # Export the given metrics in the batcher + self.exporter.export(self.meter.batcher.checkpoint_set()) + # Perform post-exporting logic based on batcher configuration + self.meter.batcher.finished_collection() diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index e4294670614..ff0f78f3cee 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -434,9 +434,7 @@ class Tracer(trace_api.Tracer): """ def __init__( - self, - source: "TracerSource", - instrumentation_info: InstrumentationInfo, + self, source: "TracerSource", instrumentation_info: InstrumentationInfo ) -> None: self.source = source self.instrumentation_info = instrumentation_info diff --git a/opentelemetry-sdk/tests/metrics/export/test_export.py b/opentelemetry-sdk/tests/metrics/export/test_export.py index 4d8e6df8575..816bfcfca9c 100644 --- a/opentelemetry-sdk/tests/metrics/export/test_export.py +++ b/opentelemetry-sdk/tests/metrics/export/test_export.py @@ -16,9 +16,16 @@ from unittest import mock from opentelemetry.sdk import metrics -from opentelemetry.sdk.metrics.export import ConsoleMetricsExporter +from opentelemetry.sdk.metrics.export import ( + ConsoleMetricsExporter, + MetricRecord, +) +from opentelemetry.sdk.metrics.export.aggregate import CounterAggregator +from opentelemetry.sdk.metrics.export.batcher import UngroupedBatcher +from opentelemetry.sdk.metrics.export.controller import PushController +# pylint: disable=protected-access class TestConsoleMetricsExporter(unittest.TestCase): # pylint: disable=no-self-use def test_export(self): @@ -34,10 +41,214 @@ def test_export(self): ) kvp = {"environment": "staging"} label_set = meter.get_label_set(kvp) - handle = metric.get_handle(label_set) - result = '{}(data="{}", label_values="{}", metric_data={})'.format( - ConsoleMetricsExporter.__name__, metric, label_set, handle + aggregator = CounterAggregator() + record = MetricRecord(aggregator, label_set, metric) + result = '{}(data="{}", label_set="{}", value={})'.format( + ConsoleMetricsExporter.__name__, + metric, + label_set.labels, + aggregator.checkpoint, ) with mock.patch("sys.stdout") as mock_stdout: - exporter.export([(metric, label_set)]) + exporter.export([record]) mock_stdout.write.assert_any_call(result) + + +class TestBatcher(unittest.TestCase): + def test_aggregator_for_counter(self): + batcher = UngroupedBatcher(True) + self.assertTrue( + isinstance( + batcher.aggregator_for(metrics.Counter), CounterAggregator + ) + ) + + # TODO: Add other aggregator tests + + def test_checkpoint_set(self): + meter = metrics.Meter() + batcher = UngroupedBatcher(True) + aggregator = CounterAggregator() + metric = metrics.Counter( + "available memory", + "available memory", + "bytes", + int, + meter, + ("environment",), + ) + aggregator.update(1.0) + label_set = metrics.LabelSet() + _batch_map = {} + _batch_map[(metric, label_set)] = aggregator + batcher._batch_map = _batch_map + records = batcher.checkpoint_set() + self.assertEqual(len(records), 1) + self.assertEqual(records[0].metric, metric) + self.assertEqual(records[0].label_set, label_set) + self.assertEqual(records[0].aggregator, aggregator) + + def test_checkpoint_set_empty(self): + batcher = UngroupedBatcher(True) + records = batcher.checkpoint_set() + self.assertEqual(len(records), 0) + + def test_finished_collection_stateless(self): + meter = metrics.Meter() + batcher = UngroupedBatcher(False) + aggregator = CounterAggregator() + metric = metrics.Counter( + "available memory", + "available memory", + "bytes", + int, + meter, + ("environment",), + ) + aggregator.update(1.0) + label_set = metrics.LabelSet() + _batch_map = {} + _batch_map[(metric, label_set)] = aggregator + batcher._batch_map = _batch_map + batcher.finished_collection() + self.assertEqual(len(batcher._batch_map), 0) + + def test_finished_collection_stateful(self): + meter = metrics.Meter() + batcher = UngroupedBatcher(True) + aggregator = CounterAggregator() + metric = metrics.Counter( + "available memory", + "available memory", + "bytes", + int, + meter, + ("environment",), + ) + aggregator.update(1.0) + label_set = metrics.LabelSet() + _batch_map = {} + _batch_map[(metric, label_set)] = aggregator + batcher._batch_map = _batch_map + batcher.finished_collection() + self.assertEqual(len(batcher._batch_map), 1) + + # TODO: Abstract the logic once other batchers implemented + def test_ungrouped_batcher_process_exists(self): + meter = metrics.Meter() + batcher = UngroupedBatcher(True) + aggregator = CounterAggregator() + aggregator2 = CounterAggregator() + metric = metrics.Counter( + "available memory", + "available memory", + "bytes", + int, + meter, + ("environment",), + ) + label_set = metrics.LabelSet() + _batch_map = {} + _batch_map[(metric, label_set)] = aggregator + aggregator2.update(1.0) + batcher._batch_map = _batch_map + record = metrics.Record(metric, label_set, aggregator2) + batcher.process(record) + self.assertEqual(len(batcher._batch_map), 1) + self.assertIsNotNone(batcher._batch_map.get((metric, label_set))) + self.assertEqual( + batcher._batch_map.get((metric, label_set)).current, 0 + ) + self.assertEqual( + batcher._batch_map.get((metric, label_set)).checkpoint, 1.0 + ) + + def test_ungrouped_batcher_process_not_exists(self): + meter = metrics.Meter() + batcher = UngroupedBatcher(True) + aggregator = CounterAggregator() + metric = metrics.Counter( + "available memory", + "available memory", + "bytes", + int, + meter, + ("environment",), + ) + label_set = metrics.LabelSet() + _batch_map = {} + aggregator.update(1.0) + batcher._batch_map = _batch_map + record = metrics.Record(metric, label_set, aggregator) + batcher.process(record) + self.assertEqual(len(batcher._batch_map), 1) + self.assertIsNotNone(batcher._batch_map.get((metric, label_set))) + self.assertEqual( + batcher._batch_map.get((metric, label_set)).current, 0 + ) + self.assertEqual( + batcher._batch_map.get((metric, label_set)).checkpoint, 1.0 + ) + + def test_ungrouped_batcher_process_not_stateful(self): + meter = metrics.Meter() + batcher = UngroupedBatcher(True) + aggregator = CounterAggregator() + metric = metrics.Counter( + "available memory", + "available memory", + "bytes", + int, + meter, + ("environment",), + ) + label_set = metrics.LabelSet() + _batch_map = {} + aggregator.update(1.0) + batcher._batch_map = _batch_map + record = metrics.Record(metric, label_set, aggregator) + batcher.process(record) + self.assertEqual(len(batcher._batch_map), 1) + self.assertIsNotNone(batcher._batch_map.get((metric, label_set))) + self.assertEqual( + batcher._batch_map.get((metric, label_set)).current, 0 + ) + self.assertEqual( + batcher._batch_map.get((metric, label_set)).checkpoint, 1.0 + ) + + +class TestAggregator(unittest.TestCase): + # TODO: test other aggregators once implemented + def test_counter_update(self): + counter = CounterAggregator() + counter.update(1.0) + counter.update(2.0) + self.assertEqual(counter.current, 3.0) + + def test_counter_checkpoint(self): + counter = CounterAggregator() + counter.update(2.0) + counter.take_checkpoint() + self.assertEqual(counter.current, 0) + self.assertEqual(counter.checkpoint, 2.0) + + def test_counter_merge(self): + counter = CounterAggregator() + counter2 = CounterAggregator() + counter.checkpoint = 1.0 + counter2.checkpoint = 3.0 + counter.merge(counter2) + self.assertEqual(counter.checkpoint, 4.0) + + +class TestController(unittest.TestCase): + def test_push_controller(self): + meter = mock.Mock() + exporter = mock.Mock() + controller = PushController(meter, exporter, 5.0) + meter.collect.assert_not_called() + exporter.export.assert_not_called() + controller.shutdown() + self.assertTrue(controller.finished.isSet()) + exporter.shutdown.assert_any_call() diff --git a/opentelemetry-sdk/tests/metrics/test_metrics.py b/opentelemetry-sdk/tests/metrics/test_metrics.py index 81e6dd2c9d5..3a08433e8da 100644 --- a/opentelemetry-sdk/tests/metrics/test_metrics.py +++ b/opentelemetry-sdk/tests/metrics/test_metrics.py @@ -17,6 +17,7 @@ from opentelemetry import metrics as metrics_api from opentelemetry.sdk import metrics +from opentelemetry.sdk.metrics import export class TestMeter(unittest.TestCase): @@ -24,6 +25,43 @@ def test_extends_api(self): meter = metrics.Meter() self.assertIsInstance(meter, metrics_api.Meter) + def test_collect(self): + meter = metrics.Meter() + batcher_mock = mock.Mock() + meter.batcher = batcher_mock + label_keys = ("key1",) + counter = metrics.Counter( + "name", "desc", "unit", float, meter, label_keys + ) + kvp = {"key1": "value1"} + label_set = meter.get_label_set(kvp) + counter.add(label_set, 1.0) + meter.metrics.add(counter) + meter.collect() + self.assertTrue(batcher_mock.process.called) + + def test_collect_no_metrics(self): + meter = metrics.Meter() + batcher_mock = mock.Mock() + meter.batcher = batcher_mock + meter.collect() + self.assertFalse(batcher_mock.process.called) + + def test_collect_disabled_metric(self): + meter = metrics.Meter() + batcher_mock = mock.Mock() + meter.batcher = batcher_mock + label_keys = ("key1",) + counter = metrics.Counter( + "name", "desc", "unit", float, meter, label_keys, False + ) + kvp = {"key1": "value1"} + label_set = meter.get_label_set(kvp) + counter.add(label_set, 1.0) + meter.metrics.add(counter) + meter.collect() + self.assertFalse(batcher_mock.process.called) + def test_record_batch(self): meter = metrics.Meter() label_keys = ("key1",) @@ -34,7 +72,7 @@ def test_record_batch(self): label_set = meter.get_label_set(kvp) record_tuples = [(counter, 1.0)] meter.record_batch(label_set, record_tuples) - self.assertEqual(counter.get_handle(label_set).data, 1.0) + self.assertEqual(counter.get_handle(label_set).aggregator.current, 1.0) def test_record_batch_multiple(self): meter = metrics.Meter() @@ -44,15 +82,16 @@ def test_record_batch_multiple(self): counter = metrics.Counter( "name", "desc", "unit", float, meter, label_keys ) - gauge = metrics.Gauge("name", "desc", "unit", int, label_keys) + gauge = metrics.Gauge("name", "desc", "unit", int, meter, label_keys) measure = metrics.Measure( "name", "desc", "unit", float, meter, label_keys ) record_tuples = [(counter, 1.0), (gauge, 5), (measure, 3.0)] meter.record_batch(label_set, record_tuples) - self.assertEqual(counter.get_handle(label_set).data, 1.0) - self.assertEqual(gauge.get_handle(label_set).data, 5) - self.assertEqual(measure.get_handle(label_set).data, 0) + self.assertEqual(counter.get_handle(label_set).aggregator.current, 1.0) + self.assertEqual(gauge.get_handle(label_set).aggregator.current, 5.0) + # TODO: Fix when aggregator implemented for measure + self.assertEqual(measure.get_handle(label_set).aggregator.current, 3.0) def test_record_batch_exists(self): meter = metrics.Meter() @@ -62,12 +101,12 @@ def test_record_batch_exists(self): counter = metrics.Counter( "name", "desc", "unit", float, meter, label_keys ) - counter.add(label_set, 1.0) + counter.add(1.0, label_set) handle = counter.get_handle(label_set) record_tuples = [(counter, 1.0)] meter.record_batch(label_set, record_tuples) self.assertEqual(counter.get_handle(label_set), handle) - self.assertEqual(handle.data, 2.0) + self.assertEqual(handle.aggregator.current, 2.0) def test_create_metric(self): meter = metrics.Meter() @@ -100,8 +139,9 @@ def test_get_label_set(self): meter = metrics.Meter() kvp = {"environment": "staging", "a": "z"} label_set = meter.get_label_set(kvp) - encoded = tuple(sorted(kvp.items())) - self.assertIs(meter.labels[encoded], label_set) + label_set2 = meter.get_label_set(kvp) + labels = set([label_set, label_set2]) + self.assertEqual(len(labels), 1) def test_get_label_set_empty(self): meter = metrics.Meter() @@ -109,13 +149,6 @@ def test_get_label_set_empty(self): label_set = meter.get_label_set(kvp) self.assertEqual(label_set, metrics.EMPTY_LABEL_SET) - def test_get_label_set_exists(self): - meter = metrics.Meter() - kvp = {"environment": "staging", "a": "z"} - label_set = meter.get_label_set(kvp) - label_set2 = meter.get_label_set(kvp) - self.assertIs(label_set, label_set2) - class TestMetric(unittest.TestCase): def test_get_handle(self): @@ -132,114 +165,155 @@ def test_get_handle(self): class TestCounter(unittest.TestCase): def test_add(self): meter = metrics.Meter() - metric = metrics.Counter("name", "desc", "unit", int, ("key",)) + metric = metrics.Counter("name", "desc", "unit", int, meter, ("key",)) kvp = {"key": "value"} label_set = meter.get_label_set(kvp) handle = metric.get_handle(label_set) - metric.add(label_set, 3) - metric.add(label_set, 2) - self.assertEqual(handle.data, 5) + metric.add(3, label_set) + metric.add(2, label_set) + self.assertEqual(handle.aggregator.current, 5) class TestGauge(unittest.TestCase): def test_set(self): meter = metrics.Meter() - metric = metrics.Gauge("name", "desc", "unit", int, ("key",)) + metric = metrics.Gauge("name", "desc", "unit", int, meter, ("key",)) kvp = {"key": "value"} label_set = meter.get_label_set(kvp) handle = metric.get_handle(label_set) - metric.set(label_set, 3) - self.assertEqual(handle.data, 3) - metric.set(label_set, 2) - self.assertEqual(handle.data, 2) + metric.set(3, label_set) + self.assertEqual(handle.aggregator.current, 3) + metric.set(2, label_set) + # TODO: Fix once other aggregators implemented + self.assertEqual(handle.aggregator.current, 5) class TestMeasure(unittest.TestCase): def test_record(self): meter = metrics.Meter() - metric = metrics.Measure("name", "desc", "unit", int, ("key",)) + metric = metrics.Measure("name", "desc", "unit", int, meter, ("key",)) kvp = {"key": "value"} label_set = meter.get_label_set(kvp) handle = metric.get_handle(label_set) - metric.record(label_set, 3) - # Record not implemented yet - self.assertEqual(handle.data, 0) + metric.record(3, label_set) + # TODO: Fix once other aggregators implemented + self.assertEqual(handle.aggregator.current, 3) class TestCounterHandle(unittest.TestCase): def test_add(self): - handle = metrics.CounterHandle(int, True, False) + aggregator = export.aggregate.CounterAggregator() + handle = metrics.CounterHandle(int, True, False, False, aggregator) handle.add(3) - self.assertEqual(handle.data, 3) + self.assertEqual(handle.aggregator.current, 3) def test_add_disabled(self): - handle = metrics.CounterHandle(int, False, False) + aggregator = export.aggregate.CounterAggregator() + handle = metrics.CounterHandle(int, False, False, False, aggregator) handle.add(3) - self.assertEqual(handle.data, 0) + self.assertEqual(handle.aggregator.current, 0) @mock.patch("opentelemetry.sdk.metrics.logger") def test_add_monotonic(self, logger_mock): - handle = metrics.CounterHandle(int, True, True) + aggregator = export.aggregate.CounterAggregator() + handle = metrics.CounterHandle(int, True, True, False, aggregator) handle.add(-3) - self.assertEqual(handle.data, 0) + self.assertEqual(handle.aggregator.current, 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) + aggregator = export.aggregate.CounterAggregator() + handle = metrics.CounterHandle(int, True, False, False, aggregator) handle.add(3.0) - self.assertEqual(handle.data, 0) + self.assertEqual(handle.aggregator.current, 0) self.assertTrue(logger_mock.warning.called) + @mock.patch("opentelemetry.sdk.metrics.time_ns") + def test_update(self, time_mock): + aggregator = export.aggregate.CounterAggregator() + handle = metrics.CounterHandle(int, True, False, False, aggregator) + time_mock.return_value = 123 + handle.update(4.0) + self.assertEqual(handle.last_update_timestamp, 123) + self.assertEqual(handle.aggregator.current, 4.0) + +# TODO: fix tests once aggregator implemented class TestGaugeHandle(unittest.TestCase): def test_set(self): - handle = metrics.GaugeHandle(int, True, False) + aggregator = export.aggregate.CounterAggregator() + handle = metrics.GaugeHandle(int, True, False, False, aggregator) handle.set(3) - self.assertEqual(handle.data, 3) + self.assertEqual(handle.aggregator.current, 3) def test_set_disabled(self): - handle = metrics.GaugeHandle(int, False, False) + aggregator = export.aggregate.CounterAggregator() + handle = metrics.GaugeHandle(int, False, False, False, aggregator) handle.set(3) - self.assertEqual(handle.data, 0) + self.assertEqual(handle.aggregator.current, 0) @mock.patch("opentelemetry.sdk.metrics.logger") def test_set_monotonic(self, logger_mock): - handle = metrics.GaugeHandle(int, True, True) + aggregator = export.aggregate.CounterAggregator() + handle = metrics.GaugeHandle(int, True, True, False, aggregator) handle.set(-3) - self.assertEqual(handle.data, 0) + self.assertEqual(handle.aggregator.current, 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) + aggregator = export.aggregate.CounterAggregator() + handle = metrics.GaugeHandle(int, True, False, False, aggregator) handle.set(3.0) - self.assertEqual(handle.data, 0) + self.assertEqual(handle.aggregator.current, 0) self.assertTrue(logger_mock.warning.called) + @mock.patch("opentelemetry.sdk.metrics.time_ns") + def test_update(self, time_mock): + aggregator = export.aggregate.CounterAggregator() + handle = metrics.GaugeHandle(int, True, False, False, aggregator) + time_mock.return_value = 123 + handle.update(4.0) + self.assertEqual(handle.last_update_timestamp, 123) + self.assertEqual(handle.aggregator.current, 4.0) + +# TODO: fix tests once aggregator implemented class TestMeasureHandle(unittest.TestCase): def test_record(self): - handle = metrics.MeasureHandle(int, False, False) + aggregator = export.aggregate.CounterAggregator() + handle = metrics.MeasureHandle(int, False, False, False, aggregator) handle.record(3) - # Record not implemented yet - self.assertEqual(handle.data, 0) + self.assertEqual(handle.aggregator.current, 0) def test_record_disabled(self): - handle = metrics.MeasureHandle(int, False, False) + aggregator = export.aggregate.CounterAggregator() + handle = metrics.MeasureHandle(int, False, False, False, aggregator) handle.record(3) - self.assertEqual(handle.data, 0) + self.assertEqual(handle.aggregator.current, 0) @mock.patch("opentelemetry.sdk.metrics.logger") def test_record_monotonic(self, logger_mock): - handle = metrics.MeasureHandle(int, True, True) + aggregator = export.aggregate.CounterAggregator() + handle = metrics.MeasureHandle(int, True, False, True, aggregator) handle.record(-3) - self.assertEqual(handle.data, 0) + self.assertEqual(handle.aggregator.current, 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) + aggregator = export.aggregate.CounterAggregator() + handle = metrics.MeasureHandle(int, True, False, False, aggregator) handle.record(3.0) - self.assertEqual(handle.data, 0) + self.assertEqual(handle.aggregator.current, 0) self.assertTrue(logger_mock.warning.called) + + @mock.patch("opentelemetry.sdk.metrics.time_ns") + def test_update(self, time_mock): + aggregator = export.aggregate.CounterAggregator() + handle = metrics.MeasureHandle(int, True, False, False, aggregator) + time_mock.return_value = 123 + handle.update(4.0) + self.assertEqual(handle.last_update_timestamp, 123) + self.assertEqual(handle.aggregator.current, 4.0) diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index 449fb51e8ee..fff520bcb2a 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -630,14 +630,13 @@ def test_ended_span(self): self.assertEqual(root.name, "root") new_status = trace_api.status.Status( - trace_api.status.StatusCanonicalCode.CANCELLED, "Test description", + trace_api.status.StatusCanonicalCode.CANCELLED, "Test description" ) with self.assertLogs(level=WARNING): root.set_status(new_status) self.assertEqual( - root.status.canonical_code, - trace_api.status.StatusCanonicalCode.OK, + root.status.canonical_code, trace_api.status.StatusCanonicalCode.OK ) def test_error_status(self): diff --git a/scripts/eachdist.py b/scripts/eachdist.py index 8d41315fc7a..406afb6ebfd 100755 --- a/scripts/eachdist.py +++ b/scripts/eachdist.py @@ -479,7 +479,7 @@ def lint_args(args): runsubprocess(args.dry_run, ("flake8", rootdir), check=True) execute_args( parse_subargs( - args, ("exec", "pylint {}", "--all", "--mode", "lintroots",), + args, ("exec", "pylint {}", "--all", "--mode", "lintroots") ) )