diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index 98214d6..6b6df2b 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -126,6 +126,20 @@ Also: coin counts (in circulation / spent), status histogram, Δ vs previous sna History TSV: `$METRICS_DIR/coins-history.tsv`. Map loaded from `${EXCHANGE_PUBLIC}/config` → `currency_specification.alt_unit_names`. +### Final statistics dashboard (e2e · ladder) + +At the end of **e2e** and **ladder**, a boxed **FINAL STATISTICS** block prints: + +| Section | Content | +|---------|---------| +| **Coins** | in circulation / spent counts + amounts + denoms (alt names) | +| **Money flow** | withdrawn events/amount, spent (paid), net (wd − spent) | +| **Tendency** | coin/amount trend over snaps (↑↓→), slope, last history rows | +| **Performance** | min / p50 / avg / max per phase; first-half vs second-half latency | +| **Load** | host loadavg + mem before/after; per-container RSS/CPU; tendencies | + +Successful withdraw/pay steps append to `flow-withdrawn.txt` / `flow-spent.txt` (ladder also uses its TSVs). + ## Load / memory (inside · e2e · ladder) Host **loadavg**, **RAM used/avail**, and per-container **RSS / CPU / block I/O** diff --git a/scripts/taler-monitoring/check_e2e.sh b/scripts/taler-monitoring/check_e2e.sh index 82c8c6c..aa1eab6 100755 --- a/scripts/taler-monitoring/check_e2e.sh +++ b/scripts/taler-monitoring/check_e2e.sh @@ -152,18 +152,14 @@ e2e_finish() { fi E2E_REPORTED=1 print_balances - # Final coin inventory + host/container load + # Final coin inventory + host/container load + statistics dashboard if [ -n "${METRICS_DIR:-}" ]; then section "metrics · e2e coins final" metrics_report_coins "e2e-end" || true - fi - if [ -n "${METRICS_DIR:-}" ] && [ "${METRICS_LOAD:-1}" != "0" ]; then - metrics_report_load "${METRICS_DIR}/load-after.json" "e2e-end" || true - if [ -f "${METRICS_DIR}/load-before.json" ] && [ -f "${METRICS_DIR}/load-after.json" ]; then - section "metrics · e2e load delta" - metrics_print_load_delta "${METRICS_DIR}/load-before.json" "${METRICS_DIR}/load-after.json" || true + if [ "${METRICS_LOAD:-1}" != "0" ]; then + metrics_report_load "${METRICS_DIR}/load-after.json" "e2e-end" || true fi - metrics_print_overall "e2e overall" || true + metrics_print_overall "e2e final" || true fi summary || true return "$code" @@ -798,6 +794,7 @@ else: done metrics_report_coins "after-ATM-${tag}" || true if [ "$ok_bal" = "1" ]; then + metrics_record_flow withdrawn "$WITHDRAW_AMT" || true return 0 fi # Bank side often already confirmed — treat as timing lag, not hard fail @@ -897,6 +894,7 @@ sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="p ' "$SCRATCH/ord-paid-$tag.json" 2>/dev/null; then ok "payment settled $PAY_AMT ($PAY_SUM · order $OID)" metrics_report_coins "after-pay-${tag}" || true + metrics_record_flow spent "$PAY_AMT" || true return 0 fi done @@ -1017,6 +1015,7 @@ sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="p ' "$SCRATCH/ord-paid-$tag.json" 2>/dev/null; then ok "shop $pname" "payment settled ($pamt · order $OID)" metrics_report_coins "after-shop-${tag}" || true + metrics_record_flow spent "$pamt" || true return 0 fi fi @@ -1024,6 +1023,7 @@ sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="p || grep -qiE 'done|paid|success|Payment' "$SCRATCH/pay-$tag.out" 2>/dev/null; then ok "shop $pname" "payment settled via wallet tx ($pamt · order $OID)" metrics_report_coins "after-shop-${tag}" || true + metrics_record_flow spent "$pamt" || true return 0 fi done diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index 41d37dc..28d71ed 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -483,58 +483,138 @@ for AMT in "$@"; do -H "Authorization: Bearer ${TOK}" -H 'Content-Type: application/json' -d '{}' \ "${BANK}/accounts/${EXP_USER}/withdrawals/${WID}/confirm" } - extract_rpub() { - # Prefer *last* match (current withdraw), not first (stale from older accepts/tx). - python3 -c ' -import re, sys, json -paths = sys.argv[1:] -found = [] -blob = "" -for p in paths: - try: - blob += open(p, errors="replace").read() + "\n" - except Exception: - pass -for pat in ( - r"\"reserve_pub\"\s*:\s*\"([A-Z0-9]+)\"", - r"\"reservePub\"\s*:\s*\"([A-Z0-9]+)\"", - r"reserve_pub[\"\s:=]+([A-Z0-9]{40,})", - r"reservePub[\"\s:=]+([A-Z0-9]{40,})", - r"reserve[_ ]?pub[\"=: ]+([A-Z0-9]{40,})", -): - found.extend(m.group(1) for m in re.finditer(pat, blob, re.I)) -def walk(o): + # Collect reserve_pub candidates for *this* withdrawal (WID + amount). + # Cumulative wallets re-print old reserves in accept/tx dumps — never trust a single "last" blindly. + # Prints unique pubs one per line, preferred order first. + extract_rpubs_for_wid() { + python3 - "$WID" "$AMT" "$SCRATCH/accept-$tag.out" "$SCRATCH/tx-$tag.json" "$SCRATCH/used-rpubs.txt" <<'PY' +import json, re, sys +from pathlib import Path + +wid = sys.argv[1] +amt = sys.argv[2] +paths = sys.argv[3:5] +used_path = sys.argv[5] +used = set() +if Path(used_path).is_file(): + used = {ln.strip() for ln in open(used_path) if ln.strip()} + +def walk_collect(o, bag, ctx=None): + ctx = dict(ctx or {}) if isinstance(o, dict): for k, v in o.items(): - if k.lower() in ("reserve_pub", "reservepub") and isinstance(v, str) and len(v) >= 40: - found.append(v) - walk(v) + kl = str(k).lower() + if kl in ("withdrawal_id", "withdraw_id", "wopid", "id") and isinstance(v, str): + ctx["id"] = v + if kl in ("amount", "rawamount", "instructedamount") and isinstance(v, str): + ctx["amount"] = v + if kl in ("taler_withdraw_uri", "talerwithdrawuri", "uri") and isinstance(v, str): + ctx["uri"] = v + if kl in ("reserve_pub", "reservepub") and isinstance(v, str) and len(v) >= 40: + bag.append((v, dict(ctx))) + walk_collect(v, bag, ctx) elif isinstance(o, list): for i in o: - walk(i) -for line in blob.splitlines(): - line = line.strip() - if not line.startswith("{"): - continue + walk_collect(i, bag, ctx) + +raw_pubs = [] # ordered as found +scored = [] # (score, pub) higher better +blob_all = "" +for p in paths: try: - walk(json.loads(line)) + blob_all += open(p, errors="replace").read() + "\n" except Exception: pass -if found: - print(found[-1]) -' "$@" 2>/dev/null || true + +# 1) full JSON objects in files +for p in paths: + try: + t = open(p, errors="replace").read() + except Exception: + continue + # whole-file JSON + for m in re.finditer(r"\{", t): + try: + o = json.loads(t[m.start():]) + except Exception: + continue + bag = [] + walk_collect(o, bag) + for pub, ctx in bag: + raw_pubs.append(pub) + score = 0 + cid = str(ctx.get("id") or "") + camt = str(ctx.get("amount") or "") + curi = str(ctx.get("uri") or "") + if wid and wid in cid: + score += 100 + if wid and wid in curi: + score += 80 + if amt and (camt == amt or camt.endswith(amt.split(":", 1)[-1])): + score += 40 + if pub in used: + score -= 200 + scored.append((score, pub)) + +# 2) regex fallback on accept file only (more current) +try: + acc = open(paths[0], errors="replace").read() +except Exception: + acc = "" +for pat in ( + r"\"reserve_pub\"\s*:\s*\"([A-Z0-9]{40,})\"", + r"\"reservePub\"\s*:\s*\"([A-Z0-9]{40,})\"", + r"reserve_pub[\"\s:=]+([A-Z0-9]{40,})", +): + for m in re.finditer(pat, acc, re.I): + pub = m.group(1) + raw_pubs.append(pub) + score = 10 + # proximity to WID in accept output + window = acc[max(0, m.start() - 400) : m.end() + 400] + if wid and wid in window: + score += 100 + if amt and amt in window: + score += 30 + if pub in used: + score -= 200 + scored.append((score, pub)) + +# prefer high score, then later occurrence +order = [] +seen = set() +for score, pub in sorted(scored, key=lambda x: (-x[0],), reverse=False): + # sort by score desc: use reverse sorted + pass +for score, pub in sorted(scored, key=lambda x: x[0], reverse=True): + if pub in seen or pub in used: + continue + seen.add(pub) + order.append(pub) +# append unused raw in reverse (newest-ish) +for pub in reversed(raw_pubs): + if pub in seen or pub in used: + continue + seen.add(pub) + order.append(pub) +for pub in order: + print(pub) +PY + } + + mark_rpub_used() { + local p="$1" + [ -n "$p" ] || return 0 + mkdir -p "$SCRATCH" 2>/dev/null || true + grep -qxF "$p" "$SCRATCH/used-rpubs.txt" 2>/dev/null || echo "$p" >>"$SCRATCH/used-rpubs.txt" } force_select_if_needed() { local st_now="$1" [ "$st_now" = "pending" ] || [ -z "$st_now" ] || return 0 - local rpub epayto code_fs - # current accept only first — avoid reusing reserve from previous rungs via tx dump - rpub=$(extract_rpub "$SCRATCH/accept-$tag.out") - if [ -z "$rpub" ]; then - wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true - rpub=$(extract_rpub "$SCRATCH/tx-$tag.json") - fi + local rpub epayto code_fs any=0 + # refresh tx dump each try (wallet may attach reserve late) + wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true epayto=$(curl -sS -m 10 "${EX%/}/keys" 2>/dev/null | python3 -c ' import json,sys d=json.load(sys.stdin) @@ -546,31 +626,46 @@ for a in acc: else: if acc: print(acc[0].get("payto_uri") or "") ' 2>/dev/null || true) - if [ -n "$rpub" ] && [ -n "$epayto" ]; then + if [ -z "$epayto" ]; then + warn bank "force-select skipped" "problem: exchange payto empty from /keys" + return 0 + fi + # Try candidates until bank leaves pending (200/204) or we exhaust + while IFS= read -r rpub; do + [ -n "$rpub" ] || continue + any=1 code_fs=$(curl -sS -m 12 -o "$SCRATCH/force-sel-$tag.json" -w '%{http_code}' -X POST \ -H 'Content-Type: application/json' \ -d "{\"reserve_pub\":\"${rpub}\",\"selected_exchange\":\"${epayto}\"}" \ "${BANK}/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || echo "000") - # 409: conflict (wrong/stale reserve, or already bound) — log body once, keep polling - if [ "$code_fs" = "409" ]; then - if [ "${FORCE_SEL_409_LOGGED:-0}" != "1" ]; then - info "force-select" "HTTP 409 rpub=${rpub:0:12}… body=$(tr '\n' ' ' <"$SCRATCH/force-sel-$tag.json" | head -c 160)" - FORCE_SEL_409_LOGGED=1 - fi - else - info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}…" - FORCE_SEL_409_LOGGED=0 + if [ "$code_fs" = "200" ] || [ "$code_fs" = "204" ]; then + info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}… (ok for WID ${WID:0:8})" + mark_rpub_used "$rpub" + return 0 fi - else - _rpub_empty=no - [ -z "$rpub" ] && _rpub_empty=yes - _epayto_empty=no - [ -z "$epayto" ] && _epayto_empty=yes + if [ "$code_fs" = "409" ]; then + # 5114 = this reserve already bound to another op — not "out of money" + info "force-select" "HTTP 409 rpub=${rpub:0:12}… (stale/used reserve — not balance; trying next)" + mark_rpub_used "$rpub" + continue + fi + info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}… body=$(tr '\n' ' ' <"$SCRATCH/force-sel-$tag.json" 2>/dev/null | head -c 120)" + # other errors: still try next candidate + done < <(extract_rpubs_for_wid) + if [ "$any" != "1" ]; then warn bank "force-select skipped" \ - "problem: cannot move bank pending->selected (reserve_pub empty=${_rpub_empty}, exchange payto empty=${_epayto_empty})" + "problem: no reserve_pub for this withdraw (WID=${WID:0:8}…); wallet may not have selected yet" fi } + # Short bounded select assist (≤8s) — not a hang path; helps attach fresh reserve_pub + if command -v perl >/dev/null 2>&1 && [ -f "$CLI_JS" ]; then + perl -e 'alarm shift; exec @ARGV' 8 \ + node "$CLI_JS" --wallet-db="$WDB" --no-throttle run-until-done \ + >"$SCRATCH/select-$tag.out" 2>&1 || true + fi + wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true + t0=$(now_ms) conf_ok=0 st="" @@ -598,9 +693,9 @@ else: break ;; esac - # force-select while pending (no run-until-done); avoid spam on repeated 409 + # force-select while pending — try alternate rpubs on 5114 (not out-of-money) if [ "$st" = "pending" ] || [ -z "$st" ]; then - if [ "$i" -eq 1 ] || [ "$i" -eq 2 ] || [ $((i % 5)) -eq 0 ]; then + if [ "$i" -eq 1 ] || [ "$i" -eq 2 ] || [ $((i % 3)) -eq 0 ]; then force_select_if_needed "$st" fi fi @@ -610,10 +705,10 @@ else: st=$(printf '%s' "${st:-}" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if [ "$conf_ok" != "1" ]; then note="${note:-confirm timeout last=${st:-empty}}" - # Soft: not confirmed after polls — WARN and continue (pending/select lag or force-select issues) + # Soft: not confirmed — usually stale reserve_pub (5114), NOT empty pool balance status="SKIP_CONFIRM" warn bank "confirm $AMT skipped" \ - "problem: bank status='${st:-empty}' after ${LADDER_CONFIRM_POLLS} polls (want selected/confirmed); ladder continues. detail: $note" + "problem: bank status='${st:-empty}' after ${LADDER_CONFIRM_POLLS} polls (want selected/confirmed). Usually reserve_pub mismatch (5114), not out-of-money — mint/accept already OK. detail: $note" ms_total=$(elapsed_ms "$t_rung") echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" continue @@ -664,6 +759,7 @@ sys.exit(0 if Decimal(sys.argv[1]) > Decimal(sys.argv[2]) else 1) OK_N=$((OK_N + 1)) echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" metrics_report_coins "ladder-r${rung}-after-${tag}" || true + metrics_record_flow withdrawn "$AMT" || true elif echo "$xfer" | grep -qi True; then status="OK_BANK" note="bank transfer_done avail=${after} $xfer (no run-until-done)" @@ -671,6 +767,7 @@ sys.exit(0 if Decimal(sys.argv[1]) > Decimal(sys.argv[2]) else 1) OK_N=$((OK_N + 1)) echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" metrics_report_coins "ladder-r${rung}-after-${tag}" || true + metrics_record_flow withdrawn "$AMT" || true else note="no coins / no transfer_done avail=${after} $xfer" err wallet "settle $AMT" "$note" @@ -897,6 +994,7 @@ sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="p PAY_OK_N=$((PAY_OK_N + 1)) echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t${ms_handle}\t${ms_psettle}\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" metrics_report_coins "after-pay-${ptag}" || true + metrics_record_flow spent "$PAMT" || true else pnote="not settled order=$OID avail=${CUR}:${after}" if [ "$IS_PMAX" = "1" ]; then @@ -1048,7 +1146,10 @@ for k, v in (rep.get("timing") or {}).items(): json.dump(out, open(sys.argv[2], "w"), indent=2) PY fi -metrics_print_overall "ladder overall" || true +export METRICS_WITHDRAW_TSV="$TSV" +export METRICS_PAY_TSV="$PAY_TSV" +metrics_report_coins "ladder-end" || true +metrics_print_overall "ladder final" || true # Keep scratch if LADDER_REPORT_DIR set; else copy key files to /tmp if [ -z "${LADDER_REPORT_DIR:-}" ]; then diff --git a/scripts/taler-monitoring/metrics.sh b/scripts/taler-monitoring/metrics.sh index 50ef343..5df8d76 100644 --- a/scripts/taler-monitoring/metrics.sh +++ b/scripts/taler-monitoring/metrics.sh @@ -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 +}