monitoring: stats.json freshness + display fields outside-in

This commit is contained in:
Hernâni Marques 2026-07-17 16:56:09 +02:00
parent 62d0e426f4
commit 3d5f412af3
No known key found for this signature in database
GPG key ID: CB5738652768F7E9
5 changed files with 273 additions and 60 deletions

View file

@ -294,11 +294,18 @@ fi
info "perf note" "RTT measured from this host (outside-in)"
# ---------------------------------------------------------------------------
# Landing load stats (stats.json written *inside* containers, usually public).
# Prefer HTTPS /intro/stats.json (no SSH). If missing and SSH works, cat from
# the container. Report loadavg + RSS + in-container probe ms — one line each.
# Landing stats.json (public outside-in) — freshness + display fields.
# Timestamps (required for external measurement):
# generated_at_unix — epoch seconds (preferred)
# generated_at — ISO-8601
# generated_at_human — footer / “updated” UI string
# Bank collector always sets all three; exchange/merchant scripts set unix too.
# Env:
# STATS_STALE_SECS warn if age > this (default 900 = 15m; timer often 515m)
# STATS_FAIL_SECS hard fail if age > this (default 3600); 0 = never hard-fail on age
# ---------------------------------------------------------------------------
: "${STATS_STALE_SECS:=900}" # warn if generated_at older than 15m
: "${STATS_STALE_SECS:=900}"
: "${STATS_FAIL_SECS:=3600}"
_fetch_landing_stats_json() {
# $1=name bank|exchange|merchant $2=base URL → writes $tmp/stats-$1.json, exit 0 if ok
@ -326,82 +333,265 @@ _fetch_landing_stats_json() {
return 1
}
report_landing_load_stats() {
# Validate one stats.json for monitoring display + outside age measurement.
# Prints lines: OK|WARN|ERR|STALE|FAIL <detail>
# Sets $tmp/stats-meta-$name.txt with gen_unix|source for shared-feed compare.
check_landing_stats_json() {
local name="$1" base="$2"
local f="$tmp/stats-${name}.json"
local line age_s
local line hard_missing=0
if ! _fetch_landing_stats_json "$name" "$base"; then
if [ "${CHECK_LANDING:-1}" = "1" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
warn "perf ${name} landing-stats" "stats.json not available (public or SSH)"
fail "stats ${name}" "HTTP/body missing · ${base}/intro/stats.json"
else
warn "stats ${name}" "stats.json not available"
fi
return
return 1
fi
ok "stats ${name} reachable" "${base}/intro/stats.json"
# Multi-line verdict from python (STATUS detail)
while IFS= read -r line; do
case "$line" in
META\ *)
printf '%s\n' "${line#META }" >"$tmp/stats-meta-${name}.txt"
;;
OK\ *)
ok "stats ${name}" "${line#OK }"
;;
WARN\ *)
warn "stats ${name}" "${line#WARN }"
;;
STALE\ *)
warn "stats ${name} freshness" "stale · ${line#STALE }"
;;
FAIL\ *)
fail "stats ${name}" "${line#FAIL }"
hard_missing=1
;;
ERR\ *)
if [ "${LOCAL_STACK:-0}" = "1" ] || [ "${CHECK_LANDING:-1}" = "1" ]; then
fail "stats ${name}" "${line#ERR }"
else
warn "stats ${name}" "${line#ERR }"
fi
hard_missing=1
;;
*)
[ -n "$line" ] && warn "stats ${name}" "unparsed · $line"
;;
esac
done < <(python3 - "$f" "$name" "${STATS_STALE_SECS}" "${STATS_FAIL_SECS}" <<'PY' 2>/dev/null || echo "ERR python check failed"
import json, sys, time, re
from datetime import datetime, timezone
path, role, stale_s, fail_s = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
now = int(time.time())
def out(kind, msg):
print("%s %s" % (kind, msg))
line=$(python3 - "$f" "${STATS_STALE_SECS}" <<'PY' 2>/dev/null || true
import json, sys, time
path, stale = sys.argv[1], int(sys.argv[2])
try:
d = json.load(open(path))
except Exception as e:
print("ERR parse:" + str(e)[:80])
out("ERR", "JSON parse: %s" % str(e)[:100])
sys.exit(0)
if not d.get("ok", True):
print("ERR ok=false " + str(d.get("error") or "")[:80])
if not isinstance(d, dict):
out("ERR", "body is not a JSON object")
sys.exit(0)
p = d.get("performance") or {}
if not isinstance(p, dict) or not p:
print("ERR no performance block")
if d.get("ok") is False:
out("ERR", "ok=false · %s" % str(d.get("error") or d.get("hint") or "")[:80])
sys.exit(0)
mem = p.get("memory") if isinstance(p.get("memory"), dict) else {}
rss = mem.get("container_rss_human") or mem.get("proc_sum_rss_human") or "?"
load = p.get("loadavg") or "?"
# latency fields differ by site
bits = []
for k in ("config_ms", "integration_ms", "webui_ms", "keys_ms", "terms_ms"):
if k in p and p[k] is not None:
bits.append("%s=%sms" % (k.replace("_ms", ""), p[k]))
lat = " ".join(bits) if bits else "latency=?"
gen = d.get("generated_at_human") or d.get("generated_at") or "?"
# staleness
age = ""
try:
gu = d.get("generated_at_unix")
if gu is not None:
age_s = int(time.time()) - int(gu)
age = " age=%ss" % age_s
if age_s > stale:
print("STALE loadavg=%s RSS=%s %s gen=%s%s" % (load, rss, lat, gen, age))
sys.exit(0)
except Exception:
pass
print("OK loadavg=%s RSS=%s %s gen=%s%s" % (load, rss, lat, gen, age))
# --- timestamps (outside-in age measurement) ---
gu = d.get("generated_at_unix")
giso = d.get("generated_at") or d.get("generated_at_iso")
ghum = d.get("generated_at_human")
age = None
src_u = None
if gu is not None and str(gu).strip() != "":
try:
src_u = int(float(gu))
# tolerate ms accidental timestamps
if src_u > 10_000_000_000:
src_u //= 1000
age = now - src_u
except Exception:
out("ERR", "generated_at_unix not numeric: %r" % gu)
src_u = None
elif giso:
# parse ISO when unix missing (legacy exchange/merchant before unix field)
try:
s = str(giso).strip()
if s.endswith("Z"):
s = s[:-1] + "+00:00"
# allow +0200 without colon
s = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", s)
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
src_u = int(dt.timestamp())
age = now - src_u
out("WARN", "no generated_at_unix — derived age from generated_at ISO (deploy collector with unix)")
except Exception as e:
out("ERR", "cannot parse generated_at=%r (%s)" % (giso, e))
else:
out("ERR", "missing generated_at_unix and generated_at — cannot measure freshness outside")
if not ghum and not giso:
out("ERR", "missing generated_at_human/generated_at — UI cannot show “updated” time")
elif ghum:
out("OK", "timestamp human=%s" % ghum)
else:
out("OK", "timestamp iso=%s" % giso)
if src_u is not None:
out("META", "%s|%s|%s" % (src_u, d.get("source") or "", d.get("currency") or ""))
if age is not None and age < -120:
out("WARN", "clock skew · generated_at_unix in the future by %ss" % (-age))
if age is not None:
out("OK", "generated_at_unix=%s age=%ss (now=%s)" % (src_u, age, now))
if fail_s > 0 and age > fail_s:
out("FAIL", "age=%ss > STATS_FAIL_SECS=%s · collector not updating" % (age, fail_s))
elif age > stale_s:
out("STALE", "age=%ss > STATS_STALE_SECS=%s · timer lag or stuck publish" % (age, stale_s))
# --- display fields for landings / monitoring ---
src = str(d.get("source") or "")
# Bank-shaped (incl. stage shared feed on all three landings)
bankish = (
role == "bank"
or "collect_bank_stats" in src
or "shared" in src
or isinstance(d.get("withdraws"), dict)
or isinstance(d.get("bank_accounts"), dict)
)
if bankish:
missing = []
if not isinstance(d.get("withdraws"), dict):
missing.append("withdraws")
ba = d.get("bank_accounts")
wl = d.get("wallets")
if not isinstance(ba, dict) and not isinstance(wl, dict):
missing.append("bank_accounts|wallets")
if d.get("currency") in (None, ""):
missing.append("currency")
if missing:
out("ERR", "bank-shaped display missing: %s" % ",".join(missing))
else:
w = d.get("withdraws") or {}
ba = ba if isinstance(ba, dict) else {}
n_acc = ba.get("total") if ba.get("total") is not None else ba.get("users")
out(
"OK",
"display bank-shape currency=%s accounts=%s withdraws.count=%s source=%s"
% (d.get("currency"), n_acc, w.get("count"), (src or "?")[:48]),
)
elif src == "exchange-db" or (role == "exchange" and "reserves" in d):
need = []
for k in ("reserves", "wire_in_count", "known_coins"):
if k not in d and k.replace("known_coins", "coins_live") not in d:
if k == "known_coins" and ("coins_live" in d or "coins_remaining_amount" in d):
continue
need.append(k)
if need:
out("WARN", "exchange display keys missing: %s" % ",".join(need))
else:
out(
"OK",
"display exchange-db reserves=%s wire_in=%s coins=%s"
% (d.get("reserves"), d.get("wire_in_count"), d.get("known_coins") or d.get("coins_live")),
)
elif src == "merchant-db" or (role == "merchant" and "instances" in d):
if "instances" not in d and "orders" not in d:
out("ERR", "merchant display missing instances/orders")
else:
out(
"OK",
"display merchant-db instances=%s orders=%s paid=%s"
% (d.get("instances"), d.get("orders"), d.get("paid")),
)
else:
out("WARN", "unknown stats schema role=%s source=%s keys=%s" % (role, src[:40], ",".join(list(d.keys())[:8])))
# performance block optional (stage shared bank feed may omit heavy probes)
p = d.get("performance")
if isinstance(p, dict) and p:
mem = p.get("memory") if isinstance(p.get("memory"), dict) else {}
rss = mem.get("container_rss_human") or mem.get("proc_sum_rss_human") or "—"
load = p.get("loadavg") or "—"
bits = []
for k in ("config_ms", "integration_ms", "webui_ms", "keys_ms", "terms_ms"):
if k in p and p[k] is not None:
bits.append("%s=%s" % (k.replace("_ms", ""), p[k]))
lat = " ".join(bits) if bits else "latency=—"
out("OK", "performance loadavg=%s RSS=%s %s" % (load, rss, lat))
else:
out("OK", "performance block optional/absent")
PY
)
case "$line" in
OK\ *)
ok "perf ${name} landing-stats" "${line#OK }"
;;
STALE\ *)
warn "perf ${name} landing-stats" "stale · ${line#STALE }"
;;
ERR\ *)
warn "perf ${name} landing-stats" "${line#ERR }"
;;
*)
warn "perf ${name} landing-stats" "unreadable stats.json"
;;
esac
return 0
}
if [ "${CHECK_LANDING:-1}" = "1" ] || [ "${LOCAL_STACK:-0}" = "1" ]; then
set_group stats
section "www · performance · landing load stats (stats.json · in-container probes)"
report_landing_load_stats "bank" "$BANK_PUBLIC"
report_landing_load_stats "exchange" "$EXCHANGE_PUBLIC"
report_landing_load_stats "merchant" "$MERCHANT_PUBLIC"
info "perf landing-stats note" "from /intro/stats.json (public); SSH container fallback if needed"
section "www · landing stats.json (freshness + display fields · outside-in)"
info "stats policy" "STALE≥${STATS_STALE_SECS}s WARN · FAIL≥${STATS_FAIL_SECS}s ERROR (0=disable fail) · need generated_at_unix"
check_landing_stats_json "bank" "$BANK_PUBLIC" || true
check_landing_stats_json "exchange" "$EXCHANGE_PUBLIC" || true
check_landing_stats_json "merchant" "$MERCHANT_PUBLIC" || true
# Shared feed: stage-style publish copies same bank stats to all three URLs
if [ -f "$tmp/stats-meta-bank.txt" ] && [ -f "$tmp/stats-meta-exchange.txt" ] && [ -f "$tmp/stats-meta-merchant.txt" ]; then
shared_line=$(python3 - "$tmp/stats-meta-bank.txt" "$tmp/stats-meta-exchange.txt" "$tmp/stats-meta-merchant.txt" <<'PY' 2>/dev/null || true
import sys
def parse(p):
try:
t = open(p).read().strip().split("|", 2)
return t[0], (t[1] if len(t) > 1 else ""), (t[2] if len(t) > 2 else "")
except Exception:
return "", "", ""
b, bs, bc = parse(sys.argv[1])
e, es, ec = parse(sys.argv[2])
m, ms, mc = parse(sys.argv[3])
if not b or not e or not m:
print("SKIP incomplete meta")
elif b == e == m:
print("SHARED gen_unix=%s bank_src=%s" % (b, (bs or "?")[:50]))
else:
# Shared feed only when all three claim the same bank-collector source
# (e.g. stage TESTPAYSAN "… shared"). GOA uses independent exchange-db /
# merchant-db collectors — different gen_unix is expected.
def bankish(s):
s = (s or "").lower()
return "shared" in s or "collect_bank_stats" in s
if bankish(bs) and bankish(es) and bankish(ms):
print("DRIFT bank=%s exchange=%s merchant=%s (expected equal for shared feed)" % (b, e, m))
else:
print("INDEPENDENT bank=%s exchange=%s merchant=%s" % (b, e, m))
PY
)
case "$shared_line" in
SHARED\ *)
ok "stats shared feed" "${shared_line#SHARED }"
;;
DRIFT\ *)
warn "stats shared feed" "timestamps differ · ${shared_line#DRIFT }"
;;
INDEPENDENT\ *)
info "stats feeds" "independent collectors · ${shared_line#INDEPENDENT }"
;;
*)
info "stats feeds" "${shared_line:-n/a}"
;;
esac
fi
info "stats note" "public GET /intro/stats.json; measure age via generated_at_unix vs wall clock"
fi
# Rollup + optional JSON for metrics_print_overall
if [ -s "$PERF_TSV" ]; then