From 616543868920964fb8b285a391f83ea9a6b47aca Mon Sep 17 00:00:00 2001 From: Mark Mercado Date: Tue, 16 Feb 2021 05:46:39 -0500 Subject: [PATCH] StatsD Module (#1793) * Pushing my WIP * Update DOCUMENTATION * Update EXAMPLES * More friendly name * Finish up the counter and gauge logic * Cleanup DOCUMENTATION and add metric_type * Apply autopep8 * Fixup the exits * Stubbing out unit tests * Whitespace * Whitespace * Removing unused modules * Remove unused modules * Might have have a prefix * Rearrange imported modules * Cleanup the if/elif blob * Require python >= 2.7 * Update DOCUMENTATION Co-authored-by: Felix Fontein * Update DOCUMENTATION Co-authored-by: Felix Fontein * Add import guarding on statsd * Add missing future import * Include missing_required_lib * Fixing sanity tests * Fixing delta default and choices * Formatting * Close tcp connection * Refactoring and unit tests * Fix pep8 sanity tests * Putting requirements.txt back to main * Apply suggestions from code review Co-authored-by: Mark Mercado Co-authored-by: Felix Fontein --- .github/BOTMETA.yml | 2 + plugins/modules/monitoring/statsd.py | 170 ++++++++++++++++++ plugins/modules/statsd.py | 1 + .../plugins/modules/monitoring/test_statsd.py | 101 +++++++++++ 4 files changed, 274 insertions(+) create mode 100644 plugins/modules/monitoring/statsd.py create mode 120000 plugins/modules/statsd.py create mode 100644 tests/unit/plugins/modules/monitoring/test_statsd.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 00a27cd837..bbd52b544e 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -418,6 +418,8 @@ files: maintainers: orgito $modules/monitoring/stackdriver.py: maintainers: bwhaley + $modules/monitoring/statsd.py: + maintainers: mamercad $modules/monitoring/statusio_maintenance.py: maintainers: bhcopeland $modules/monitoring/uptimerobot.py: diff --git a/plugins/modules/monitoring/statsd.py b/plugins/modules/monitoring/statsd.py new file mode 100644 index 0000000000..b07851641b --- /dev/null +++ b/plugins/modules/monitoring/statsd.py @@ -0,0 +1,170 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +module: statsd +short_description: Send metrics to StatsD +version_added: 2.1.0 +description: + - The C(statsd) module sends metrics to StatsD. + - For more information, see U(https://statsd-metrics.readthedocs.io/en/latest/). + - Supported metric types are C(counter) and C(gauge). + Currently unupported metric types are C(timer), C(set), and C(gaugedelta). +author: "Mark Mercado (@mamercad)" +requirements: + - statsd +options: + state: + type: str + description: + - State of the check, only C(present) makes sense. + choices: ["present"] + default: present + host: + type: str + default: localhost + description: + - StatsD host (hostname or IP) to send metrics to. + port: + type: int + default: 8125 + description: + - The port on C(host) which StatsD is listening on. + protocol: + type: str + default: udp + choices: ["udp", "tcp"] + description: + - The transport protocol to send metrics over. + timeout: + type: float + default: 1.0 + description: + - Sender timeout, only applicable if C(protocol) is C(tcp). + metric: + type: str + required: true + description: + - The name of the metric. + metric_type: + type: str + required: true + choices: ["counter", "gauge"] + description: + - The type of metric. + metric_prefix: + type: str + description: + - The prefix to add to the metric. + value: + type: int + required: true + description: + - The value of the metric. + delta: + type: bool + default: false + description: + - If the metric is of type C(gauge), change the value by C(delta). +''' + +EXAMPLES = ''' +- name: Increment the metric my_counter by 1 + community.general.statsd: + host: localhost + port: 9125 + protocol: tcp + metric: my_counter + metric_type: counter + value: 1 + +- name: Set the gauge my_gauge to 7 + community.general.statsd: + host: localhost + port: 9125 + protocol: tcp + metric: my_gauge + metric_type: gauge + value: 7 +''' + + +from ansible.module_utils.basic import (AnsibleModule, missing_required_lib) + +try: + from statsd import StatsClient, TCPStatsClient + HAS_STATSD = True +except ImportError: + HAS_STATSD = False + + +def udp_statsd_client(**client_params): + return StatsClient(**client_params) + + +def tcp_statsd_client(**client_params): + return TCPStatsClient(**client_params) + + +def main(): + + module = AnsibleModule( + argument_spec=dict( + state=dict(type='str', default='present', choices=['present']), + host=dict(type='str', default='localhost'), + port=dict(type='int', default=8125), + protocol=dict(type='str', default='udp', choices=['udp', 'tcp']), + timeout=dict(type='float', default=1.0), + metric=dict(type='str', required=True), + metric_type=dict(type='str', required=True, choices=['counter', 'gauge']), + metric_prefix=dict(type='str', default=''), + value=dict(type='int', required=True), + delta=dict(type='bool', default=False), + ), + supports_check_mode=False + ) + + if not HAS_STATSD: + module.fail_json(msg=missing_required_lib('statsd')) + + host = module.params.get('host') + port = module.params.get('port') + protocol = module.params.get('protocol') + timeout = module.params.get('timeout') + metric = module.params.get('metric') + metric_type = module.params.get('metric_type') + metric_prefix = module.params.get('metric_prefix') + value = module.params.get('value') + delta = module.params.get('delta') + + if protocol == 'udp': + client = udp_statsd_client(host=host, port=port, prefix=metric_prefix, maxudpsize=512, ipv6=False) + elif protocol == 'tcp': + client = tcp_statsd_client(host=host, port=port, timeout=timeout, prefix=metric_prefix, ipv6=False) + + metric_name = '%s/%s' % (metric_prefix, metric) if metric_prefix else metric + metric_display_value = '%s (delta=%s)' % (value, delta) if metric_type == 'gauge' else value + + try: + if metric_type == 'counter': + client.incr(metric, value) + elif metric_type == 'gauge': + client.gauge(metric, value, delta=delta) + + except Exception as exc: + module.fail_json(msg='Failed sending to StatsD %s' % str(exc)) + + finally: + if protocol == 'tcp': + client.close() + + module.exit_json(msg="Sent %s %s -> %s to StatsD" % (metric_type, metric_name, str(metric_display_value)), changed=True) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/statsd.py b/plugins/modules/statsd.py new file mode 120000 index 0000000000..a906f4df1a --- /dev/null +++ b/plugins/modules/statsd.py @@ -0,0 +1 @@ +monitoring/statsd.py \ No newline at end of file diff --git a/tests/unit/plugins/modules/monitoring/test_statsd.py b/tests/unit/plugins/modules/monitoring/test_statsd.py new file mode 100644 index 0000000000..205080e754 --- /dev/null +++ b/tests/unit/plugins/modules/monitoring/test_statsd.py @@ -0,0 +1,101 @@ +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest + +from ansible_collections.community.general.plugins.modules.monitoring import statsd +from ansible_collections.community.general.tests.unit.compat.mock import patch, MagicMock +from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, set_module_args + + +class FakeStatsD(MagicMock): + + def incr(self, *args, **kwargs): + pass + + def gauge(self, *args, **kwargs): + pass + + def close(self, *args, **kwargs): + pass + + +class TestStatsDModule(ModuleTestCase): + + def setUp(self): + super(TestStatsDModule, self).setUp() + statsd.HAS_STATSD = True + self.module = statsd + + def tearDown(self): + super(TestStatsDModule, self).tearDown() + + def patch_udp_statsd_client(self, **kwargs): + return patch('ansible_collections.community.general.plugins.modules.monitoring.statsd.udp_statsd_client', autospec=True, **kwargs) + + def patch_tcp_statsd_client(self, **kwargs): + return patch('ansible_collections.community.general.plugins.modules.monitoring.statsd.tcp_statsd_client', autospec=True, **kwargs) + + def test_udp_without_parameters(self): + """Test udp without parameters""" + with self.patch_udp_statsd_client(side_effect=FakeStatsD) as fake_statsd: + with self.assertRaises(AnsibleFailJson) as result: + set_module_args({}) + self.module.main() + + def test_tcp_without_parameters(self): + """Test tcp without parameters""" + with self.patch_tcp_statsd_client(side_effect=FakeStatsD) as fake_statsd: + with self.assertRaises(AnsibleFailJson) as result: + set_module_args({}) + self.module.main() + + def test_udp_with_parameters(self): + """Test udp with parameters""" + with self.patch_udp_statsd_client(side_effect=FakeStatsD) as fake_statsd: + with self.assertRaises(AnsibleExitJson) as result: + set_module_args({ + 'metric': 'my_counter', + 'metric_type': 'counter', + 'value': 1, + }) + self.module.main() + self.assertEqual(result.exception.args[0]['msg'], 'Sent counter my_counter -> 1 to StatsD') + self.assertEqual(result.exception.args[0]['changed'], True) + with self.patch_udp_statsd_client(side_effect=FakeStatsD) as fake_statsd: + with self.assertRaises(AnsibleExitJson) as result: + set_module_args({ + 'metric': 'my_gauge', + 'metric_type': 'gauge', + 'value': 3, + }) + self.module.main() + self.assertEqual(result.exception.args[0]['msg'], 'Sent gauge my_gauge -> 3 (delta=False) to StatsD') + self.assertEqual(result.exception.args[0]['changed'], True) + + def test_tcp_with_parameters(self): + """Test tcp with parameters""" + with self.patch_tcp_statsd_client(side_effect=FakeStatsD) as fake_statsd: + with self.assertRaises(AnsibleExitJson) as result: + set_module_args({ + 'protocol': 'tcp', + 'metric': 'my_counter', + 'metric_type': 'counter', + 'value': 1, + }) + self.module.main() + self.assertEqual(result.exception.args[0]['msg'], 'Sent counter my_counter -> 1 to StatsD') + self.assertEqual(result.exception.args[0]['changed'], True) + with self.patch_tcp_statsd_client(side_effect=FakeStatsD) as fake_statsd: + with self.assertRaises(AnsibleExitJson) as result: + set_module_args({ + 'protocol': 'tcp', + 'metric': 'my_gauge', + 'metric_type': 'gauge', + 'value': 3, + }) + self.module.main() + self.assertEqual(result.exception.args[0]['msg'], 'Sent gauge my_gauge -> 3 (delta=False) to StatsD') + self.assertEqual(result.exception.args[0]['changed'], True)