Merge "Vendor metrics library from Ironic-Lib & deprecate"

This commit is contained in:
Zuul 2024-11-05 16:11:20 +00:00 committed by Gerrit Code Review
commit 5746ac1222
13 changed files with 1189 additions and 14 deletions

View File

@ -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 <https://opendev.org/openstack/ironic-lib/src/branch/master/ironic_lib>`_.

View File

@ -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__)

View File

@ -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):

View File

@ -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:
_prefix<delim>name
where:
- _prefix: [global_prefix<delim>][uuid<delim>][host_name<delim>]prefix
- name: the name of this metric
- <delim>: 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:
_prefix<delim>name
where:
- _prefix: [global_prefix<delim>][uuid<delim>][host_name<delim>]
prefix
- name: the name of this metric
- <delim>: 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

View File

@ -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

View File

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

View File

@ -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)]

View File

@ -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<delim>][host_name<delim>]prefix
where <delim> 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)

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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')

View File

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