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 41bdba85972..246d6c3507d 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 @@ -30,17 +30,17 @@ ("environment",), ) -label_values = ("staging",) +label_set = meter.get_label_set({"environment": "staging"}) # Direct metric usage -counter.add(label_values, 25) +counter.add(label_set, 25) # Handle usage -counter_handle = counter.get_handle(label_values) +counter_handle = counter.get_handle(label_set) counter_handle.add(100) # Record batch usage -meter.record_batch(label_values, [(counter, 50)]) +meter.record_batch(label_set, [(counter, 50)]) print(counter_handle.data) # TODO: exporters diff --git a/opentelemetry-api/src/opentelemetry/metrics/__init__.py b/opentelemetry-api/src/opentelemetry/metrics/__init__.py index e866aa97cfa..465020606d2 100644 --- a/opentelemetry-api/src/opentelemetry/metrics/__init__.py +++ b/opentelemetry-api/src/opentelemetry/metrics/__init__.py @@ -26,7 +26,8 @@ """ -from typing import Callable, Optional, Sequence, Tuple, Type, TypeVar +import abc +from typing import Callable, Dict, Optional, Sequence, Tuple, Type, TypeVar from opentelemetry.util import loader @@ -67,6 +68,25 @@ def record(self, value: ValueT) -> None: """ +class LabelSet(abc.ABC): + """A canonicalized set of labels useful for preaggregation + + Re-usable LabelSet objects provide a potential optimization for scenarios + where handles might not be effective. For example, if the LabelSet will be + re-used but only used once per metrics, handles do not offer any + optimization. It may best to pre-compute a canonicalized LabelSet once and + re-use it with the direct calling convention. LabelSets are immutable and + should be opaque in implementation. + """ + + +class DefaultLabelSet(LabelSet): + """The default LabelSet. + + Used when no LabelSet implementation is available. + """ + + class Metric: """Base class for various types of metrics. @@ -74,7 +94,7 @@ class Metric: handle that the metric holds. """ - def get_handle(self, label_values: Sequence[str]) -> "object": + def get_handle(self, label_set: LabelSet) -> "object": """Gets a handle, used for repeated-use of metrics instruments. Handles are useful to reduce the cost of repeatedly recording a metric @@ -85,18 +105,18 @@ def get_handle(self, label_values: Sequence[str]) -> "object": a value was not provided are permitted. Args: - label_values: Values to associate with the returned handle. + label_set: `LabelSet` to associate with the returned handle. """ class DefaultMetric(Metric): """The default Metric used when no Metric implementation is available.""" - def get_handle(self, label_values: Sequence[str]) -> "DefaultMetricHandle": + def get_handle(self, label_set: LabelSet) -> "DefaultMetricHandle": """Gets a `DefaultMetricHandle`. Args: - label_values: The label values associated with the handle. + label_set: `LabelSet` to associate with the returned handle. """ return DefaultMetricHandle() @@ -104,15 +124,15 @@ def get_handle(self, label_values: Sequence[str]) -> "DefaultMetricHandle": class Counter(Metric): """A counter type metric that expresses the computation of a sum.""" - def get_handle(self, label_values: Sequence[str]) -> "CounterHandle": + def get_handle(self, label_set: LabelSet) -> "CounterHandle": """Gets a `CounterHandle`.""" return CounterHandle() - def add(self, label_values: Sequence[str], value: ValueT) -> None: + def add(self, label_set: LabelSet, value: ValueT) -> None: """Increases the value of the counter by ``value``. Args: - label_values: The label values associated with the metric. + label_set: `LabelSet` to associate with the returned handle. value: The value to add to the counter metric. """ @@ -126,15 +146,15 @@ class Gauge(Metric): the measurement interval is arbitrary. """ - def get_handle(self, label_values: Sequence[str]) -> "GaugeHandle": + def get_handle(self, label_set: LabelSet) -> "GaugeHandle": """Gets a `GaugeHandle`.""" return GaugeHandle() - def set(self, label_values: Sequence[str], value: ValueT) -> None: + def set(self, label_set: LabelSet, value: ValueT) -> None: """Sets the value of the gauge to ``value``. Args: - label_values: The label values associated with the metric. + label_set: `LabelSet` to associate with the returned handle. value: The value to set the gauge metric to. """ @@ -147,15 +167,15 @@ class Measure(Metric): Negative inputs will be discarded when monotonic is True. """ - def get_handle(self, label_values: Sequence[str]) -> "MeasureHandle": + def get_handle(self, label_set: LabelSet) -> "MeasureHandle": """Gets a `MeasureHandle` with a float value.""" return MeasureHandle() - def record(self, label_values: Sequence[str], value: ValueT) -> None: + def record(self, label_set: LabelSet, value: ValueT) -> None: """Records the ``value`` to the measure. Args: - label_values: The label values associated with the metric. + label_set: `LabelSet` to associate with the returned handle. value: The value to record to this measure metric. """ @@ -174,7 +194,7 @@ class Meter: def record_batch( self, - label_values: Sequence[str], + label_set: LabelSet, record_tuples: Sequence[Tuple["Metric", ValueT]], ) -> None: """Atomically records a batch of `Metric` and value pairs. @@ -184,7 +204,7 @@ def record_batch( match the key-value pairs in the label tuples. Args: - label_values: The label values associated with all measurements in + label_set: The `LabelSet` associated with all measurements in the batch. A measurement is a tuple, representing the `Metric` being recorded and the corresponding value to record. record_tuples: A sequence of pairs of `Metric` s and the @@ -211,8 +231,6 @@ def create_metric( 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. @@ -221,6 +239,17 @@ def create_metric( # pylint: disable=no-self-use return DefaultMetric() + def get_label_set(self, labels: Dict[str, str]) -> "LabelSet": + """Gets a `LabelSet` with the given labels. + + Args: + labels: A dictionary representing label key to label value pairs. + + Returns: A `LabelSet` object canonicalized using the given input. + """ + # pylint: disable=no-self-use + return DefaultLabelSet() + # Once https://github.com/python/mypy/issues/7092 is resolved, # the following type definition should be replaced with diff --git a/opentelemetry-api/tests/metrics/test_metrics.py b/opentelemetry-api/tests/metrics/test_metrics.py index 758534f2356..f8610a6fa4c 100644 --- a/opentelemetry-api/tests/metrics/test_metrics.py +++ b/opentelemetry-api/tests/metrics/test_metrics.py @@ -24,45 +24,57 @@ def setUp(self): def test_record_batch(self): counter = metrics.Counter() - self.meter.record_batch(("values"), ((counter, 1),)) + label_set = metrics.LabelSet() + self.meter.record_batch(label_set, ((counter, 1),)) def test_create_metric(self): metric = self.meter.create_metric("", "", "", float, metrics.Counter) self.assertIsInstance(metric, metrics.DefaultMetric) + def test_get_label_set(self): + metric = self.meter.get_label_set({}) + self.assertIsInstance(metric, metrics.DefaultLabelSet) + class TestMetrics(unittest.TestCase): def test_default(self): default = metrics.DefaultMetric() - handle = default.get_handle(("test", "test1")) + default_ls = metrics.DefaultLabelSet() + handle = default.get_handle(default_ls) self.assertIsInstance(handle, metrics.DefaultMetricHandle) def test_counter(self): counter = metrics.Counter() - handle = counter.get_handle(("test", "test1")) + label_set = metrics.LabelSet() + handle = counter.get_handle(label_set) self.assertIsInstance(handle, metrics.CounterHandle) def test_counter_add(self): counter = metrics.Counter() - counter.add(("value",), 1) + label_set = metrics.LabelSet() + counter.add(label_set, 1) def test_gauge(self): gauge = metrics.Gauge() - handle = gauge.get_handle(("test", "test1")) + label_set = metrics.LabelSet() + handle = gauge.get_handle(label_set) self.assertIsInstance(handle, metrics.GaugeHandle) def test_gauge_set(self): gauge = metrics.Gauge() - gauge.set(("value",), 1) + label_set = metrics.LabelSet() + gauge.set(label_set, 1) def test_measure(self): measure = metrics.Measure() - handle = measure.get_handle(("test", "test1")) + label_set = metrics.LabelSet() + handle = measure.get_handle(label_set) self.assertIsInstance(handle, metrics.MeasureHandle) def test_measure_record(self): measure = metrics.Measure() - measure.record(("value",), 1) + label_set = metrics.LabelSet() + measure.record(label_set, 1) def test_default_handle(self): metrics.DefaultMetricHandle() diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py index e6f5d53166b..bb495bc1be3 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py @@ -13,7 +13,8 @@ # limitations under the License. import logging -from typing import Sequence, Tuple, Type +from collections import OrderedDict +from typing import Dict, Sequence, Tuple, Type from opentelemetry import metrics as metrics_api from opentelemetry.util import time_ns @@ -21,6 +22,14 @@ logger = logging.getLogger(__name__) +# pylint: disable=redefined-outer-name +class LabelSet(metrics_api.LabelSet): + """See `opentelemetry.metrics.LabelSet.""" + + def __init__(self, labels: Dict[str, str] = None): + self.labels = labels + + class BaseHandle: def __init__( self, @@ -107,14 +116,14 @@ def __init__( self.monotonic = monotonic self.handles = {} - def get_handle(self, label_values: Sequence[str]) -> BaseHandle: + def get_handle(self, label_set: LabelSet) -> BaseHandle: """See `opentelemetry.metrics.Metric.get_handle`.""" - handle = self.handles.get(label_values) + handle = self.handles.get(label_set) if not handle: handle = self.HANDLE_TYPE( self.value_type, self.enabled, self.monotonic ) - self.handles[label_values] = handle + self.handles[label_set] = handle return handle def __repr__(self): @@ -155,11 +164,9 @@ def __init__( monotonic=monotonic, ) - def add( - self, label_values: Sequence[str], value: metrics_api.ValueT - ) -> None: + def add(self, label_set: LabelSet, value: metrics_api.ValueT) -> None: """See `opentelemetry.metrics.Counter.add`.""" - self.get_handle(label_values).add(value) + self.get_handle(label_set).add(value) UPDATE_FUNCTION = add @@ -193,11 +200,9 @@ def __init__( monotonic=monotonic, ) - def set( - self, label_values: Sequence[str], value: metrics_api.ValueT - ) -> None: + def set(self, label_set: LabelSet, value: metrics_api.ValueT) -> None: """See `opentelemetry.metrics.Gauge.set`.""" - self.get_handle(label_values).set(value) + self.get_handle(label_set).set(value) UPDATE_FUNCTION = set @@ -231,26 +236,31 @@ def __init__( monotonic=monotonic, ) - def record( - self, label_values: Sequence[str], value: metrics_api.ValueT - ) -> None: + def record(self, label_set: LabelSet, value: metrics_api.ValueT) -> None: """See `opentelemetry.metrics.Measure.record`.""" - self.get_handle(label_values).record(value) + self.get_handle(label_set).record(value) UPDATE_FUNCTION = record +# Used when getting a LabelSet with no key/values +EMPTY_LABEL_SET = LabelSet() + + class Meter(metrics_api.Meter): """See `opentelemetry.metrics.Meter`.""" + def __init__(self): + self.labels = {} + def record_batch( self, - label_values: Sequence[str], + label_set: LabelSet, record_tuples: Sequence[Tuple[metrics_api.Metric, metrics_api.ValueT]], ) -> None: """See `opentelemetry.metrics.Meter.record_batch`.""" for metric, value in record_tuples: - metric.UPDATE_FUNCTION(label_values, value) + metric.UPDATE_FUNCTION(label_set, value) def create_metric( self, @@ -275,5 +285,22 @@ def create_metric( monotonic=monotonic, ) + def get_label_set(self, labels: Dict[str, str]): + """See `opentelemetry.metrics.Meter.create_metric`. + + This implementation encodes the labels to use as a map key. + + Args: + labels: The dictionary of label keys to label values. + """ + 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() diff --git a/opentelemetry-sdk/tests/metrics/export/test_export.py b/opentelemetry-sdk/tests/metrics/export/test_export.py index ca8e8a36311..4d8e6df8575 100644 --- a/opentelemetry-sdk/tests/metrics/export/test_export.py +++ b/opentelemetry-sdk/tests/metrics/export/test_export.py @@ -22,19 +22,22 @@ class TestConsoleMetricsExporter(unittest.TestCase): # pylint: disable=no-self-use def test_export(self): + meter = metrics.Meter() exporter = ConsoleMetricsExporter() metric = metrics.Counter( "available memory", "available memory", "bytes", int, + meter, ("environment",), ) - label_values = ("staging",) - handle = metric.get_handle(label_values) + 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_values, handle + ConsoleMetricsExporter.__name__, metric, label_set, handle ) with mock.patch("sys.stdout") as mock_stdout: - exporter.export([(metric, label_values)]) + exporter.export([(metric, label_set)]) mock_stdout.write.assert_any_call(result) diff --git a/opentelemetry-sdk/tests/metrics/test_metrics.py b/opentelemetry-sdk/tests/metrics/test_metrics.py index cc37bc1a8aa..81e6dd2c9d5 100644 --- a/opentelemetry-sdk/tests/metrics/test_metrics.py +++ b/opentelemetry-sdk/tests/metrics/test_metrics.py @@ -27,35 +27,46 @@ def test_extends_api(self): def test_record_batch(self): meter = metrics.Meter() label_keys = ("key1",) - label_values = ("value1",) - counter = metrics.Counter("name", "desc", "unit", float, label_keys) + counter = metrics.Counter( + "name", "desc", "unit", float, meter, label_keys + ) + kvp = {"key1": "value1"} + label_set = meter.get_label_set(kvp) record_tuples = [(counter, 1.0)] - meter.record_batch(label_values, record_tuples) - self.assertEqual(counter.get_handle(label_values).data, 1.0) + meter.record_batch(label_set, record_tuples) + self.assertEqual(counter.get_handle(label_set).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) + kvp = {"key1": "value1", "key2": "value2", "key3": "value3"} + label_set = meter.get_label_set(kvp) + counter = metrics.Counter( + "name", "desc", "unit", float, meter, label_keys + ) gauge = metrics.Gauge("name", "desc", "unit", int, label_keys) - measure = metrics.Measure("name", "desc", "unit", float, 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_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) + 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) def test_record_batch_exists(self): meter = metrics.Meter() label_keys = ("key1",) - label_values = ("value1",) - counter = metrics.Counter("name", "desc", "unit", float, label_keys) - counter.add(label_values, 1.0) - handle = counter.get_handle(label_values) + kvp = {"key1": "value1"} + label_set = meter.get_label_set(kvp) + counter = metrics.Counter( + "name", "desc", "unit", float, meter, label_keys + ) + counter.add(label_set, 1.0) + handle = counter.get_handle(label_set) record_tuples = [(counter, 1.0)] - meter.record_batch(label_values, record_tuples) - self.assertEqual(counter.get_handle(label_values), handle) + meter.record_batch(label_set, record_tuples) + self.assertEqual(counter.get_handle(label_set), handle) self.assertEqual(handle.data, 2.0) def test_create_metric(self): @@ -85,41 +96,72 @@ def test_create_measure(self): self.assertEqual(measure.value_type, float) self.assertEqual(measure.name, "name") + 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) + + def test_get_label_set_empty(self): + meter = metrics.Meter() + kvp = {} + 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): + meter = metrics.Meter() 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) + metric = _type("name", "desc", "unit", int, meter, ("key",)) + kvp = {"key": "value"} + label_set = meter.get_label_set(kvp) + handle = metric.get_handle(label_set) + self.assertEqual(metric.handles.get(label_set), handle) class TestCounter(unittest.TestCase): def test_add(self): + meter = metrics.Meter() metric = metrics.Counter("name", "desc", "unit", int, ("key",)) - handle = metric.get_handle(("value",)) - metric.add(("value",), 3) - metric.add(("value",), 2) + 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) class TestGauge(unittest.TestCase): def test_set(self): + meter = metrics.Meter() metric = metrics.Gauge("name", "desc", "unit", int, ("key",)) - handle = metric.get_handle(("value",)) - metric.set(("value",), 3) + 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(("value",), 2) + metric.set(label_set, 2) self.assertEqual(handle.data, 2) class TestMeasure(unittest.TestCase): def test_record(self): + meter = metrics.Meter() metric = metrics.Measure("name", "desc", "unit", int, ("key",)) - handle = metric.get_handle(("value",)) - metric.record(("value",), 3) + 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) diff --git a/tox.ini b/tox.ini index 9d8e679dfa0..c1511133389 100644 --- a/tox.ini +++ b/tox.ini @@ -27,7 +27,7 @@ deps = test: pytest!=5.2.3 coverage: pytest!=5.2.3 coverage: pytest-cov - mypy,mypyinstalled: mypy~=0.740 + mypy,mypyinstalled: mypy==0.740 setenv = mypy: MYPYPATH={toxinidir}/opentelemetry-api/src/