blob: b958af61b2945cae9a59edcc2f011dffc922cce8 [file] [log] [blame]
Dan Smithc2772c22022-04-08 08:48:49 -07001#!/usr/bin/python3
2
3import argparse
Dan Smith64d68672022-04-22 07:58:29 -07004import csv
Dan Smithc2772c22022-04-08 08:48:49 -07005import datetime
6import glob
7import itertools
8import json
Dan Smith64d68672022-04-22 07:58:29 -07009import logging
Dan Smithc2772c22022-04-08 08:48:49 -070010import os
Dan Smithc2772c22022-04-08 08:48:49 -070011import re
12import socket
13import subprocess
14import sys
Dan Smith1b601c72022-04-25 07:47:56 -070015
16try:
17 import psutil
18except ImportError:
19 psutil = None
20 print('No psutil, process information will not be included',
21 file=sys.stderr)
22
23try:
24 import pymysql
25except ImportError:
26 pymysql = None
27 print('No pymysql, database information will not be included',
28 file=sys.stderr)
Dan Smithc2772c22022-04-08 08:48:49 -070029
Dan Smith64d68672022-04-22 07:58:29 -070030LOG = logging.getLogger('perf')
31
Dan Smithc2772c22022-04-08 08:48:49 -070032# https://www.elastic.co/blog/found-crash-elasticsearch#mapping-explosion
33
34
35def tryint(value):
36 try:
37 return int(value)
38 except (ValueError, TypeError):
39 return value
40
41
42def get_service_stats(service):
43 stats = {'MemoryCurrent': 0}
44 output = subprocess.check_output(['/usr/bin/systemctl', 'show', service] +
45 ['-p%s' % stat for stat in stats])
46 for line in output.decode().split('\n'):
47 if not line:
48 continue
49 stat, val = line.split('=')
Harald Jensåsbab0c922022-04-26 15:46:56 +020050 stats[stat] = tryint(val)
Dan Smithc2772c22022-04-08 08:48:49 -070051
52 return stats
53
54
55def get_services_stats():
56 services = [os.path.basename(s) for s in
Dan Smithe85c68e2022-05-26 09:31:36 -070057 glob.glob('/etc/systemd/system/devstack@*.service')] + \
58 ['apache2.service']
Dan Smithc2772c22022-04-08 08:48:49 -070059 return [dict(service=service, **get_service_stats(service))
60 for service in services]
61
62
63def get_process_stats(proc):
64 cmdline = proc.cmdline()
65 if 'python' in cmdline[0]:
66 cmdline = cmdline[1:]
67 return {'cmd': cmdline[0],
68 'pid': proc.pid,
69 'args': ' '.join(cmdline[1:]),
70 'rss': proc.memory_info().rss}
71
72
73def get_processes_stats(matches):
74 me = os.getpid()
75 procs = psutil.process_iter()
76
77 def proc_matches(proc):
78 return me != proc.pid and any(
79 re.search(match, ' '.join(proc.cmdline()))
80 for match in matches)
81
82 return [
83 get_process_stats(proc)
84 for proc in procs
85 if proc_matches(proc)]
86
87
88def get_db_stats(host, user, passwd):
89 dbs = []
Dan Smith1cdf4132022-05-23 13:56:13 -070090 try:
91 db = pymysql.connect(host=host, user=user, password=passwd,
92 database='stats',
93 cursorclass=pymysql.cursors.DictCursor)
94 except pymysql.err.OperationalError as e:
95 if 'Unknown database' in str(e):
96 print('No stats database; assuming devstack failed',
97 file=sys.stderr)
98 return []
99 raise
100
Dan Smithc2772c22022-04-08 08:48:49 -0700101 with db:
102 with db.cursor() as cur:
Dan Smithfe52d7f2022-04-28 12:34:38 -0700103 cur.execute('SELECT db,op,count FROM queries')
Dan Smithc2772c22022-04-08 08:48:49 -0700104 for row in cur:
105 dbs.append({k: tryint(v) for k, v in row.items()})
106 return dbs
107
108
109def get_http_stats_for_log(logfile):
110 stats = {}
Dan Smith64d68672022-04-22 07:58:29 -0700111 apache_fields = ('host', 'a', 'b', 'date', 'tz', 'request', 'status',
112 'length', 'c', 'agent')
113 ignore_agents = ('curl', 'uwsgi', 'nova-status')
Dan Smithfe7cfa62022-06-23 09:25:22 -0700114 ignored_services = set()
Dan Smith64d68672022-04-22 07:58:29 -0700115 for line in csv.reader(open(logfile), delimiter=' '):
116 fields = dict(zip(apache_fields, line))
117 if len(fields) != len(apache_fields):
118 # Not a combined access log, so we can bail completely
119 return []
120 try:
121 method, url, http = fields['request'].split(' ')
122 except ValueError:
123 method = url = http = ''
124 if 'HTTP' not in http:
125 # Not a combined access log, so we can bail completely
126 return []
Dan Smithc2772c22022-04-08 08:48:49 -0700127
Dan Smith64d68672022-04-22 07:58:29 -0700128 # Tempest's User-Agent is unchanged, but client libraries and
129 # inter-service API calls use proper strings. So assume
130 # 'python-urllib' is tempest so we can tell it apart.
131 if 'python-urllib' in fields['agent'].lower():
132 agent = 'tempest'
133 else:
134 agent = fields['agent'].split(' ')[0]
135 if agent.startswith('python-'):
136 agent = agent.replace('python-', '')
137 if '/' in agent:
138 agent = agent.split('/')[0]
Dan Smithc2772c22022-04-08 08:48:49 -0700139
Dan Smith64d68672022-04-22 07:58:29 -0700140 if agent in ignore_agents:
141 continue
142
143 try:
144 service, rest = url.strip('/').split('/', 1)
145 except ValueError:
146 # Root calls like "GET /identity"
147 service = url.strip('/')
148 rest = ''
149
Dan Smithfe7cfa62022-06-23 09:25:22 -0700150 if not service.isalpha():
151 ignored_services.add(service)
152 continue
153
Dan Smith64d68672022-04-22 07:58:29 -0700154 method_key = '%s-%s' % (agent, method)
155 try:
156 length = int(fields['length'])
157 except ValueError:
158 LOG.warning('[%s] Failed to parse length %r from line %r' % (
159 logfile, fields['length'], line))
160 length = 0
161 stats.setdefault(service, {'largest': 0})
162 stats[service].setdefault(method_key, 0)
163 stats[service][method_key] += 1
164 stats[service]['largest'] = max(stats[service]['largest'],
165 length)
Dan Smithc2772c22022-04-08 08:48:49 -0700166
Dan Smithfe7cfa62022-06-23 09:25:22 -0700167 if ignored_services:
168 LOG.warning('Ignored services: %s' % ','.join(
169 sorted(ignored_services)))
170
Dan Smithc2772c22022-04-08 08:48:49 -0700171 # Flatten this for ES
172 return [{'service': service, 'log': os.path.basename(logfile),
173 **vals}
174 for service, vals in stats.items()]
175
176
177def get_http_stats(logfiles):
178 return list(itertools.chain.from_iterable(get_http_stats_for_log(log)
179 for log in logfiles))
180
181
182def get_report_info():
183 return {
184 'timestamp': datetime.datetime.now().isoformat(),
185 'hostname': socket.gethostname(),
Dan Smith64d68672022-04-22 07:58:29 -0700186 'version': 2,
Dan Smithc2772c22022-04-08 08:48:49 -0700187 }
188
189
190if __name__ == '__main__':
191 process_defaults = ['privsep', 'mysqld', 'erlang', 'etcd']
192 parser = argparse.ArgumentParser()
193 parser.add_argument('--db-user', default='root',
194 help=('MySQL user for collecting stats '
195 '(default: "root")'))
196 parser.add_argument('--db-pass', default=None,
197 help='MySQL password for db-user')
198 parser.add_argument('--db-host', default='localhost',
199 help='MySQL hostname')
200 parser.add_argument('--apache-log', action='append', default=[],
201 help='Collect API call stats from this apache log')
202 parser.add_argument('--process', action='append',
203 default=process_defaults,
204 help=('Include process stats for this cmdline regex '
205 '(default is %s)' % ','.join(process_defaults)))
206 args = parser.parse_args()
207
Dan Smith64d68672022-04-22 07:58:29 -0700208 logging.basicConfig(level=logging.WARNING)
209
Dan Smithc2772c22022-04-08 08:48:49 -0700210 data = {
211 'services': get_services_stats(),
Dan Smith1b601c72022-04-25 07:47:56 -0700212 'db': pymysql and args.db_pass and get_db_stats(args.db_host,
213 args.db_user,
214 args.db_pass) or [],
215 'processes': psutil and get_processes_stats(args.process) or [],
Dan Smithc2772c22022-04-08 08:48:49 -0700216 'api': get_http_stats(args.apache_log),
217 'report': get_report_info(),
218 }
219
220 print(json.dumps(data, indent=2))