|  | #!/usr/bin/python3 | 
|  |  | 
|  | import argparse | 
|  | import csv | 
|  | import datetime | 
|  | import glob | 
|  | import itertools | 
|  | import json | 
|  | import logging | 
|  | import os | 
|  | import re | 
|  | import socket | 
|  | import subprocess | 
|  | import sys | 
|  |  | 
|  | try: | 
|  | import psutil | 
|  | except ImportError: | 
|  | psutil = None | 
|  | print('No psutil, process information will not be included', | 
|  | file=sys.stderr) | 
|  |  | 
|  | try: | 
|  | import pymysql | 
|  | except ImportError: | 
|  | pymysql = None | 
|  | print('No pymysql, database information will not be included', | 
|  | file=sys.stderr) | 
|  |  | 
|  | LOG = logging.getLogger('perf') | 
|  |  | 
|  | # https://www.elastic.co/blog/found-crash-elasticsearch#mapping-explosion | 
|  |  | 
|  |  | 
|  | def tryint(value): | 
|  | try: | 
|  | return int(value) | 
|  | except (ValueError, TypeError): | 
|  | return value | 
|  |  | 
|  |  | 
|  | def get_service_stats(service): | 
|  | stats = {'MemoryCurrent': 0} | 
|  | output = subprocess.check_output(['/usr/bin/systemctl', 'show', service] + | 
|  | ['-p%s' % stat for stat in stats]) | 
|  | for line in output.decode().split('\n'): | 
|  | if not line: | 
|  | continue | 
|  | stat, val = line.split('=') | 
|  | stats[stat] = tryint(val) | 
|  |  | 
|  | return stats | 
|  |  | 
|  |  | 
|  | def get_services_stats(): | 
|  | services = [os.path.basename(s) for s in | 
|  | glob.glob('/etc/systemd/system/devstack@*.service')] + \ | 
|  | ['apache2.service'] | 
|  | return [dict(service=service, **get_service_stats(service)) | 
|  | for service in services] | 
|  |  | 
|  |  | 
|  | def get_process_stats(proc): | 
|  | cmdline = proc.cmdline() | 
|  | if 'python' in cmdline[0]: | 
|  | cmdline = cmdline[1:] | 
|  | return {'cmd': cmdline[0], | 
|  | 'pid': proc.pid, | 
|  | 'args': ' '.join(cmdline[1:]), | 
|  | 'rss': proc.memory_info().rss} | 
|  |  | 
|  |  | 
|  | def get_processes_stats(matches): | 
|  | me = os.getpid() | 
|  | procs = psutil.process_iter() | 
|  |  | 
|  | def proc_matches(proc): | 
|  | return me != proc.pid and any( | 
|  | re.search(match, ' '.join(proc.cmdline())) | 
|  | for match in matches) | 
|  |  | 
|  | return [ | 
|  | get_process_stats(proc) | 
|  | for proc in procs | 
|  | if proc_matches(proc)] | 
|  |  | 
|  |  | 
|  | def get_db_stats(host, user, passwd): | 
|  | dbs = [] | 
|  | try: | 
|  | db = pymysql.connect(host=host, user=user, password=passwd, | 
|  | database='stats', | 
|  | cursorclass=pymysql.cursors.DictCursor) | 
|  | except pymysql.err.OperationalError as e: | 
|  | if 'Unknown database' in str(e): | 
|  | print('No stats database; assuming devstack failed', | 
|  | file=sys.stderr) | 
|  | return [] | 
|  | raise | 
|  |  | 
|  | with db: | 
|  | with db.cursor() as cur: | 
|  | cur.execute('SELECT db,op,count FROM queries') | 
|  | for row in cur: | 
|  | dbs.append({k: tryint(v) for k, v in row.items()}) | 
|  | return dbs | 
|  |  | 
|  |  | 
|  | def get_http_stats_for_log(logfile): | 
|  | stats = {} | 
|  | apache_fields = ('host', 'a', 'b', 'date', 'tz', 'request', 'status', | 
|  | 'length', 'c', 'agent') | 
|  | ignore_agents = ('curl', 'uwsgi', 'nova-status') | 
|  | ignored_services = set() | 
|  | for line in csv.reader(open(logfile), delimiter=' '): | 
|  | fields = dict(zip(apache_fields, line)) | 
|  | if len(fields) != len(apache_fields): | 
|  | # Not a combined access log, so we can bail completely | 
|  | return [] | 
|  | try: | 
|  | method, url, http = fields['request'].split(' ') | 
|  | except ValueError: | 
|  | method = url = http = '' | 
|  | if 'HTTP' not in http: | 
|  | # Not a combined access log, so we can bail completely | 
|  | return [] | 
|  |  | 
|  | # Tempest's User-Agent is unchanged, but client libraries and | 
|  | # inter-service API calls use proper strings. So assume | 
|  | # 'python-urllib' is tempest so we can tell it apart. | 
|  | if 'python-urllib' in fields['agent'].lower(): | 
|  | agent = 'tempest' | 
|  | else: | 
|  | agent = fields['agent'].split(' ')[0] | 
|  | if agent.startswith('python-'): | 
|  | agent = agent.replace('python-', '') | 
|  | if '/' in agent: | 
|  | agent = agent.split('/')[0] | 
|  |  | 
|  | if agent in ignore_agents: | 
|  | continue | 
|  |  | 
|  | try: | 
|  | service, rest = url.strip('/').split('/', 1) | 
|  | except ValueError: | 
|  | # Root calls like "GET /identity" | 
|  | service = url.strip('/') | 
|  | rest = '' | 
|  |  | 
|  | if not service.isalpha(): | 
|  | ignored_services.add(service) | 
|  | continue | 
|  |  | 
|  | method_key = '%s-%s' % (agent, method) | 
|  | try: | 
|  | length = int(fields['length']) | 
|  | except ValueError: | 
|  | LOG.warning('[%s] Failed to parse length %r from line %r' % ( | 
|  | logfile, fields['length'], line)) | 
|  | length = 0 | 
|  | stats.setdefault(service, {'largest': 0}) | 
|  | stats[service].setdefault(method_key, 0) | 
|  | stats[service][method_key] += 1 | 
|  | stats[service]['largest'] = max(stats[service]['largest'], | 
|  | length) | 
|  |  | 
|  | if ignored_services: | 
|  | LOG.warning('Ignored services: %s' % ','.join( | 
|  | sorted(ignored_services))) | 
|  |  | 
|  | # Flatten this for ES | 
|  | return [{'service': service, 'log': os.path.basename(logfile), | 
|  | **vals} | 
|  | for service, vals in stats.items()] | 
|  |  | 
|  |  | 
|  | def get_http_stats(logfiles): | 
|  | return list(itertools.chain.from_iterable(get_http_stats_for_log(log) | 
|  | for log in logfiles)) | 
|  |  | 
|  |  | 
|  | def get_report_info(): | 
|  | return { | 
|  | 'timestamp': datetime.datetime.now().isoformat(), | 
|  | 'hostname': socket.gethostname(), | 
|  | 'version': 2, | 
|  | } | 
|  |  | 
|  |  | 
|  | if __name__ == '__main__': | 
|  | process_defaults = ['privsep', 'mysqld', 'erlang', 'etcd'] | 
|  | parser = argparse.ArgumentParser() | 
|  | parser.add_argument('--db-user', default='root', | 
|  | help=('MySQL user for collecting stats ' | 
|  | '(default: "root")')) | 
|  | parser.add_argument('--db-pass', default=None, | 
|  | help='MySQL password for db-user') | 
|  | parser.add_argument('--db-host', default='localhost', | 
|  | help='MySQL hostname') | 
|  | parser.add_argument('--apache-log', action='append', default=[], | 
|  | help='Collect API call stats from this apache log') | 
|  | parser.add_argument('--process', action='append', | 
|  | default=process_defaults, | 
|  | help=('Include process stats for this cmdline regex ' | 
|  | '(default is %s)' % ','.join(process_defaults))) | 
|  | args = parser.parse_args() | 
|  |  | 
|  | logging.basicConfig(level=logging.WARNING) | 
|  |  | 
|  | data = { | 
|  | 'services': get_services_stats(), | 
|  | 'db': pymysql and args.db_pass and get_db_stats(args.db_host, | 
|  | args.db_user, | 
|  | args.db_pass) or [], | 
|  | 'processes': psutil and get_processes_stats(args.process) or [], | 
|  | 'api': get_http_stats(args.apache_log), | 
|  | 'report': get_report_info(), | 
|  | } | 
|  |  | 
|  | print(json.dumps(data, indent=2)) |