| # | |
| # Licensed to the Apache Software Foundation (ASF) under one or more | |
| # contributor license agreements. See the NOTICE file distributed with | |
| # this work for additional information regarding copyright ownership. | |
| # The ASF licenses this file to You 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. | |
| # | |
| """ | |
| User-facing classes for Metrics API. | |
| The classes in this file allow users to define and use metrics to be collected | |
| and displayed as part of their pipeline execution. | |
| - Metrics - This class lets pipeline and transform writers create and access | |
| metric objects such as counters, distributions, etc. | |
| """ | |
| # pytype: skip-file | |
| # mypy: disallow-untyped-defs | |
| import logging | |
| import re | |
| from typing import TYPE_CHECKING | |
| from typing import Iterable | |
| from typing import Iterator | |
| from typing import Optional | |
| from typing import Union | |
| from apache_beam.metrics import cells | |
| from apache_beam.metrics.cells import HistogramCellFactory | |
| from apache_beam.metrics.execution import MetricResult | |
| from apache_beam.metrics.execution import MetricUpdater | |
| from apache_beam.metrics.metricbase import BoundedTrie | |
| from apache_beam.metrics.metricbase import Counter | |
| from apache_beam.metrics.metricbase import Distribution | |
| from apache_beam.metrics.metricbase import Gauge | |
| from apache_beam.metrics.metricbase import Histogram | |
| from apache_beam.metrics.metricbase import MetricName | |
| from apache_beam.metrics.metricbase import StringSet | |
| if TYPE_CHECKING: | |
| from apache_beam.internal.metrics.metric import MetricLogger | |
| from apache_beam.metrics.execution import MetricKey | |
| from apache_beam.metrics.metricbase import Metric | |
| from apache_beam.utils.histogram import BucketType | |
| __all__ = ['Metrics', 'MetricsFilter', 'Lineage'] | |
| _LOGGER = logging.getLogger(__name__) | |
| class Metrics(object): | |
| """Lets users create/access metric objects during pipeline execution.""" | |
| @staticmethod | |
| def get_namespace(namespace: Union[type, str]) -> str: | |
| if isinstance(namespace, type): | |
| return '{}.{}'.format(namespace.__module__, namespace.__name__) | |
| elif isinstance(namespace, str): | |
| return namespace | |
| else: | |
| raise ValueError('Unknown namespace type') | |
| @staticmethod | |
| def counter( | |
| namespace: Union[type, str], name: str) -> 'Metrics.DelegatingCounter': | |
| """Obtains or creates a Counter metric. | |
| Args: | |
| namespace: A class or string that gives the namespace to a metric | |
| name: A string that gives a unique name to a metric | |
| Returns: | |
| A Counter object. | |
| """ | |
| namespace = Metrics.get_namespace(namespace) | |
| return Metrics.DelegatingCounter(MetricName(namespace, name)) | |
| @staticmethod | |
| def distribution( | |
| namespace: Union[type, str], | |
| name: str, | |
| process_wide: bool = False) -> 'Metrics.DelegatingDistribution': | |
| """Obtains or creates a Distribution metric. | |
| Distribution metrics are restricted to integer-only distributions. | |
| Args: | |
| namespace: A class or string that gives the namespace to a metric | |
| name: A string that gives a unique name to a metric | |
| Returns: | |
| A Distribution object. | |
| """ | |
| namespace = Metrics.get_namespace(namespace) | |
| return Metrics.DelegatingDistribution( | |
| MetricName(namespace, name), process_wide=process_wide) | |
| @staticmethod | |
| def gauge( | |
| namespace: Union[type, str], | |
| name: str, | |
| process_wide: bool = False) -> 'Metrics.DelegatingGauge': | |
| """Obtains or creates a Gauge metric. | |
| Gauge metrics are restricted to integer-only values. | |
| Args: | |
| namespace: A class or string that gives the namespace to a metric | |
| name: A string that gives a unique name to a metric | |
| process_wide: Whether or not the metric is specific to the current bundle | |
| or should be calculated for the entire process. | |
| Returns: | |
| A Gauge object. | |
| """ | |
| namespace = Metrics.get_namespace(namespace) | |
| return Metrics.DelegatingGauge( | |
| MetricName(namespace, name), process_wide=process_wide) | |
| @staticmethod | |
| def string_set( | |
| namespace: Union[type, str], name: str) -> 'Metrics.DelegatingStringSet': | |
| """Obtains or creates a String set metric. | |
| String set metrics are restricted to string values. | |
| Args: | |
| namespace: A class or string that gives the namespace to a metric | |
| name: A string that gives a unique name to a metric | |
| Returns: | |
| A StringSet object. | |
| """ | |
| namespace = Metrics.get_namespace(namespace) | |
| return Metrics.DelegatingStringSet(MetricName(namespace, name)) | |
| @staticmethod | |
| def bounded_trie( | |
| namespace: Union[type, str], | |
| name: str) -> 'Metrics.DelegatingBoundedTrie': | |
| """Obtains or creates a Bounded Trie metric. | |
| Args: | |
| namespace: A class or string that gives the namespace to a metric | |
| name: A string that gives a unique name to a metric | |
| Returns: | |
| A BoundedTrie object. | |
| """ | |
| namespace = Metrics.get_namespace(namespace) | |
| return Metrics.DelegatingBoundedTrie(MetricName(namespace, name)) | |
| @staticmethod | |
| def histogram( | |
| namespace: Union[type, str], | |
| name: str, | |
| bucket_type: 'BucketType', | |
| logger: Optional['MetricLogger'] = None) -> 'Metrics.DelegatingHistogram': | |
| """Obtains or creates a Histogram metric. | |
| Args: | |
| namespace: A class or string that gives the namespace to a metric | |
| name: A string that gives a unique name to a metric | |
| bucket_type: A type of bucket used in a histogram. A subclass of | |
| apache_beam.utils.histogram.BucketType | |
| logger: MetricLogger for logging locally aggregated metric | |
| Returns: | |
| A Histogram object. | |
| """ | |
| namespace = Metrics.get_namespace(namespace) | |
| return Metrics.DelegatingHistogram( | |
| MetricName(namespace, name), bucket_type, logger) | |
| class DelegatingHistogram(Histogram): | |
| """Metrics Histogram that Delegates functionality to MetricsEnvironment.""" | |
| def __init__( | |
| self, | |
| metric_name: MetricName, | |
| bucket_type: 'BucketType', | |
| logger: Optional['MetricLogger']) -> None: | |
| super().__init__(metric_name) | |
| self.metric_name = metric_name | |
| self.cell_type = HistogramCellFactory(bucket_type) | |
| self.logger = logger | |
| self.updater = MetricUpdater(self.cell_type, self.metric_name) | |
| def update(self, value: object) -> None: | |
| self.updater(value) | |
| if self.logger: | |
| self.logger.update(self.cell_type, self.metric_name, value) | |
| class DelegatingCounter(Counter): | |
| """Metrics Counter that Delegates functionality to MetricsEnvironment.""" | |
| def __init__( | |
| self, metric_name: MetricName, process_wide: bool = False) -> None: | |
| super().__init__(metric_name) | |
| self.inc = MetricUpdater( # type: ignore[method-assign] | |
| cells.CounterCell, | |
| metric_name, | |
| default_value=1, | |
| process_wide=process_wide) | |
| class DelegatingDistribution(Distribution): | |
| """Metrics Distribution Delegates functionality to MetricsEnvironment.""" | |
| def __init__( | |
| self, metric_name: MetricName, process_wide: bool = False) -> None: | |
| super().__init__(metric_name) | |
| self.update = MetricUpdater(cells.DistributionCell, metric_name, process_wide=process_wide) # type: ignore[method-assign] | |
| class DelegatingGauge(Gauge): | |
| """Metrics Gauge that Delegates functionality to MetricsEnvironment.""" | |
| def __init__( | |
| self, metric_name: MetricName, process_wide: bool = False) -> None: | |
| super().__init__(metric_name) | |
| self.set = MetricUpdater( # type: ignore[method-assign] | |
| cells.GaugeCell, | |
| metric_name, | |
| process_wide=process_wide) | |
| class DelegatingStringSet(StringSet): | |
| """Metrics StringSet that Delegates functionality to MetricsEnvironment.""" | |
| def __init__(self, metric_name: MetricName) -> None: | |
| super().__init__(metric_name) | |
| self.add = MetricUpdater(cells.StringSetCell, metric_name) # type: ignore[method-assign] | |
| class DelegatingBoundedTrie(BoundedTrie): | |
| """Metrics BoundedTrie that Delegates functionality to MetricsEnvironment.""" | |
| def __init__(self, metric_name: MetricName) -> None: | |
| super().__init__(metric_name) | |
| self.add = MetricUpdater(cells.BoundedTrieCell, metric_name) # type: ignore[method-assign] | |
| class MetricResults(object): | |
| COUNTERS = "counters" | |
| DISTRIBUTIONS = "distributions" | |
| GAUGES = "gauges" | |
| STRINGSETS = "string_sets" | |
| BOUNDED_TRIES = "bounded_tries" | |
| HISTOGRAMS = "histograms" | |
| @staticmethod | |
| def _matches_name(filter: 'MetricsFilter', metric_key: 'MetricKey') -> bool: | |
| if ((filter.namespaces and | |
| metric_key.metric.namespace not in filter.namespaces) or | |
| (filter.names and metric_key.metric.name not in filter.names)): | |
| return False | |
| else: | |
| return True | |
| @staticmethod | |
| def _is_sub_list(needle: list[str], haystack: list[str]) -> bool: | |
| """True iff `needle` is a sub-list of `haystack` (i.e. a contiguous slice | |
| of `haystack` exactly matches `needle`""" | |
| needle_len = len(needle) | |
| haystack_len = len(haystack) | |
| for i in range(0, haystack_len - needle_len + 1): | |
| if haystack[i:i + needle_len] == needle: | |
| return True | |
| return False | |
| @staticmethod | |
| def _matches_sub_path(actual_scope: str, filter_scope: str) -> bool: | |
| """True iff the '/'-delimited pieces of filter_scope exist as a sub-list | |
| of the '/'-delimited pieces of actual_scope""" | |
| return bool( | |
| actual_scope and MetricResults._is_sub_list( | |
| filter_scope.split('/'), actual_scope.split('/'))) | |
| @staticmethod | |
| def _matches_scope(filter: 'MetricsFilter', metric_key: 'MetricKey') -> bool: | |
| if not filter.steps: | |
| return True | |
| for step in filter.steps: | |
| if MetricResults._matches_sub_path(metric_key.step, step): | |
| return True | |
| return False | |
| @staticmethod | |
| def matches( | |
| filter: Optional['MetricsFilter'], metric_key: 'MetricKey') -> bool: | |
| if filter is None: | |
| return True | |
| if (MetricResults._matches_name(filter, metric_key) and | |
| MetricResults._matches_scope(filter, metric_key)): | |
| return True | |
| return False | |
| def query( | |
| self, | |
| filter: Optional['MetricsFilter'] = None | |
| ) -> dict[str, list['MetricResult']]: | |
| """Queries the runner for existing user metrics that match the filter. | |
| It should return a dictionary, with lists of each kind of metric, and | |
| each list contains the corresponding kind of MetricResult. Like so: | |
| { | |
| "counters": [MetricResult(counter_key, committed, attempted), ...], | |
| "distributions": [MetricResult(dist_key, committed, attempted), ...], | |
| "gauges": [], // Empty list if nothing matched the filter. | |
| "string_sets": [] [MetricResult(string_set_key, committed, attempted), | |
| ...] | |
| } | |
| The committed / attempted values are DistributionResult / GaugeResult / int | |
| / set objects. | |
| """ | |
| raise NotImplementedError | |
| class MetricsFilter(object): | |
| """Simple object to filter metrics results. | |
| If filters by matching a result's step-namespace-name with three internal | |
| sets. No execution/matching logic is added to this object, so that it may | |
| be used to construct arguments as an RPC request. It is left for runners | |
| to implement matching logic by themselves. | |
| Note: This class only supports user defined metrics. | |
| """ | |
| def __init__(self) -> None: | |
| self._names: set[str] = set() | |
| self._namespaces: set[str] = set() | |
| self._steps: set[str] = set() | |
| @property | |
| def steps(self) -> frozenset[str]: | |
| return frozenset(self._steps) | |
| @property | |
| def names(self) -> frozenset[str]: | |
| return frozenset(self._names) | |
| @property | |
| def namespaces(self) -> frozenset[str]: | |
| return frozenset(self._namespaces) | |
| def with_metric(self, metric: 'Metric') -> 'MetricsFilter': | |
| name = metric.metric_name.name or '' | |
| namespace = metric.metric_name.namespace or '' | |
| return self.with_name(name).with_namespace(namespace) | |
| def with_name(self, name: str) -> 'MetricsFilter': | |
| return self.with_names([name]) | |
| def with_names(self, names: Iterable[str]) -> 'MetricsFilter': | |
| if isinstance(names, str): | |
| raise ValueError('Names must be a collection, not a string') | |
| self._names.update(names) | |
| return self | |
| def with_namespace(self, namespace: Union[type, str]) -> 'MetricsFilter': | |
| return self.with_namespaces([namespace]) | |
| def with_namespaces( | |
| self, namespaces: Iterable[Union[type, str]]) -> 'MetricsFilter': | |
| if isinstance(namespaces, str): | |
| raise ValueError('Namespaces must be an iterable, not a string') | |
| self._namespaces.update([Metrics.get_namespace(ns) for ns in namespaces]) | |
| return self | |
| def with_step(self, step: str) -> 'MetricsFilter': | |
| return self.with_steps([step]) | |
| def with_steps(self, steps: Iterable[str]) -> 'MetricsFilter': | |
| if isinstance(steps, str): | |
| raise ValueError('Steps must be an iterable, not a string') | |
| self._steps.update(steps) | |
| return self | |
| class Lineage: | |
| """Standard collection of metrics used to record source and sinks information | |
| for lineage tracking.""" | |
| LINEAGE_NAMESPACE = "lineage" | |
| SOURCE = "sources_v2" | |
| SINK = "sinks_v2" | |
| _METRICS = { | |
| SOURCE: Metrics.bounded_trie(LINEAGE_NAMESPACE, SOURCE), | |
| SINK: Metrics.bounded_trie(LINEAGE_NAMESPACE, SINK) | |
| } | |
| def __init__(self, label: str) -> None: | |
| """Create a Lineage with valid label (:data:`~Lineage.SOURCE` or | |
| :data:`~Lineage.SINK`) | |
| """ | |
| self.metric = Lineage._METRICS[label] | |
| @classmethod | |
| def sources(cls) -> 'Lineage': | |
| return cls(Lineage.SOURCE) | |
| @classmethod | |
| def sinks(cls) -> 'Lineage': | |
| return cls(Lineage.SINK) | |
| _RESERVED_CHARS = re.compile(r'[:\s.]') | |
| @staticmethod | |
| def wrap_segment(segment: str) -> str: | |
| """Wrap segment to valid segment name. | |
| Specifically, If there are reserved chars (colon, whitespace, dot), escape | |
| with backtick. If the segment is already wrapped, return the original. | |
| """ | |
| if segment.startswith("`") and segment.endswith("`"): return segment | |
| if Lineage._RESERVED_CHARS.search(segment): | |
| return "`" + segment + "`" | |
| return segment | |
| @staticmethod | |
| def get_fq_name( | |
| system: str, *segments: str, subtype: Optional[str] = None) -> str: | |
| """Assemble fully qualified name | |
| (`FQN <https://cloud.google.com/data-catalog/docs/fully-qualified-names>`_). | |
| Format: | |
| - `system:segment1.segment2` | |
| - `system:subtype:segment1.segment2` | |
| - `system:`segment1.with.dots:colons`.segment2` | |
| This helper method is for internal and testing usage only. | |
| """ | |
| segs = '.'.join(map(Lineage.wrap_segment, segments)) | |
| if subtype: | |
| return ':'.join((system, subtype, segs)) | |
| return ':'.join((system, segs)) | |
| @staticmethod | |
| def _get_fqn_parts( | |
| system: str, | |
| *segments: str, | |
| subtype: Optional[str] = None, | |
| last_segment_sep: Optional[str] = None) -> Iterator[str]: | |
| yield system + ':' | |
| if subtype: | |
| yield subtype + ':' | |
| if segments: | |
| for segment in segments[:-1]: | |
| yield segment + '.' | |
| if last_segment_sep: | |
| sub_segments = segments[-1].split(last_segment_sep) | |
| for sub_segment in sub_segments[:-1]: | |
| yield sub_segment + last_segment_sep | |
| yield sub_segments[-1] | |
| else: | |
| yield segments[-1] | |
| def add( | |
| self, | |
| system: str, | |
| *segments: str, | |
| subtype: Optional[str] = None, | |
| last_segment_sep: Optional[str] = None) -> None: | |
| """ | |
| Adds the given details as Lineage. | |
| For asset level lineage the resource location should be specified as | |
| Dataplex FQN, see | |
| https://cloud.google.com/data-catalog/docs/fully-qualified-names | |
| Example of adding FQN components: | |
| - `add("system", "segment1", "segment2")` | |
| - `add("system", "segment1", "segment2", subtype="subtype")` | |
| Example of adding a FQN: | |
| - `add("system:segment1.segment2")` | |
| - `add("system:subtype:segment1.segment2")` | |
| The first positional argument serves as system, if full segments are | |
| provided, or the full FQN if it is provided as a single argument. | |
| """ | |
| self.add_raw( | |
| *self._get_fqn_parts( | |
| system, | |
| *segments, | |
| subtype=subtype, | |
| last_segment_sep=last_segment_sep)) | |
| def add_raw(self, *rollup_segments: str) -> None: | |
| """Adds the given fqn as lineage. | |
| `rollup_segments` should be an iterable of strings whose concatenation | |
| is a valid Dataplex FQN. In particular, this means they will often have | |
| trailing delimiters. | |
| """ | |
| self.metric.add(rollup_segments) | |
| @staticmethod | |
| def query(results: MetricResults, | |
| label: str, | |
| truncated_marker: str = '*') -> set[str]: | |
| if not label in Lineage._METRICS: | |
| raise ValueError("Label {} does not exist for Lineage", label) | |
| response = results.query( | |
| MetricsFilter().with_namespace(Lineage.LINEAGE_NAMESPACE).with_name( | |
| label))[MetricResults.BOUNDED_TRIES] | |
| result = set() | |
| for metric in response: | |
| for fqn in metric.committed.flattened(): | |
| result.add(''.join(fqn[:-1]) + (truncated_marker if fqn[-1] else '')) | |
| for fqn in metric.attempted.flattened(): | |
| result.add(''.join(fqn[:-1]) + (truncated_marker if fqn[-1] else '')) | |
| return result |