Skip to content

Commit 800e2d7

Browse files
authored
Implement histogram user metric for python SDK (#36335)
1 parent b9c2772 commit 800e2d7

8 files changed

Lines changed: 238 additions & 14 deletions

File tree

sdks/python/apache_beam/internal/metrics/cells.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
from apache_beam.metrics.cells import MetricCell
3232
from apache_beam.metrics.cells import MetricCellFactory
33+
from apache_beam.portability.api import metrics_pb2
3334
from apache_beam.utils.histogram import Histogram
3435

3536
if TYPE_CHECKING:
@@ -66,10 +67,12 @@ def get_cumulative(self) -> 'HistogramData':
6667
return self.data.get_cumulative()
6768

6869
def to_runner_api_monitoring_info(self, name, transform_id):
69-
# Histogram metric is currently worker-local and internal
70-
# use only. This method should be implemented when runners
71-
# support Histogram metric reporting.
72-
return None
70+
from apache_beam.metrics import monitoring_infos
71+
return monitoring_infos.user_histogram(
72+
name.namespace,
73+
name.name,
74+
self.get_cumulative(),
75+
ptransform=transform_id)
7376

7477

7578
class HistogramCellFactory(MetricCellFactory):
@@ -150,3 +153,13 @@ def combine(self, other: Optional['HistogramData']) -> 'HistogramData':
150153
@staticmethod
151154
def identity_element(bucket_type) -> 'HistogramData':
152155
return HistogramData(Histogram(bucket_type))
156+
157+
def to_proto(self) -> metrics_pb2.HistogramValue:
158+
return self.histogram.to_runner_api()
159+
160+
@classmethod
161+
def from_proto(cls, proto: metrics_pb2.HistogramValue):
162+
return cls(Histogram.from_runner_api(proto))
163+
164+
def get_result(self):
165+
return self.histogram

sdks/python/apache_beam/internal/metrics/metric_test.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,21 @@
1717

1818
# pytype: skip-file
1919

20+
import re
2021
import unittest
2122

2223
from mock import patch
2324

25+
import apache_beam as beam
2426
from apache_beam.internal.metrics.cells import HistogramCellFactory
2527
from apache_beam.internal.metrics.metric import Metrics as InternalMetrics
2628
from apache_beam.internal.metrics.metric import MetricLogger
2729
from apache_beam.metrics.execution import MetricsContainer
2830
from apache_beam.metrics.execution import MetricsEnvironment
2931
from apache_beam.metrics.metric import Metrics
32+
from apache_beam.metrics.metric import MetricsFilter
3033
from apache_beam.metrics.metricbase import MetricName
34+
from apache_beam.runners.direct.direct_runner import BundleBasedDirectRunner
3135
from apache_beam.runners.worker import statesampler
3236
from apache_beam.utils import counters
3337
from apache_beam.utils.histogram import LinearBucket
@@ -87,5 +91,40 @@ def test_create_process_wide(self):
8791
sampler.stop()
8892

8993

94+
class HistogramTest(unittest.TestCase):
95+
def test_histogram(self):
96+
class WordExtractingDoFn(beam.DoFn):
97+
def __init__(self):
98+
super().__init__()
99+
self.word_lengths_dist = InternalMetrics.histogram(
100+
self.__class__,
101+
'latency_histogram_ms',
102+
LinearBucket(0, 1, num_buckets=10))
103+
104+
def process(self, element):
105+
text_line = element.strip()
106+
words = re.findall(r'[\w\']+', text_line, re.UNICODE)
107+
for w in words:
108+
self.word_lengths_dist.update(len(w))
109+
return words
110+
111+
with beam.Pipeline(runner=BundleBasedDirectRunner()) as p:
112+
lines = p | 'read' >> beam.Create(["x x x yyyyyy yyyyyy yyyyyy"])
113+
_ = (
114+
lines
115+
| 'split' >>
116+
(beam.ParDo(WordExtractingDoFn()).with_output_types(str)))
117+
118+
result = p.result
119+
120+
filter = MetricsFilter().with_name('latency_histogram_ms')
121+
query_result = result.metrics().query(filter)
122+
histogram = query_result['histograms'][0].committed
123+
assert histogram._buckets == {1: 3, 6: 3}
124+
assert histogram.total_count() == 6
125+
assert 1 < histogram.get_linear_interpolation(0.50) < 3
126+
assert histogram.get_linear_interpolation(0.99) > 3
127+
128+
90129
if __name__ == '__main__':
91130
unittest.main()

sdks/python/apache_beam/metrics/execution.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
from typing import Union
4343
from typing import cast
4444

45+
from apache_beam.internal.metrics.cells import HistogramCellFactory
46+
from apache_beam.internal.metrics.cells import HistogramData
4547
from apache_beam.metrics import monitoring_infos
4648
from apache_beam.metrics.cells import BoundedTrieCell
4749
from apache_beam.metrics.cells import CounterCell
@@ -310,8 +312,14 @@ def get_cumulative(self):
310312
for k, v in self.metrics.items() if k.cell_type == BoundedTrieCell
311313
}
312314

315+
histograms = {
316+
MetricKey(self.step_name, k.metric_name): v.get_cumulative()
317+
for k, v in self.metrics.items()
318+
if isinstance(k.cell_type, HistogramCellFactory)
319+
}
320+
313321
return MetricUpdates(
314-
counters, distributions, gauges, string_sets, bounded_tries)
322+
counters, distributions, gauges, string_sets, bounded_tries, histograms)
315323

316324
def to_runner_api(self):
317325
return [
@@ -365,6 +373,7 @@ def __init__(
365373
gauges=None, # type: Optional[Dict[MetricKey, GaugeData]]
366374
string_sets=None, # type: Optional[Dict[MetricKey, StringSetData]]
367375
bounded_tries=None, # type: Optional[Dict[MetricKey, BoundedTrieData]]
376+
histograms=None, # type: Optional[Dict[MetricKey, HistogramData]]
368377
):
369378
# type: (...) -> None
370379

@@ -382,3 +391,4 @@ def __init__(
382391
self.gauges = gauges or {}
383392
self.string_sets = string_sets or {}
384393
self.bounded_tries = bounded_tries or {}
394+
self.histograms = histograms or {}

sdks/python/apache_beam/metrics/metric.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ class MetricResults(object):
195195
GAUGES = "gauges"
196196
STRINGSETS = "string_sets"
197197
BOUNDED_TRIES = "bounded_tries"
198+
HISTOGRAMS = "histograms"
198199

199200
@staticmethod
200201
def _matches_name(filter: 'MetricsFilter', metric_key: 'MetricKey') -> bool:

sdks/python/apache_beam/metrics/monitoring_infos.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
from apache_beam.coders import coder_impl
2929
from apache_beam.coders import coders
30+
from apache_beam.internal.metrics.cells import HistogramData
3031
from apache_beam.metrics.cells import BoundedTrieData
3132
from apache_beam.metrics.cells import DistributionData
3233
from apache_beam.metrics.cells import DistributionResult
@@ -47,6 +48,7 @@
4748
common_urns.monitoring_info_specs.FINISH_BUNDLE_MSECS.spec.urn)
4849
TOTAL_MSECS_URN = common_urns.monitoring_info_specs.TOTAL_MSECS.spec.urn
4950
USER_COUNTER_URN = common_urns.monitoring_info_specs.USER_SUM_INT64.spec.urn
51+
USER_HISTOGRAM_URN = common_urns.monitoring_info_specs.USER_HISTOGRAM.spec.urn
5052
USER_DISTRIBUTION_URN = (
5153
common_urns.monitoring_info_specs.USER_DISTRIBUTION_INT64.spec.urn)
5254
USER_GAUGE_URN = common_urns.monitoring_info_specs.USER_LATEST_INT64.spec.urn
@@ -59,6 +61,7 @@
5961
USER_GAUGE_URN,
6062
USER_STRING_SET_URN,
6163
USER_BOUNDED_TRIE_URN,
64+
USER_HISTOGRAM_URN
6265
])
6366
WORK_REMAINING_URN = common_urns.monitoring_info_specs.WORK_REMAINING.spec.urn
6467
WORK_COMPLETED_URN = common_urns.monitoring_info_specs.WORK_COMPLETED.spec.urn
@@ -77,12 +80,14 @@
7780
PROGRESS_TYPE = common_urns.monitoring_info_types.PROGRESS_TYPE.urn
7881
STRING_SET_TYPE = common_urns.monitoring_info_types.SET_STRING_TYPE.urn
7982
BOUNDED_TRIE_TYPE = common_urns.monitoring_info_types.BOUNDED_TRIE_TYPE.urn
83+
HISTOGRAM_TYPE = common_urns.monitoring_info_types.HISTOGRAM.urn
8084

8185
COUNTER_TYPES = set([SUM_INT64_TYPE])
8286
DISTRIBUTION_TYPES = set([DISTRIBUTION_INT64_TYPE])
8387
GAUGE_TYPES = set([LATEST_INT64_TYPE])
8488
STRING_SET_TYPES = set([STRING_SET_TYPE])
8589
BOUNDED_TRIE_TYPES = set([BOUNDED_TRIE_TYPE])
90+
HISTOGRAM_TYPES = set([HISTOGRAM_TYPE])
8691

8792
# TODO(migryz) extract values from beam_fn_api.proto::MonitoringInfoLabels
8893
PCOLLECTION_LABEL = (
@@ -177,6 +182,14 @@ def extract_bounded_trie_value(monitoring_info_proto):
177182
metrics_pb2.BoundedTrie.FromString(monitoring_info_proto.payload))
178183

179184

185+
def extract_histogram_value(monitoring_info_proto):
186+
if not is_histogram(monitoring_info_proto):
187+
raise ValueError('Unsupported type %s' % monitoring_info_proto.type)
188+
189+
return HistogramData.from_proto(
190+
metrics_pb2.HistogramValue.FromString(monitoring_info_proto.payload))
191+
192+
180193
def create_labels(ptransform=None, namespace=None, name=None, pcollection=None):
181194
"""Create the label dictionary based on the provided values.
182195
@@ -334,6 +347,25 @@ def user_set_string(namespace, name, metric, ptransform=None):
334347
USER_STRING_SET_URN, STRING_SET_TYPE, metric, labels)
335348

336349

350+
def user_histogram(namespace, name, metric: HistogramData, ptransform=None):
351+
"""Return the histogram monitoring info for the URN, metric and labels.
352+
353+
Args:
354+
namespace: User-defined namespace of Histogram.
355+
name: Name of Histogram.
356+
metric: The Histogram representing the metrics.
357+
ptransform: The ptransform id used as a label.
358+
"""
359+
labels = create_labels(ptransform=ptransform, namespace=namespace, name=name)
360+
metric_proto = metric.to_proto()
361+
362+
return create_monitoring_info(
363+
USER_HISTOGRAM_URN,
364+
HISTOGRAM_TYPE,
365+
metric_proto.SerializeToString(),
366+
labels)
367+
368+
337369
def user_bounded_trie(namespace, name, metric, ptransform=None):
338370
"""Return the string set monitoring info for the URN, metric and labels.
339371
@@ -353,7 +385,7 @@ def user_bounded_trie(namespace, name, metric, ptransform=None):
353385

354386
def create_monitoring_info(
355387
urn, type_urn, payload, labels=None) -> metrics_pb2.MonitoringInfo:
356-
"""Return the gauge monitoring info for the URN, type, metric and labels.
388+
"""Return the monitoring info for the URN, type, metric and labels.
357389
358390
Args:
359391
urn: The URN of the monitoring info/metric.
@@ -386,6 +418,11 @@ def is_distribution(monitoring_info_proto):
386418
return monitoring_info_proto.type in DISTRIBUTION_TYPES
387419

388420

421+
def is_histogram(monitoring_info_proto):
422+
"""Returns true if the monitoring info is a distrbution metric."""
423+
return monitoring_info_proto.type in HISTOGRAM_TYPES
424+
425+
389426
def is_string_set(monitoring_info_proto):
390427
"""Returns true if the monitoring info is a StringSet metric."""
391428
return monitoring_info_proto.type in STRING_SET_TYPES

sdks/python/apache_beam/metrics/monitoring_infos_test.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@
1818

1919
import unittest
2020

21+
from apache_beam.internal.metrics.cells import HistogramCell
22+
from apache_beam.internal.metrics.cells import HistogramData
2123
from apache_beam.metrics import monitoring_infos
2224
from apache_beam.metrics.cells import CounterCell
2325
from apache_beam.metrics.cells import GaugeCell
2426
from apache_beam.metrics.cells import StringSetCell
27+
from apache_beam.utils.histogram import Histogram
28+
from apache_beam.utils.histogram import LinearBucket
2529

2630

2731
class MonitoringInfosTest(unittest.TestCase):
@@ -76,6 +80,17 @@ def test_parse_namespace_and_name_for_user_string_set_metric(self):
7680
self.assertEqual(namespace, "stringsetnamespace")
7781
self.assertEqual(name, "stringsetname")
7882

83+
def test_parse_namespace_and_name_for_user_histogram_metric(self):
84+
urn = monitoring_infos.USER_HISTOGRAM_URN
85+
labels = {}
86+
labels[monitoring_infos.NAMESPACE_LABEL] = "histogramnamespace"
87+
labels[monitoring_infos.NAME_LABEL] = "histogramname"
88+
input = monitoring_infos.create_monitoring_info(
89+
urn, "typeurn", None, labels)
90+
namespace, name = monitoring_infos.parse_namespace_and_name(input)
91+
self.assertEqual(name, "histogramname")
92+
self.assertEqual(namespace, "histogramnamespace")
93+
7994
def test_int64_user_gauge(self):
8095
metric = GaugeCell().get_cumulative()
8196
result = monitoring_infos.int64_user_gauge(
@@ -130,6 +145,26 @@ def test_user_set_string(self):
130145
self.assertEqual(set(), string_set_value)
131146
self.assertEqual(result.labels, expected_labels)
132147

148+
def test_user_histogram(self):
149+
datapoints = [5, 50, 90]
150+
expected_labels = {}
151+
expected_labels[monitoring_infos.NAMESPACE_LABEL] = "histogramnamespace"
152+
expected_labels[monitoring_infos.NAME_LABEL] = "histogramname"
153+
154+
cell = HistogramCell(LinearBucket(0, 1, 100))
155+
for point in datapoints:
156+
cell.update(point)
157+
metric = cell.get_cumulative()
158+
result = monitoring_infos.user_histogram(
159+
'histogramnamespace', 'histogramname', metric)
160+
histogramvalue = monitoring_infos.extract_histogram_value(result)
161+
162+
self.assertEqual(result.labels, expected_labels)
163+
exp_histogram = Histogram(LinearBucket(0, 1, 100))
164+
for point in datapoints:
165+
exp_histogram.record(point)
166+
self.assertEqual(HistogramData(exp_histogram), histogramvalue)
167+
133168

134169
if __name__ == '__main__':
135170
unittest.main()

sdks/python/apache_beam/runners/direct/direct_metrics.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,27 @@ def result(self, x):
8080
return int(x)
8181

8282

83+
_IDENTITY_HISTOGRAM = object()
84+
85+
86+
class HistogramAggregator(MetricAggregator):
87+
@staticmethod
88+
def identity_element():
89+
return _IDENTITY_HISTOGRAM
90+
91+
def combine(self, x, y):
92+
if x is _IDENTITY_HISTOGRAM:
93+
return y
94+
if y is _IDENTITY_HISTOGRAM:
95+
return x
96+
return x.combine(y)
97+
98+
def result(self, x):
99+
if x is _IDENTITY_HISTOGRAM:
100+
raise TypeError
101+
return x.get_result()
102+
103+
83104
class GenericAggregator(MetricAggregator):
84105
def __init__(self, data_class):
85106
self._data_class = data_class
@@ -105,6 +126,7 @@ def __init__(self):
105126
lambda: DirectMetric(GenericAggregator(StringSetData)))
106127
self._bounded_tries = defaultdict(
107128
lambda: DirectMetric(GenericAggregator(BoundedTrieData)))
129+
self._histograms = defaultdict(lambda: DirectMetric(HistogramAggregator()))
108130

109131
def _apply_operation(self, bundle, updates, op):
110132
for k, v in updates.counters.items():
@@ -122,6 +144,9 @@ def _apply_operation(self, bundle, updates, op):
122144
for k, v in updates.bounded_tries.items():
123145
op(self._bounded_tries[k], bundle, v)
124146

147+
for k, v in updates.histograms.items():
148+
op(self._histograms[k], bundle, v)
149+
125150
def commit_logical(self, bundle, updates):
126151
op = lambda obj, bundle, update: obj.commit_logical(bundle, update)
127152
self._apply_operation(bundle, updates, op)
@@ -170,13 +195,21 @@ def query(self, filter=None):
170195
v.extract_latest_attempted())
171196
for k, v in self._bounded_tries.items() if self.matches(filter, k)
172197
]
198+
histograms = [
199+
MetricResult(
200+
MetricKey(k.step, k.metric),
201+
v.extract_committed(),
202+
v.extract_latest_attempted()) for k, v in self._histograms.items()
203+
if self.matches(filter, k)
204+
]
173205

174206
return {
175207
self.COUNTERS: counters,
176208
self.DISTRIBUTIONS: distributions,
177209
self.GAUGES: gauges,
178210
self.STRINGSETS: string_sets,
179211
self.BOUNDED_TRIES: bounded_tries,
212+
self.HISTOGRAMS: histograms,
180213
}
181214

182215

0 commit comments

Comments
 (0)