File indexing completed on 2026-11-09 10:47:23
0001
0002 """panda-server-reporter.py — what only the PanDA server host can see.
0003
0004 Runs on pandaserver01 and posts one record per run to swf-monitor
0005 (docs/PANDA_SERVER_REPORTER.md): the web tier's request and status
0006 counts since the last run, the error log's markers, the age of every
0007 PanDA daemon log, the four PanDA units with their restart counters, the
0008 httpd and pandaserver processes with their resident memory, the
0009 database's reachability from this host, and the host's own load,
0010 memory and volumes. The platform component merges the record as its
0011 server_host group (docs/SNAPPER_PLATFORM.md).
0012
0013 Standard library only, no virtual environment. Every source that
0014 cannot be read is delivered as an error field rather than dropped,
0015 because a missing field and an unreadable source are otherwise the
0016 same thing to a reader. The access and error logs are read from the
0017 position the previous run left, so each record covers exactly the
0018 lines appended since it; a rotated log restarts from its beginning and
0019 says so.
0020
0021 Usage::
0022
0023 panda-server-reporter.py # collect and post
0024 panda-server-reporter.py --print # collect and print, post nothing
0025
0026 Configuration, from the environment or an environment file named by
0027 --env (mode 600): SWF_MONITOR_URL, SWF_REPORT_TOKEN.
0028 """
0029 import argparse
0030 import json
0031 import os
0032 import re
0033 import socket
0034 import ssl
0035 import subprocess
0036 import sys
0037 import time
0038 from datetime import datetime
0039
0040 HOST = 'pandaserver01'
0041 LOG_DIR = '/var/log/panda'
0042 ACCESS_LOG = os.path.join(LOG_DIR, 'panda_server_access_log')
0043 ERROR_LOG = os.path.join(LOG_DIR, 'panda_server_error_log')
0044 HTTPD_CONF = '/opt/panda/etc/panda/panda_server-httpd.conf'
0045 SERVER_CFG = '/etc/panda/panda_server.cfg'
0046
0047
0048 OVERRIDES = '/opt/panda/etc/panda/schedconfig/schedconfig-overrides.json'
0049
0050 POOL_LINE = re.compile(r'(wlcg|epic):any [^:]*: (\w+) \(([\w, ]+)\): ')
0051
0052 REGULATION_FIELD = re.compile(r'(\w+)=(\S+)')
0053 UNITS = ('panda_httpd', 'panda_daemon', 'panda_jedi', 'panda_mcp')
0054 DEFAULT_STATE = os.path.expanduser('~/.swf-panda-server-reporter')
0055 POST_TIMEOUT_S = 30
0056 BUFFER_KEEP = 48
0057
0058
0059 MAX_READ_BYTES = 64 * 1024 * 1024
0060 TOP_PATHS = 20
0061
0062
0063
0064 ACCESS_RE = re.compile(
0065 r'^\[([^\]]+)\] (\S+) "(\S+) (\S+)[^"]*" (\d{3}) (\S+)')
0066
0067 ERROR_LEVEL_RE = re.compile(r'\] \[(\w+):(\w+)\]')
0068
0069
0070 ERROR_MARKERS = (
0071 ('worker_saturation', 'MaxRequestWorkers'),
0072 ('wsgi_timeout', 'Timeout when reading response headers'),
0073 ('wsgi_truncated', 'Truncated or oversized response headers'),
0074 ('ssl_read', 'SSL_read'),
0075 ('child_exit_signal', 'exit signal'),
0076 ('no_memory', 'Cannot allocate memory'),
0077 )
0078
0079
0080 def now_iso():
0081 return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
0082
0083
0084 def run(cmd, timeout=30):
0085 """A command's stdout, or None with the reason on the record."""
0086 try:
0087 done = subprocess.run(cmd, stdout=subprocess.PIPE,
0088 stderr=subprocess.PIPE,
0089 universal_newlines=True, timeout=timeout)
0090 except Exception as exc:
0091 return None, '{}: {}'.format(exc.__class__.__name__, exc)
0092 if done.returncode != 0:
0093 return None, 'exit {}: {}'.format(done.returncode,
0094 (done.stderr or '').strip()[:400])
0095 return done.stdout, None
0096
0097
0098
0099
0100 def read_since(path, positions, first_run):
0101 """The lines appended to ``path`` since the last run, and how the
0102 read went. ``positions`` is the state map, updated in place. On the
0103 first run nothing is counted: the position is set to the end so the
0104 next run covers a known interval. A new inode or a shorter file is a
0105 rotation: the read restarts from the file's beginning."""
0106 info = {'bytes': 0, 'rotated': False, 'truncated': False}
0107 try:
0108 st = os.stat(path)
0109 except OSError as exc:
0110 info['error'] = 'cannot stat: {}'.format(exc)
0111 return [], info
0112 prev = positions.get(path) or {}
0113 offset = prev.get('offset', 0)
0114 if first_run or not prev:
0115 positions[path] = {'inode': st.st_ino, 'offset': st.st_size}
0116 info['first_run'] = True
0117 return [], info
0118 if prev.get('inode') != st.st_ino or offset > st.st_size:
0119 info['rotated'] = True
0120 offset = 0
0121 to_read = st.st_size - offset
0122 if to_read > MAX_READ_BYTES:
0123 info['truncated'] = True
0124 offset = st.st_size - MAX_READ_BYTES
0125 to_read = MAX_READ_BYTES
0126 lines = []
0127 try:
0128 with open(path, 'rb') as handle:
0129 handle.seek(offset)
0130 chunk = handle.read(to_read)
0131
0132 end = chunk.rfind(b'\n') + 1
0133 lines = chunk[:end].decode('utf-8', 'replace').splitlines()
0134 info['bytes'] = end
0135 positions[path] = {'inode': st.st_ino, 'offset': offset + end}
0136 except OSError as exc:
0137 info['error'] = 'cannot read: {}'.format(exc)
0138 return lines, info
0139
0140
0141 def endpoint_class(path):
0142 """The request's class for counting: the two pilot calls that carry
0143 the workload, the rest of the pilot API, harvester, the schedconfig
0144 cache, statistics, other."""
0145 if path.endswith('/update_job') or path.endswith('/updateJob'):
0146 return 'update_job'
0147 if path.endswith('/acquire_jobs') or path.endswith('/getJob'):
0148 return 'acquire_jobs'
0149 if '/pilot/' in path:
0150 return 'pilot_other'
0151 if 'harvester' in path.lower():
0152 return 'harvester'
0153 if path.startswith('/cache/'):
0154 return 'cache'
0155 if '/statistics/' in path:
0156 return 'statistics'
0157 return 'other'
0158
0159
0160 def web_tier(lines, info):
0161 """Requests since the last run by endpoint class and status class,
0162 the top paths, and the 5xx count the platform view plots."""
0163 out = {'read': info, 'requests': 0, 'unparsed': 0,
0164 'by_endpoint': {}, 'by_status_class': {}, 'status_5xx': 0,
0165 'paths': {}, 'clients': 0}
0166 paths = {}
0167 clients = set()
0168 for line in lines:
0169 found = ACCESS_RE.match(line)
0170 if not found:
0171 out['unparsed'] += 1
0172 continue
0173 client, path, status = found.group(2), found.group(4), found.group(5)
0174 path = path.split('?', 1)[0]
0175 out['requests'] += 1
0176 cls = endpoint_class(path)
0177 out['by_endpoint'][cls] = out['by_endpoint'].get(cls, 0) + 1
0178 sc = status[0] + 'xx'
0179 out['by_status_class'][sc] = out['by_status_class'].get(sc, 0) + 1
0180 if status.startswith('5'):
0181 out['status_5xx'] += 1
0182 paths[path] = paths.get(path, 0) + 1
0183 clients.add(client)
0184 top = sorted(paths.items(), key=lambda kv: (-kv[1], kv[0]))[:TOP_PATHS]
0185 out['paths'] = dict(top)
0186 out['paths_distinct'] = len(paths)
0187 out['clients'] = len(clients)
0188 return out
0189
0190
0191 def error_markers(lines, info):
0192 """Error-log lines since the last run by level and by named marker,
0193 with the last few non-info lines for the card."""
0194 out = {'read': info, 'lines': len(lines), 'by_level': {},
0195 'markers': {name: 0 for name, _ in ERROR_MARKERS},
0196 'recent': []}
0197 for line in lines:
0198 found = ERROR_LEVEL_RE.search(line)
0199 level = found.group(2) if found else 'unknown'
0200 out['by_level'][level] = out['by_level'].get(level, 0) + 1
0201 for name, needle in ERROR_MARKERS:
0202 if needle in line:
0203 out['markers'][name] += 1
0204 if level not in ('info', 'debug', 'trace1', 'trace2', 'trace3',
0205 'trace4', 'trace5', 'trace6', 'trace7', 'trace8'):
0206 out['recent'].append(line[:300])
0207 out['recent'] = out['recent'][-5:]
0208 return out
0209
0210
0211 def web_limits():
0212 """The web tier's declared capacity, from the httpd configuration."""
0213 out = {}
0214 try:
0215 with open(HTTPD_CONF) as handle:
0216 text = handle.read()
0217 except OSError as exc:
0218 return {'error': 'cannot read {}: {}'.format(HTTPD_CONF, exc)}
0219 for key, pattern in (
0220 ('max_request_workers', r'^\s*MaxRequestWorkers\s+(\d+)'),
0221 ('server_limit', r'^\s*ServerLimit\s+(\d+)'),
0222 ('threads_per_child', r'^\s*ThreadsPerChild\s+(\d+)'),
0223 ('wsgi_daemon_processes',
0224 r'WSGIDaemonProcess\s+\S+\s+processes=(\d+)')):
0225 found = re.findall(pattern, text, re.M)
0226 out[key] = int(found[-1]) if found else None
0227 return out
0228
0229
0230
0231
0232 def daemons():
0233 """Every PanDA log on the host by the age of its last write: a
0234 daemon is named by its log, and that age is what says whether it is
0235 working (the submit host reporter's idiom)."""
0236 out = {}
0237 try:
0238 names = sorted(n for n in os.listdir(LOG_DIR) if n.endswith('.log'))
0239 except OSError as exc:
0240 return {'error': 'cannot list {}: {}'.format(LOG_DIR, exc)}
0241 now = time.time()
0242 for name in names:
0243 label = name[len('panda-'):-len('.log')] if name.startswith('panda-') \
0244 else name[:-len('.log')]
0245 path = os.path.join(LOG_DIR, name)
0246 try:
0247 out[label] = {'log_age_seconds': int(now - os.path.getmtime(path)),
0248 'log_bytes': os.path.getsize(path)}
0249 except OSError as exc:
0250 out[label] = {'error': str(exc)}
0251 return out
0252
0253
0254 def services():
0255 """The four PanDA units: state, restarts since boot, seconds active."""
0256 out = {}
0257 try:
0258 with open('/proc/uptime') as handle:
0259 uptime = float(handle.read().split()[0])
0260 except (OSError, ValueError, IndexError):
0261 uptime = None
0262 text, err = run(['systemctl', 'show', '-p', 'Id', '-p', 'ActiveState',
0263 '-p', 'SubState', '-p', 'NRestarts',
0264 '-p', 'ActiveEnterTimestampMonotonic', '--no-pager']
0265 + list(UNITS))
0266 if err:
0267 return {'error': 'systemctl unavailable: {}'.format(err)}
0268 for block in (text or '').strip().split('\n\n'):
0269 props = dict(line.split('=', 1) for line in block.splitlines()
0270 if '=' in line)
0271 unit = (props.get('Id') or '').replace('.service', '')
0272 if not unit:
0273 continue
0274 entry = {'active': props.get('ActiveState'),
0275 'sub': props.get('SubState'),
0276 'restarts': int(props.get('NRestarts') or 0)}
0277 mono = props.get('ActiveEnterTimestampMonotonic')
0278 if uptime is not None and mono and mono.isdigit() and int(mono):
0279 entry['active_seconds'] = int(uptime - int(mono) / 1e6)
0280 out[unit] = entry
0281 for unit in UNITS:
0282 out.setdefault(unit, {'error': 'not reported by systemctl'})
0283 return out
0284
0285
0286 def processes():
0287 """The httpd workers and the pandaserver python processes: count and
0288 resident memory, read from /proc."""
0289 out = {'httpd': {'count': 0, 'rss_mb': 0.0},
0290 'pandaserver': {'count': 0, 'rss_mb': 0.0}}
0291 try:
0292 pids = [p for p in os.listdir('/proc') if p.isdigit()]
0293 except OSError as exc:
0294 return {'error': 'cannot list /proc: {}'.format(exc)}
0295 for pid in pids:
0296 try:
0297 with open('/proc/{}/cmdline'.format(pid), 'rb') as handle:
0298 cmd = handle.read().replace(b'\0', b' ').decode('utf-8',
0299 'replace')
0300 if 'panda_server-httpd.conf' in cmd:
0301 bucket = 'httpd'
0302 elif 'pandaserver' in cmd and 'python' in cmd:
0303 bucket = 'pandaserver'
0304 else:
0305 continue
0306 rss_kb = 0
0307 with open('/proc/{}/status'.format(pid)) as handle:
0308 for line in handle:
0309 if line.startswith('VmRSS:'):
0310 rss_kb = int(line.split()[1])
0311 break
0312 except (OSError, ValueError, IndexError):
0313 continue
0314 out[bucket]['count'] += 1
0315 out[bucket]['rss_mb'] += rss_kb / 1024.0
0316 for bucket in out.values():
0317 bucket['rss_mb'] = round(bucket['rss_mb'], 1)
0318 return out
0319
0320
0321
0322
0323 def database():
0324 """A timed TCP connect to the database host the server configuration
0325 names. No credential is used, so this is reachability and the
0326 network's cost, not a query."""
0327 out = {}
0328 host = port = None
0329 try:
0330 with open(SERVER_CFG) as handle:
0331 for line in handle:
0332 key, _, value = line.partition('=')
0333 key = key.strip()
0334 if key == 'dbhost':
0335 host = value.strip()
0336 elif key == 'dbport':
0337 port = int(value.strip())
0338 except (OSError, ValueError) as exc:
0339 return {'error': 'cannot read {}: {}'.format(SERVER_CFG, exc)}
0340 if not host or not port:
0341 return {'error': 'dbhost/dbport not found in {}'.format(SERVER_CFG)}
0342 out['host'] = host
0343 out['port'] = port
0344 started = time.time()
0345 try:
0346 sock = socket.create_connection((host, port), timeout=5)
0347 sock.close()
0348 out['connect_ms'] = round((time.time() - started) * 1000, 1)
0349 out['reachable'] = True
0350 except OSError as exc:
0351 out['reachable'] = False
0352 out['error'] = '{}: {}'.format(exc.__class__.__name__, exc)
0353 return out
0354
0355
0356 def host_state():
0357 """The host's own state: load, memory, the root and /var volumes."""
0358 out = {}
0359 try:
0360 with open('/proc/loadavg') as handle:
0361 parts = handle.read().split()
0362 out['load'] = {'1m': float(parts[0]), '5m': float(parts[1]),
0363 '15m': float(parts[2])}
0364 except (OSError, ValueError, IndexError) as exc:
0365 out['load_error'] = str(exc)
0366 mem = {}
0367 try:
0368 with open('/proc/meminfo') as handle:
0369 for line in handle:
0370 key, _, rest = line.partition(':')
0371 if key in ('MemTotal', 'MemAvailable', 'SwapTotal',
0372 'SwapFree'):
0373 mem[key] = int(rest.split()[0])
0374 out['memory_kb'] = mem
0375 except (OSError, ValueError, IndexError) as exc:
0376 out['memory_error'] = str(exc)
0377 volumes = {}
0378 for mount in ('/', '/var'):
0379 try:
0380 st = os.statvfs(mount)
0381 total = st.f_blocks * st.f_frsize
0382 free = st.f_bavail * st.f_frsize
0383 volumes[mount] = {
0384 'total_bytes': total, 'free_bytes': free,
0385 'used_percent': round(100.0 * (total - free) / total, 1)
0386 if total else None}
0387 except OSError as exc:
0388 volumes[mount] = {'error': str(exc)}
0389 out['volumes'] = volumes
0390 try:
0391 with open('/proc/uptime') as handle:
0392 out['uptime_seconds'] = int(float(handle.read().split()[0]))
0393 except (OSError, ValueError, IndexError) as exc:
0394 out['uptime_error'] = str(exc)
0395 return out
0396
0397
0398
0399
0400 def load_state(state_dir):
0401 path = os.path.join(state_dir, 'state.json')
0402 try:
0403 with open(path) as handle:
0404 return json.load(handle)
0405 except (OSError, ValueError):
0406 return {}
0407
0408
0409 def save_state(state_dir, state):
0410 try:
0411 os.makedirs(state_dir, exist_ok=True)
0412 path = os.path.join(state_dir, 'state.json')
0413 with open(path + '.tmp', 'w') as handle:
0414 json.dump(state, handle)
0415 os.replace(path + '.tmp', path)
0416 except OSError as exc:
0417 print('could not save state: {}'.format(exc), file=sys.stderr)
0418
0419
0420 def jedi_regulation():
0421 """Bounded tail of runtime evidence emitted after the regulator's checks,
0422 and each pool the throttler last read with its member queues."""
0423 path = os.path.join(LOG_DIR, 'panda-EpicProdJobThrottler.log')
0424 voices = {}
0425 pools = {}
0426 unread = {'count': 0, 'latest': ''}
0427 try:
0428 with open(path, 'rb') as handle:
0429 handle.seek(0, os.SEEK_END)
0430 handle.seek(max(0, handle.tell() - 1024 * 1024))
0431 lines = handle.read().decode('utf-8', 'replace').splitlines()
0432 for line in lines:
0433 stamp = re.search(r'\d{4}-\d\d-\d\d[ T]\d\d:\d\d:\d\d', line)
0434 if 'REGULATION_OK' in line:
0435
0436
0437
0438
0439 fields = dict(REGULATION_FIELD.findall(line.split('REGULATION_OK', 1)[1]))
0440 verified = (fields.get('vo') in ('wlcg', 'epic')
0441 and fields.get('mode') == 'throttle'
0442 and fields.get('lock') == 'true'
0443 and fields.get('exclusions') == 'true'
0444 and fields.get('decision') in ('PASS', 'SKIP'))
0445 if verified and stamp:
0446 voices[fields['vo']] = {'at': stamp.group().replace(' ', 'T') + 'Z',
0447 'decision': fields['decision']}
0448 elif not (stamp and fields.get('vo') and fields.get('decision')):
0449 unread['count'] += 1
0450 unread['latest'] = line[-300:]
0451 pool = POOL_LINE.search(line)
0452 if pool and stamp:
0453 pools.setdefault(pool.group(1), {})[pool.group(2)] = {
0454 'members': [q.strip() for q in pool.group(3).split(',') if q.strip()],
0455 'at': stamp.group().replace(' ', 'T') + 'Z'}
0456 return {'voices': voices, 'pools': pools, 'unread': unread}
0457 except OSError as exc:
0458 return {'error': str(exc), 'voices': voices, 'pools': pools, 'unread': unread}
0459
0460
0461 def queue_overrides():
0462 """Each queue's status in the installed schedconfig overrides; None
0463 where an entry sets none."""
0464 try:
0465 with open(OVERRIDES) as handle:
0466 data = json.load(handle)
0467 except (OSError, ValueError) as exc:
0468 return {'error': 'cannot read {}: {}'.format(OVERRIDES, exc)}
0469 return {'status': {queue: entry.get('status') for queue, entry in data.items()
0470 if isinstance(entry, dict)}}
0471
0472
0473 def collect(state):
0474 """The record this run delivers; ``state`` is advanced in place."""
0475 started = time.time()
0476 record = {'host': HOST, 'collected_at': now_iso(),
0477 'reporter_version': '1.3'}
0478 positions = state.setdefault('positions', {})
0479 last_run = state.get('last_run_at')
0480 first_run = not last_run
0481 record['interval'] = {'since': last_run,
0482 'seconds': None, 'first_run': first_run}
0483 if last_run:
0484 try:
0485 since = datetime.strptime(last_run, '%Y-%m-%dT%H:%M:%SZ')
0486 record['interval']['seconds'] = int(
0487 (datetime.utcnow() - since).total_seconds())
0488 except ValueError:
0489 record['interval']['error'] = 'unparsable last_run_at'
0490
0491 lines, info = read_since(ACCESS_LOG, positions, first_run)
0492 record['web_tier'] = web_tier(lines, info)
0493 record['web_tier']['limits'] = web_limits()
0494 lines, info = read_since(ERROR_LOG, positions, first_run)
0495 record['error_log'] = error_markers(lines, info)
0496 record['daemons'] = daemons()
0497 record['services'] = services()
0498 record['processes'] = processes()
0499 record['database'] = database()
0500 record['jedi_regulation'] = jedi_regulation()
0501 record['queue_overrides'] = queue_overrides()
0502 record['health'] = host_state()
0503 record['collect_seconds'] = round(time.time() - started, 2)
0504 state['last_run_at'] = record['collected_at']
0505 return record
0506
0507
0508
0509
0510 def load_env(path):
0511 """Read KEY=value lines from an environment file, if there is one."""
0512 if not path or not os.path.exists(path):
0513 return
0514 with open(path) as handle:
0515 for line in handle:
0516 line = line.strip()
0517 if not line or line.startswith('#') or '=' not in line:
0518 continue
0519 key, value = line.split('=', 1)
0520 os.environ.setdefault(key.strip(), value.strip().strip('"\''))
0521
0522
0523 def post(record, url, token):
0524 """Deliver one record. Returns None on success, the reason on failure."""
0525 import urllib.request
0526 body = json.dumps(record).encode('utf-8')
0527 endpoint = '{}/api/host-reports/{}/'.format(url.rstrip('/'), HOST)
0528 request = urllib.request.Request(
0529 endpoint, data=body, method='POST',
0530 headers={'Content-Type': 'application/json',
0531 'Authorization': 'Token {}'.format(token)})
0532
0533
0534 context = ssl._create_unverified_context()
0535 try:
0536 with urllib.request.urlopen(request, timeout=POST_TIMEOUT_S,
0537 context=context) as response:
0538 response.read()
0539 return None
0540 except Exception as exc:
0541 return '{}: {}'.format(exc.__class__.__name__, exc)
0542
0543
0544 def buffered(state_dir):
0545 """Records held from earlier runs the monitor could not take."""
0546 try:
0547 names = sorted(os.listdir(state_dir))
0548 except OSError:
0549 return []
0550 return [os.path.join(state_dir, n) for n in names
0551 if n.startswith('record-') and n.endswith('.json')]
0552
0553
0554 def buffer_record(state_dir, record):
0555 """Hold a record for the next run, keeping the buffer bounded."""
0556 try:
0557 os.makedirs(state_dir, exist_ok=True)
0558 path = os.path.join(state_dir, 'record-{}.json'.format(
0559 record['collected_at'].replace(':', '')))
0560 with open(path, 'w') as handle:
0561 json.dump(record, handle)
0562 held = buffered(state_dir)
0563 for stale in held[:-BUFFER_KEEP]:
0564 os.unlink(stale)
0565 except OSError as exc:
0566 print('could not buffer the record: {}'.format(exc), file=sys.stderr)
0567
0568
0569 def main():
0570 parser = argparse.ArgumentParser()
0571 parser.add_argument('--print', dest='show', action='store_true',
0572 help='print the record, post nothing, leave the '
0573 'state untouched')
0574 parser.add_argument('--env', default=os.path.expanduser(
0575 '~/.swf-panda-server-reporter.env'))
0576 parser.add_argument('--state', default=DEFAULT_STATE)
0577 args = parser.parse_args()
0578
0579 state = load_state(args.state)
0580 record = collect(state)
0581 if args.show:
0582 print(json.dumps(record, indent=2, sort_keys=True))
0583 return 0
0584 save_state(args.state, state)
0585
0586 load_env(args.env)
0587 url = os.environ.get('SWF_MONITOR_URL')
0588 token = os.environ.get('SWF_REPORT_TOKEN')
0589 if not url or not token:
0590 print('SWF_MONITOR_URL and SWF_REPORT_TOKEN are required '
0591 '(environment or --env file)', file=sys.stderr)
0592 buffer_record(args.state, record)
0593 return 2
0594
0595
0596 for path in buffered(args.state):
0597 try:
0598 with open(path) as handle:
0599 held = json.load(handle)
0600 except (OSError, ValueError):
0601 os.unlink(path)
0602 continue
0603 if post(held, url, token) is None:
0604 os.unlink(path)
0605 else:
0606 break
0607
0608 failure = post(record, url, token)
0609 if failure:
0610 print('post failed, record buffered: {}'.format(failure),
0611 file=sys.stderr)
0612 buffer_record(args.state, record)
0613 return 1
0614 web = record.get('web_tier') or {}
0615 print('reported: {} request(s), {} 5xx, {} error-log line(s), '
0616 'db connect {} ms'.format(
0617 web.get('requests'), web.get('status_5xx'),
0618 (record.get('error_log') or {}).get('lines'),
0619 (record.get('database') or {}).get('connect_ms', '?')))
0620 return 0
0621
0622
0623 if __name__ == '__main__':
0624 sys.exit(main())