monitoring: final stats dashboard and ladder reserve matching

Print a boxed FINAL STATISTICS block after e2e/ladder (coins, flow,
tendencies, perf, load) and match force-select reserve_pub to the current
withdrawal so cumulative ladder wallets do not reuse stale pubs.
This commit is contained in:
Hernâni Marques 2026-07-17 08:28:00 +02:00
parent 48f77139c5
commit b65018cd0b
No known key found for this signature in database
4 changed files with 609 additions and 105 deletions

View file

@ -976,44 +976,433 @@ for role in ("bank","exchange","merchant"):
PY
}
# Record a successful withdraw/pay amount for end statistics (one amount per line).
# $1=withdrawn|spent $2=amount (CUR:n)
metrics_record_flow() {
local kind="$1" amt="$2"
[ -n "$amt" ] || return 0
mkdir -p "${METRICS_DIR}" 2>/dev/null || true
case "$kind" in
withdrawn|withdraw) printf '%s\n' "$amt" >>"${METRICS_DIR}/flow-withdrawn.txt" ;;
spent|pay) printf '%s\n' "$amt" >>"${METRICS_DIR}/flow-spent.txt" ;;
*) return 1 ;;
esac
}
# Overall end-of-run statistics block
# Args via env / files:
# METRICS_DIR, optional: WITHDRAW_REPORT, PAY_REPORT, phase timings JSON files
# Files under METRICS_DIR:
# coins-final.json, coins-history.tsv, perf-summary.json,
# load-before/after.json, flow-withdrawn.txt, flow-spent.txt,
# optional ladder TSV via METRICS_WITHDRAW_TSV / METRICS_PAY_TSV
metrics_print_overall() {
local title="${1:-overall statistics}"
section "metrics · $title"
if [ -f "${METRICS_DIR}/load-before.json" ]; then
info "load BEFORE" ""
metrics_print_load "${METRICS_DIR}/load-before.json" "before"
fi
if [ -f "${METRICS_DIR}/load-after.json" ]; then
info "load AFTER" ""
metrics_print_load "${METRICS_DIR}/load-after.json" "after"
metrics_print_load_delta "${METRICS_DIR}/load-before.json" "${METRICS_DIR}/load-after.json"
fi
if [ -f "${METRICS_DIR}/coins-final.json" ]; then
info "coins final" "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("summary","?"))' "${METRICS_DIR}/coins-final.json" 2>/dev/null || echo n/a)"
fi
if [ -f "${METRICS_DIR}/coins-history.tsv" ]; then
echo " --- coins after each withdraw ---"
# header + rows
if [ -s "${METRICS_DIR}/coins-history.tsv" ]; then
column -t -s $'\t' "${METRICS_DIR}/coins-history.tsv" 2>/dev/null \
|| cat "${METRICS_DIR}/coins-history.tsv"
fi
fi
if [ -f "${METRICS_DIR}/perf-summary.json" ]; then
info "performance" ""
python3 - "${METRICS_DIR}/perf-summary.json" <<'PY'
import json,sys
d=json.load(open(sys.argv[1]))
for k,v in d.items():
if isinstance(v, dict) and "n" in v:
print(f" {k:14} n={v.get('n')} min={v.get('min_ms')}ms p50={v.get('p50_ms')}ms avg={v.get('avg_ms')}ms max={v.get('max_ms')}ms")
else:
print(f" {k}: {v}")
PY
fi
# free-form extras from caller
section "statistics · $title"
metrics_print_final_stats || true
[ -n "${METRICS_EXTRA_LINES:-}" ] && printf '%s\n' "$METRICS_EXTRA_LINES"
}
# Rich final dashboard: coins, withdrawn/spent, performance, tendencies
metrics_print_final_stats() {
python3 - \
"${METRICS_DIR:-/tmp}" \
"${ALT_UNITS_FILE:-}" \
"${METRICS_WITHDRAW_TSV:-}" \
"${METRICS_PAY_TSV:-}" \
"${CUR:-GOA}" \
<<'PY'
import csv, json, os, sys
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from pathlib import Path
mdir = Path(sys.argv[1])
alt_path = sys.argv[2] or ""
wd_tsv = sys.argv[3] or ""
pay_tsv = sys.argv[4] or ""
cur = sys.argv[5] or "GOA"
alt = {}
if alt_path and Path(alt_path).is_file():
try:
alt = json.load(open(alt_path))
except Exception:
alt = {}
if not alt:
alt = {"0": cur}
def D(x, default=Decimal(0)):
try:
return Decimal(str(x))
except Exception:
return default
def parse_amt(s):
s = str(s or "").strip()
if not s:
return cur, Decimal(0)
if ":" in s:
c, v = s.split(":", 1)
try:
return c, Decimal(v)
except InvalidOperation:
return c, Decimal(0)
try:
return cur, Decimal(s)
except InvalidOperation:
return cur, Decimal(0)
def fmt_num(v: Decimal) -> str:
if v == v.to_integral():
return format(int(v), "d")
s = format(v.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP), "f")
return s.rstrip("0").rstrip(".")
def fmt_amt(c, v: Decimal) -> str:
return "%s:%s" % (c, fmt_num(v))
def fmt_alt(c, v: Decimal) -> str:
base_s = fmt_amt(c, v)
base_name = alt.get("0") or c
if v == 0:
return "0 %s (%s)" % (base_name, base_s)
scales = []
for k, name in alt.items():
try:
scales.append((int(k), str(name)))
except Exception:
pass
scales.sort(key=lambda x: -x[0])
absval = abs(v)
for sc, name in scales:
unit = Decimal(10) ** sc
coeff = absval / unit
if coeff >= 1:
if sc == 0:
return "%s %s" % (fmt_num(v), name)
return "%s %s (%s)" % (fmt_num(coeff if v >= 0 else -coeff), name, base_s)
return "%s %s (%s)" % (fmt_num(v), base_name, base_s)
def sum_amount_file(path: Path):
total = Decimal(0)
ccy = cur
n = 0
if not path.is_file():
return ccy, total, n
for line in path.read_text().splitlines():
line = line.strip()
if not line:
continue
c, v = parse_amt(line)
ccy = c
total += v
n += 1
return ccy, total, n
def sum_tsv_amounts(path, amount_col="amount", status_ok=None):
total = Decimal(0)
ccy = cur
n = 0
if not path or not Path(path).is_file():
return ccy, total, n
with open(path, newline="") as f:
for row in csv.DictReader(f, delimiter="\t"):
st = (row.get("status") or "").upper()
if status_ok is not None and st not in status_ok:
continue
c, v = parse_amt(row.get(amount_col) or "")
if v <= 0:
continue
ccy = c
total += v
n += 1
return ccy, total, n
def trend_label(delta, eps=Decimal("0.01")):
if delta > eps:
return "↑ rising"
if delta < -eps:
return "↓ falling"
return "→ stable"
def half_trend(values):
"""Compare avg of second half vs first half of a list of numbers."""
xs = [float(x) for x in values if x is not None]
if len(xs) < 4:
if len(xs) < 2:
return "n/a (few samples)", None, None
a, b = xs[0], xs[-1]
return trend_label(Decimal(str(b - a))), a, b
mid = len(xs) // 2
first = sum(xs[:mid]) / mid
second = sum(xs[mid:]) / (len(xs) - mid)
d = second - first
lab = "↑ slowing (worse)" if d > 0.05 * max(abs(first), 1) else (
"↓ faster (better)" if d < -0.05 * max(abs(first), 1) else "→ steady"
)
return lab, first, second
print("")
print("╔══════════════════════════════════════════════════════════════╗")
print("║ FINAL STATISTICS ║")
print("╚══════════════════════════════════════════════════════════════╝")
# --- coins snapshot ---
coins = {}
cf = mdir / "coins-final.json"
if cf.is_file():
try:
coins = json.load(open(cf))
except Exception:
coins = {}
print("")
print("── Coins (wallet) ─────────────────────────────────────────────")
if coins.get("ok"):
circ_n = coins.get("in_circulation") or 0
spent_n = coins.get("spent") or 0
total_n = coins.get("total_coins") or 0
amt_alt = coins.get("amount_in_circulation_alt") or coins.get("amount_in_circulation_s") or "?"
spent_alt = coins.get("amount_spent_alt") or coins.get("amount_spent_s") or "0"
print(" total coins %s" % total_n)
print(" in circulation %s coins · %s" % (circ_n, amt_alt))
print(" spent (in dump) %s coins · %s" % (spent_n, spent_alt))
circ = coins.get("by_denom_circulation") or []
if circ:
print(" denoms in circ:")
for x in circ:
print(
" · %s × %s = %s"
% (
x.get("denom_alt") or x.get("denom"),
x.get("count"),
x.get("amount_alt") or x.get("amount"),
)
)
spent = coins.get("by_denom_spent") or []
if spent:
print(" denoms spent:")
for x in spent:
print(
" · %s × %s = %s"
% (
x.get("denom_alt") or x.get("denom"),
x.get("count"),
x.get("amount_alt") or x.get("amount"),
)
)
else:
print(" (no coins-final.json — run with wallet snaps)")
# --- withdrawn / spent flows ---
print("")
print("── Money flow (this run) ──────────────────────────────────────")
# Prefer ladder TSV if provided; else flow-*.txt from e2e
wd_ok = {"OK", "OK_BANK", "ZERO_REJECT", "ZERO_SKIP", "SKIP_DENOM", "CEILING_REJECT"}
pay_ok = {"OK", "ZERO_SKIP", "CEILING_SKIP", "CEILING_REJECT"}
c1, wd_sum, wd_n = sum_tsv_amounts(wd_tsv, "amount", None)
# count successful withdraws by status prefix OK or soft
if wd_tsv and Path(wd_tsv).is_file():
c1, wd_sum, wd_n = Decimal(0), Decimal(0), 0
ccy = cur
with open(wd_tsv, newline="") as f:
for row in csv.DictReader(f, delimiter="\t"):
st = (row.get("status") or "").upper()
# count value when mint/settle produced money in wallet
if st in ("OK", "OK_BANK"):
c, v = parse_amt(row.get("amount"))
ccy, wd_sum, wd_n = c, wd_sum + v, wd_n + 1
c1 = ccy
else:
c1, wd_sum, wd_n = sum_amount_file(mdir / "flow-withdrawn.txt")
if pay_tsv and Path(pay_tsv).is_file():
c2, pay_sum, pay_n = Decimal(0), Decimal(0), 0
ccy = cur
with open(pay_tsv, newline="") as f:
for row in csv.DictReader(f, delimiter="\t"):
st = (row.get("status") or "").upper()
if st == "OK":
c, v = parse_amt(row.get("amount"))
ccy, pay_sum, pay_n = c, pay_sum + v, pay_n + 1
c2 = ccy
else:
c2, pay_sum, pay_n = sum_amount_file(mdir / "flow-spent.txt")
print(" withdrawn %s events · %s" % (wd_n, fmt_alt(c1, wd_sum)))
print(" spent (paid) %s events · %s" % (pay_n, fmt_alt(c2, pay_sum)))
net = wd_sum - pay_sum
print(" net (wd spent) %s %s" % (fmt_alt(c1, net), trend_label(net)))
if coins.get("ok"):
# circulation amount vs net flow
circ_map = coins.get("amount_in_circulation") or {}
circ_v = Decimal(0)
for _c, vs in circ_map.items():
circ_v += D(vs)
print(" wallet circ now %s" % fmt_alt(cur, circ_v))
print(" residual check wallet_circ vs net: Δ %s"
% fmt_alt(cur, circ_v - net))
# --- coin history tendency ---
print("")
print("── Tendency · coins over run ─────────────────────────────────")
hist = mdir / "coins-history.tsv"
if hist.is_file() and hist.stat().st_size > 0:
rows = list(csv.DictReader(open(hist), delimiter="\t"))
if rows:
def circ_amt(row):
# amount_circ column may be "GOA:20" or alt form — try numeric from total/in_circ
a = row.get("amount_circ") or "0"
if ":" in a and " " not in a:
return parse_amt(a)[1]
# try extract GOA: from parentheses
import re
m = re.search(r"\(([^)]+:[0-9.]+)\)", a)
if m:
return parse_amt(m.group(1))[1]
return D(row.get("in_circ") or 0)
first, last = rows[0], rows[-1]
c0, c1_ = D(first.get("in_circ") or 0), D(last.get("in_circ") or 0)
a0, a1 = circ_amt(first), circ_amt(last)
print(" samples %d snaps (%s → %s)"
% (len(rows), first.get("label"), last.get("label")))
print(" coins in circ %s → %s (Δ %+d) %s"
% (fmt_num(c0), fmt_num(c1_), int(c1_ - c0), trend_label(c1_ - c0)))
print(" amount in circ %s → %s %s"
% (fmt_alt(cur, a0), fmt_alt(cur, a1), trend_label(a1 - a0)))
# simple linear slope on coin count
if len(rows) >= 3:
ys = [float(D(r.get("in_circ") or 0)) for r in rows]
n = len(ys)
xs = list(range(n))
xm, ym = sum(xs) / n, sum(ys) / n
num = sum((x - xm) * (y - ym) for x, y in zip(xs, ys))
den = sum((x - xm) ** 2 for x in xs) or 1
slope = num / den
print(" trend slope %+.3f coins/snap %s"
% (slope, "↑ accumulating" if slope > 0.05 else ("↓ draining" if slope < -0.05 else "→ flat")))
print(" history (label · in_circ · amount):")
for r in rows[-12:]: # last 12
print(" · %-28s circ=%-4s %s"
% (r.get("label", "?")[:28], r.get("in_circ"), r.get("amount_circ")))
if len(rows) > 12:
print(" · … (%d earlier snaps)" % (len(rows) - 12))
else:
print(" (empty history)")
else:
print(" (no coins-history.tsv)")
# --- performance ---
print("")
print("── Performance indicators ───────────────────────────────────")
perf = {}
pf = mdir / "perf-summary.json"
if pf.is_file():
try:
perf = json.load(open(pf))
except Exception:
perf = {}
def print_bucket(name, v):
if not isinstance(v, dict) or not v.get("n"):
return
print(
" %-14s n=%s min=%sms p50=%sms avg=%sms max=%sms"
% (name, v.get("n"), v.get("min_ms"), v.get("p50_ms"), v.get("avg_ms"), v.get("max_ms"))
)
if perf:
for k, v in perf.items():
print_bucket(k, v)
else:
print(" (no perf-summary.json)")
# per-rung timing tendency from ladder TSV
for label, path, cols in (
("withdraw rungs", wd_tsv, ("ms_total", "ms_mint", "ms_settle")),
("pay rungs", pay_tsv, ("ms_total", "ms_order", "ms_handle", "ms_settle")),
):
if not path or not Path(path).is_file():
continue
with open(path, newline="") as f:
rrows = list(csv.DictReader(f, delimiter="\t"))
if len(rrows) < 2:
continue
print(" tendency · %s:" % label)
for col in cols:
vals = []
for r in rrows:
try:
vals.append(int(r.get(col) or 0))
except Exception:
pass
vals = [v for v in vals if v > 0]
if len(vals) < 2:
continue
lab, a, b = half_trend(vals)
if a is None:
print(" %-12s %s" % (col, lab))
else:
print(" %-12s first-half avg=%.0fms → second-half avg=%.0fms %s"
% (col, a, b, lab))
# --- load ---
print("")
print("── Host / container load ────────────────────────────────────")
lb, la = mdir / "load-before.json", mdir / "load-after.json"
def load_brief(path, tag):
if not path.is_file():
print(" %s: (missing)" % tag)
return None
try:
d = json.load(open(path))
except Exception:
print(" %s: (unreadable)" % tag)
return None
if not d.get("ok"):
print(" %s: %s" % (tag, d.get("reason", "?")))
return d
h = d.get("host") or {}
mem = h.get("memory") or {}
la_ = h.get("loadavg") or []
avail = mem.get("mem_available_b")
tot = mem.get("mem_total_b")
def gi(b):
return "?" if b is None else "%.2f GiB" % (b / 1024 / 1024 / 1024)
used = (tot - avail) if (tot is not None and avail is not None) else None
print(" %s loadavg=%s mem_used=%s / %s"
% (tag, la_, gi(used), gi(tot)))
for role in ("bank", "exchange", "merchant"):
c = (d.get("taler") or {}).get(role) or {}
if not c.get("running"):
continue
pr = c.get("processes") or {}
st = c.get("podman_stats") or {}
rss = pr.get("rss_total_b")
rss_s = "?" if rss is None else "%.2f GiB" % (rss / 1024 / 1024 / 1024)
print(" %-8s rss=%s cpu=%s procs=%s"
% (role, rss_s, st.get("cpu_pct") or "?", pr.get("proc_total")))
return d
db = load_brief(lb, "before")
da = load_brief(la, "after")
if db and da and db.get("ok") and da.get("ok"):
bla = (db.get("host") or {}).get("loadavg") or [0]
ala = (da.get("host") or {}).get("loadavg") or [0]
if bla and ala:
dload = float(ala[0]) - float(bla[0])
print(" loadavg1 tendency %.2f → %.2f (Δ %+.2f) %s"
% (float(bla[0]), float(ala[0]), dload,
"↑ higher load" if dload > 0.1 else ("↓ lower load" if dload < -0.1 else "→ steady")))
bm = (db.get("host") or {}).get("memory") or {}
am = (da.get("host") or {}).get("memory") or {}
if bm.get("mem_available_b") is not None and am.get("mem_available_b") is not None:
dmem = (am["mem_available_b"] - bm["mem_available_b"]) / 1024 / 1024 / 1024
print(" mem_avail tendency %.2f → %.2f GiB (Δ %+.3f) %s"
% (bm["mem_available_b"] / 1024**3, am["mem_available_b"] / 1024**3, dmem,
"↑ more free" if dmem > 0.05 else ("↓ less free" if dmem < -0.05 else "→ steady")))
print("")
print("──────────────────────────────────────────────────────────────")
PY
}