Files
rippled/docker/telemetry/workload/collect_system_metrics.sh
Pratik Mankawde 50eff17dd4 docs(telemetry): drop the host name from the sampling-clock comment
The comment measured date +%s%N cost 'on a dev box'; say 'on one Linux
host' instead. The number is the point, not where it was taken.
2026-09-15 14:26:23 +01:00

409 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
# collect_system_metrics.sh — Collect CPU, memory, and RPC latency metrics
# from running xrpld nodes for benchmark comparison.
#
# Samples system metrics at regular intervals and writes a JSON summary.
# Used by benchmark.sh for baseline vs telemetry comparison.
#
# Usage:
# ./collect_system_metrics.sh <rpc_ports_csv> <duration_seconds> <output_file> [pids_csv]
#
# pids_csv narrows process sampling to exactly those pids. Without it the scope
# is every xrpld on the host, which averages in any other cluster's nodes and
# reports the largest of them as the RSS peak. benchmark.sh passes its own pids
# because run-full-validation.sh leaves five validation nodes running while the
# benchmark's three start, and diluting the arms alike hides the delta.
#
# Example:
# ./collect_system_metrics.sh "5005,5006,5007" 300 /tmp/metrics-baseline.json
# ./collect_system_metrics.sh "5020,5021,5022" 120 /tmp/m.json "8801,8802,8803"
#
# Output JSON format:
# {
# "cpu_pct_avg": 12.5,
# "memory_rss_mb_peak": 450.2,
# "rpc_p99_ms": 15.3,
# "tps": 4.8,
# "consensus_round_mean_ms": 3200,
# "metrics_complete": true,
# "samples": 60
# }
#
# Exit codes:
# 0 Every metric was measured; "metrics_complete" is true.
# 1 Cannot run at all: bad arguments, no GNU date with %N, or a failed
# process sample. No output file is written.
# 3 Output file was written, but at least one measurement source was empty,
# so the affected metrics are 0 placeholders and "metrics_complete" is
# false. Callers must treat this as inconclusive, never as a pass.
set -euo pipefail
# ---------------------------------------------------------------------------
# Colored output helpers
# ---------------------------------------------------------------------------
log() { printf "\033[1;34m[METRICS]\033[0m %s\n" "$*"; }
ok() { printf "\033[1;32m[METRICS]\033[0m %s\n" "$*"; }
# Warnings go to stderr so they never mix into the JSON echoed on stdout.
warn() { printf "\033[1;33m[METRICS]\033[0m %s\n" "$*" >&2; }
die() {
printf "\033[1;31m[METRICS]\033[0m %s\n" "$*" >&2
exit 1
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
usage() {
echo "Usage: $0 <rpc_ports_csv> <duration_seconds> <output_file>"
echo ""
echo "Arguments:"
echo " rpc_ports_csv Comma-separated RPC ports (e.g., 5005,5006,5007)"
echo " duration_seconds How long to collect metrics"
echo " output_file Path to write JSON results"
echo " pids_csv Optional: sample only these pids, not every host xrpld"
exit 1
}
if [ $# -lt 3 ]; then
usage
fi
RPC_PORTS_CSV="$1"
DURATION="$2"
OUTPUT_FILE="$3"
PIDS_CSV="${4:-}"
# Reject a malformed pid list rather than silently sampling the whole host,
# which is the outcome this argument exists to prevent.
case "$PIDS_CSV" in
'') ;;
*[!0-9,]*) die "pids_csv must be a comma-separated list of pids, got '$PIDS_CSV'" ;;
esac
IFS=',' read -ra RPC_PORTS <<<"$RPC_PORTS_CSV"
# consensus_round_mean_ms below counts DISTINCT ledger sequences seen by this
# loop, so it cannot resolve a close interval shorter than SAMPLE_INTERVAL. At
# 5 s it read back 5000 ms for every close time from 2 s to 5 s, so a 10%
# regression measured 0% and the benchmark's 1% consensus gate could never fire.
# 2 s is the close-time floor itself (ledgerMinClose, ConsensusParms.h:93), which
# is enough: measured against this file's own arithmetic, a 10% regression shows
# up as at least 9.3% anywhere in the 2 s to 5 s band. 1 s only doubles the
# probe load for slightly worse numbers.
SAMPLE_INTERVAL=2
# Hard ceiling on every RPC probe below. curl applies no overall timeout of its
# own, so a node that accepts the connection and then stops answering — what a
# stalled job queue looks like from outside — parks the sampling loop for the
# rest of the run. 5 s is some thousands of times a healthy server_info, so it
# bounds a wedged node's cost to a couple of lost samples while never
# truncating a real reply. A probe that hits the ceiling exits
# non-zero and is therefore skipped rather than recorded, which is the same
# rule the latency loop already applies to a refused connection; if every
# probe hits it, the empty file trips the placeholder warning below.
CURL_MAX_TIME=5
# Reject anything the sample arithmetic cannot use, instead of silently
# treating it as 0.
case "$DURATION" in
'' | *[!0-9]*) die "duration_seconds must be a positive integer, got '$DURATION'" ;;
esac
# Normalise to base 10 once, right after the digits check. Bash arithmetic
# reads a leading zero as octal, so "08" aborted with "value too great for
# base" and "0100" was silently taken as 64. Doing it here also keeps the
# value a valid JSON number in the output below, where "08" is not.
DURATION=$((10#$DURATION))
# Round up, so a duration shorter than one interval still takes one sample
# rather than truncating to zero and emitting an all-zero JSON.
SAMPLES=$(((DURATION + SAMPLE_INTERVAL - 1) / SAMPLE_INTERVAL))
if [ "$SAMPLES" -lt 1 ]; then
die "duration_seconds=$DURATION yields $SAMPLES samples; need at least 1"
fi
log "Collecting metrics for ${DURATION}s (${SAMPLES} samples, ${#RPC_PORTS[@]} nodes)..."
# ---------------------------------------------------------------------------
# Nanosecond clock
#
# GNU date supports "+%s%N". BSD/macOS date has no %N and echoes it back
# literally, which used to abort the sampling loop under `set -e` and still
# exit 0 with an all-zero JSON — a silent false pass.
#
# The clock has to be cheap as well as precise, because the latency it
# measures is compared against a 2 ms threshold. Measured on one Linux host:
# `date +%s%N` costs ~1.2 ms per call, forking python3 for the same value ~13 ms.
# Two calls bracket every request, so a python3 fallback would add ~26 ms of
# its own overhead to a 2 ms budget and make the number meaningless. There is
# no cheap alternative worth having, so probe once and refuse to run without
# GNU date rather than report a figure that is quietly an order of magnitude
# wrong.
# ---------------------------------------------------------------------------
if [[ ! "$(date +%s%N 2>/dev/null)" =~ ^[0-9]+$ ]]; then
die "GNU coreutils date with %N is required for RPC latency timing; this date does not support it"
fi
# Echo the current time in nanoseconds since the epoch.
now_ns() {
date +%s%N
}
# ---------------------------------------------------------------------------
# Temporary files for aggregation
# ---------------------------------------------------------------------------
TMPDIR_METRICS="$(mktemp -d)"
CPU_FILE="$TMPDIR_METRICS/cpu.txt"
MEM_FILE="$TMPDIR_METRICS/mem.txt"
RPC_FILE="$TMPDIR_METRICS/rpc.txt"
LEDGER_FILE="$TMPDIR_METRICS/ledger.txt"
touch "$CPU_FILE" "$MEM_FILE" "$RPC_FILE" "$LEDGER_FILE"
cleanup() {
rm -rf "$TMPDIR_METRICS"
}
trap cleanup EXIT
# ---------------------------------------------------------------------------
# Get initial ledger sequence for TPS calculation
# ---------------------------------------------------------------------------
INITIAL_SEQ=0
INITIAL_TIME=$(date +%s)
for port in "${RPC_PORTS[@]}"; do
seq=$(curl -sf --max-time "$CURL_MAX_TIME" "http://localhost:$port" \
-d '{"method":"server_info"}' 2>/dev/null |
jq -r '.result.info.validated_ledger.seq // 0' 2>/dev/null || echo 0)
if [ "$seq" -gt "$INITIAL_SEQ" ]; then
INITIAL_SEQ=$seq
fi
done
log "Initial validated ledger seq: $INITIAL_SEQ"
# ---------------------------------------------------------------------------
# Sampling loop
# ---------------------------------------------------------------------------
for sample in $(seq 1 "$SAMPLES"); do
# Sample CPU% and RSS for the xrpld processes. One ps pass feeds both
# files, so the two numbers always come from the same instant.
#
# Selection is on argv[0]'s basename, NOT on "the command line mentions
# xrpld". The loose form matched every bystander whose command line
# happened to contain the string: this harness's own launcher (invoked as
# `--xrpld .build/xrpld`, whose argv[0] is bash), an editor's clangd, and
# any shell sitting in a directory with xrpld in its path. Measured
# against a live 5-node cluster, that pulled in 8-15 processes instead of
# 5, halved the CPU average with idle bystanders, and reported clangd's
# 1.1 GB RSS as xrpld's peak against a 5 MB threshold. `ps -C xrpld` is
# not an alternative: xrpld renames itself, so its comm is "xrpld-main"
# and -C matches nothing. "rippled" is accepted alongside "xrpld" so a
# rename of the binary cannot silently zero the collector.
#
# Scope is the pid list when one was given, and the whole host otherwise.
# Host scope averages in any other cluster's xrpld and reports the largest
# of them as the RSS peak, which reads as noise against a 5 MB threshold.
#
# A %cpu of exactly 0.0 is a real reading and is counted — dropping idle
# samples would inflate the average — while non-numeric output is
# rejected by the pattern. An RSS of 0 is not a live process, so it
# contributes no memory sample; counting it would leave the file non-empty
# and mark a dead cluster's 0 MB peak as a complete measurement.
ps -eo pid=,%cpu=,rss=,args= |
awk -v cpu_file="$CPU_FILE" -v mem_file="$MEM_FILE" -v pids="$PIDS_CSV" '
BEGIN { if (pids != "") { n = split(pids, a, ","); for (i = 1; i <= n; i++) want[a[i]] = 1 } }
pids != "" && !($1 in want) { next }
pids == "" && $4 !~ /(^|\/)(xrpld|rippled)$/ { next }
$2 ~ /^[0-9]+(\.[0-9]+)?$/ { cpu_sum += $2; cpu_n++ }
$3 ~ /^[0-9]+$/ && $3 + 0 > 0 { printf("%.2f\n", $3 / 1024) >> mem_file }
END {
# With an explicit pid list the expected count is known, so a
# dead node is detectable. Without it, exit 1 would fire on any
# host with no xrpld at all, which the empty-file check below
# already reports.
if (pids != "" && cpu_n != n) exit 1
if (cpu_n > 0) printf("%.2f\n", cpu_sum / cpu_n) >> cpu_file
}
' || die "process sampling failed on sample $sample/$SAMPLES: expected $(echo "$PIDS_CSV" | tr -cd , | wc -c)+1 live pids"
# Collect RPC latency from each node. Only a successful call is a latency
# measurement: a refused connection returns in well under a millisecond,
# and recording that as ~0 ms would pull the reported p99 down.
for port in "${RPC_PORTS[@]}"; do
start_ns=$(now_ns)
if curl -sf --max-time "$CURL_MAX_TIME" "http://localhost:$port" \
-d '{"method":"server_info"}' >/dev/null 2>&1; then
end_ns=$(now_ns)
latency_ms=$(((end_ns - start_ns) / 1000000))
echo "$latency_ms" >>"$RPC_FILE"
fi
done
# Record current validated ledger seq.
for port in "${RPC_PORTS[@]}"; do
seq=$(curl -sf --max-time "$CURL_MAX_TIME" "http://localhost:$port" \
-d '{"method":"server_info"}' 2>/dev/null |
jq -r '.result.info.validated_ledger.seq // 0' 2>/dev/null || echo 0)
echo "$seq" >>"$LEDGER_FILE"
break # Only need one node's seq per sample.
done
# Progress indicator.
if [ $((sample % 10)) -eq 0 ]; then
log " Sample $sample/$SAMPLES..."
fi
sleep "$SAMPLE_INTERVAL"
done
# ---------------------------------------------------------------------------
# Compute aggregated metrics
# ---------------------------------------------------------------------------
log "Computing aggregated metrics..."
# Cleared by any empty measurement source. A 0 metric is otherwise
# indistinguishable from a real reading, so the flag is exported in the JSON
# and drives the exit-3 contract documented in the header.
METRICS_COMPLETE=true
# CPU average.
if [ -s "$CPU_FILE" ]; then
CPU_AVG=$(awk '{ sum += $1; n++ } END { if (n>0) printf "%.2f", sum/n; else print "0" }' "$CPU_FILE")
else
# Now that the selector cannot match this harness's own processes, an
# empty file means no xrpld process was running for any sample.
warn "No CPU samples collected (no xrpld process matched); cpu_pct_avg is a 0 placeholder"
CPU_AVG="0"
METRICS_COMPLETE=false
fi
# Memory peak RSS (MB).
if [ -s "$MEM_FILE" ]; then
MEM_PEAK=$(sort -n "$MEM_FILE" | tail -1)
else
warn "No memory samples collected (no xrpld process matched); memory_rss_mb_peak is a 0 placeholder"
MEM_PEAK="0"
METRICS_COMPLETE=false
fi
# RPC latency p99 (ms).
if [ -s "$RPC_FILE" ]; then
RPC_COUNT=$(wc -l <"$RPC_FILE")
# Nearest-rank p99: ceil(count * 99 / 100), clamped into [1, count].
# Integer arithmetic avoids both the floor bias of the old bc expression
# and the bc dependency, and the lower clamp keeps sed off line address 0
# (a file with no trailing newline makes wc -l report 0).
P99_INDEX=$(((RPC_COUNT * 99 + 99) / 100))
if [ "$P99_INDEX" -lt 1 ]; then
P99_INDEX=1
fi
if [ "$P99_INDEX" -gt "$RPC_COUNT" ]; then
P99_INDEX="$RPC_COUNT"
fi
RPC_P99=$(sort -n "$RPC_FILE" | sed -n "${P99_INDEX}p")
if [ -z "$RPC_P99" ]; then
warn "RPC latency file has no line $P99_INDEX; rpc_p99_ms is a 0 placeholder"
RPC_P99="0"
METRICS_COMPLETE=false
fi
else
warn "No successful RPC probes; rpc_p99_ms is a 0 placeholder"
RPC_P99="0"
METRICS_COMPLETE=false
fi
# TPS calculation from ledger sequence advancement.
FINAL_SEQ=0
for port in "${RPC_PORTS[@]}"; do
seq=$(curl -sf --max-time "$CURL_MAX_TIME" "http://localhost:$port" \
-d '{"method":"server_info"}' 2>/dev/null |
jq -r '.result.info.validated_ledger.seq // 0' 2>/dev/null || echo 0)
if [ "$seq" -gt "$FINAL_SEQ" ]; then
FINAL_SEQ=$seq
fi
done
FINAL_TIME=$(date +%s)
ELAPSED=$((FINAL_TIME - INITIAL_TIME))
LEDGER_ADVANCE=$((FINAL_SEQ - INITIAL_SEQ))
if [ "$ELAPSED" -gt 0 ] && [ "$LEDGER_ADVANCE" -gt 0 ]; then
# Rough TPS: assume ~avg_txs_per_ledger * ledgers / elapsed.
# Without tx count, use ledger close rate as proxy.
#
# awk rather than bc, because bc omits the leading zero: `scale=2` prints
# ".25", not "0.25", and a bare ".25" is not valid JSON. A value below 1 is
# the normal case here, not an edge case — ledgers close every few seconds,
# so advance/elapsed is well under 1 for any realistic window. jq happens
# to accept the malformed form, which is why it survived earlier checks,
# but a strict parser rejects the whole file. awk's %.2f always pads.
TPS=$(awk -v a="$LEDGER_ADVANCE" -v b="$ELAPSED" 'BEGIN { printf "%.2f", a / b }')
else
# No ledger advance means the cluster produced nothing to divide, so 0 here
# is an absence of data, not a measured rate. Flagged like every other empty
# source so the run reports itself inconclusive instead of a real 0 TPS.
warn "Ledger seq did not advance (advance=$LEDGER_ADVANCE over ${ELAPSED}s); tps is a 0 placeholder"
TPS="0"
METRICS_COMPLETE=false
fi
# Mean inter-ledger interval in ms: ELAPSED / (distinct ledgers - 1) * 1000.
#
# ELAPSED, not DURATION: DURATION is what the caller asked for, while the loop
# also spends time on its own probes, so it always runs longer. TPS above uses
# ELAPSED for the same reason.
#
# This is a MEAN, not a percentile — the JSON key says so. It still cannot
# resolve a close interval at or below SAMPLE_INTERVAL, because LEDGER_FILE
# gets at most one sequence per sample. Read it as a coarse trend.
if [ -s "$LEDGER_FILE" ]; then
UNIQUE_LEDGERS=$(sort -u "$LEDGER_FILE" | wc -l)
# The > 1 test also keeps the divisor below at 1 or more.
if [ "$UNIQUE_LEDGERS" -gt 1 ]; then
# awk rather than bc, for the same reason as the TPS calculation above.
# bc is an optional package, and the `|| echo "0"` this replaces turned
# a missing or failing bc into a 0 that read exactly like a measured
# value: no warn(), and METRICS_COMPLETE left true, so the run reported
# a complete measurement and exited 0. awk is already required by the
# sampling loop and the CPU average, so computing this with awk removes
# the failure path rather than reporting it. The divisor is >= 1 by the
# test above.
CONSENSUS_MEAN=$(awk -v d="$ELAPSED" -v u="$UNIQUE_LEDGERS" \
'BEGIN { printf "%.0f", d * 1000 / (u - 1) }')
else
warn "Ledger seq never advanced ($UNIQUE_LEDGERS distinct); consensus_round_mean_ms is a 0 placeholder"
CONSENSUS_MEAN="0"
METRICS_COMPLETE=false
fi
else
warn "No ledger samples collected; consensus_round_mean_ms is a 0 placeholder"
CONSENSUS_MEAN="0"
METRICS_COMPLETE=false
fi
# ---------------------------------------------------------------------------
# Write output JSON
# ---------------------------------------------------------------------------
cat >"$OUTPUT_FILE" <<EOF_JSON
{
"cpu_pct_avg": $CPU_AVG,
"memory_rss_mb_peak": $MEM_PEAK,
"rpc_p99_ms": $RPC_P99,
"tps": $TPS,
"consensus_round_mean_ms": $CONSENSUS_MEAN,
"metrics_complete": $METRICS_COMPLETE,
"samples": $SAMPLES,
"duration_seconds": $DURATION,
"node_count": ${#RPC_PORTS[@]},
"initial_ledger_seq": $INITIAL_SEQ,
"final_ledger_seq": $FINAL_SEQ
}
EOF_JSON
ok "Metrics written to $OUTPUT_FILE"
cat "$OUTPUT_FILE"
# The file is always written first so CI still has an artifact to publish.
if [ "$METRICS_COMPLETE" != "true" ]; then
warn "metrics_complete=false — treat this run as inconclusive (exit 3)"
exit 3
fi