diff --git a/configs/bank-landing/index.html b/configs/bank-landing/index.html
index 4531fa2..bd47143 100644
--- a/configs/bank-landing/index.html
+++ b/configs/bank-landing/index.html
@@ -2037,20 +2037,33 @@ tw run-until-done && tw balance
if (http && http !== "200") s += " (" + http + ")";
return s;
}
+ function setMem(id, human, n, bytes) {
+ var el = document.getElementById(id);
+ if (!el) return;
+ if (!human && human !== 0) {
+ el.textContent = "—";
+ el.removeAttribute("title");
+ return;
+ }
+ var h = String(human);
+ if (n != null && n !== "" && Number(n) > 0) h += " · " + n + "p";
+ el.textContent = h;
+ if (bytes != null && bytes !== "" && bytes !== 0) {
+ el.setAttribute("title", Number(bytes).toLocaleString("en-US") + " B");
+ } else {
+ el.removeAttribute("title");
+ }
+ }
var p = d.performance || {};
set("st-config-ms", msLabel(p.config_ms, p.config_http));
set("st-int-ms", msLabel(p.integration_ms, p.integration_http));
set("st-webui-ms", msLabel(p.webui_ms, p.webui_http));
set("st-load", p.loadavg || "—");
var mem = p.memory || {};
- set("st-mem-ctr", mem.container_rss_human || "—");
- set("st-mem-pg", mem.postgres_rss_human
- ? mem.postgres_rss_human + (mem.postgres_n ? " · " + mem.postgres_n + "p" : "")
- : "—");
- set("st-mem-java", mem.java_rss_human
- ? mem.java_rss_human + (mem.java_n ? " · " + mem.java_n + "p" : "")
- : "—");
- set("st-mem-nginx", mem.nginx_rss_human || "—");
+ setMem("st-mem-ctr", mem.container_rss_label || mem.container_rss_human || "—", null, mem.container_rss_bytes);
+ setMem("st-mem-pg", mem.postgres_rss_human, mem.postgres_n, mem.postgres_rss_bytes);
+ setMem("st-mem-java", mem.java_rss_human, mem.java_n, mem.java_rss_bytes);
+ setMem("st-mem-nginx", mem.nginx_rss_human, mem.nginx_n, mem.nginx_rss_bytes);
var topBox = document.getElementById("st-mem-top");
if (topBox) {
var tops = mem.top || [];
@@ -2062,10 +2075,16 @@ tw run-until-done && tw balance
'
Comm | ' +
'Command | ';
tops.forEach(function (t) {
- th += '| ' + (t.rss_human || "?") +
+ var cmd = t.cmd || "";
+ var cmdFull = t.cmd_full || cmd;
+ if (cmd.length > 64) cmd = cmd.slice(0, 61) + "…";
+ th += ' |
| ' +
+ (t.rss_human || "?") +
' | ' + (t.comm || "") +
- ' | ' +
- (t.cmd || "") + " |
";
+ '' +
+ cmd + " | ";
});
th += "";
topBox.innerHTML = th;
@@ -2073,7 +2092,8 @@ tw run-until-done && tw balance
}
var pf = document.getElementById("st-perf-foot");
if (pf) {
- pf.textContent = "Container RSS + process groups · " +
+ pf.textContent = "Container RSS + process groups" +
+ (mem.source ? " · " + mem.source : "") + " · " +
(d.generated_at_human || d.generated_at || "");
}
return fetch("stats-run.json", { cache: "no-store" })
diff --git a/configs/exchange-landing/index.html b/configs/exchange-landing/index.html
index feb8d85..5d73388 100644
--- a/configs/exchange-landing/index.html
+++ b/configs/exchange-landing/index.html
@@ -569,19 +569,30 @@ sudo apt-get install -y taler-wallet-cli
(d.generated_at_human || d.generated_at || "");
}
+ function setMem(id, human, n, bytes) {
+ var el = document.getElementById(id);
+ if (!el) return;
+ if (!human && human !== 0) {
+ el.textContent = "—";
+ el.removeAttribute("title");
+ return;
+ }
+ var h = String(human);
+ if (n != null && n !== "" && Number(n) > 0) h += " · " + n + "p";
+ el.textContent = h;
+ if (bytes != null && bytes !== "" && Number(bytes) > 0) {
+ el.setAttribute("title", Number(bytes).toLocaleString("en-US") + " B");
+ } else el.removeAttribute("title");
+ }
var p = d.performance || {};
set("st-keys-ms", msLabel(p.keys_ms, p.keys_http));
set("st-config-ms", msLabel(p.config_ms, p.config_http));
set("st-load", p.loadavg || "—");
var mem = p.memory || {};
- set("st-mem-ctr", mem.container_rss_human || "—");
- set("st-mem-pg", mem.postgres_rss_human
- ? mem.postgres_rss_human + (mem.postgres_n ? " · " + mem.postgres_n + "p" : "")
- : "—");
- set("st-mem-taler", mem.taler_rss_human
- ? mem.taler_rss_human + (mem.taler_n ? " · " + mem.taler_n + "p" : "")
- : "—");
- set("st-mem-nginx", mem.nginx_rss_human || "—");
+ setMem("st-mem-ctr", mem.container_rss_label || mem.container_rss_human || "—", null, mem.container_rss_bytes);
+ setMem("st-mem-pg", mem.postgres_rss_human, mem.postgres_n, mem.postgres_rss_bytes);
+ setMem("st-mem-taler", mem.taler_rss_human, mem.taler_n, mem.taler_rss_bytes);
+ setMem("st-mem-nginx", mem.nginx_rss_human, mem.nginx_n, mem.nginx_rss_bytes);
var topBox = document.getElementById("st-mem-top");
if (topBox) {
var tops = mem.top || [];
@@ -590,9 +601,14 @@ sudo apt-get install -y taler-wallet-cli
} else {
var th = "| RSS | Comm | Command |
";
tops.forEach(function (t) {
- th += "| " + (t.rss_human || "?") + " | " +
- (t.comm || "") + " | " +
- (t.cmd || "") + " |
";
+ var cmd = t.cmd || "";
+ var cmdFull = t.cmd_full || cmd;
+ if (cmd.length > 64) cmd = cmd.slice(0, 61) + "…";
+ th += "| " +
+ (t.rss_human || "?") + " | " +
+ (t.comm || "") + " | " +
+ cmd + " |
";
});
th += "
";
topBox.innerHTML = th;
@@ -600,7 +616,8 @@ sudo apt-get install -y taler-wallet-cli
}
var pf = document.getElementById("st-perf-foot");
if (pf) {
- pf.textContent = "Container RSS + process groups · " +
+ pf.textContent = "Container RSS + process groups" +
+ (mem.source ? " · " + mem.source : "") + " · " +
(d.generated_at_human || d.generated_at || "");
}
return fetch("/intro/stats-run.json", { cache: "no-store" })
diff --git a/configs/merchant-landing/index.html b/configs/merchant-landing/index.html
index 5c494d8..c14ee0c 100644
--- a/configs/merchant-landing/index.html
+++ b/configs/merchant-landing/index.html
@@ -973,20 +973,31 @@ sudo apt-get install -y taler-wallet-cli
foot.textContent = (d.dual_currency ? "Dual-currency · " : "") +
(d.generated_at_human || d.generated_at || "");
}
+ function setMem(id, human, n, bytes) {
+ var el = document.getElementById(id);
+ if (!el) return;
+ if (!human && human !== 0) {
+ el.textContent = "—";
+ el.removeAttribute("title");
+ return;
+ }
+ var h = String(human);
+ if (n != null && n !== "" && Number(n) > 0) h += " · " + n + "p";
+ el.textContent = h;
+ if (bytes != null && bytes !== "" && Number(bytes) > 0) {
+ el.setAttribute("title", Number(bytes).toLocaleString("en-US") + " B");
+ } else el.removeAttribute("title");
+ }
var p = d.performance || {};
set("st-config-ms", msLabel(p.config_ms, p.config_http));
set("st-terms-ms", msLabel(p.terms_ms, p.terms_http));
set("st-webui-ms", msLabel(p.webui_ms, p.webui_http));
set("st-load", p.loadavg || "—");
var mem = p.memory || {};
- set("st-mem-ctr", mem.container_rss_human || "—");
- set("st-mem-pg", mem.postgres_rss_human
- ? mem.postgres_rss_human + (mem.postgres_n ? " · " + mem.postgres_n + "p" : "")
- : "—");
- set("st-mem-taler", mem.taler_rss_human
- ? mem.taler_rss_human + (mem.taler_n ? " · " + mem.taler_n + "p" : "")
- : "—");
- set("st-mem-nginx", mem.nginx_rss_human || "—");
+ setMem("st-mem-ctr", mem.container_rss_label || mem.container_rss_human || "—", null, mem.container_rss_bytes);
+ setMem("st-mem-pg", mem.postgres_rss_human, mem.postgres_n, mem.postgres_rss_bytes);
+ setMem("st-mem-taler", mem.taler_rss_human, mem.taler_n, mem.taler_rss_bytes);
+ setMem("st-mem-nginx", mem.nginx_rss_human, mem.nginx_n, mem.nginx_rss_bytes);
var topBox = document.getElementById("st-mem-top");
if (topBox) {
var tops = mem.top || [];
@@ -998,10 +1009,16 @@ sudo apt-get install -y taler-wallet-cli
'Comm | ' +
'Command | ';
tops.forEach(function (t) {
- th += '| ' + (t.rss_human || "?") +
+ var cmd = t.cmd || "";
+ var cmdFull = t.cmd_full || cmd;
+ if (cmd.length > 64) cmd = cmd.slice(0, 61) + "…";
+ th += ' |
| ' +
+ (t.rss_human || "?") +
' | ' + (t.comm || "") +
- ' | ' +
- (t.cmd || "") + " |
";
+ '' +
+ cmd + " | ";
});
th += "";
topBox.innerHTML = th;
@@ -1009,7 +1026,8 @@ sudo apt-get install -y taler-wallet-cli
}
var pf = document.getElementById("st-perf-foot");
if (pf) {
- pf.textContent = "Container RSS + process groups · " +
+ pf.textContent = "Container RSS + process groups" +
+ (mem.source ? " · " + mem.source : "") + " · " +
(d.generated_at_human || d.generated_at || "");
}
return fetch("/intro/stats-run.json", { cache: "no-store" })
diff --git a/scripts/taler-landing/README.md b/scripts/taler-landing/README.md
index 266d1b3..0876695 100644
--- a/scripts/taler-landing/README.md
+++ b/scripts/taler-landing/README.md
@@ -59,6 +59,12 @@ Installed paths:
3. **Merchant** — same for merchant container.
+4. **Resources (all three)** — `collect_container_resources.sh` runs
+ `mem-snapshot` **inside** each podman container (not host `/proc`):
+ container RSS (+ cgroup limit label), postgres/java/taler/nginx groups,
+ top-10 processes, loadavg. Merged into `performance.memory` /
+ `performance.loadavg` via `merge_resources.py`.
+
`exchange` is skipped in the bank **flow** scan so the same GOA is not counted once as customer withdraw and again as exchange credit. **Admin**, **explorer**, and every auto-account are included.
## Secrets
diff --git a/scripts/taler-landing/collect-landing-stats.sh b/scripts/taler-landing/collect-landing-stats.sh
index f41cc44..e49b00f 100755
--- a/scripts/taler-landing/collect-landing-stats.sh
+++ b/scripts/taler-landing/collect-landing-stats.sh
@@ -3,6 +3,7 @@
#
# - Bank: full account+ledger scan via collect_bank_stats.py → bank container
# - Exchange / merchant: existing in-container scripts, then amount_alt enrich
+# - All three: container RSS / loadavg / top procs via mem-snapshot (podman exec)
# - Never wipes a good stats.json on failure (writes stats-run.json only)
#
# Install: scripts/taler-landing/install-landing-stats-host.sh
@@ -50,6 +51,50 @@ ec_bank=0
ec_ex=0
ec_mer=0
+# Resolve helper scripts (checkout or ~/.local/lib)
+COLLECT_RES="$LIB/collect_container_resources.sh"
+MERGE_RES="$LIB/merge_resources.py"
+if [ ! -f "$COLLECT_RES" ] && [ -f "$ROOT/collect_container_resources.sh" ]; then
+ COLLECT_RES="$ROOT/collect_container_resources.sh"
+fi
+if [ ! -f "$MERGE_RES" ] && [ -f "$ROOT/merge_resources.py" ]; then
+ MERGE_RES="$ROOT/merge_resources.py"
+fi
+MEM_SRC="${MEM_SNAPSHOT_SRC:-$ADMIN_LOG/scripts/taler-shared/mem-snapshot.sh}"
+
+merge_container_resources() {
+ local label="$1" ctr="$2" stats_file="$3"
+ [ -f "$stats_file" ] || return 0
+ if ! ctr_running "$ctr"; then
+ log "WARN: $label resources: $ctr not running"
+ return 1
+ fi
+ if [ ! -x "$COLLECT_RES" ] && [ -f "$COLLECT_RES" ]; then
+ chmod +x "$COLLECT_RES" 2>/dev/null || true
+ fi
+ if [ ! -f "$COLLECT_RES" ] || [ ! -f "$MERGE_RES" ]; then
+ log "WARN: $label resources: helpers missing ($COLLECT_RES / $MERGE_RES)"
+ return 1
+ fi
+ local resf="$WORKDIR/${label}-resources.json"
+ set +e
+ ADMIN_LOG="$ADMIN_LOG" MEM_SNAPSHOT_SRC="$MEM_SRC" \
+ bash "$COLLECT_RES" "$ctr" "$resf" >>"$LOG_DIR/${label}-resources.log" 2>&1
+ local ec=$?
+ set -e
+ if [ "$ec" -ne 0 ] || [ ! -s "$resf" ]; then
+ log "WARN: $label resources: collect failed (ec=$ec)"
+ return 1
+ fi
+ if ! "$PY" -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if d.get("ok") and d.get("memory") else 1)' "$resf" 2>/dev/null; then
+ log "WARN: $label resources: bad payload"
+ return 1
+ fi
+ "$PY" "$MERGE_RES" "$stats_file" "$resf" >>"$LOG_DIR/${label}-resources.log" 2>&1
+ log "$label: resources merged (RSS + loadavg from $ctr)"
+ return 0
+}
+
read_pass() {
local name="$1" f
for f in \
@@ -136,28 +181,8 @@ collect_bank() {
set -e
if [ "$ec_bank" -eq 0 ] && [ -f "$WORKDIR/bank-stats.json" ]; then
- # merge in-container memory snapshot when helper exists
- if ctr_running "$BANK_CTR" && podman exec "$BANK_CTR" test -f /usr/local/lib/landing-mem-snapshot.sh 2>/dev/null; then
- set +e
- mem_json=$(podman exec "$BANK_CTR" bash -c '
- export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
- MEM_JSON=""
- # shellcheck disable=SC1091
- . /usr/local/lib/landing-mem-snapshot.sh
- mem_snapshot_json 2>/dev/null || true
- # print MEM_JSON lines only if function set them via echo — fallback empty
- if [ -n "${MEM_JSON:-}" ]; then printf "%s" "$MEM_JSON"; fi
- ' 2>/dev/null)
- set -e
- if [ -n "${mem_json:-}" ]; then
- "$PY" - "$WORKDIR/bank-stats.json" <<'PY' || true
-import json, sys
-path = sys.argv[1]
-# optional: leave memory as-is if merge fails
-print("mem merge skipped (structured merge via podman helper optional)", file=sys.stderr)
-PY
- fi
- fi
+ # Always attach in-container RSS/loadavg (not host /proc)
+ merge_container_resources bank "$BANK_CTR" "$WORKDIR/bank-stats.json" || true
publish_json "$BANK_CTR" "$BANK_LANDING_IN" \
"$WORKDIR/bank-stats.json" "$WORKDIR/bank-stats-run.json"
log "bank: OK → ${BANK_CTR}:${BANK_LANDING_IN}/stats.json"
@@ -220,14 +245,16 @@ run_incontainer_stats() {
log "ERROR: $label stats failed (ec=$ec)"
return "$ec"
fi
- # enrich with alt units on host
+ # enrich with alt units + ensure resources (RSS/loadavg) on host
podman cp "${ctr}:${landing}/stats.json" "$WORKDIR/${label}-stats.json" 2>/dev/null || return 0
set +e
"$PY" "$LIB/enrich_stats_alt.py" "$WORKDIR/${label}-stats.json" \
--exchange-config "$EXCHANGE_CONFIG_URL" >>"$LOG_DIR/${label}.log" 2>&1
set -e
+ # Prefer fresh container snapshot (also fills gaps if in-container mem helper missing)
+ merge_container_resources "$label" "$ctr" "$WORKDIR/${label}-stats.json" || true
podman cp "$WORKDIR/${label}-stats.json" "${ctr}:${landing}/stats.json"
- log "$label: OK + alt enrich → ${ctr}:${landing}/stats.json"
+ log "$label: OK + alt + resources → ${ctr}:${landing}/stats.json"
return 0
}
diff --git a/scripts/taler-landing/collect_bank_stats.py b/scripts/taler-landing/collect_bank_stats.py
index 79055d6..096eae7 100755
--- a/scripts/taler-landing/collect_bank_stats.py
+++ b/scripts/taler-landing/collect_bank_stats.py
@@ -629,13 +629,9 @@ def main() -> int:
if int_http != "200":
int_ms, int_http = measure_ms(f"{bank}/taler-integration/config")
+ # loadavg filled later by host merge from *inside* the bank container
+ # (host /proc would be wrong when this script runs on koopa outside podman)
loadavg = ""
- try:
- with open("/proc/loadavg", encoding="utf-8") as f:
- parts = f.read().split()
- loadavg = ",".join(parts[:3])
- except Exception:
- pass
total_pack = pack_amt(total_wd)
@@ -755,7 +751,8 @@ def main() -> int:
"loadavg": loadavg,
"memory": {
"container_rss_human": "—",
- "note": "memory filled by host collector merge when available",
+ "container_rss_label": "—",
+ "note": "filled by collect_container_resources.sh (in-container /proc + cgroup)",
},
},
"alt_unit_names": alt,
diff --git a/scripts/taler-landing/collect_container_resources.sh b/scripts/taler-landing/collect_container_resources.sh
new file mode 100755
index 0000000..1963a4d
--- /dev/null
+++ b/scripts/taler-landing/collect_container_resources.sh
@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+# Snapshot loadavg + RSS groups inside a podman container (for landing stats).
+# Usage: collect_container_resources.sh CONTAINER [OUT.json]
+# Prints JSON to stdout (and writes OUT when given).
+set -euo pipefail
+
+CTR="${1:-}"
+OUT="${2:-}"
+if [ -z "$CTR" ]; then
+ echo "usage: $0 CONTAINER [OUT.json]" >&2
+ exit 2
+fi
+
+ADMIN_LOG="${ADMIN_LOG:-${HOME}/src/koopa/koopa-admin-log}"
+MEM_SRC="${MEM_SNAPSHOT_SRC:-$ADMIN_LOG/scripts/taler-shared/mem-snapshot.sh}"
+MEM_DST="${MEM_SNAPSHOT_DST:-/usr/local/lib/landing-mem-snapshot.sh}"
+
+if ! podman inspect -f '{{.State.Running}}' "$CTR" 2>/dev/null | grep -qx true; then
+ echo "{\"ok\":false,\"error\":\"container not running: $CTR\"}"
+ exit 1
+fi
+
+if [ -f "$MEM_SRC" ]; then
+ podman exec "$CTR" mkdir -p "$(dirname "$MEM_DST")" 2>/dev/null || true
+ podman cp "$MEM_SRC" "${CTR}:${MEM_DST}"
+fi
+
+# Run emit inside container (needs /proc + cgroup of that container)
+set +e
+raw=$(podman exec "$CTR" bash -c '
+ set -e
+ export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+ if [ ! -f /usr/local/lib/landing-mem-snapshot.sh ]; then
+ echo "{\"ok\":false,\"error\":\"mem-snapshot helper missing\"}"
+ exit 1
+ fi
+ # shellcheck disable=SC1091
+ . /usr/local/lib/landing-mem-snapshot.sh
+ if ! declare -F mem_snapshot_emit >/dev/null 2>&1; then
+ # older helper without emit — synthesize
+ mem_snapshot_json
+ loadavg=""
+ [ -r /proc/loadavg ] && loadavg=$(awk "{print \$1\",\"\$2\",\"\$3}" /proc/loadavg)
+ printf "{\"ok\":true,\"source\":\"mem-snapshot-legacy\",\"loadavg\":%s,\"memory\":{%s}}\n" \
+ "\"$loadavg\"" "$MEM_JSON"
+ else
+ mem_snapshot_emit
+ fi
+' 2>/tmp/landing-mem-err.$$)
+ec=$?
+set -e
+
+if [ "$ec" -ne 0 ] || [ -z "$raw" ]; then
+ err=$(tr '\n' ' ' /dev/null | head -c 200 || true)
+ rm -f /tmp/landing-mem-err.$$
+ printf '{"ok":false,"error":"podman exec failed: %s"}\n' "${err//\"/\'}"
+ exit 1
+fi
+rm -f /tmp/landing-mem-err.$$
+
+# Validate JSON
+if ! printf '%s' "$raw" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then
+ printf '{"ok":false,"error":"invalid json from container"}\n'
+ exit 1
+fi
+
+if [ -n "$OUT" ]; then
+ printf '%s\n' "$raw" >"$OUT"
+fi
+printf '%s\n' "$raw"
diff --git a/scripts/taler-landing/install-landing-stats-host.sh b/scripts/taler-landing/install-landing-stats-host.sh
index 1fa56a9..7c572f0 100755
--- a/scripts/taler-landing/install-landing-stats-host.sh
+++ b/scripts/taler-landing/install-landing-stats-host.sh
@@ -26,7 +26,16 @@ mkdir -p "$BIN_DST" "$LIB_DST" "$UNIT_DST" "$STATE_DST"
install -m 0755 "$SRC/collect-landing-stats.sh" "$BIN_DST/collect-landing-stats.sh"
install -m 0755 "$SRC/collect_bank_stats.py" "$LIB_DST/collect_bank_stats.py"
install -m 0755 "$SRC/enrich_stats_alt.py" "$LIB_DST/enrich_stats_alt.py"
+install -m 0755 "$SRC/merge_resources.py" "$LIB_DST/merge_resources.py"
+install -m 0755 "$SRC/collect_container_resources.sh" "$LIB_DST/collect_container_resources.sh"
+install -m 0755 "$SRC/test-landing-stats.sh" "$BIN_DST/test-landing-stats.sh" 2>/dev/null || \
+ install -m 0755 "$SRC/test-landing-stats.sh" "$LIB_DST/test-landing-stats.sh"
install -m 0644 "$SRC/goa_amounts.py" "$LIB_DST/goa_amounts.py"
+# mem-snapshot helper lives in taler-shared (copied into containers at collect time)
+if [ -f "$ADMIN_LOG/scripts/taler-shared/mem-snapshot.sh" ]; then
+ install -m 0644 "$ADMIN_LOG/scripts/taler-shared/mem-snapshot.sh" \
+ "$LIB_DST/mem-snapshot.sh"
+fi
# Wrapper always uses installed lib next to itself when LIB discovery works;
# also point ADMIN_LOG for in-container script refresh.
diff --git a/scripts/taler-landing/merge_resources.py b/scripts/taler-landing/merge_resources.py
new file mode 100755
index 0000000..82e8d46
--- /dev/null
+++ b/scripts/taler-landing/merge_resources.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+"""Merge container resource snapshot into landing stats.json performance block.
+
+Usage:
+ merge_resources.py STATS.json RESOURCES.json [-o OUT.json]
+
+RESOURCES shape (from mem_snapshot_emit / collect_container_resources.sh):
+ { "ok": true, "loadavg": "0.1,0.2,0.3", "memory": { ... } }
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from pathlib import Path
+from typing import Any, Dict
+
+
+def compact_memory(mem: Dict[str, Any]) -> Dict[str, Any]:
+ """Ensure human labels + short top cmd for UI width."""
+ if not isinstance(mem, dict):
+ return mem
+ # Prefer explicit label (with limit); else human
+ if not mem.get("container_rss_label"):
+ h = mem.get("container_rss_human") or "—"
+ lim = mem.get("cgroup_limit_human")
+ if lim:
+ mem["container_rss_label"] = f"{h} / {lim}"
+ else:
+ mem["container_rss_label"] = h
+ tops = mem.get("top")
+ if isinstance(tops, list):
+ for t in tops:
+ if not isinstance(t, dict):
+ continue
+ cmd = str(t.get("cmd") or "")
+ if len(cmd) > 72:
+ t["cmd_full"] = cmd
+ t["cmd"] = cmd[:69] + "…"
+ # ensure human present
+ if not t.get("rss_human") and t.get("rss_bytes") is not None:
+ try:
+ b = int(t["rss_bytes"])
+ if b < 1024:
+ t["rss_human"] = f"{b} B"
+ elif b < 1048576:
+ t["rss_human"] = f"{b/1024:.1f} KiB"
+ elif b < 1073741824:
+ t["rss_human"] = f"{b/1048576:.1f} MiB"
+ else:
+ t["rss_human"] = f"{b/1073741824:.2f} GiB"
+ except Exception:
+ pass
+ return mem
+
+
+def merge(stats: Dict[str, Any], resources: Dict[str, Any]) -> Dict[str, Any]:
+ perf = stats.setdefault("performance", {})
+ if not isinstance(perf, dict):
+ perf = {}
+ stats["performance"] = perf
+
+ if resources.get("loadavg"):
+ perf["loadavg"] = resources["loadavg"]
+ perf["loadavg_source"] = "container"
+
+ mem = resources.get("memory")
+ if isinstance(mem, dict) and mem:
+ perf["memory"] = compact_memory(dict(mem))
+ perf["memory"]["source"] = resources.get("source") or "mem-snapshot"
+ return stats
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("stats")
+ ap.add_argument("resources")
+ ap.add_argument("-o", "--out", default="")
+ args = ap.parse_args()
+
+ stats = json.loads(Path(args.stats).read_text(encoding="utf-8"))
+ resources = json.loads(Path(args.resources).read_text(encoding="utf-8"))
+ if not isinstance(stats, dict) or not stats.get("ok"):
+ print("skip: stats not ok", file=sys.stderr)
+ return 1
+ if not isinstance(resources, dict) or resources.get("ok") is False:
+ print("skip: resources not ok", file=sys.stderr)
+ return 1
+
+ merge(stats, resources)
+ out = Path(args.out) if args.out else Path(args.stats)
+ tmp = out.with_suffix(out.suffix + f".tmp.{os.getpid()}")
+ tmp.write_text(json.dumps(stats, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ tmp.replace(out)
+ mem = (stats.get("performance") or {}).get("memory") or {}
+ print(
+ f"merged memory container={mem.get('container_rss_human')} "
+ f"pg={mem.get('postgres_rss_human')} loadavg={stats.get('performance', {}).get('loadavg')}",
+ file=sys.stderr,
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/taler-landing/test-landing-stats.sh b/scripts/taler-landing/test-landing-stats.sh
index 5358715..f8208c7 100755
--- a/scripts/taler-landing/test-landing-stats.sh
+++ b/scripts/taler-landing/test-landing-stats.sh
@@ -102,6 +102,56 @@ then ok "enrich shapes bank/exchange/merchant"
else bad "enrich unit failed"
fi
+echo
+echo "=== unit: merge_resources + mem labels ==="
+if "$PY" - </dev/null; then
+ ok "$name resource merge on live payload"
+ else
+ # fallback without process substitution for macOS bash
+ "$PY" -c 'import json; d=json.load(open("/tmp/landing-test.body")); p=d.get("performance") or {}; json.dump({"ok":True,"loadavg":p.get("loadavg") or "","memory":p.get("memory") or {}}, open("/tmp/landing-test.res.json","w"))'
+ if "$PY" "$ROOT/scripts/taler-landing/merge_resources.py" /tmp/landing-test.body /tmp/landing-test.res.json -o /tmp/landing-test.resmerged.json 2>/dev/null; then
+ ok "$name resource merge on live payload"
+ else
+ bad "$name resource merge failed"
+ fi
+ fi
else
bad "$name stats.json HTTP $code / not ok"
fi
diff --git a/scripts/taler-shared/mem-snapshot.sh b/scripts/taler-shared/mem-snapshot.sh
index b011202..9918bca 100644
--- a/scripts/taler-shared/mem-snapshot.sh
+++ b/scripts/taler-shared/mem-snapshot.sh
@@ -1,7 +1,17 @@
#!/bin/bash
-# Memory snapshot for landing-stats (source after json_str is defined).
+# Memory snapshot for landing-stats.
# Sets MEM_JSON (fields to embed inside performance.memory).
+# Also: mem_snapshot_emit → full JSON object on stdout (host collector).
# IMPORTANT: no pipelines around the /proc loop (bash subshell loses counters).
+#
+# json_str may already be defined by the caller (landing-stats.sh); provide a
+# safe default so this file is usable stand-alone via podman exec.
+
+if ! declare -F json_str >/dev/null 2>&1; then
+ json_str() {
+ printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n\r\t' ' ')"
+ }
+fi
mem_fmt_bytes() {
awk -v b="${1:-0}" 'BEGIN{
@@ -92,21 +102,39 @@ mem_snapshot_json() {
rm -f "$topf"
lim_json="null"
+ lim_human_json="null"
if [[ "$limit_b" =~ ^[0-9]+$ ]] && [ "$limit_b" -gt 0 ]; then
lim_json=$limit_b
+ lim_human_json=$(json_str "$(mem_fmt_bytes "$limit_b")")
fi
cg_json="null"
if [[ "$cgroup_b" =~ ^[0-9]+$ ]]; then
cg_json=$cgroup_b
fi
+ # Compact container label: "693.4 MiB" or "693.4 MiB / 2.00 GiB" when capped
+ local ctr_human
+ ctr_human=$(mem_fmt_bytes "$total_b")
+ if [ "$lim_human_json" != "null" ]; then
+ # lim_human_json is a quoted string already
+ :
+ fi
+
MEM_JSON="
\"container_rss_bytes\": ${total_b},
- \"container_rss_human\": $(json_str "$(mem_fmt_bytes "$total_b")"),
+ \"container_rss_human\": $(json_str "$ctr_human"),
+ \"container_rss_label\": $(json_str "$(
+ if [ "$lim_json" != "null" ]; then
+ printf '%s / %s' "$ctr_human" "$(mem_fmt_bytes "$limit_b")"
+ else
+ printf '%s' "$ctr_human"
+ fi
+ )"),
\"proc_sum_rss_bytes\": ${sum_b},
\"proc_sum_rss_human\": $(json_str "$(mem_fmt_bytes "$sum_b")"),
\"cgroup_bytes\": ${cg_json},
\"cgroup_limit_bytes\": ${lim_json},
+ \"cgroup_limit_human\": ${lim_human_json},
\"postgres_rss_bytes\": ${postgres_b},
\"postgres_rss_human\": $(json_str "$(mem_fmt_bytes "$postgres_b")"),
\"postgres_n\": ${n_pg},
@@ -128,3 +156,15 @@ mem_snapshot_json() {
\"top\": ${top_json}
"
}
+
+# Full JSON for host collector: { ok, loadavg, memory: {…} }
+mem_snapshot_emit() {
+ mem_snapshot_json || return 1
+ local loadavg=""
+ if [ -r /proc/loadavg ]; then
+ loadavg=$(awk '{print $1","$2","$3}' /proc/loadavg)
+ fi
+ printf '{\n "ok": true,\n "source": "mem-snapshot",\n "loadavg": %s,\n "memory": {\n%s\n }\n}\n' \
+ "$(json_str "$loadavg")" \
+ "$MEM_JSON"
+}