Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-11-09 10:47:23

0001 #!/usr/bin/env python3
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 # The installed schedconfig overrides: each queue's PanDA status, a copy
0047 # the queue registry checks (swf-epicprod docs/QUEUE_REGISTRY.md).
0048 OVERRIDES = '/opt/panda/etc/panda/schedconfig/schedconfig-overrides.json'
0049 # The throttler's reading of a pool: '<vo>:any ...: <pool> (<queue>, ...): '
0050 POOL_LINE = re.compile(r'(wlcg|epic):any [^:]*: (\w+) \(([\w, ]+)\): ')
0051 # A field of the throttler's REGULATION_OK decision line: key=value
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 # A run reads at most this much appended log; beyond it the record
0058 # says it was truncated rather than stalling the reporter on a flood.
0059 MAX_READ_BYTES = 64 * 1024 * 1024
0060 TOP_PATHS = 20
0061 
0062 # Apache combined-style access line as the PanDA web tier writes it:
0063 # [date] client "METHOD path HTTP/x" status bytes
0064 ACCESS_RE = re.compile(
0065     r'^\[([^\]]+)\] (\S+) "(\S+) (\S+)[^"]*" (\d{3}) (\S+)')
0066 # Apache error line level: [module:level]
0067 ERROR_LEVEL_RE = re.compile(r'\] \[(\w+):(\w+)\]')
0068 # Error-log markers worth counting by name; the level counts carry the
0069 # rest. Each is a substring of the line as Apache or mod_wsgi writes it.
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:                                  # noqa: BLE001
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 # ── Log tailing from the previous run's position ─────────────────────────
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         # A partial final line belongs to the next run.
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 # ── Daemons, units, processes ────────────────────────────────────────────
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                    # the process ended under the read
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 # ── Database reachability, host ──────────────────────────────────────────
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 # ── The record ───────────────────────────────────────────────────────────
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                 # The decision line is read as key=value fields in any order,
0436                 # so fields added or moved (accounting=, scope=, 2026-10-28)
0437                 # do not stop the reading. A line missing a field the front
0438                 # checks is counted unread with a sample, never dropped.
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 # ── Delivery (the house pattern: env file, post, buffer) ─────────────────
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     # The monitor is inside the facility and serves a certificate this
0533     # host's trust store does not carry; the token is the authentication.
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:                                  # noqa: BLE001
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     # The backlog first, oldest first, so a reader sees the sequence.
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())