Newer
Older
nextcloud-monitoring-dashboard / zabbix-agent-scripts / zabbix-redis.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

#
# Note from RCA Systems
#
# This script is developed by Allenta Consulting and available in Github:

# https://github.com/allenta/zabbix-template-for-redis

# This is taken from release v14.0

# https://github.com/allenta/zabbix-template-for-redis/releases/tag/v14.0


# :url: https://github.com/allenta/zabbix-template-for-redis
# :copyright: (c) Allenta Consulting S.L. <info@allenta.com>.
# :license: BSD, see LICENSE.txt for more details.

from __future__ import absolute_import, division, print_function, unicode_literals
import json
import re
import subprocess
import sys
from argparse import ArgumentParser

TYPE_COUNTER = 1
TYPE_GAUGE = 2
TYPE_OTHER = 3
TYPES = (TYPE_COUNTER, TYPE_GAUGE, TYPE_OTHER)

ITEMS = {
    'server': (
        (r'server:uptime_in_seconds', TYPE_GAUGE),
        (r'clients:connected_clients', TYPE_GAUGE),
        (r'clients:blocked_clients', TYPE_GAUGE),
        (r'memory:used_memory', TYPE_GAUGE),
        (r'memory:used_memory_rss', TYPE_GAUGE),
        (r'memory:used_memory_lua', TYPE_GAUGE),
        (r'memory:mem_fragmentation_ratio', TYPE_GAUGE),
        (r'persistence:rdb_changes_since_last_save', TYPE_GAUGE),
        (r'persistence:rdb_last_save_time', TYPE_GAUGE),
        (r'persistence:rdb_last_bgsave_status', TYPE_OTHER),
        (r'persistence:rdb_last_bgsave_time_sec', TYPE_GAUGE),
        (r'persistence:aof_last_rewrite_time_sec', TYPE_GAUGE),
        (r'persistence:aof_last_bgrewrite_status', TYPE_OTHER),
        (r'persistence:aof_last_write_status', TYPE_OTHER),
        (r'stats:total_connections_received', TYPE_COUNTER),
        (r'stats:total_commands_processed', TYPE_COUNTER),
        (r'stats:total_net_input_bytes', TYPE_COUNTER),
        (r'stats:total_net_output_bytes', TYPE_COUNTER),
        (r'stats:rejected_connections', TYPE_COUNTER),
        (r'stats:sync_full', TYPE_COUNTER),
        (r'stats:sync_partial_ok', TYPE_COUNTER),
        (r'stats:sync_partial_err', TYPE_COUNTER),
        (r'stats:expired_keys', TYPE_COUNTER),
        (r'stats:evicted_keys', TYPE_COUNTER),
        (r'stats:keyspace_hits', TYPE_COUNTER),
        (r'stats:keyspace_misses', TYPE_COUNTER),
        (r'stats:pubsub_channels', TYPE_GAUGE),
        (r'stats:pubsub_patterns', TYPE_GAUGE),
        (r'replication:role', TYPE_GAUGE),
        (r'replication:connected_slaves', TYPE_GAUGE),
        (r'cpu:used_cpu_sys', TYPE_COUNTER),
        (r'cpu:used_cpu_user', TYPE_COUNTER),
        (r'cpu:used_cpu_sys_children', TYPE_COUNTER),
        (r'cpu:used_cpu_user_children', TYPE_COUNTER),
        (r'commandstats:[^:]+:calls', TYPE_COUNTER),
        (r'commandstats:[^:]+:usec_per_call', TYPE_GAUGE),
        (r'keyspace:[^:]+:(?:keys|expires|avg_ttl)', TYPE_GAUGE),
        (r'cluster:cluster_state', TYPE_GAUGE),
        (r'cluster:cluster_slots_assigned', TYPE_GAUGE),
        (r'cluster:cluster_slots_ok', TYPE_GAUGE),
        (r'cluster:cluster_slots_pfail', TYPE_GAUGE),
        (r'cluster:cluster_slots_fail', TYPE_GAUGE),
        (r'cluster:cluster_known_nodes', TYPE_GAUGE),
        (r'cluster:cluster_size', TYPE_GAUGE),
    ),
    'sentinel': (
        (r'server:uptime_in_seconds', TYPE_GAUGE),
        (r'sentinel:sentinel_masters', TYPE_GAUGE),
        (r'sentinel:sentinel_running_scripts', TYPE_GAUGE),
        (r'sentinel:sentinel_scripts_queue_length', TYPE_GAUGE),
        (r'masters:[^:]+:(?:status|slaves|sentinels|ckquorum|usable_sentinels)', TYPE_GAUGE),
    ),
}

SUBJECTS = {
    'server': {
        'items': None,
        'commandstats': re.compile(r'^commandstats:([^:]+):.+$'),
        'keyspace': re.compile(r'^keyspace:([^:]+):.+$'),
    },
    'sentinel': {
        'items': None,
        'masters': re.compile(r'^masters:([^:]+):.+$'),
    },
}


###############################################################################
## 'stats' COMMAND
###############################################################################

def stats(options):
    # Initializations.
    result = {}

    # Build master item contents.
    for instance in options.redis_instances.split(','):
        instance = instance.strip()
        stats = _stats(
            instance,
            options.redis_type,
            options.redis_user,
            options.redis_password)
        for item in stats.items:
            result['%(instance)s.%(name)s' % {
                'instance': _safe_zabbix_string(instance),
                'name': _safe_zabbix_string(item.name),
            }] = item.value

    # Render output.
    sys.stdout.write(json.dumps(result, separators=(',', ':')))


###############################################################################
## 'discover' COMMAND
###############################################################################

def discover(options):
    # Initializations.
    discovery = {
        'data': [],
    }

    # Build Zabbix discovery input.
    for instance in options.redis_instances.split(','):
        instance = instance.strip()
        if options.subject == 'items':
            discovery['data'].append({
                '{#LOCATION}': instance,
                '{#LOCATION_ID}': _safe_zabbix_string(instance),
            })
        else:
            stats = _stats(
                instance,
                options.redis_type,
                options.redis_user,
                options.redis_password)
            for subject in stats.subjects(options.subject):
                discovery['data'].append({
                    '{#LOCATION}': instance,
                    '{#LOCATION_ID}': _safe_zabbix_string(instance),
                    '{#SUBJECT}': subject,
                    '{#SUBJECT_ID}': _safe_zabbix_string(subject),
                })

    # Render output.
    sys.stdout.write(json.dumps(discovery, sort_keys=True, indent=2))


###############################################################################
## HELPERS
###############################################################################

class Item(object):
    '''
    A class to hold all relevant information about an item in the stats: name,
    value, type and subject (type & value).
    '''

    def __init__(
            self, name, value, type, subject_type=None, subject_value=None):
        # Set name and value.
        self._name = name
        self._value = value
        self._type = type
        self._subject_type = subject_type or 'items'
        self._subject_value = subject_value

    @property
    def name(self):
        return self._name

    @property
    def value(self):
        return self._value

    @property
    def type(self):
        return self._type

    @property
    def subject_type(self):
        return self._subject_type

    @property
    def subject_value(self):
        return self._subject_value

    def aggregate(self, value):
        # Aggregate another value. Only counter and gauges can be aggregated.
        # In any other case, mark this item's value as discarded.
        if self.type in (TYPE_COUNTER, TYPE_GAUGE):
            self._value += value
        else:
            self._value = None


class Stats(object):
    '''
    A class to hold results for a call to _stats: keeps all processed items and
    all subjects seen per subject type and provides helper methods to build and
    process those items.
    '''

    def __init__(self, items_definitions, subjects_patterns, log_handler=None):
        # Build items regular expression that will be used to match item names
        # and discover item types.
        items_re = dict((type, []) for type in TYPES)
        for item_re, item_type in items_definitions:
            items_re[item_type].append(item_re)
        self._items_patterns = dict(
            (type, re.compile(r'^(?:' + '|'.join(res) + r')$'))
            for type, res in items_re.items())

        # Set subject patterns that will be used to assign subject type and
        # subject values to items.
        self._subjects_patterns = subjects_patterns

        # Other initializations.
        self._log_handler = log_handler or sys.stderr.write
        self._items = {}
        self._subjects = {}

    @property
    def items(self):
        # Return all items that haven't had their value discarded because an
        # invalid aggregation.
        return (item for item in self._items.values() if item.value is not None)

    def add(self, name, value, type=None, subject_type=None,
            subject_value=None):
        # Add a new item to the internal state or simply aggregate it's value
        # if an item with the same name has already been added.
        if name in self._items:
            self._items[name].aggregate(value)
        else:
            # Build item.
            item = self._build_item(
                name, value, type, subject_type, subject_value)

            if item is not None:
                # Add new item to the internal state.
                self._items[item.name] = item

                # Also, register this item's subject in the corresponding set.
                if item.subject_type != None and item.subject_value != None:
                    if item.subject_type not in self._subjects:
                        self._subjects[item.subject_type] = set()
                    self._subjects[item.subject_type].add(item.subject_value)

    def get(self, name, default=None):
        # Return current value for a particular item or the given default value
        # if that item is not available or has had it's value discarded.
        if name in self._items and self._items[name].value is not None:
            return self._items[name].value
        else:
            return default

    def subjects(self, subject_type):
        # Return the set of registered subjects for a given subject type.
        return self._subjects.get(subject_type, set())

    def log(self, message):
        self._log_handler(message)

    def _build_item(
            self, name, value, type=None, subject_type=None,
            subject_value=None):
        # Initialize type if none was provided.
        if type is None:
            type = next((
                type for type in TYPES
                if self._items_patterns[type].match(name) is not None), None)

        # Filter invalid items.
        if type not in TYPES:
            return None

        # Initialize subject_type and subject_value if none were provided.
        if subject_type is None and subject_value is None:
            for subject, subject_re in self._subjects_patterns.items():
                if subject_re is not None:
                    match = subject_re.match(name)
                    if match is not None:
                        subject_type = subject
                        subject_value = match.group(1)
                        break

        # Return item instance.
        return Item(
            name=name,
            value=value,
            type=type,
            subject_type=subject_type,
            subject_value=subject_value
        )


def _stats(location, type, user, password):
    # Initializations.
    stats = Stats(ITEMS[type], SUBJECTS[type])
    clustered = False

    # Parse location of the Redis instance.
    prefix = 'unix://'
    if location.startswith(prefix):
        opts = '-s "%s"' % location[len(prefix):]
    else:
        if ':' in location:
            opts = '-h "%s" -p "%s"' % tuple(location.split(':', 1))
        else:
            opts = '-p "%s"' % location

    # Authenticate as an user other than default?
    if user is not None:
        opts += ' --user "%s"' % user

    # Use password?
    if password is not None:
        opts += ' -a "%s"' % password

    # Fetch general stats through redis-cli.
    rc, output = _execute('redis-cli %(opts)s INFO %(section)s' % {
        'opts': opts,
        'section': 'all' if type == 'server' else 'default',
    })
    if rc == 0:
        section = None
        for line in output.splitlines():
            # Start of section. Keep it's name.
            if line.startswith('#'):
                section = line[1:].strip().lower()

            # Item. Process it.
            elif section is not None and ':' in line:
                # Extract item name and item value.
                key, value = (v.strip() for v in line.split(':', 1))
                if section == 'commandstats' and \
                   key.startswith('cmdstat_'):
                    key = key[8:].upper()
                name = '%s:%s' % (section, key)

                # If this item enables cluster mode set the corresponding flag
                # to later check for cluster stats.
                if name == 'cluster:cluster_enabled' and value == '1':
                    clustered = True

                # Special keys with subvalues.
                if ((type == 'server' and
                     section in ('keyspace', 'commandstats')) or \
                    (type == 'sentinel' and
                     section == 'sentinel' and
                     key.startswith('master'))):
                    # Extract subvalues.
                    subvalues = {}
                    for item in value.split(','):
                        if '=' in item:
                            subkey, subvalue = item.split('=', 1)
                            subvalues[subkey.strip()] = subvalue.strip()

                    # Process subvalues.
                    for subkey, subvalue in subvalues.items():
                        if type == 'sentinel' and subkey == 'name':
                            _stats_sentinel(
                                stats,
                                opts,
                                subvalue,
                                'masters:%s(%s)' % (key, subvalue))
                        else:
                            subname = None
                            if type != 'sentinel':
                                subname = '%s:%s' % (name, subkey)
                            elif subkey != 'name' and 'name' in subvalues:
                                subname = 'masters:%s(%s):%s' % (
                                    key, subvalues['name'], subkey)
                            if subname is not None:
                                # Add item to the result.
                                stats.add(subname, subvalue)

                # Simple keys with no subvalues.
                else:
                    # Add item to the result.
                    stats.add(name, value)

    # Error recovering information from redis-cli.
    else:
        stats.log(output)

    # Fetch cluster stats through redis-cli.
    if type == 'server' and clustered:
        _stats_cluster(stats, opts)

    # Done!
    return stats


def _stats_sentinel(stats, opts, master_name, prefix):
    # Fetch sentinel stats through redis-cli.
    rc, output = _execute('redis-cli %(opts)s SENTINEL ckquorum %(name)s' % {
        'opts': opts,
        'name': master_name,
    })
    if rc == 0:
        # Examples:
        #   - OK 3 usable Sentinels. Quorum and failover authorization can be
        #     reached.
        #   - NOQUORUM 1 usable Sentinels. Not enough available Sentinels to
        #     reach the majority and authorize a failover.
        stats.add(
            name='%s:ckquorum' % prefix,
            value='1' if output.startswith('OK') else '0')
        items = output.split(' ', 2)
        if len(items) >= 2 and items[1].isdigit():
            stats.add(
                name='%s:usable_sentinels' % prefix,
                value=items[1])

    # Error recovering information from redis-cli.
    else:
        stats.log(output)


def _stats_cluster(stats, opts):
    # Fetch cluster stats through redis-cli.
    rc, output = _execute('redis-cli %(opts)s CLUSTER INFO' % {
        'opts': opts,
    })
    if rc == 0:
        for line in output.splitlines():
            if ':' in line:
                key, value = line.split(':', 1)
                stats.add(
                    name='cluster:%s' % key.strip(),
                    value=value.strip())

    # Error recovering information from redis-cli.
    else:
        stats.log(output)


def _safe_zabbix_string(value):
    # Return a modified version of 'value' safe to be used as part of:
    #   - A quoted key parameter (see https://www.zabbix.com/documentation/5.0/manual/config/items/item/key).
    #   - A JSON string.
    return value.replace('"', '\\"')


def _execute(command, stdin=None):
    child = subprocess.Popen(
        command,
        shell=True,
        stdout=subprocess.PIPE,
        stdin=subprocess.PIPE,
        stderr=subprocess.STDOUT)
    output = child.communicate(
        input=stdin.encode('utf-8') if stdin is not None else None)[0].decode('utf-8')
    return child.returncode, output


###############################################################################
## MAIN
###############################################################################

def main():
    # Set up the base command line parser.
    parser = ArgumentParser()
    parser.add_argument(
        '-i', '--redis-instances', dest='redis_instances',
        type=str, required=True,
        help='comma-delimited list of Redis instances to get stats from '
             '(port, host:port and unix:///path/to/socket formats are alowed')
    parser.add_argument(
        '-t', '--redis-type', dest='redis_type',
        type=str, required=True, choices=SUBJECTS.keys(),
        help='the type of the Redis instance to get stats from')
    parser.add_argument(
        '--redis-user', dest='redis_user',
        type=str, default=None,
        help='user name to be used in Redis instances authentication (redis >= 6.0)')
    parser.add_argument(
        '--redis-password', dest='redis_password',
        type=str, default=None,
        help='password required to access to Redis instances')
    subparsers = parser.add_subparsers(dest='command')

    # Set up 'stats' command.
    subparser = subparsers.add_parser(
        'stats',
        help='collect Redis stats')

    # Set up 'discover' command.
    subparser = subparsers.add_parser(
        'discover',
        help='generate Zabbix discovery schema')
    subparser.add_argument(
        'subject', type=str,
        help='dynamic resources to be discovered')

    # Parse command line arguments.
    options = parser.parse_args()

    # Check subject to be discovered.
    if options.command == 'discover':
        subjects = SUBJECTS[options.redis_type].keys()
        if options.subject not in subjects:
            sys.stderr.write('Invalid subject (choose from %(subjects)s)\n' % {
                'subjects': ', '.join("'{0}'".format(s) for s in subjects),
            })
            sys.exit(1)

    # Execute command.
    if options.command:
        globals()[options.command](options)
    else:
        parser.print_help()
        sys.exit(1)
    sys.exit(0)

if __name__ == '__main__':
    main()