diff options
Diffstat (limited to 'scripts')
| -rwxr-xr-x | scripts/newos4-prune-debug.py | 92 |
1 files changed, 92 insertions, 0 deletions
diff --git a/scripts/newos4-prune-debug.py b/scripts/newos4-prune-debug.py new file mode 100755 index 0000000..7fe0fcb --- /dev/null +++ b/scripts/newos4-prune-debug.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Prune self-referential / stale debug rows from newOS4 telemetry. + +Read-only unless --apply. Coordinates with PHP via the SAME flock on errors.json +(ingest.php opens it 'c' + LOCK_EX), and always archives before rewriting. +""" +import argparse, fcntl, json, os, re, time + +ROOT = '/var/www/webx11.duckdns.org/spaces/newos4' +DEBUG = os.path.join(ROOT, 'data', 'debug') +ERRORS = os.path.join(DEBUG, 'errors.json') +ARCHIVE = os.path.join(DEBUG, 'archive') +SELF = re.compile(r'/api/debug/(ingest|report)\.php', re.I) + + +def iso_to_epoch(s): + """Parse '2026-10-20T16:57:31.589Z' or '...+00:00'. Returns None when unparseable.""" + if not s: + return None + t = str(s).strip().replace('Z', '+00:00') + if '.' in t: + head, rest = t.split('.', 1) + frac = rest[:6].ljust(6, '0') + tail = rest[6:] + t = f'{head}.{frac}{tail}' + try: + import datetime + d = datetime.datetime.fromisoformat(t) + if d.tzinfo is None: + d = d.replace(tzinfo=datetime.timezone.utc) + return d.timestamp() + except Exception: + return None + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--apply', action='store_true') + ap.add_argument('--max-age-days', type=int, default=30) + a = ap.parse_args() + cutoff = time.time() - a.max_age_days * 86400 + + fp = open(ERRORS, 'r+', encoding='utf-8') + fcntl.flock(fp, fcntl.LOCK_EX) + try: + errors = json.load(fp) or [] + keep, drop = [], [] + for e in errors: + msg = str(e.get('message', '')) + age_ref = iso_to_epoch(e.get('last_seen')) or iso_to_epoch(e.get('ingested_at')) + if SELF.search(msg): + drop.append((e, 'self-referential')) + elif age_ref is not None and age_ref < cutoff: + drop.append((e, f'older than {a.max_age_days}d')) + else: + keep.append(e) + + print(f'records={len(errors)} keep={len(keep)} drop={len(drop)}') + reasons = {} + for e, why in drop: + reasons[why] = reasons.get(why, 0) + 1 + for why, n in sorted(reasons.items()): + print(f' drop {n:>2} reason={why}') + for e, why in drop[:8]: + print(f" count={e.get('count')} {why}: {str(e.get('message'))[:80]}") + print(f"occurrences before={sum(int(e.get('count') or 1) for e in errors)} " + f"after={sum(int(e.get('count') or 1) for e in keep)}") + + if not a.apply: + print('dry-run; re-run with --apply to write') + return + + os.makedirs(ARCHIVE, exist_ok=True) + archive = os.path.join(ARCHIVE, f'errors-{time.strftime("%Y%m%d_%H%M%S")}.json') + with open(archive, 'w', encoding='utf-8') as af: + json.dump(errors, af, indent=2) + os.chmod(archive, 0o644) + + fp.seek(0) + fp.truncate() + json.dump(keep, fp, indent=2) + fp.flush() + os.fsync(fp.fileno()) + open(os.path.join(DEBUG, '.rebuild_clusters'), 'w').write(str(int(time.time()))) + print(f'applied: {archive} written, errors.json rewritten, cluster rebuild flagged') + finally: + fcntl.flock(fp, fcntl.LOCK_UN) + fp.close() + + +if __name__ == '__main__': + main() |