From 75abdb4148f6450cfc917c4577a8d3c3f6ea2262 Mon Sep 17 00:00:00 2001 From: Jay Faulkner Date: Tue, 22 Oct 2024 14:31:34 -0700 Subject: [PATCH] Vendor metrics library from Ironic-Lib & deprecate We are phasing out use of ironic-lib, and as such are removing the metrics module from it. However, due to it's requirement of having a statsd instance on the same subnet as the agent and there being no support for prometheus exporting of metrics from IPA, these metrics are no longer valuable (in the agent). We are vendoring the module for the deprecation in order to facilitate its removal from ironic-lib. Change-Id: Ie50e078bc3f78d65cfa53680dc4116d1119ce155 --- doc/source/contributor/metrics.rst | 25 +- ironic_python_agent/api/app.py | 2 +- ironic_python_agent/config.py | 9 +- ironic_python_agent/metrics_lib/metrics.py | 306 ++++++++++++++++++ .../metrics_lib/metrics_collector.py | 119 +++++++ .../metrics_lib/metrics_exception.py | 34 ++ .../metrics_lib/metrics_statsd.py | 104 ++++++ .../metrics_lib/metrics_utils.py | 102 ++++++ .../tests/unit/metrics_lib/test_metrics.py | 220 +++++++++++++ .../metrics_lib/test_metrics_collector.py | 68 ++++ .../unit/metrics_lib/test_metrics_statsd.py | 104 ++++++ .../unit/metrics_lib/test_metrics_utils.py | 104 ++++++ ...e-and-vendor-metrics-1df0ca4c865613f8.yaml | 6 + 13 files changed, 1189 insertions(+), 14 deletions(-) create mode 100644 ironic_python_agent/metrics_lib/metrics.py create mode 100644 ironic_python_agent/metrics_lib/metrics_collector.py create mode 100644 ironic_python_agent/metrics_lib/metrics_exception.py create mode 100644 ironic_python_agent/metrics_lib/metrics_statsd.py create mode 100644 ironic_python_agent/metrics_lib/metrics_utils.py create mode 100644 ironic_python_agent/tests/unit/metrics_lib/test_metrics.py create mode 100644 ironic_python_agent/tests/unit/metrics_lib/test_metrics_collector.py create mode 100644 ironic_python_agent/tests/unit/metrics_lib/test_metrics_statsd.py create mode 100644 ironic_python_agent/tests/unit/metrics_lib/test_metrics_utils.py create mode 100644 releasenotes/notes/deprecate-and-vendor-metrics-1df0ca4c865613f8.yaml diff --git a/doc/source/contributor/metrics.rst b/doc/source/contributor/metrics.rst index 70c0bfc6a..d4ae60526 100644 --- a/doc/source/contributor/metrics.rst +++ b/doc/source/contributor/metrics.rst @@ -4,26 +4,31 @@ Emitting metrics from Ironic-Python-Agent (IPA) =============================================== +.. warning:: + IPA metrics are deprecated and scheduled for removal at or after the + 2026.1 OpenStack release cycle. + This document describes how to emit metrics from IPA, including timers and counters in code to directly emitting hardware metrics from a custom HardwareManager. Overview ======== -IPA uses the metrics implementation from ironic-lib, with a few caveats due -to the dynamic configuration done at lookup time. You cannot cache the metrics -instance as the MetricsLogger returned will change after lookup if configs -different than the default setting have been used. This also means that the -method decorator supported by ironic-lib cannot be used in IPA. +IPA uses a vendored version of the metrics implementation originally from +ironic-lib, with a few caveats due to the dynamic configuration done at +lookup time. You cannot cache the metrics instance as the MetricsLogger +returned will change after lookup if configs different than the default +setting have been used. This also means that the method decorator +cannot be used in IPA. Using a context manager ======================= Using the context manager is the recommended way for sending metrics that time or count sections of code. However, given that you cannot cache the MetricsLogger, you have to explicitly call get_metrics_logger() from -ironic-lib every time. For example:: +every time. For example:: - from ironic_lib import metrics_utils + from ironic_python_agent.metrics_lib import metrics_utils def my_method(): with metrics_utils.get_metrics_logger(__name__).timer('my_method'): @@ -41,13 +46,9 @@ HardwareManagers is the ability to explicitly send metrics. For instance, you could add a cleaning step which would retrieve metrics about a device and ship them using the provided metrics library. For example:: - from ironic_lib import metrics_utils + from ironic_python_agent.metrics_lib import metrics_utils def my_cleaning_step(): for name, value in _get_smart_data(): metrics_utils.get_metrics_logger(__name__).send_gauge(name, value) -References -========== -For more information, please read the source of the metrics module in -`ironic-lib `_. diff --git a/ironic_python_agent/api/app.py b/ironic_python_agent/api/app.py index 47f5dd85b..d44e3793a 100644 --- a/ironic_python_agent/api/app.py +++ b/ironic_python_agent/api/app.py @@ -14,7 +14,6 @@ import json -from ironic_lib import metrics_utils from oslo_log import log from oslo_service import wsgi import werkzeug @@ -22,6 +21,7 @@ from werkzeug import exceptions as http_exc from werkzeug import routing from ironic_python_agent import encoding +from ironic_python_agent.metrics_lib import metrics_utils LOG = log.getLogger(__name__) diff --git a/ironic_python_agent/config.py b/ironic_python_agent/config.py index 75527b39c..9d0ba6cc7 100644 --- a/ironic_python_agent/config.py +++ b/ironic_python_agent/config.py @@ -15,9 +15,12 @@ from oslo_config import cfg from oslo_log import log as logging +from ironic_python_agent.metrics_lib.metrics_statsd import statsd_opts +from ironic_python_agent.metrics_lib.metrics_utils import metrics_opts from ironic_python_agent import netutils from ironic_python_agent import utils + LOG = logging.getLogger(__name__) CONF = cfg.CONF @@ -438,7 +441,9 @@ disk_part_opts = [ def list_opts(): return [('DEFAULT', cli_opts), ('disk_utils', disk_utils_opts), - ('disk_partitioner', disk_part_opts)] + ('disk_partitioner', disk_part_opts), + ('metrics', metrics_opts), + ('metrics_statsd', statsd_opts)] def populate_config(): @@ -446,6 +451,8 @@ def populate_config(): CONF.register_cli_opts(cli_opts) CONF.register_opts(disk_utils_opts, group='disk_utils') CONF.register_opts(disk_part_opts, group='disk_partitioner') + CONF.register_opts(metrics_opts, group='metrics') + CONF.register_opts(statsd_opts, group='metrics_statsd') def override(params): diff --git a/ironic_python_agent/metrics_lib/metrics.py b/ironic_python_agent/metrics_lib/metrics.py new file mode 100644 index 000000000..ef24bbe55 --- /dev/null +++ b/ironic_python_agent/metrics_lib/metrics.py @@ -0,0 +1,306 @@ +# Copyright 2016 Rackspace Hosting +# All Rights Reserved. +# +# 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 +import functools +import random +import time + +from ironic_python_agent.metrics_lib import metrics_exception as exception + + +class Timer(object): + """A timer decorator and context manager. + + This metric type times the decorated method or code running inside the + context manager, and emits the time as the metric value. It is bound to + this MetricLogger. For example:: + + from ironic_lib import metrics_utils + + METRICS = metrics_utils.get_metrics_logger() + + @METRICS.timer('foo') + def foo(bar, baz): + print bar, baz + + with METRICS.timer('foo'): + do_something() + """ + def __init__(self, metrics, name): + """Init the decorator / context manager. + + :param metrics: The metric logger + :param name: The metric name + """ + if not isinstance(name, str): + raise TypeError("The metric name is expected to be a string. " + "Value is %s" % name) + self.metrics = metrics + self.name = name + self._start = None + + def __call__(self, f): + @functools.wraps(f) + def wrapped(*args, **kwargs): + start = _time() + result = f(*args, **kwargs) + duration = _time() - start + + # Log the timing data (in ms) + self.metrics.send_timer(self.metrics.get_metric_name(self.name), + duration * 1000) + return result + return wrapped + + def __enter__(self): + self._start = _time() + + def __exit__(self, exc_type, exc_val, exc_tb): + duration = _time() - self._start + # Log the timing data (in ms) + self.metrics.send_timer(self.metrics.get_metric_name(self.name), + duration * 1000) + + +class Counter(object): + """A counter decorator and context manager. + + This metric type increments a counter every time the decorated method or + context manager is executed. It is bound to this MetricLogger. For + example:: + + from ironic_lib import metrics_utils + + METRICS = metrics_utils.get_metrics_logger() + + @METRICS.counter('foo') + def foo(bar, baz): + print bar, baz + + with METRICS.counter('foo'): + do_something() + """ + def __init__(self, metrics, name, sample_rate): + """Init the decorator / context manager. + + :param metrics: The metric logger + :param name: The metric name + :param sample_rate: Probabilistic rate at which the values will be sent + """ + if not isinstance(name, str): + raise TypeError("The metric name is expected to be a string. " + "Value is %s" % name) + + if (sample_rate is not None + and (sample_rate < 0.0 or sample_rate > 1.0)): + msg = ("sample_rate is set to %s. Value must be None " + "or in the interval [0.0, 1.0]" % sample_rate) + raise ValueError(msg) + + self.metrics = metrics + self.name = name + self.sample_rate = sample_rate + + def __call__(self, f): + @functools.wraps(f) + def wrapped(*args, **kwargs): + self.metrics.send_counter( + self.metrics.get_metric_name(self.name), + 1, sample_rate=self.sample_rate) + + result = f(*args, **kwargs) + + return result + return wrapped + + def __enter__(self): + self.metrics.send_counter(self.metrics.get_metric_name(self.name), + 1, sample_rate=self.sample_rate) + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + +class Gauge(object): + """A gauge decorator. + + This metric type returns the value of the decorated method as a metric + every time the method is executed. It is bound to this MetricLogger. For + example:: + + from ironic_lib import metrics_utils + + METRICS = metrics_utils.get_metrics_logger() + + @METRICS.gauge('foo') + def add_foo(bar, baz): + return (bar + baz) + """ + def __init__(self, metrics, name): + """Init the decorator / context manager. + + :param metrics: The metric logger + :param name: The metric name + """ + if not isinstance(name, str): + raise TypeError("The metric name is expected to be a string. " + "Value is %s" % name) + self.metrics = metrics + self.name = name + + def __call__(self, f): + @functools.wraps(f) + def wrapped(*args, **kwargs): + result = f(*args, **kwargs) + self.metrics.send_gauge(self.metrics.get_metric_name(self.name), + result) + + return result + return wrapped + + +def _time(): + """Wraps time.time() for simpler testing.""" + return time.time() + + +class MetricLogger(object, metaclass=abc.ABCMeta): + """Abstract class representing a metrics logger. + + A MetricLogger sends data to a backend (noop or statsd). + The data can be a gauge, a counter, or a timer. + + The data sent to the backend is composed of: + - a full metric name + - a numeric value + + The format of the full metric name is: + _prefixname + where: + - _prefix: [global_prefix][uuid][host_name]prefix + - name: the name of this metric + - : the delimiter. Default is '.' + """ + + def __init__(self, prefix='', delimiter='.'): + """Init a MetricLogger. + + :param prefix: Prefix for this metric logger. This string will prefix + all metric names. + :param delimiter: Delimiter used to generate the full metric name. + """ + self._prefix = prefix + self._delimiter = delimiter + + def get_metric_name(self, name): + """Get the full metric name. + + The format of the full metric name is: + _prefixname + where: + - _prefix: [global_prefix][uuid][host_name] + prefix + - name: the name of this metric + - : the delimiter. Default is '.' + + + :param name: The metric name. + :return: The full metric name, with logger prefix, as a string. + """ + if not self._prefix: + return name + return self._delimiter.join([self._prefix, name]) + + def send_gauge(self, name, value): + """Send gauge metric data. + + Gauges are simple values. + The backend will set the value of gauge 'name' to 'value'. + + :param name: Metric name + :param value: Metric numeric value that will be sent to the backend + """ + self._gauge(name, value) + + def send_counter(self, name, value, sample_rate=None): + """Send counter metric data. + + Counters are used to count how many times an event occurred. + The backend will increment the counter 'name' by the value 'value'. + + Optionally, specify sample_rate in the interval [0.0, 1.0] to + sample data probabilistically where:: + + P(send metric data) = sample_rate + + If sample_rate is None, then always send metric data, but do not + have the backend send sample rate information (if supported). + + :param name: Metric name + :param value: Metric numeric value that will be sent to the backend + :param sample_rate: Probabilistic rate at which the values will be + sent. Value must be None or in the interval [0.0, 1.0]. + """ + if (sample_rate is None or random.random() < sample_rate): + return self._counter(name, value, + sample_rate=sample_rate) + + def send_timer(self, name, value): + """Send timer data. + + Timers are used to measure how long it took to do something. + + :param m_name: Metric name + :param m_value: Metric numeric value that will be sent to the backend + """ + self._timer(name, value) + + def timer(self, name): + return Timer(self, name) + + def counter(self, name, sample_rate=None): + return Counter(self, name, sample_rate) + + def gauge(self, name): + return Gauge(self, name) + + @abc.abstractmethod + def _gauge(self, name, value): + """Abstract method for backends to implement gauge behavior.""" + + @abc.abstractmethod + def _counter(self, name, value, sample_rate=None): + """Abstract method for backends to implement counter behavior.""" + + @abc.abstractmethod + def _timer(self, name, value): + """Abstract method for backends to implement timer behavior.""" + + def get_metrics_data(self): + """Return the metrics collection, if available.""" + raise exception.MetricsNotSupported() + + +class NoopMetricLogger(MetricLogger): + """Noop metric logger that throws away all metric data.""" + def _gauge(self, name, value): + pass + + def _counter(self, name, value, sample_rate=None): + pass + + def _timer(self, m_name, value): + pass diff --git a/ironic_python_agent/metrics_lib/metrics_collector.py b/ironic_python_agent/metrics_lib/metrics_collector.py new file mode 100644 index 000000000..876af7461 --- /dev/null +++ b/ironic_python_agent/metrics_lib/metrics_collector.py @@ -0,0 +1,119 @@ +# 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. + + +from oslo_concurrency import lockutils +from oslo_config import cfg + +from ironic_python_agent.metrics_lib import metrics + + +CONF = cfg.CONF +STATISTIC_DATA = {} + + +class DictCollectionMetricLogger(metrics.MetricLogger): + """Metric logger that collects internal counters.""" + + # These are internal typing labels in Ironic-lib. + GAUGE_TYPE = 'g' + COUNTER_TYPE = 'c' + TIMER_TYPE = 'ms' + + def __init__(self, prefix, delimiter='.'): + """Initialize the Collection Metrics Logger. + + The logger stores metrics data in a dictionary which can then be + retrieved by the program utilizing it whenever needed utilizing a + get_metrics_data call to return the metrics data structure. + + :param prefix: Prefix for this metric logger. + :param delimiter: Delimiter used to generate the full metric name. + """ + super(DictCollectionMetricLogger, self).__init__( + prefix, delimiter=delimiter) + + @lockutils.synchronized('statistics-update') + def _send(self, name, value, metric_type, sample_rate=None): + """Send the metrics to be stored in memory. + + This memory updates the internal dictionary to facilitate + collection of statistics, and the retrieval of them for + consumers or plugins in Ironic to retrieve the statistic + data utilizing the `get_metrics_data` method. + + :param name: Metric name + :param value: Metric value + :param metric_type: Metric type (GAUGE_TYPE, COUNTER_TYPE), + TIMER_TYPE is not supported. + :param sample_rate: Not Applicable. + """ + + global STATISTIC_DATA + if metric_type == self.TIMER_TYPE: + if name in STATISTIC_DATA: + STATISTIC_DATA[name] = { + 'count': STATISTIC_DATA[name]['count'] + 1, + 'sum': STATISTIC_DATA[name]['sum'] + value, + 'type': 'timer' + } + else: + # Set initial data value. + STATISTIC_DATA[name] = { + 'count': 1, + 'sum': value, + 'type': 'timer' + } + elif metric_type == self.GAUGE_TYPE: + STATISTIC_DATA[name] = { + 'value': value, + 'type': 'gauge' + } + elif metric_type == self.COUNTER_TYPE: + if name in STATISTIC_DATA: + # NOTE(TheJulia): Value is hard coded for counter + # data types as a value of 1. + STATISTIC_DATA[name] = { + 'count': STATISTIC_DATA[name]['count'] + 1, + 'type': 'counter' + } + else: + STATISTIC_DATA[name] = { + 'count': 1, + 'type': 'counter' + } + + def _gauge(self, name, value): + return self._send(name, value, self.GAUGE_TYPE) + + def _counter(self, name, value, sample_rate=None): + return self._send(name, value, self.COUNTER_TYPE, + sample_rate=sample_rate) + + def _timer(self, name, value): + return self._send(name, value, self.TIMER_TYPE) + + def get_metrics_data(self): + """Return the metrics collection dictionary. + + :returns: Dictionary containing the keys and values of + data stored via the metrics collection hooks. + The values themselves are dictionaries which + contain a type field, indicating if the statistic + is a counter, gauge, or timer. A counter has a + `count` field, a gauge value has a `value` field, + and a 'timer' fiend las a 'count' and 'sum' fields. + The multiple fields for for a timer type allows + for additional statistics to be implied from the + data once collected and compared over time. + """ + return STATISTIC_DATA diff --git a/ironic_python_agent/metrics_lib/metrics_exception.py b/ironic_python_agent/metrics_lib/metrics_exception.py new file mode 100644 index 000000000..9aa34b6f1 --- /dev/null +++ b/ironic_python_agent/metrics_lib/metrics_exception.py @@ -0,0 +1,34 @@ +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# 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. + +"""Ironic base exception handling. + +Includes decorator for re-raising Ironic-type exceptions. + +SHOULD include dedicated exception logging. + +""" + +from ironic_lib.exception import IronicException + + +class InvalidMetricConfig(IronicException): + _msg_fmt = "Invalid value for metrics config option: %(reason)s" + + +class MetricsNotSupported(IronicException): + _msg_fmt = ("Metrics action is not supported. You may need to " + "adjust the [metrics] section in ironic.conf.") diff --git a/ironic_python_agent/metrics_lib/metrics_statsd.py b/ironic_python_agent/metrics_lib/metrics_statsd.py new file mode 100644 index 000000000..1b8d652e0 --- /dev/null +++ b/ironic_python_agent/metrics_lib/metrics_statsd.py @@ -0,0 +1,104 @@ +# Copyright 2016 Rackspace Hosting +# All Rights Reserved. +# +# 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 contextlib +import logging +import socket + +from oslo_config import cfg + +from ironic_python_agent.metrics_lib import metrics + +statsd_opts = [ + cfg.StrOpt('statsd_host', + default='localhost', + help='Host for use with the statsd backend.'), + cfg.PortOpt('statsd_port', + default=8125, + help='Port to use with the statsd backend.') +] + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + + +class StatsdMetricLogger(metrics.MetricLogger): + """Metric logger that reports data via the statsd protocol.""" + + GAUGE_TYPE = 'g' + COUNTER_TYPE = 'c' + TIMER_TYPE = 'ms' + + def __init__(self, prefix, delimiter='.', host=None, port=None): + """Initialize a StatsdMetricLogger + + The logger uses the given prefix list, delimiter, host, and port. + + :param prefix: Prefix for this metric logger. + :param delimiter: Delimiter used to generate the full metric name. + :param host: The statsd host + :param port: The statsd port + """ + super(StatsdMetricLogger, self).__init__(prefix, + delimiter=delimiter) + + self._host = host or CONF.metrics_statsd.statsd_host + self._port = port or CONF.metrics_statsd.statsd_port + + self._target = (self._host, self._port) + + def _send(self, name, value, metric_type, sample_rate=None): + """Send metrics to the statsd backend + + :param name: Metric name + :param value: Metric value + :param metric_type: Metric type (GAUGE_TYPE, COUNTER_TYPE, + or TIMER_TYPE) + :param sample_rate: Probabilistic rate at which the values will be sent + """ + if sample_rate is None: + metric = '%s:%s|%s' % (name, value, metric_type) + else: + metric = '%s:%s|%s@%s' % (name, value, metric_type, sample_rate) + + # Ideally, we'd cache a sending socket in self, but that + # results in a socket getting shared by multiple green threads. + with contextlib.closing(self._open_socket()) as sock: + try: + sock.settimeout(0.0) + sock.sendto(metric.encode(), self._target) + except socket.error as e: + LOG.warning("Failed to send the metric value to host " + "%(host)s, port %(port)s. Error: %(error)s", + {'host': self._host, 'port': self._port, + 'error': e}) + + def _open_socket(self): + return socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + def _gauge(self, name, value): + return self._send(name, value, self.GAUGE_TYPE) + + def _counter(self, name, value, sample_rate=None): + return self._send(name, value, self.COUNTER_TYPE, + sample_rate=sample_rate) + + def _timer(self, name, value): + return self._send(name, value, self.TIMER_TYPE) + + +def list_opts(): + """Entry point for oslo-config-generator.""" + return [('metrics_statsd', statsd_opts)] diff --git a/ironic_python_agent/metrics_lib/metrics_utils.py b/ironic_python_agent/metrics_lib/metrics_utils.py new file mode 100644 index 000000000..1c0f1abbc --- /dev/null +++ b/ironic_python_agent/metrics_lib/metrics_utils.py @@ -0,0 +1,102 @@ +# Copyright 2016 Rackspace Hosting +# All Rights Reserved. +# +# 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. + +from oslo_config import cfg + +from ironic_python_agent.metrics_lib import metrics +from ironic_python_agent.metrics_lib import metrics_collector +from ironic_python_agent.metrics_lib import metrics_exception as exception +from ironic_python_agent.metrics_lib import metrics_statsd + +metrics_opts = [ + cfg.StrOpt('backend', + default='noop', + choices=[ + ('noop', 'Do nothing in relation to metrics.'), + ('statsd', 'Transmits metrics data to a statsd backend.'), + ('collector', 'Collects metrics data and saves it in ' + 'memory for use by the running application.'), + ], + help='Backend to use for the metrics system.'), + cfg.BoolOpt('prepend_host', + default=False, + help='Prepend the hostname to all metric names. ' + 'The format of metric names is ' + '[global_prefix.][host_name.]prefix.metric_name.'), + cfg.BoolOpt('prepend_host_reverse', + default=True, + help='Split the prepended host value by "." and reverse it ' + '(to better match the reverse hierarchical form of ' + 'domain names).'), + cfg.StrOpt('global_prefix', + help='Prefix all metric names with this value. ' + 'By default, there is no global prefix. ' + 'The format of metric names is ' + '[global_prefix.][host_name.]prefix.metric_name.') +] + + +CONF = cfg.CONF + + +def get_metrics_logger(prefix='', backend=None, host=None, delimiter='.'): + """Return a metric logger with the specified prefix. + + The format of the prefix is: + [global_prefix][host_name]prefix + where is the delimiter (default is '.') + + :param prefix: Prefix for this metric logger. + Value should be a string or None. + :param backend: Backend to use for the metrics system. + Possible values are 'noop' and 'statsd'. + :param host: Name of this node. + :param delimiter: Delimiter to use for the metrics name. + :return: The new MetricLogger. + """ + if not isinstance(prefix, str): + msg = ("This metric prefix (%s) is of unsupported type. " + "Value should be a string or None" + % str(prefix)) + raise exception.InvalidMetricConfig(msg) + + if CONF.metrics.prepend_host and host: + if CONF.metrics.prepend_host_reverse: + host = '.'.join(reversed(host.split('.'))) + + if prefix: + prefix = delimiter.join([host, prefix]) + else: + prefix = host + + if CONF.metrics.global_prefix: + if prefix: + prefix = delimiter.join([CONF.metrics.global_prefix, prefix]) + else: + prefix = CONF.metrics.global_prefix + + backend = backend or CONF.metrics.backend + if backend == 'statsd': + return metrics_statsd.StatsdMetricLogger(prefix, delimiter=delimiter) + elif backend == 'noop': + return metrics.NoopMetricLogger(prefix, delimiter=delimiter) + elif backend == 'collector': + return metrics_collector.DictCollectionMetricLogger( + prefix, delimiter=delimiter) + else: + msg = ("The backend is set to an unsupported type: " + "%s. Value should be 'noop' or 'statsd'." + % backend) + raise exception.InvalidMetricConfig(msg) diff --git a/ironic_python_agent/tests/unit/metrics_lib/test_metrics.py b/ironic_python_agent/tests/unit/metrics_lib/test_metrics.py new file mode 100644 index 000000000..8958762b9 --- /dev/null +++ b/ironic_python_agent/tests/unit/metrics_lib/test_metrics.py @@ -0,0 +1,220 @@ +# Copyright 2016 Rackspace Hosting +# All Rights Reserved +# +# 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 types +from unittest import mock + +from oslo_utils import reflection + +from ironic_python_agent.metrics_lib import metrics as metricslib +from ironic_python_agent.metrics_lib import metrics_utils +from ironic_python_agent.tests.unit import base + + +METRICS = metrics_utils.get_metrics_logger(prefix='foo', backend='noop') + + +@METRICS.timer('testing1') +def timer_check(run, timer=None): + pass + + +@METRICS.counter('testing2') +def counter_check(run, counter=None): + pass + + +@METRICS.gauge('testing2') +def gauge_check(run, gauge=None): + pass + + +class MockedMetricLogger(metricslib.MetricLogger): + _gauge = mock.Mock(spec_set=types.FunctionType) + _counter = mock.Mock(spec_set=types.FunctionType) + _timer = mock.Mock(spec_set=types.FunctionType) + + +class TestMetricReflection(base.IronicAgentTest): + def test_timer_reflection(self): + # Ensure our decorator is done correctly (functools.wraps) and we can + # get the arguments of our decorated function. + expected = ['run', 'timer'] + signature = reflection.get_signature(timer_check) + parameters = list(signature.parameters) + self.assertEqual(expected, parameters) + + def test_counter_reflection(self): + # Ensure our decorator is done correctly (functools.wraps) and we can + # get the arguments of our decorated function. + expected = ['run', 'counter'] + signature = reflection.get_signature(counter_check) + parameters = list(signature.parameters) + self.assertEqual(expected, parameters) + + def test_gauge_reflection(self): + # Ensure our decorator is done correctly (functools.wraps) and we can + # get the arguments of our decorated function. + expected = ['run', 'gauge'] + signature = reflection.get_signature(gauge_check) + parameters = list(signature.parameters) + self.assertEqual(expected, parameters) + + +class TestMetricLogger(base.IronicAgentTest): + def setUp(self): + super(TestMetricLogger, self).setUp() + self.ml = MockedMetricLogger('prefix', '.') + self.ml_no_prefix = MockedMetricLogger('', '.') + self.ml_other_delim = MockedMetricLogger('prefix', '*') + self.ml_default = MockedMetricLogger() + + def test_init(self): + self.assertEqual(self.ml._prefix, 'prefix') + self.assertEqual(self.ml._delimiter, '.') + + self.assertEqual(self.ml_no_prefix._prefix, '') + self.assertEqual(self.ml_other_delim._delimiter, '*') + self.assertEqual(self.ml_default._prefix, '') + + def test_get_metric_name(self): + self.assertEqual( + self.ml.get_metric_name('metric'), + 'prefix.metric') + + self.assertEqual( + self.ml_no_prefix.get_metric_name('metric'), + 'metric') + + self.assertEqual( + self.ml_other_delim.get_metric_name('metric'), + 'prefix*metric') + + def test_send_gauge(self): + self.ml.send_gauge('prefix.metric', 10) + self.ml._gauge.assert_called_once_with('prefix.metric', 10) + + def test_send_counter(self): + self.ml.send_counter('prefix.metric', 10) + self.ml._counter.assert_called_once_with( + 'prefix.metric', 10, + sample_rate=None) + self.ml._counter.reset_mock() + + self.ml.send_counter('prefix.metric', 10, sample_rate=1.0) + self.ml._counter.assert_called_once_with( + 'prefix.metric', 10, + sample_rate=1.0) + self.ml._counter.reset_mock() + + self.ml.send_counter('prefix.metric', 10, sample_rate=0.0) + self.assertFalse(self.ml._counter.called) + + def test_send_timer(self): + self.ml.send_timer('prefix.metric', 10) + self.ml._timer.assert_called_once_with('prefix.metric', 10) + + @mock.patch( + 'ironic_python_agent.metrics_lib.metrics._time', autospec=True) + @mock.patch( + 'ironic_python_agent.metrics_lib.metrics.MetricLogger.send_timer', + autospec=True) + def test_decorator_timer(self, mock_timer, mock_time): + mock_time.side_effect = [1, 43] + + @self.ml.timer('foo.bar.baz') + def func(x): + return x * x + + func(10) + + mock_timer.assert_called_once_with(self.ml, 'prefix.foo.bar.baz', + 42 * 1000) + + @mock.patch( + 'ironic_python_agent.metrics_lib.metrics.MetricLogger.send_counter', + autospec=True) + def test_decorator_counter(self, mock_counter): + + @self.ml.counter('foo.bar.baz') + def func(x): + return x * x + + func(10) + + mock_counter.assert_called_once_with(self.ml, 'prefix.foo.bar.baz', 1, + sample_rate=None) + + @mock.patch( + 'ironic_python_agent.metrics_lib.metrics.MetricLogger.send_counter', + autospec=True) + def test_decorator_counter_sample_rate(self, mock_counter): + + @self.ml.counter('foo.bar.baz', sample_rate=0.5) + def func(x): + return x * x + + func(10) + + mock_counter.assert_called_once_with(self.ml, 'prefix.foo.bar.baz', 1, + sample_rate=0.5) + + @mock.patch( + 'ironic_python_agent.metrics_lib.metrics.MetricLogger.send_gauge', + autospec=True) + def test_decorator_gauge(self, mock_gauge): + @self.ml.gauge('foo.bar.baz') + def func(x): + return x + + func(10) + + mock_gauge.assert_called_once_with(self.ml, 'prefix.foo.bar.baz', 10) + + @mock.patch( + 'ironic_python_agent.metrics_lib.metrics._time', autospec=True) + @mock.patch( + 'ironic_python_agent.metrics_lib.metrics.MetricLogger.send_timer', + autospec=True) + def test_context_mgr_timer(self, mock_timer, mock_time): + mock_time.side_effect = [1, 43] + + with self.ml.timer('foo.bar.baz'): + pass + + mock_timer.assert_called_once_with(self.ml, 'prefix.foo.bar.baz', + 42 * 1000) + + @mock.patch( + 'ironic_python_agent.metrics_lib.metrics.MetricLogger.send_counter', + autospec=True) + def test_context_mgr_counter(self, mock_counter): + + with self.ml.counter('foo.bar.baz'): + pass + + mock_counter.assert_called_once_with(self.ml, 'prefix.foo.bar.baz', 1, + sample_rate=None) + + @mock.patch( + 'ironic_python_agent.metrics_lib.metrics.MetricLogger.send_counter', + autospec=True) + def test_context_mgr_counter_sample_rate(self, mock_counter): + + with self.ml.counter('foo.bar.baz', sample_rate=0.5): + pass + + mock_counter.assert_called_once_with(self.ml, 'prefix.foo.bar.baz', 1, + sample_rate=0.5) diff --git a/ironic_python_agent/tests/unit/metrics_lib/test_metrics_collector.py b/ironic_python_agent/tests/unit/metrics_lib/test_metrics_collector.py new file mode 100644 index 000000000..3a3b81a6e --- /dev/null +++ b/ironic_python_agent/tests/unit/metrics_lib/test_metrics_collector.py @@ -0,0 +1,68 @@ +# Copyright 2016 Rackspace Hosting +# All Rights Reserved +# +# 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. + +from unittest import mock + + +from ironic_python_agent.metrics_lib import metrics_collector +from ironic_python_agent.tests.unit import base + + +def connect(family=None, type=None, proto=None): + """Dummy function to provide signature for autospec""" + pass + + +class TestDictCollectionMetricLogger(base.IronicAgentTest): + def setUp(self): + super(TestDictCollectionMetricLogger, self).setUp() + self.ml = metrics_collector.DictCollectionMetricLogger( + 'prefix', '.') + + @mock.patch('ironic_python_agent.metrics_lib.metrics_collector.' + 'DictCollectionMetricLogger._send', + autospec=True) + def test_gauge(self, mock_send): + self.ml._gauge('metric', 10) + mock_send.assert_called_once_with(self.ml, 'metric', 10, 'g') + + @mock.patch('ironic_python_agent.metrics_lib.metrics_collector.' + 'DictCollectionMetricLogger._send', + autospec=True) + def test_counter(self, mock_send): + self.ml._counter('metric', 10) + mock_send.assert_called_once_with(self.ml, 'metric', 10, 'c', + sample_rate=None) + + @mock.patch('ironic_python_agent.metrics_lib.metrics_collector.' + 'DictCollectionMetricLogger._send', + autospec=True) + def test_timer(self, mock_send): + self.ml._timer('metric', 10) + mock_send.assert_called_once_with(self.ml, 'metric', 10, 'ms') + + def test_send(self): + expected = { + 'part1.part1': {'count': 2, 'type': 'counter'}, + 'part1.part2': {'type': 'gauge', 'value': 66}, + 'part1.magic': {'count': 2, 'sum': 22, 'type': 'timer'}, + } + self.ml._send('part1.part1', 1, 'c') + self.ml._send('part1.part1', 1, 'c') + self.ml._send('part1.part2', 66, 'g') + self.ml._send('part1.magic', 2, 'ms') + self.ml._send('part1.magic', 20, 'ms') + results = self.ml.get_metrics_data() + self.assertEqual(expected, results) diff --git a/ironic_python_agent/tests/unit/metrics_lib/test_metrics_statsd.py b/ironic_python_agent/tests/unit/metrics_lib/test_metrics_statsd.py new file mode 100644 index 000000000..672d7465d --- /dev/null +++ b/ironic_python_agent/tests/unit/metrics_lib/test_metrics_statsd.py @@ -0,0 +1,104 @@ +# Copyright 2016 Rackspace Hosting +# All Rights Reserved +# +# 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 socket +from unittest import mock + + +from ironic_python_agent.metrics_lib import metrics_statsd +from ironic_python_agent.tests.unit import base + + +def connect(family=None, type=None, proto=None): + """Dummy function to provide signature for autospec""" + pass + + +class TestStatsdMetricLogger(base.IronicAgentTest): + def setUp(self): + super(TestStatsdMetricLogger, self).setUp() + self.ml = metrics_statsd.StatsdMetricLogger('prefix', '.', 'test-host', + 4321) + + def test_init(self): + self.assertEqual(self.ml._host, 'test-host') + self.assertEqual(self.ml._port, 4321) + self.assertEqual(self.ml._target, ('test-host', 4321)) + + @mock.patch( + ('ironic_python_agent.metrics_lib.metrics_statsd.StatsdMetricLogger.' + '_send'), autospec=True) + def test_gauge(self, mock_send): + self.ml._gauge('metric', 10) + mock_send.assert_called_once_with(self.ml, 'metric', 10, 'g') + + @mock.patch( + ('ironic_python_agent.metrics_lib.metrics_statsd.StatsdMetricLogger.' + '_send'), autospec=True) + def test_counter(self, mock_send): + self.ml._counter('metric', 10) + mock_send.assert_called_once_with(self.ml, 'metric', 10, 'c', + sample_rate=None) + mock_send.reset_mock() + + self.ml._counter('metric', 10, sample_rate=1.0) + mock_send.assert_called_once_with(self.ml, 'metric', 10, 'c', + sample_rate=1.0) + + @mock.patch( + ('ironic_python_agent.metrics_lib.metrics_statsd.StatsdMetricLogger.' + '_send'), autospec=True) + def test_timer(self, mock_send): + self.ml._timer('metric', 10) + mock_send.assert_called_once_with(self.ml, 'metric', 10, 'ms') + + @mock.patch('socket.socket', autospec=connect) + def test_open_socket(self, mock_socket_constructor): + self.ml._open_socket() + mock_socket_constructor.assert_called_once_with( + socket.AF_INET, + socket.SOCK_DGRAM) + + @mock.patch('socket.socket', autospec=connect) + def test_send(self, mock_socket_constructor): + mock_socket = mock.Mock() + mock_socket_constructor.return_value = mock_socket + + self.ml._send('part1.part2', 2, 'type') + mock_socket.sendto.assert_called_once_with( + b'part1.part2:2|type', + ('test-host', 4321)) + mock_socket.close.assert_called_once_with() + mock_socket.reset_mock() + + self.ml._send('part1.part2', 3.14159, 'type') + mock_socket.sendto.assert_called_once_with( + b'part1.part2:3.14159|type', + ('test-host', 4321)) + mock_socket.close.assert_called_once_with() + mock_socket.reset_mock() + + self.ml._send('part1.part2', 5, 'type') + mock_socket.sendto.assert_called_once_with( + b'part1.part2:5|type', + ('test-host', 4321)) + mock_socket.close.assert_called_once_with() + mock_socket.reset_mock() + + self.ml._send('part1.part2', 5, 'type', sample_rate=0.5) + mock_socket.sendto.assert_called_once_with( + b'part1.part2:5|type@0.5', + ('test-host', 4321)) + mock_socket.close.assert_called_once_with() diff --git a/ironic_python_agent/tests/unit/metrics_lib/test_metrics_utils.py b/ironic_python_agent/tests/unit/metrics_lib/test_metrics_utils.py new file mode 100644 index 000000000..35b162463 --- /dev/null +++ b/ironic_python_agent/tests/unit/metrics_lib/test_metrics_utils.py @@ -0,0 +1,104 @@ +# Copyright 2016 Rackspace Hosting +# All Rights Reserved +# +# 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. + +from oslo_config import cfg + +from ironic_python_agent.metrics_lib import metrics as metricslib +from ironic_python_agent.metrics_lib import metrics_exception as exception +from ironic_python_agent.metrics_lib import metrics_statsd +from ironic_python_agent.metrics_lib import metrics_utils +from ironic_python_agent.tests.unit import base + +CONF = cfg.CONF + + +class TestGetLogger(base.IronicAgentTest): + def setUp(self): + super(TestGetLogger, self).setUp() + + def test_default_backend(self): + metrics = metrics_utils.get_metrics_logger('foo') + self.assertIsInstance(metrics, metricslib.NoopMetricLogger) + + def test_statsd_backend(self): + CONF.set_override('backend', 'statsd', group='metrics') + + metrics = metrics_utils.get_metrics_logger('foo') + self.assertIsInstance(metrics, metrics_statsd.StatsdMetricLogger) + CONF.clear_override('backend', group='metrics') + + def test_nonexisting_backend(self): + self.assertRaises(exception.InvalidMetricConfig, + metrics_utils.get_metrics_logger, 'foo', 'test') + + def test_numeric_prefix(self): + self.assertRaises(exception.InvalidMetricConfig, + metrics_utils.get_metrics_logger, 1) + + def test_numeric_list_prefix(self): + self.assertRaises(exception.InvalidMetricConfig, + metrics_utils.get_metrics_logger, (1, 2)) + + def test_default_prefix(self): + metrics = metrics_utils.get_metrics_logger() + self.assertIsInstance(metrics, metricslib.NoopMetricLogger) + self.assertEqual(metrics.get_metric_name("bar"), "bar") + + def test_prepend_host_backend(self): + CONF.set_override('prepend_host', True, group='metrics') + CONF.set_override('prepend_host_reverse', False, group='metrics') + + metrics = metrics_utils.get_metrics_logger(prefix='foo', + host="host.example.com") + self.assertIsInstance(metrics, metricslib.NoopMetricLogger) + self.assertEqual(metrics.get_metric_name("bar"), + "host.example.com.foo.bar") + + CONF.clear_override('prepend_host', group='metrics') + CONF.clear_override('prepend_host_reverse', group='metrics') + + def test_prepend_global_prefix_host_backend(self): + CONF.set_override('prepend_host', True, group='metrics') + CONF.set_override('prepend_host_reverse', False, group='metrics') + CONF.set_override('global_prefix', 'global_pre', group='metrics') + + metrics = metrics_utils.get_metrics_logger(prefix='foo', + host="host.example.com") + self.assertIsInstance(metrics, metricslib.NoopMetricLogger) + self.assertEqual(metrics.get_metric_name("bar"), + "global_pre.host.example.com.foo.bar") + + CONF.clear_override('prepend_host', group='metrics') + CONF.clear_override('prepend_host_reverse', group='metrics') + CONF.clear_override('global_prefix', group='metrics') + + def test_prepend_other_delim(self): + metrics = metrics_utils.get_metrics_logger('foo', delimiter='*') + self.assertIsInstance(metrics, metricslib.NoopMetricLogger) + self.assertEqual(metrics.get_metric_name("bar"), + "foo*bar") + + def test_prepend_host_reverse_backend(self): + CONF.set_override('prepend_host', True, group='metrics') + CONF.set_override('prepend_host_reverse', True, group='metrics') + + metrics = metrics_utils.get_metrics_logger('foo', + host="host.example.com") + self.assertIsInstance(metrics, metricslib.NoopMetricLogger) + self.assertEqual(metrics.get_metric_name("bar"), + "com.example.host.foo.bar") + + CONF.clear_override('prepend_host', group='metrics') + CONF.clear_override('prepend_host_reverse', group='metrics') diff --git a/releasenotes/notes/deprecate-and-vendor-metrics-1df0ca4c865613f8.yaml b/releasenotes/notes/deprecate-and-vendor-metrics-1df0ca4c865613f8.yaml new file mode 100644 index 000000000..57c7e920e --- /dev/null +++ b/releasenotes/notes/deprecate-and-vendor-metrics-1df0ca4c865613f8.yaml @@ -0,0 +1,6 @@ +deprecations: + - | + Ironic Python Agent has had limited capabilities to emit metrics, but was + unable to support prometheus exporting of those metrics, as was possible + for the conductor. Support for sending these metrics will be removed during + or after the 2026.1 OpenStack release cycle.