#!/usr/bin/env bash # Nightly rollup: one JSON line per day in /var/lib/hnefatafl/rollup.jsonl: # the day's traffic from Caddy's access log (people and bots apart, by page, # with referrers and the campaign tags links carry), the rooms opened, # started and finished, and the box's vitals. Counts only: no addresses are # kept. The pulse reads it for trends the self-rotating access log loses. # hnefatafl-rollup.sh [YYYY-MM-DD] (default: yesterday, UTC) # Cron: /etc/cron.d/hnefatafl, installed by deploy.sh; SCHEDULE below # must match it, because the Sentry monitor is told the same shape. # Sentry Crons check-in: /root/.hnefatafl-sentry-cron-rollup holds the # check-in URL (absent = no check-ins). set -u OUT="/var/lib/hnefatafl/rollup.jsonl" LOG="/var/log/hnefatafl/rollup.log" DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}" SCHEDULE="12 0 * * *" CRON_URL=$(cat /root/.hnefatafl-sentry-cron-rollup 2>/dev/null || true) # The check-in carries the schedule: Sentry keeps a monitor only once told its shape. checkin() { [ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \ -d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \ "$CRON_URL" || true } mkdir -p "$(dirname "$LOG")" checkin in_progress if python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1 import datetime, glob, json, os, shutil, sys sys.path.insert(0, "/usr/local/lib/hnefatafl") import traffic day, out = sys.argv[1], sys.argv[2] d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc) t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp() t = traffic.scan(day) created = started = finished = turned = turns = 0 humans, bots = set(), 0 for f in glob.glob("/var/lib/hnefatafl/rooms/*.jsonl"): try: lines = [json.loads(l) for l in open(f) if l.strip()] except Exception: continue if not lines or lines[0].get("t") != "room": continue made = lines[0]["createdAt"] / 1000 if t0 <= made < t1: created += 1 if any(x["t"] == "start" for x in lines): started += 1 if any(x["t"] == "turn" for x in lines): turned += 1 for x in lines: if x["t"] == "seat": if x["bot"]: bots += 1 else: humans.add(x["name"]) elif x["t"] == "unseat": bots -= 1 turns += sum(1 for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1) # The over line is written with the turn that ends the game, so a finish counts on its own day. if any(x["t"] == "over" and t0 <= x["at"] / 1000 < t1 for x in lines): finished += 1 disk = shutil.disk_usage("/") mem = {} for line in open("/proc/meminfo"): k, v = line.split(":", 1); mem[k] = int(v.split()[0]) load = os.getloadavg()[1] row = dict(day=day, requests=t["requests"], human=t["human"], bot=t["bot"], humanAddresses=len(t["addresses"]), botAddresses=len(t["botAddresses"]), paths=dict(t["pages"]), roomLinks=sum(t["joins"].values()), referrers=dict(t["referrers"]), campaigns=dict(t["campaigns"]), rooms=dict(created=created, started=started, withTurns=turned, finished=finished, humanNames=len(humans), botSeats=bots), turns=turns, ledgers=len(glob.glob("/var/lib/hnefatafl/rooms/*.jsonl")), vitals=dict(diskUsedPct=round(disk.used * 100 / disk.total), memAvailableMb=mem.get("MemAvailable", 0) // 1024, load5=round(load, 2)), at=datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")) rows = [] if os.path.exists(out): rows = [json.loads(l) for l in open(out) if l.strip()] rows = [r for r in rows if r.get("day") != day] + [row] rows.sort(key=lambda r: r["day"]) with open(out + ".tmp", "w") as fh: for r in rows: fh.write(json.dumps(r) + "\n") os.replace(out + ".tmp", out) print("%s rolled: %d people, %d requests, %d rooms" % (day, len(t["addresses"]), t["human"], created)) PY then checkin ok; else checkin error; fi