Skip to content

Commit

Permalink
Implement LabelSet for metrics (#258)
Browse files Browse the repository at this point in the history
The primary purpose of LabelSets are to have an optimal way of re-using handles
with the same label values. We achieve this by having the keys and values of
the labels encoded and stored in each LabelSet instance, so we can have an easy
lookup to the corresponding handle for each metric instrument.
  • Loading branch information
lzchen authored and c24t committed Dec 3, 2019
1 parent d3bb228 commit 4ead3f4
Show file tree
Hide file tree
Showing 7 changed files with 195 additions and 82 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
65 changes: 47 additions & 18 deletions opentelemetry-api/src/opentelemetry/metrics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -67,14 +68,33 @@ 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.
Metric class that inherit from this class are specialized with the type of
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
Expand All @@ -85,34 +105,34 @@ 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()


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.
"""

Expand All @@ -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.
"""

Expand All @@ -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.
"""

Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down
28 changes: 20 additions & 8 deletions opentelemetry-api/tests/metrics/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
63 changes: 45 additions & 18 deletions opentelemetry-sdk/src/opentelemetry/sdk/metrics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,23 @@
# 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

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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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()
Loading

0 comments on commit 4ead3f4

Please sign in to comment.