← back to Butlr

scripts/status-snapshot.sh

126 lines

#!/usr/bin/env bash
# Status-snapshot cron — runs every 2 minutes on prod, captures health
# of butlr + wallco-ai (the two pm2 processes Steve cares about most),
# appends JSONL to ~/status/butlr-wallco.jsonl, and rotates a "latest.json"
# sidecar that surfaces at /admin/status (see routes/admin.js).
#
# Triggers an alert (writes to ~/status/ALERT-<ts>.txt) on:
#   - HTTP healthcheck returns non-2xx
#   - pm2 process is not 'online'
#   - restart_time jumped by >5 since the last snapshot (flap detector)
#
# Wired via launchd: ~/Library/LaunchAgents/com.steve.butlr-status-cron.plist
# (StartInterval 120). Idempotent — safe to run by hand.

set -euo pipefail

STATUS_DIR="${HOME}/status"
mkdir -p "$STATUS_DIR"

LOG="$STATUS_DIR/butlr-wallco.jsonl"
LATEST="$STATUS_DIR/latest.json"

ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

# Healthchecks (curl --max-time 8)
butlr_code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 8 https://butlr.agentabrams.com/healthz || echo "000")
wallco_code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 8 https://wallco.ai/design/70n -L || echo "000")

# pm2 stats — JSON via ssh; falls back to '?' on ssh failure
pm2_blob=$(ssh -o ConnectTimeout=5 root@45.61.58.125 'pm2 jlist 2>/dev/null' 2>/dev/null || echo '[]')

# Extract per-process stats in python (jq isn't always present)
stats=$(echo "$pm2_blob" | python3 -c "
import sys, json, time, os
try:
  d = json.load(sys.stdin)
except Exception:
  print(json.dumps({'butlr': None, 'wallco-ai': None}))
  sys.exit(0)
out = {}
for n in ('butlr', 'wallco-ai'):
  proc = next((p for p in d if p['name'] == n), None)
  if not proc:
    out[n] = None
    continue
  e = proc.get('pm2_env', {})
  age_s = int(time.time() * 1000 - e.get('pm_uptime', 0)) // 1000
  out[n] = {
    'pid': proc.get('pid'),
    'uptime_s': age_s,
    'restarts': e.get('restart_time', 0),
    'status': e.get('status'),
    'memory_mb': round(proc.get('monit', {}).get('memory', 0) / 1024 / 1024, 1),
    'cpu_pct': proc.get('monit', {}).get('cpu', 0),
  }
print(json.dumps(out))
")

# Build snapshot JSON
snapshot=$(python3 -c "
import json, sys
stats = json.loads('$stats')
snap = {
  'ts': '$ts',
  'butlr': {
    'healthz_code': '$butlr_code',
    'pm2': stats.get('butlr'),
  },
  'wallco_ai': {
    'healthz_code': '$wallco_code',
    'pm2': stats.get('wallco-ai'),
  },
}
print(json.dumps(snap))
")

# Append to log + update latest
echo "$snapshot" >> "$LOG"
echo "$snapshot" > "$LATEST"

# Alert detection
alerts=()
if [[ ! "$butlr_code" =~ ^2 ]]; then alerts+=("butlr healthz=$butlr_code"); fi
if [[ ! "$wallco_code" =~ ^2 ]]; then alerts+=("wallco healthz=$wallco_code"); fi

# Flap detection: compare restart_time to prior snapshot
prior=$(tail -2 "$LOG" 2>/dev/null | head -1 || echo "")
if [[ -n "$prior" ]] && [[ "$prior" != "$snapshot" ]]; then
  flap_check=$(python3 -c "
import json
prev = json.loads('''$prior''')
curr = json.loads('''$snapshot''')
out = []
for k, label in [('butlr', 'butlr'), ('wallco_ai', 'wallco-ai')]:
  pp = (prev.get(k, {}) or {}).get('pm2') or {}
  cp = (curr.get(k, {}) or {}).get('pm2') or {}
  pr = pp.get('restarts', 0) or 0
  cr = cp.get('restarts', 0) or 0
  if cr - pr > 5:
    out.append(f'{label} flap: {pr}→{cr} (+{cr-pr})')
print('\n'.join(out))
" 2>/dev/null || echo "")
  if [[ -n "$flap_check" ]]; then alerts+=("$flap_check"); fi
fi

if (( ${#alerts[@]} > 0 )); then
  alert_file="$STATUS_DIR/ALERT-$(date -u +"%Y%m%dT%H%M%SZ").txt"
  {
    echo "Time: $ts"
    echo ""
    echo "Issues:"
    for a in "${alerts[@]}"; do echo "  - $a"; done
    echo ""
    echo "Snapshot:"
    echo "$snapshot" | python3 -m json.tool
  } > "$alert_file"
fi

# Trim log to last 10000 entries (keep ~14 days at 2-min cadence)
if [[ -f "$LOG" ]]; then
  lines=$(wc -l < "$LOG")
  if (( lines > 10000 )); then
    tail -10000 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
  fi
fi