#!/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()