Refactor load testing scripts to support Bash and PowerShell
- Updated README.md to reflect changes in load testing commands and added usage examples for Bash scripts. - Converted PowerShell load testing scripts to Bash equivalents for better cross-platform compatibility. - Added new Bash scripts: run-loadtests.sh, summarize-artillery.sh, and summarize-k6.sh to mirror existing PowerShell functionality. - Updated admin-api.js and ws-chat.yml for consistency and improved performance metrics reporting. - Enhanced error handling and output formatting in summary scripts for better readability.
This commit is contained in:
1 parent
9f60087a4c
commit
33b6296c5e
82 files changed
+19682
-21133
No files matched your search
+106
-106
@@ -1,106 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
||||
const zoneTag = process.env.CLOUDFLARE_ZONE_TAG;
|
||||
const hostname = process.env.CLOUDFLARE_HOSTNAME || "linearvoid.tv";
|
||||
const windowMinutes = Number.parseInt(process.env.CLOUDFLARE_CACHE_HIT_RATIO_WINDOW_MINUTES || "60", 10);
|
||||
const endpoint = process.env.CLOUDFLARE_GRAPHQL_ENDPOINT || "https://api.cloudflare.com/client/v4/graphql";
|
||||
|
||||
if (!apiToken || !zoneTag) {
|
||||
console.error("Missing required environment variables: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_TAG");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - windowMinutes * 60 * 1000);
|
||||
|
||||
const iso = (value) => value.toISOString();
|
||||
|
||||
const query = `
|
||||
query CacheHitRatio($zoneTag: string, $start: Time, $end: Time, $hostname: String) {
|
||||
viewer {
|
||||
zones(filter: { zoneTag: $zoneTag }) {
|
||||
requests: httpRequestsAdaptiveGroups(
|
||||
limit: 1000
|
||||
filter: {
|
||||
datetime_geq: $start
|
||||
datetime_lt: $end
|
||||
clientRequestHTTPHost: $hostname
|
||||
requestSource: "eyeball"
|
||||
}
|
||||
) {
|
||||
count
|
||||
dimensions {
|
||||
cacheStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: {
|
||||
zoneTag,
|
||||
hostname,
|
||||
start: iso(start),
|
||||
end: iso(end),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.errors) {
|
||||
const message = payload.errors ? JSON.stringify(payload.errors) : `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const groups = payload?.data?.viewer?.zones?.[0]?.requests || [];
|
||||
let totalRequests = 0;
|
||||
let hitRequests = 0;
|
||||
|
||||
for (const group of groups) {
|
||||
const count = Number(group?.count || 0);
|
||||
const status = String(group?.dimensions?.cacheStatus || "").toUpperCase();
|
||||
totalRequests += count;
|
||||
if (status === "HIT") {
|
||||
hitRequests += count;
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = totalRequests > 0 ? hitRequests / totalRequests : 0;
|
||||
|
||||
console.log(
|
||||
`# HELP linearvoid_cache_hit_ratio Cloudflare cache hit ratio for the selected hostname and time window.`,
|
||||
);
|
||||
console.log(`# TYPE linearvoid_cache_hit_ratio gauge`);
|
||||
console.log(
|
||||
`linearvoid_cache_hit_ratio{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${ratio.toFixed(6)}`,
|
||||
);
|
||||
console.log(`# HELP linearvoid_cache_hit_requests_total Cloudflare cache HIT requests counted in the time window.`);
|
||||
console.log(`# TYPE linearvoid_cache_hit_requests_total counter`);
|
||||
console.log(
|
||||
`linearvoid_cache_hit_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${hitRequests}`,
|
||||
);
|
||||
console.log(
|
||||
`# HELP linearvoid_cache_total_requests_total Cloudflare total eyeball requests counted in the time window.`,
|
||||
);
|
||||
console.log(`# TYPE linearvoid_cache_total_requests_total counter`);
|
||||
console.log(
|
||||
`linearvoid_cache_total_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${totalRequests}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
#!/usr/bin/env node
|
||||
|
||||
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
||||
const zoneTag = process.env.CLOUDFLARE_ZONE_TAG;
|
||||
const hostname = process.env.CLOUDFLARE_HOSTNAME || "linearvoid.tv";
|
||||
const windowMinutes = Number.parseInt(process.env.CLOUDFLARE_CACHE_HIT_RATIO_WINDOW_MINUTES || "60", 10);
|
||||
const endpoint = process.env.CLOUDFLARE_GRAPHQL_ENDPOINT || "https://api.cloudflare.com/client/v4/graphql";
|
||||
|
||||
if (!apiToken || !zoneTag) {
|
||||
console.error("Missing required environment variables: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_TAG");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - windowMinutes * 60 * 1000);
|
||||
|
||||
const iso = (value) => value.toISOString();
|
||||
|
||||
const query = `
|
||||
query CacheHitRatio($zoneTag: string, $start: Time, $end: Time, $hostname: String) {
|
||||
viewer {
|
||||
zones(filter: { zoneTag: $zoneTag }) {
|
||||
requests: httpRequestsAdaptiveGroups(
|
||||
limit: 1000
|
||||
filter: {
|
||||
datetime_geq: $start
|
||||
datetime_lt: $end
|
||||
clientRequestHTTPHost: $hostname
|
||||
requestSource: "eyeball"
|
||||
}
|
||||
) {
|
||||
count
|
||||
dimensions {
|
||||
cacheStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: {
|
||||
zoneTag,
|
||||
hostname,
|
||||
start: iso(start),
|
||||
end: iso(end),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.errors) {
|
||||
const message = payload.errors ? JSON.stringify(payload.errors) : `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const groups = payload?.data?.viewer?.zones?.[0]?.requests || [];
|
||||
let totalRequests = 0;
|
||||
let hitRequests = 0;
|
||||
|
||||
for (const group of groups) {
|
||||
const count = Number(group?.count || 0);
|
||||
const status = String(group?.dimensions?.cacheStatus || "").toUpperCase();
|
||||
totalRequests += count;
|
||||
if (status === "HIT") {
|
||||
hitRequests += count;
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = totalRequests > 0 ? hitRequests / totalRequests : 0;
|
||||
|
||||
console.log(
|
||||
`# HELP linearvoid_cache_hit_ratio Cloudflare cache hit ratio for the selected hostname and time window.`,
|
||||
);
|
||||
console.log(`# TYPE linearvoid_cache_hit_ratio gauge`);
|
||||
console.log(
|
||||
`linearvoid_cache_hit_ratio{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${ratio.toFixed(6)}`,
|
||||
);
|
||||
console.log(`# HELP linearvoid_cache_hit_requests_total Cloudflare cache HIT requests counted in the time window.`);
|
||||
console.log(`# TYPE linearvoid_cache_hit_requests_total counter`);
|
||||
console.log(
|
||||
`linearvoid_cache_hit_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${hitRequests}`,
|
||||
);
|
||||
console.log(
|
||||
`# HELP linearvoid_cache_total_requests_total Cloudflare total eyeball requests counted in the time window.`,
|
||||
);
|
||||
console.log(`# TYPE linearvoid_cache_total_requests_total counter`);
|
||||
console.log(
|
||||
`linearvoid_cache_total_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${totalRequests}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env pwsh
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$nodeScript = Join-Path $scriptDir 'cache-hit-ratio.js'
|
||||
|
||||
& node $nodeScript @args
|
||||
exit $LASTEXITCODE
|
||||
#!/usr/bin/env pwsh
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$nodeScript = Join-Path $scriptDir 'cache-hit-ratio.js'
|
||||
|
||||
& node $nodeScript @args
|
||||
exit $LASTEXITCODE
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# cache-hit-ratio.sh — Thin wrapper that runs the Node.js cache-hit-ratio tool.
|
||||
# Mirrors scripts/cache-hit-ratio.ps1.
|
||||
set -euo pipefail
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec node "$script_dir/cache-hit-ratio.js" "$@"
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-env.sh — Verify the deployment environment for Linear Void.
|
||||
# Mirrors scripts/check-env.ps1.
|
||||
#
|
||||
# Checks, against the target Docker host (default test lab 192.168.88.40):
|
||||
# 1. Docker Engine reachability over the TCP socket (port 2375).
|
||||
# 2. Coolify helper stack (proxy/Traefik, sentinel, cloudflared).
|
||||
# 3. Linear Void containers (playout, chat, nginx, redis, db, prometheus, grafana).
|
||||
# 4. Public endpoints (app, HLS manifest, admin).
|
||||
# 5. Health endpoints exposed by the services.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/check-env.sh
|
||||
# ./scripts/check-env.sh -t 192.168.88.40
|
||||
# ./scripts/check-env.sh -t 192.168.88.40 -p 2375 --only-containers
|
||||
#
|
||||
# Exit code is 0 when all required checks pass, 1 otherwise.
|
||||
set -uo pipefail
|
||||
|
||||
TARGET=192.168.88.40
|
||||
DOCKER_PORT=2375
|
||||
ONLY_CONTAINERS=0
|
||||
ONLY_ENDPOINTS=0
|
||||
TIMEOUT=10
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-t|--target) TARGET="$2"; shift 2;;
|
||||
-p|--docker-port) DOCKER_PORT="$2"; shift 2;;
|
||||
--only-containers) ONLY_CONTAINERS=1; shift;;
|
||||
--only-endpoints) ONLY_ENDPOINTS=1; shift;;
|
||||
--timeout) TIMEOUT="$2"; shift 2;;
|
||||
*) echo "Unknown option: $1" >&2; exit 2;;
|
||||
esac
|
||||
done
|
||||
|
||||
export DOCKER_HOST="tcp://${TARGET}:${DOCKER_PORT}"
|
||||
|
||||
FAILED=0
|
||||
fail() { printf ' [FAIL] %s\n' "$1"; FAILED=1; }
|
||||
ok() { printf ' [OK] %s\n' "$1"; }
|
||||
warn() { printf ' [WARN] %s\n' "$1"; }
|
||||
|
||||
echo "== Linear Void environment check =="
|
||||
echo "Target Docker host : $DOCKER_HOST"
|
||||
echo "Date : $(date -Iseconds)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Docker Engine reachability
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "$ONLY_ENDPOINTS" -eq 0 ]]; then
|
||||
echo ""
|
||||
echo "-- Docker Engine (${TARGET}:${DOCKER_PORT}) --"
|
||||
if ver=$(docker version --format '{{.Server.Version}}' 2>&1) && [[ "$ver" =~ [0-9] ]]; then
|
||||
ok "Docker Engine reachable (server version $ver)"
|
||||
else
|
||||
fail "Docker Engine not reachable: $ver"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 2. Coolify helper stack
|
||||
# -------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "-- Coolify managed-server helpers --"
|
||||
for c in coolify-proxy coolify-sentinel coolify-cloudflared; do
|
||||
if state=$(docker inspect "$c" --format '{{.State.Status}} ({{.State.Health.Status}})' 2>/dev/null); then
|
||||
if [[ "$state" =~ running.*(healthy|running) ]]; then ok "coolify helper: $c -> $state"; else warn "coolify helper: $c -> $state"; fi
|
||||
else
|
||||
warn "coolify helper: $c -> NOT FOUND (is this a Coolify-managed server?)"
|
||||
fi
|
||||
done
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 3. Linear Void containers
|
||||
# -------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "-- Linear Void containers --"
|
||||
required=(linearvoid-playout linearvoid-chat linearvoid-nginx linearvoid-redis linearvoid-db linearvoid-prometheus linearvoid-grafana)
|
||||
running="$(docker ps --format '{{.Names}}' 2>/dev/null)"
|
||||
all="$(docker ps -a --format '{{.Names}}\t{{.Status}}' 2>/dev/null)"
|
||||
for name in "${required[@]}"; do
|
||||
if grep -qx "$name" <<<"$running"; then
|
||||
ok "container: $name is running"
|
||||
elif grep -q "^$name[[:space:]]" <<<"$all"; then
|
||||
status="$(grep "^$name[[:space:]]" <<<"$all" | head -1)"
|
||||
fail "container: $name EXISTS but not running -> $status"
|
||||
else
|
||||
fail "container: $name MISSING"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Health endpoints (via Docker host)
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "$ONLY_CONTAINERS" -eq 0 ]]; then
|
||||
echo ""
|
||||
echo "-- Health endpoints --"
|
||||
checks=(
|
||||
"chat /health|http://${TARGET}:3000/health"
|
||||
"nginx /health|http://${TARGET}:8081/health"
|
||||
"prometheus|http://${TARGET}:9090/-/healthy"
|
||||
"grafana /api/health|http://${TARGET}:3001/api/health"
|
||||
)
|
||||
for entry in "${checks[@]}"; do
|
||||
name="${entry%%|*}"; url="${entry#*|}"
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$TIMEOUT" "$url" 2>/dev/null)
|
||||
if [[ "$code" == "200" ]]; then ok "$name -> 200"; elif [[ "$code" =~ ^[0-9]{3}$ ]]; then warn "$name -> $code"; else fail "$name -> unreachable"; fi
|
||||
done
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 5. Public endpoints (routed through the Docker host for reliable TLS)
|
||||
# -------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "-- Public endpoints (via Docker host) --"
|
||||
pub=(
|
||||
"linearvoid.tv app|https://linearvoid.tv/"
|
||||
"media HLS manifest|https://media.linearvoid.tv/hls/playlist.m3u8"
|
||||
"coolify dashboard|https://coolify.allucanget.biz/"
|
||||
)
|
||||
for entry in "${pub[@]}"; do
|
||||
name="${entry%%|*}"; url="${entry#*|}"
|
||||
code=$(docker run --rm curlimages/curl:latest -sS -o /dev/null -w '%{http_code}' --max-time "$TIMEOUT" -L "$url" 2>/dev/null | tr -d '[:space:]')
|
||||
if [[ "$code" =~ ^[0-9]{3}$ ]]; then ok "$name -> HTTP $code"; else warn "$name -> $code"; fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
if [[ "$FAILED" -ne 0 ]]; then echo "RESULT: FAILED"; exit 1; fi
|
||||
echo "RESULT: OK"
|
||||
exit 0
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-tokens.sh — Verify that the credentials in the environment actually work.
|
||||
# Mirrors scripts/check-tokens.ps1.
|
||||
#
|
||||
# Reads .env (or a supplied file) and validates access against live services:
|
||||
# 1. Gitea container registry (CI_REGISTRY_TOKEN / CI_REGISTRY_USER).
|
||||
# 2. Cloudflare API token(s) (CLOUDFLARE_API_TOKEN, and any R2 admin token).
|
||||
# 3. R2/S3 bucket access (R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY) — lists buckets.
|
||||
# 4. Linear Void admin login (ADMIN_USERNAME / ADMIN_PASSWORD) against the app origin.
|
||||
#
|
||||
# NOTE: Runs the checks through the target Docker host (default 192.168.88.40) so
|
||||
# network + TLS work regardless of this machine's proxy/firewall. Uses the
|
||||
# curlimages/curl and amazon/aws-cli images, which are pulled on demand.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/check-tokens.sh
|
||||
# ./scripts/check-tokens.sh -e .env
|
||||
# ./scripts/check-tokens.sh -t 192.168.88.40
|
||||
#
|
||||
# Exit code 0 = all credentials valid; 1 = at least one check failed.
|
||||
set -uo pipefail
|
||||
|
||||
ENV_FILE=""
|
||||
TARGET=192.168.88.40
|
||||
DOCKER_PORT=2375
|
||||
TIMEOUT=20
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-e|--env-file) ENV_FILE="$2"; shift 2;;
|
||||
-t|--target) TARGET="$2"; shift 2;;
|
||||
-p|--docker-port) DOCKER_PORT="$2"; shift 2;;
|
||||
--timeout) TIMEOUT="$2"; shift 2;;
|
||||
*) echo "Unknown option: $1" >&2; exit 2;;
|
||||
esac
|
||||
done
|
||||
|
||||
export DOCKER_HOST="tcp://${TARGET}:${DOCKER_PORT}"
|
||||
|
||||
FAILED=0
|
||||
fail() { printf ' [FAIL] %s\n' "$1"; FAILED=1; }
|
||||
ok() { printf ' [OK] %s\n' "$1"; }
|
||||
warn() { printf ' [WARN] %s\n' "$1"; }
|
||||
|
||||
# Resolve env file: explicit, or $PWD/.env, or repo root.
|
||||
if [[ -z "$ENV_FILE" ]]; then
|
||||
for cand in "$PWD/.env" "$(dirname "$PWD")/.env"; do
|
||||
if [[ -f "$cand" ]]; then ENV_FILE="$cand"; break; fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "$ENV_FILE" || ! -f "$ENV_FILE" ]]; then
|
||||
echo "No .env file found. Pass one with -e." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse KEY=VALUE lines into an associative array.
|
||||
declare -A envs
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
if [[ "$line" =~ ^[[:space:]]*([A-Za-z0-9_]+)[[:space:]]*=[[:space:]]*(.*)[[:space:]]*$ ]]; then
|
||||
envs["${BASH_REMATCH[1]}"]="${BASH_REMATCH[2]}"
|
||||
fi
|
||||
done < "$ENV_FILE"
|
||||
|
||||
get_val() { [[ -n "${envs[$1]+x}" ]] && echo "${envs[$1]}"; }
|
||||
|
||||
# Pull helper images once.
|
||||
echo "Preparing helper images (curl, aws-cli) on $TARGET..."
|
||||
docker pull curlimages/curl:latest >/dev/null 2>&1 || true
|
||||
docker pull amazon/aws-cli:latest >/dev/null 2>&1 || true
|
||||
|
||||
# dc_curl URL [bearer] [token] — run curl via the curlimages/curl container.
|
||||
dc_curl() {
|
||||
local url="$1" bearer="${2:-}" token="${3:-}"
|
||||
local -a args=(run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT")
|
||||
[[ -n "$bearer" ]] && args+=( -H "Authorization: Bearer $bearer" )
|
||||
[[ -n "$token" ]] && args+=( -H "Authorization: token $token" )
|
||||
args+=( "$url" )
|
||||
docker "${args[@]}" 2>&1 | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "== Linear Void credential / token check =="
|
||||
echo "Env file : $ENV_FILE"
|
||||
echo "Docker host: $DOCKER_HOST"
|
||||
|
||||
# --- 1. Gitea registry token ---
|
||||
echo ""
|
||||
echo "-- Gitea container registry --"
|
||||
reg_user="$(get_val CI_REGISTRY_USER)"
|
||||
reg_token="$(get_val CI_REGISTRY_TOKEN)"
|
||||
if [[ -n "$reg_user" && -n "$reg_token" ]]; then
|
||||
# Non-sensitive probe: /api/v1/user returns a scope message for token-based creds.
|
||||
out="$(dc_curl 'https://git.allucanget.biz/api/v1/user' "" "$reg_token")"
|
||||
if [[ "$out" =~ token.does.not.have.at.least.one.of.required.scope || "$out" =~ '"login"' || "$out" =~ invalid_grant ]]; then
|
||||
if [[ "$out" =~ invalid_grant|Unauthorized|invalid ]]; then
|
||||
fail "Gitea registry token rejected ($reg_user): $out"
|
||||
else
|
||||
ok "Gitea registry token valid for '$reg_user' (scope: write:package/write:repository)"
|
||||
fi
|
||||
else
|
||||
warn "Gitea registry token response unexpected: $out"
|
||||
fi
|
||||
else
|
||||
warn "CI_REGISTRY_USER / CI_REGISTRY_TOKEN not set; skipping Gitea registry check"
|
||||
fi
|
||||
|
||||
# --- 2. Cloudflare API token ---
|
||||
echo ""
|
||||
echo "-- Cloudflare API tokens --"
|
||||
cf_token="$(get_val CLOUDFLARE_API_TOKEN)"
|
||||
if [[ -n "$cf_token" ]]; then
|
||||
out="$(dc_curl 'https://api.cloudflare.com/client/v4/user/tokens/verify' "$cf_token")"
|
||||
if [[ "$out" =~ '"success":true' ]]; then
|
||||
if [[ "$out" =~ '"status":"active"' ]]; then ok 'Cloudflare API token valid and ACTIVE'; else warn "Cloudflare API token valid but status: $out"; fi
|
||||
else
|
||||
fail "Cloudflare API token rejected: $out"
|
||||
fi
|
||||
else
|
||||
warn "CLOUDFLARE_API_TOKEN not set; skipping Cloudflare check"
|
||||
fi
|
||||
|
||||
# --- 3. Cloudflare R2 / S3 bucket access ---
|
||||
echo ""
|
||||
echo "-- Cloudflare R2 (S3-compatible) --"
|
||||
r2_key="$(get_val R2_ACCESS_KEY_ID)"
|
||||
r2_secret="$(get_val R2_SECRET_ACCESS_KEY)"
|
||||
r2_ep="$(get_val R2_ENDPOINT)"
|
||||
bucket="$(get_val R2_BUCKET_NAME)"
|
||||
|
||||
# Use the admin key pair (which has ListBuckets) when present, else the regular key.
|
||||
list_k="$(get_val R2_ADMIN_ACCESS_KEY_ID)"; list_s="$(get_val R2_ADMIN_SECRET_ACCESS_KEY)"
|
||||
if [[ -z "$list_k" || -z "$list_s" ]]; then list_k="$r2_key"; list_s="$r2_secret"; fi
|
||||
|
||||
if [[ -n "$list_k" && -n "$list_s" && -n "$r2_ep" ]]; then
|
||||
out="$(docker run --rm --entrypoint sh amazon/aws-cli:latest -c "export AWS_ACCESS_KEY_ID='$list_k'; export AWS_SECRET_ACCESS_KEY='$list_s'; aws s3 ls --endpoint-url '$r2_ep' --region auto 2>&1" 2>&1)"
|
||||
if grep -q "$bucket" <<<"$out"; then ok "R2 bucket listing OK, '$bucket' present"
|
||||
elif grep -q 'AccessDenied' <<<"$out"; then fail "R2 listing denied (no ListBuckets permission): $out"
|
||||
elif grep -qE '^[^[:space:]]+[[:space:]]+' <<<"$out"; then warn "R2 listing returned (unexpected): $out"
|
||||
else fail "R2 access failed: $out"; fi
|
||||
else
|
||||
warn "R2 credentials / endpoint not fully set; skipping R2 check"
|
||||
fi
|
||||
|
||||
# Verify the specific application bucket is reachable with the primary key (read).
|
||||
if [[ -n "$r2_key" && -n "$r2_secret" && -n "$r2_ep" && -n "$bucket" ]]; then
|
||||
out="$(docker run --rm --entrypoint sh amazon/aws-cli:latest -c "export AWS_ACCESS_KEY_ID='$r2_key'; export AWS_SECRET_ACCESS_KEY='$r2_secret'; aws s3 ls s3://$bucket/ --endpoint-url '$r2_ep' --region auto --max-keys 5 2>&1 | head -5" 2>&1)"
|
||||
if grep -q 'AccessDenied' <<<"$out"; then fail "Bucket '$bucket' read denied with primary key"
|
||||
elif [[ -z "$out" ]]; then ok "Bucket '$bucket' reachable (empty listing, expected)"
|
||||
elif grep -q 'NoSuchBucket' <<<"$out"; then fail "Bucket '$bucket' NOT FOUND"
|
||||
else ok "Bucket '$bucket' reachable"; fi
|
||||
elif [[ -z "$bucket" ]]; then
|
||||
warn "R2_BUCKET_NAME not set; skipping bucket-level check"
|
||||
fi
|
||||
|
||||
# --- 4. Linear Void admin login ---
|
||||
echo ""
|
||||
echo "-- Linear Void admin login --"
|
||||
admin_user="$(get_val ADMIN_USERNAME)"
|
||||
admin_pass="$(get_val ADMIN_PASSWORD)"
|
||||
if [[ -n "$admin_user" && -n "$admin_pass" ]]; then
|
||||
# Hit the chat backend directly (public origin is caught by nginx SPA fallback).
|
||||
url="http://${TARGET}:3000/api/admin/login"
|
||||
body="{\"username\":\"$admin_user\",\"password\":\"$admin_pass\"}"
|
||||
wrong_body="{\"username\":\"$admin_user\",\"password\":\"__definitely_wrong__\"}"
|
||||
ok_code="$(docker run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT" -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null | tr -d '[:space:]')"
|
||||
wrong_code="$(docker run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT" -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$wrong_body" "$url" 2>/dev/null | tr -d '[:space:]')"
|
||||
if [[ "$ok_code" == "200" && "$wrong_code" == "401" ]]; then ok "Admin credentials valid (correct=200, wrong=401)"
|
||||
else fail "Admin login unexpected (correct=$ok_code, wrong=$wrong_code)"; fi
|
||||
else
|
||||
warn "ADMIN_USERNAME / ADMIN_PASSWORD not set; skipping admin check"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
if [[ "$FAILED" -ne 0 ]]; then echo "RESULT: FAILED"; exit 1; fi
|
||||
echo "RESULT: OK (no failing checks)"
|
||||
exit 0
|
||||
+20
-49
@@ -2,66 +2,42 @@
|
||||
|
||||
These files back the [scaling/performance playbook](../../docs/operations/scaling-performance-playbook.md).
|
||||
|
||||
## WebSocket chat
|
||||
## WebSocket chat (artillery)
|
||||
|
||||
Run with an explicit WebSocket URL, for example:
|
||||
Run with an explicit WebSocket URL, capturing output to an artifact file:
|
||||
|
||||
```bash
|
||||
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml
|
||||
```
|
||||
|
||||
Capture output to an artifact file for the worksheet:
|
||||
|
||||
```bash
|
||||
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
|
||||
```
|
||||
|
||||
PowerShell equivalent:
|
||||
|
||||
```powershell
|
||||
$env:WS_URL = 'wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
export WS_URL='wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||
mkdir -p artifacts
|
||||
npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
|
||||
```
|
||||
|
||||
Summarize key counters, rates, and available percentile stats from a saved Artillery JSON report:
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/summarize-artillery.ps1 -ReportFile artifacts/ws-chat-report.json
|
||||
```bash
|
||||
scripts/loadtest/summarize-artillery.sh artifacts/ws-chat-report.json
|
||||
```
|
||||
|
||||
## Admin API
|
||||
## Admin API (k6)
|
||||
|
||||
Run with a cookie or session header that can access admin endpoints:
|
||||
Run with a cookie or session header that can access admin endpoints, capturing machine-readable metrics and a text summary:
|
||||
|
||||
```bash
|
||||
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run scripts/loadtest/admin-api.js
|
||||
```
|
||||
|
||||
Capture machine-readable metrics and a text summary:
|
||||
|
||||
```bash
|
||||
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js > artifacts/admin-api-output.txt
|
||||
```
|
||||
|
||||
PowerShell equivalent:
|
||||
|
||||
```powershell
|
||||
$env:ADMIN_BASE_URL = 'https://linearvoid.tv'
|
||||
$env:ADMIN_SESSION_COOKIE = 'session=...'
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | Tee-Object -FilePath artifacts/admin-api-output.txt
|
||||
export ADMIN_BASE_URL='https://linearvoid.tv'
|
||||
export ADMIN_SESSION_COOKIE='session=...'
|
||||
mkdir -p artifacts
|
||||
k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | tee artifacts/admin-api-output.txt
|
||||
```
|
||||
|
||||
The script emits global and per-endpoint thresholds (overview, analytics, chat-log, users) so p95 and error-rate regressions are visible per route.
|
||||
|
||||
Summarize endpoint p95 and failure rates from a saved k6 JSON summary:
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/summarize-k6.ps1 -SummaryFile artifacts/admin-api-summary.json
|
||||
```bash
|
||||
scripts/loadtest/summarize-k6.sh artifacts/admin-api-summary.json
|
||||
```
|
||||
|
||||
## PowerShell helper
|
||||
## Runner helper
|
||||
|
||||
Use the helper to run one scenario or both and automatically write outputs into a dated artifact folder:
|
||||
|
||||
@@ -69,16 +45,11 @@ Use the helper to run one scenario or both and automatically write outputs into
|
||||
- WebSocket runs also emit `ws-chat-summary.txt` with connection/error counters and available summary percentiles.
|
||||
- Admin runs also emit `admin-api-endpoint-summary.txt` with per-endpoint p95/error values.
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario ws -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario admin -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...'
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario all -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main' -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...'
|
||||
```bash
|
||||
scripts/loadtest/run-loadtests.sh -s ws -w 'wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||
scripts/loadtest/run-loadtests.sh -s admin -a 'https://linearvoid.tv' -c 'session=...'
|
||||
scripts/loadtest/run-loadtests.sh -s all -w 'wss://linearvoid.tv/ws?username=loadtest&room=main' \
|
||||
-a 'https://linearvoid.tv' -c 'session=...'
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
import http from "k6/http";
|
||||
import { check, sleep } from "k6";
|
||||
|
||||
const baseUrl = __ENV.ADMIN_BASE_URL || "https://linearvoid.tv";
|
||||
const adminCookie = __ENV.ADMIN_SESSION_COOKIE || "";
|
||||
const requestParams = adminCookie ? { headers: { Cookie: adminCookie } } : {};
|
||||
|
||||
const endpoints = [
|
||||
{ name: "overview", path: "/api/admin/overview" },
|
||||
{ name: "analytics", path: "/api/admin/analytics" },
|
||||
{ name: "chat_log", path: "/api/admin/chat-log" },
|
||||
{ name: "users", path: "/api/admin/users" },
|
||||
];
|
||||
|
||||
export const options = {
|
||||
stages: [
|
||||
{ duration: "30s", target: 10 },
|
||||
{ duration: "60s", target: 50 },
|
||||
{ duration: "60s", target: 100 },
|
||||
{ duration: "30s", target: 0 },
|
||||
],
|
||||
thresholds: {
|
||||
http_req_duration: ["p(95)<1000"],
|
||||
http_req_failed: ["rate<0.01"],
|
||||
"http_req_duration{endpoint:overview}": ["p(95)<1000"],
|
||||
"http_req_duration{endpoint:analytics}": ["p(95)<1000"],
|
||||
"http_req_duration{endpoint:chat_log}": ["p(95)<1000"],
|
||||
"http_req_duration{endpoint:users}": ["p(95)<1000"],
|
||||
"http_req_failed{endpoint:overview}": ["rate<0.01"],
|
||||
"http_req_failed{endpoint:analytics}": ["rate<0.01"],
|
||||
"http_req_failed{endpoint:chat_log}": ["rate<0.01"],
|
||||
"http_req_failed{endpoint:users}": ["rate<0.01"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const requests = endpoints.map((endpoint) => [
|
||||
"GET",
|
||||
`${baseUrl}${endpoint.path}`,
|
||||
null,
|
||||
{
|
||||
...requestParams,
|
||||
tags: {
|
||||
endpoint: endpoint.name,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const responses = http.batch(requests);
|
||||
|
||||
responses.forEach((response, index) => {
|
||||
const endpointPath = endpoints[index].path;
|
||||
check(response, {
|
||||
[`status is 200 for ${endpointPath}`]: (r) => r.status === 200,
|
||||
});
|
||||
});
|
||||
|
||||
sleep(1);
|
||||
}
|
||||
import http from "k6/http";
|
||||
import { check, sleep } from "k6";
|
||||
|
||||
const baseUrl = __ENV.ADMIN_BASE_URL || "https://linearvoid.tv";
|
||||
const adminCookie = __ENV.ADMIN_SESSION_COOKIE || "";
|
||||
const requestParams = adminCookie ? { headers: { Cookie: adminCookie } } : {};
|
||||
|
||||
const endpoints = [
|
||||
{ name: "overview", path: "/api/admin/overview" },
|
||||
{ name: "analytics", path: "/api/admin/analytics" },
|
||||
{ name: "chat_log", path: "/api/admin/chat-log" },
|
||||
{ name: "users", path: "/api/admin/users" },
|
||||
];
|
||||
|
||||
export const options = {
|
||||
stages: [
|
||||
{ duration: "30s", target: 10 },
|
||||
{ duration: "60s", target: 50 },
|
||||
{ duration: "60s", target: 100 },
|
||||
{ duration: "30s", target: 0 },
|
||||
],
|
||||
thresholds: {
|
||||
http_req_duration: ["p(95)<1000"],
|
||||
http_req_failed: ["rate<0.01"],
|
||||
"http_req_duration{endpoint:overview}": ["p(95)<1000"],
|
||||
"http_req_duration{endpoint:analytics}": ["p(95)<1000"],
|
||||
"http_req_duration{endpoint:chat_log}": ["p(95)<1000"],
|
||||
"http_req_duration{endpoint:users}": ["p(95)<1000"],
|
||||
"http_req_failed{endpoint:overview}": ["rate<0.01"],
|
||||
"http_req_failed{endpoint:analytics}": ["rate<0.01"],
|
||||
"http_req_failed{endpoint:chat_log}": ["rate<0.01"],
|
||||
"http_req_failed{endpoint:users}": ["rate<0.01"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const requests = endpoints.map((endpoint) => [
|
||||
"GET",
|
||||
`${baseUrl}${endpoint.path}`,
|
||||
null,
|
||||
{
|
||||
...requestParams,
|
||||
tags: {
|
||||
endpoint: endpoint.name,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const responses = http.batch(requests);
|
||||
|
||||
responses.forEach((response, index) => {
|
||||
const endpointPath = endpoints[index].path;
|
||||
check(response, {
|
||||
[`status is 200 for ${endpointPath}`]: (r) => r.status === 200,
|
||||
});
|
||||
});
|
||||
|
||||
sleep(1);
|
||||
}
|
||||
@@ -1,99 +1,99 @@
|
||||
#!/usr/bin/env pwsh
|
||||
param(
|
||||
[ValidateSet('ws', 'admin', 'all')]
|
||||
[string]$Scenario = 'all',
|
||||
[string]$ArtifactRoot = 'artifacts',
|
||||
[string]$WsUrl = $env:WS_URL,
|
||||
[string]$AdminBaseUrl = $env:ADMIN_BASE_URL,
|
||||
[string]$AdminSessionCookie = $env:ADMIN_SESSION_COOKIE
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = Resolve-Path (Join-Path $scriptDir '..\..')
|
||||
$wsScenarioPath = Join-Path $scriptDir 'ws-chat.yml'
|
||||
$wsSummaryScriptPath = Join-Path $scriptDir 'summarize-artillery.ps1'
|
||||
$adminScriptPath = Join-Path $scriptDir 'admin-api.js'
|
||||
$adminSummaryScriptPath = Join-Path $scriptDir 'summarize-k6.ps1'
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($ArtifactRoot)) {
|
||||
$artifactBase = $ArtifactRoot
|
||||
} else {
|
||||
$artifactBase = Join-Path $repoRoot $ArtifactRoot
|
||||
}
|
||||
|
||||
if ($Scenario -in @('ws', 'all')) {
|
||||
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
|
||||
throw 'npx is required for ws scenario but was not found in PATH.'
|
||||
}
|
||||
}
|
||||
|
||||
if ($Scenario -in @('admin', 'all')) {
|
||||
if (-not (Get-Command k6 -ErrorAction SilentlyContinue)) {
|
||||
throw 'k6 is required for admin scenario but was not found in PATH.'
|
||||
}
|
||||
}
|
||||
|
||||
$timestamp = Get-Date -Format 'yyyy-MM-dd_HHmmss'
|
||||
$artifactDir = Join-Path $artifactBase $timestamp
|
||||
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
|
||||
|
||||
function Invoke-WebSocketLoadTest {
|
||||
param(
|
||||
[string]$TargetWsUrl,
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($TargetWsUrl)) {
|
||||
throw 'WS_URL is required for ws scenario. Pass -WsUrl or set $env:WS_URL.'
|
||||
}
|
||||
|
||||
$env:WS_URL = $TargetWsUrl
|
||||
& npx artillery run $wsScenarioPath --output $OutputPath
|
||||
}
|
||||
|
||||
function Invoke-AdminApiLoadTest {
|
||||
param(
|
||||
[string]$BaseUrl,
|
||||
[string]$SessionCookie,
|
||||
[string]$SummaryPath,
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
|
||||
throw 'ADMIN_BASE_URL is required for admin scenario. Pass -AdminBaseUrl or set $env:ADMIN_BASE_URL.'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($SessionCookie)) {
|
||||
throw 'ADMIN_SESSION_COOKIE is required for admin scenario. Pass -AdminSessionCookie or set $env:ADMIN_SESSION_COOKIE.'
|
||||
}
|
||||
|
||||
$env:ADMIN_BASE_URL = $BaseUrl
|
||||
$env:ADMIN_SESSION_COOKIE = $SessionCookie
|
||||
& k6 run --summary-export $SummaryPath $adminScriptPath | Tee-Object -FilePath $OutputPath
|
||||
}
|
||||
|
||||
if ($Scenario -in @('ws', 'all')) {
|
||||
if (-not (Test-Path $wsSummaryScriptPath)) {
|
||||
throw "Missing summary helper: $wsSummaryScriptPath"
|
||||
}
|
||||
|
||||
$wsOutput = Join-Path $artifactDir 'ws-chat-report.json'
|
||||
$wsSummary = Join-Path $artifactDir 'ws-chat-summary.txt'
|
||||
Invoke-WebSocketLoadTest -TargetWsUrl $WsUrl -OutputPath $wsOutput
|
||||
& pwsh -File $wsSummaryScriptPath -ReportFile $wsOutput | Tee-Object -FilePath $wsSummary
|
||||
}
|
||||
|
||||
if ($Scenario -in @('admin', 'all')) {
|
||||
if (-not (Test-Path $adminSummaryScriptPath)) {
|
||||
throw "Missing summary helper: $adminSummaryScriptPath"
|
||||
}
|
||||
|
||||
$adminSummary = Join-Path $artifactDir 'admin-api-summary.json'
|
||||
$adminOutput = Join-Path $artifactDir 'admin-api-output.txt'
|
||||
$adminEndpointSummary = Join-Path $artifactDir 'admin-api-endpoint-summary.txt'
|
||||
Invoke-AdminApiLoadTest -BaseUrl $AdminBaseUrl -SessionCookie $AdminSessionCookie -SummaryPath $adminSummary -OutputPath $adminOutput
|
||||
& pwsh -File $adminSummaryScriptPath -SummaryFile $adminSummary | Tee-Object -FilePath $adminEndpointSummary
|
||||
}
|
||||
|
||||
#!/usr/bin/env pwsh
|
||||
param(
|
||||
[ValidateSet('ws', 'admin', 'all')]
|
||||
[string]$Scenario = 'all',
|
||||
[string]$ArtifactRoot = 'artifacts',
|
||||
[string]$WsUrl = $env:WS_URL,
|
||||
[string]$AdminBaseUrl = $env:ADMIN_BASE_URL,
|
||||
[string]$AdminSessionCookie = $env:ADMIN_SESSION_COOKIE
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = Resolve-Path (Join-Path $scriptDir '..\..')
|
||||
$wsScenarioPath = Join-Path $scriptDir 'ws-chat.yml'
|
||||
$wsSummaryScriptPath = Join-Path $scriptDir 'summarize-artillery.ps1'
|
||||
$adminScriptPath = Join-Path $scriptDir 'admin-api.js'
|
||||
$adminSummaryScriptPath = Join-Path $scriptDir 'summarize-k6.ps1'
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($ArtifactRoot)) {
|
||||
$artifactBase = $ArtifactRoot
|
||||
} else {
|
||||
$artifactBase = Join-Path $repoRoot $ArtifactRoot
|
||||
}
|
||||
|
||||
if ($Scenario -in @('ws', 'all')) {
|
||||
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
|
||||
throw 'npx is required for ws scenario but was not found in PATH.'
|
||||
}
|
||||
}
|
||||
|
||||
if ($Scenario -in @('admin', 'all')) {
|
||||
if (-not (Get-Command k6 -ErrorAction SilentlyContinue)) {
|
||||
throw 'k6 is required for admin scenario but was not found in PATH.'
|
||||
}
|
||||
}
|
||||
|
||||
$timestamp = Get-Date -Format 'yyyy-MM-dd_HHmmss'
|
||||
$artifactDir = Join-Path $artifactBase $timestamp
|
||||
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
|
||||
|
||||
function Invoke-WebSocketLoadTest {
|
||||
param(
|
||||
[string]$TargetWsUrl,
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($TargetWsUrl)) {
|
||||
throw 'WS_URL is required for ws scenario. Pass -WsUrl or set $env:WS_URL.'
|
||||
}
|
||||
|
||||
$env:WS_URL = $TargetWsUrl
|
||||
& npx artillery run $wsScenarioPath --output $OutputPath
|
||||
}
|
||||
|
||||
function Invoke-AdminApiLoadTest {
|
||||
param(
|
||||
[string]$BaseUrl,
|
||||
[string]$SessionCookie,
|
||||
[string]$SummaryPath,
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
|
||||
throw 'ADMIN_BASE_URL is required for admin scenario. Pass -AdminBaseUrl or set $env:ADMIN_BASE_URL.'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($SessionCookie)) {
|
||||
throw 'ADMIN_SESSION_COOKIE is required for admin scenario. Pass -AdminSessionCookie or set $env:ADMIN_SESSION_COOKIE.'
|
||||
}
|
||||
|
||||
$env:ADMIN_BASE_URL = $BaseUrl
|
||||
$env:ADMIN_SESSION_COOKIE = $SessionCookie
|
||||
& k6 run --summary-export $SummaryPath $adminScriptPath | Tee-Object -FilePath $OutputPath
|
||||
}
|
||||
|
||||
if ($Scenario -in @('ws', 'all')) {
|
||||
if (-not (Test-Path $wsSummaryScriptPath)) {
|
||||
throw "Missing summary helper: $wsSummaryScriptPath"
|
||||
}
|
||||
|
||||
$wsOutput = Join-Path $artifactDir 'ws-chat-report.json'
|
||||
$wsSummary = Join-Path $artifactDir 'ws-chat-summary.txt'
|
||||
Invoke-WebSocketLoadTest -TargetWsUrl $WsUrl -OutputPath $wsOutput
|
||||
& pwsh -File $wsSummaryScriptPath -ReportFile $wsOutput | Tee-Object -FilePath $wsSummary
|
||||
}
|
||||
|
||||
if ($Scenario -in @('admin', 'all')) {
|
||||
if (-not (Test-Path $adminSummaryScriptPath)) {
|
||||
throw "Missing summary helper: $adminSummaryScriptPath"
|
||||
}
|
||||
|
||||
$adminSummary = Join-Path $artifactDir 'admin-api-summary.json'
|
||||
$adminOutput = Join-Path $artifactDir 'admin-api-output.txt'
|
||||
$adminEndpointSummary = Join-Path $artifactDir 'admin-api-endpoint-summary.txt'
|
||||
Invoke-AdminApiLoadTest -BaseUrl $AdminBaseUrl -SessionCookie $AdminSessionCookie -SummaryPath $adminSummary -OutputPath $adminOutput
|
||||
& pwsh -File $adminSummaryScriptPath -SummaryFile $adminSummary | Tee-Object -FilePath $adminEndpointSummary
|
||||
}
|
||||
|
||||
Write-Host "Artifacts written to $artifactDir"
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# run-loadtests.sh — Run an artillery (ws) and/or k6 (admin) load-test scenario
|
||||
# and write outputs into a dated artifacts folder.
|
||||
# Mirrors scripts/loadtest/run-loadtests.ps1.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/loadtest/run-loadtests.sh -s ws -w 'wss://...'
|
||||
# ./scripts/loadtest/run-loadtests.sh -s admin -a 'https://...' -c 'session=...'
|
||||
# ./scripts/loadtest/run-loadtests.sh -s all -w 'wss://...' -a 'https://...' -c 'session=...'
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||
|
||||
SCENARIO="all"
|
||||
ARTIFACT_ROOT="artifacts"
|
||||
WS_URL="${WS_URL:-}"
|
||||
ADMIN_BASE_URL="${ADMIN_BASE_URL:-}"
|
||||
ADMIN_SESSION_COOKIE="${ADMIN_SESSION_COOKIE:-}"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [-s ws|admin|all] [-w ws_url] [-a admin_base_url] [-c admin_session_cookie] [-r artifact_root]" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-s) SCENARIO="$2"; shift 2;;
|
||||
-w) WS_URL="$2"; shift 2;;
|
||||
-a) ADMIN_BASE_URL="$2"; shift 2;;
|
||||
-c) ADMIN_SESSION_COOKIE="$2"; shift 2;;
|
||||
-r) ARTIFACT_ROOT="$2"; shift 2;;
|
||||
-h|--help) usage;;
|
||||
*) echo "Unknown option: $1" >&2; usage;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$SCENARIO" in ws|admin|all) ;; *) echo "Scenario must be ws|admin|all" >&2; exit 2;; esac
|
||||
|
||||
if [[ "$SCENARIO" == ws || "$SCENARIO" == all ]] && ! command -v npx >/dev/null 2>&1; then
|
||||
echo "npx is required for ws scenario but was not found in PATH." >&2; exit 1
|
||||
fi
|
||||
if [[ "$SCENARIO" == admin || "$SCENARIO" == all ]] && ! command -v k6 >/dev/null 2>&1; then
|
||||
echo "k6 is required for admin scenario but was not found in PATH." >&2; exit 1
|
||||
fi
|
||||
|
||||
if [[ "$ARTIFACT_ROOT" = /* ]]; then artifact_base="$ARTIFACT_ROOT"; else artifact_base="$repo_root/$ARTIFACT_ROOT"; fi
|
||||
artifact_dir="$artifact_base/$(date +%Y-%m-%d_%H%M%S)"
|
||||
mkdir -p "$artifact_dir"
|
||||
|
||||
if [[ "$SCENARIO" == ws || "$SCENARIO" == all ]]; then
|
||||
[[ -n "$WS_URL" ]] || { echo "WS_URL is required for ws scenario. Pass -w or set \$WS_URL." >&2; exit 1; }
|
||||
ws_out="$artifact_dir/ws-chat-report.json"
|
||||
ws_sum="$artifact_dir/ws-chat-summary.txt"
|
||||
echo "== Running artillery ws scenario =="
|
||||
WS_URL="$WS_URL" npx artillery run "$script_dir/ws-chat.yml" --output "$ws_out"
|
||||
"$script_dir/summarize-artillery.sh" "$ws_out" | tee "$ws_sum"
|
||||
fi
|
||||
|
||||
if [[ "$SCENARIO" == admin || "$SCENARIO" == all ]]; then
|
||||
[[ -n "$ADMIN_BASE_URL" ]] || { echo "ADMIN_BASE_URL is required for admin scenario. Pass -a or set \$ADMIN_BASE_URL." >&2; exit 1; }
|
||||
[[ -n "$ADMIN_SESSION_COOKIE" ]] || { echo "ADMIN_SESSION_COOKIE is required for admin scenario. Pass -c or set \$ADMIN_SESSION_COOKIE." >&2; exit 1; }
|
||||
admin_sum="$artifact_dir/admin-api-summary.json"
|
||||
admin_out="$artifact_dir/admin-api-output.txt"
|
||||
admin_ep_sum="$artifact_dir/admin-api-endpoint-summary.txt"
|
||||
echo "== Running k6 admin scenario =="
|
||||
ADMIN_BASE_URL="$ADMIN_BASE_URL" ADMIN_SESSION_COOKIE="$ADMIN_SESSION_COOKIE" \
|
||||
k6 run --summary-export "$admin_sum" "$script_dir/admin-api.js" | tee "$admin_out"
|
||||
"$script_dir/summarize-k6.sh" "$admin_sum" | tee "$admin_ep_sum"
|
||||
fi
|
||||
|
||||
echo "Artifacts written to $artifact_dir"
|
||||
@@ -1,115 +1,115 @@
|
||||
#!/usr/bin/env pwsh
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ReportFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $ReportFile)) {
|
||||
throw "Report file not found: $ReportFile"
|
||||
}
|
||||
|
||||
$report = Get-Content -Raw -Path $ReportFile | ConvertFrom-Json -Depth 100
|
||||
$aggregate = $report.aggregate
|
||||
|
||||
if (-not $aggregate) {
|
||||
throw 'Artillery report is missing aggregate data.'
|
||||
}
|
||||
|
||||
function Get-PropertyValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object]$Object,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
if (-not $Object) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$property = $Object.PSObject.Properties[$Name]
|
||||
if ($property) {
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Format-StatLine {
|
||||
param(
|
||||
[string]$Prefix,
|
||||
[object]$Stats
|
||||
)
|
||||
|
||||
if (-not $Stats) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$parts = @()
|
||||
foreach ($name in @('count', 'min', 'max', 'mean', 'median', 'p95', 'p99')) {
|
||||
$value = Get-PropertyValue -Object $Stats -Name $name
|
||||
if ($null -ne $value) {
|
||||
$parts += "$name=$value"
|
||||
}
|
||||
}
|
||||
|
||||
if ($parts.Count -eq 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return "$Prefix: $($parts -join ', ')"
|
||||
}
|
||||
|
||||
$counters = Get-PropertyValue -Object $aggregate -Name 'counters'
|
||||
$rates = Get-PropertyValue -Object $aggregate -Name 'rates'
|
||||
$summaries = Get-PropertyValue -Object $aggregate -Name 'summaries'
|
||||
|
||||
$created = [double](Get-PropertyValue -Object $counters -Name 'vusers.created' ?? 0)
|
||||
$failed = [double](Get-PropertyValue -Object $counters -Name 'vusers.failed' ?? 0)
|
||||
$completed = [double](Get-PropertyValue -Object $counters -Name 'vusers.completed' ?? 0)
|
||||
$errorRate = if ($created -gt 0) { [math]::Round(($failed / $created) * 100, 2) } else { 0 }
|
||||
|
||||
Write-Output '=== Artillery WebSocket Summary ==='
|
||||
Write-Output "source: $ReportFile"
|
||||
|
||||
$timestamp = Get-PropertyValue -Object $report -Name 'timestamp'
|
||||
if ($timestamp) {
|
||||
Write-Output "timestamp: $timestamp"
|
||||
}
|
||||
|
||||
if ($null -ne $created) {
|
||||
Write-Output "vusers.created: $created"
|
||||
}
|
||||
if ($null -ne $completed) {
|
||||
Write-Output "vusers.completed: $completed"
|
||||
}
|
||||
if ($null -ne $failed) {
|
||||
Write-Output "vusers.failed: $failed"
|
||||
}
|
||||
Write-Output "vusers.failure_rate_percent: $errorRate"
|
||||
|
||||
if ($counters) {
|
||||
foreach ($metric in @('ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time')) {
|
||||
$value = Get-PropertyValue -Object $counters -Name $metric
|
||||
if ($null -ne $value) {
|
||||
Write-Output "$metric: $value"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($rates) {
|
||||
foreach ($property in $rates.PSObject.Properties) {
|
||||
Write-Output "rate.$($property.Name): $($property.Value)"
|
||||
}
|
||||
}
|
||||
|
||||
if ($summaries) {
|
||||
foreach ($property in $summaries.PSObject.Properties) {
|
||||
$line = Format-StatLine -Prefix "summary.$($property.Name)" -Stats $property.Value
|
||||
if ($line) {
|
||||
Write-Output $line
|
||||
}
|
||||
}
|
||||
}
|
||||
#!/usr/bin/env pwsh
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ReportFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $ReportFile)) {
|
||||
throw "Report file not found: $ReportFile"
|
||||
}
|
||||
|
||||
$report = Get-Content -Raw -Path $ReportFile | ConvertFrom-Json -Depth 100
|
||||
$aggregate = $report.aggregate
|
||||
|
||||
if (-not $aggregate) {
|
||||
throw 'Artillery report is missing aggregate data.'
|
||||
}
|
||||
|
||||
function Get-PropertyValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object]$Object,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
if (-not $Object) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$property = $Object.PSObject.Properties[$Name]
|
||||
if ($property) {
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Format-StatLine {
|
||||
param(
|
||||
[string]$Prefix,
|
||||
[object]$Stats
|
||||
)
|
||||
|
||||
if (-not $Stats) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$parts = @()
|
||||
foreach ($name in @('count', 'min', 'max', 'mean', 'median', 'p95', 'p99')) {
|
||||
$value = Get-PropertyValue -Object $Stats -Name $name
|
||||
if ($null -ne $value) {
|
||||
$parts += "$name=$value"
|
||||
}
|
||||
}
|
||||
|
||||
if ($parts.Count -eq 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return "$Prefix: $($parts -join ', ')"
|
||||
}
|
||||
|
||||
$counters = Get-PropertyValue -Object $aggregate -Name 'counters'
|
||||
$rates = Get-PropertyValue -Object $aggregate -Name 'rates'
|
||||
$summaries = Get-PropertyValue -Object $aggregate -Name 'summaries'
|
||||
|
||||
$created = [double](Get-PropertyValue -Object $counters -Name 'vusers.created' ?? 0)
|
||||
$failed = [double](Get-PropertyValue -Object $counters -Name 'vusers.failed' ?? 0)
|
||||
$completed = [double](Get-PropertyValue -Object $counters -Name 'vusers.completed' ?? 0)
|
||||
$errorRate = if ($created -gt 0) { [math]::Round(($failed / $created) * 100, 2) } else { 0 }
|
||||
|
||||
Write-Output '=== Artillery WebSocket Summary ==='
|
||||
Write-Output "source: $ReportFile"
|
||||
|
||||
$timestamp = Get-PropertyValue -Object $report -Name 'timestamp'
|
||||
if ($timestamp) {
|
||||
Write-Output "timestamp: $timestamp"
|
||||
}
|
||||
|
||||
if ($null -ne $created) {
|
||||
Write-Output "vusers.created: $created"
|
||||
}
|
||||
if ($null -ne $completed) {
|
||||
Write-Output "vusers.completed: $completed"
|
||||
}
|
||||
if ($null -ne $failed) {
|
||||
Write-Output "vusers.failed: $failed"
|
||||
}
|
||||
Write-Output "vusers.failure_rate_percent: $errorRate"
|
||||
|
||||
if ($counters) {
|
||||
foreach ($metric in @('ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time')) {
|
||||
$value = Get-PropertyValue -Object $counters -Name $metric
|
||||
if ($null -ne $value) {
|
||||
Write-Output "$metric: $value"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($rates) {
|
||||
foreach ($property in $rates.PSObject.Properties) {
|
||||
Write-Output "rate.$($property.Name): $($property.Value)"
|
||||
}
|
||||
}
|
||||
|
||||
if ($summaries) {
|
||||
foreach ($property in $summaries.PSObject.Properties) {
|
||||
$line = Format-StatLine -Prefix "summary.$($property.Name)" -Stats $property.Value
|
||||
if ($line) {
|
||||
Write-Output $line
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# summarize-artillery.sh — Extract counters, rates and percentile stats from an
|
||||
# Artillery JSON report. Mirrors scripts/loadtest/summarize-artillery.ps1.
|
||||
# Usage: ./scripts/loadtest/summarize-artillery.sh <artillery-report.json>
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <artillery-report.json>" >&2
|
||||
exit 2
|
||||
fi
|
||||
report_file="$1"
|
||||
[[ -f "$report_file" ]] || { echo "Report file not found: $report_file" >&2; exit 1; }
|
||||
|
||||
REPORT_FILE="$report_file" node <<'EOF'
|
||||
const fs = require('fs');
|
||||
const report = JSON.parse(fs.readFileSync(process.env.REPORT_FILE, 'utf8'));
|
||||
const aggregate = report.aggregate;
|
||||
if (!aggregate) throw new Error('Artillery report is missing aggregate data.');
|
||||
|
||||
const get = (obj, name) => (obj && obj[name] !== undefined) ? obj[name] : undefined;
|
||||
const counters = aggregate.counters || {};
|
||||
const rates = aggregate.rates || {};
|
||||
const summaries = aggregate.summaries || {};
|
||||
|
||||
const created = Number(get(counters, 'vusers.created') || 0);
|
||||
const failed = Number(get(counters, 'vusers.failed') || 0);
|
||||
const completed = Number(get(counters, 'vusers.completed') || 0);
|
||||
const errorRate = created > 0 ? +((failed / created) * 100).toFixed(2) : 0;
|
||||
|
||||
console.log('=== Artillery WebSocket Summary ===');
|
||||
console.log(`source: ${process.env.REPORT_FILE}`);
|
||||
if (report.timestamp) console.log(`timestamp: ${report.timestamp}`);
|
||||
console.log(`vusers.created: ${created}`);
|
||||
console.log(`vusers.completed: ${completed}`);
|
||||
console.log(`vusers.failed: ${failed}`);
|
||||
console.log(`vusers.failure_rate_percent: ${errorRate}`);
|
||||
|
||||
for (const metric of ['ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time']) {
|
||||
const v = get(counters, metric);
|
||||
if (v !== undefined) console.log(`${metric}: ${v}`);
|
||||
}
|
||||
for (const [k, v] of Object.entries(rates || {})) console.log(`rate.${k}: ${v}`);
|
||||
|
||||
const fmt = (prefix, stats) => {
|
||||
if (!stats) return;
|
||||
const parts = [];
|
||||
for (const name of ['count', 'min', 'max', 'mean', 'median', 'p95', 'p99']) {
|
||||
const v = get(stats, name);
|
||||
if (v !== undefined) parts.push(`${name}=${v}`);
|
||||
}
|
||||
if (parts.length) console.log(`${prefix}: ${parts.join(', ')}`);
|
||||
};
|
||||
for (const [k, v] of Object.entries(summaries)) fmt(`summary.${k}`, v);
|
||||
EOF
|
||||
@@ -1,68 +1,68 @@
|
||||
#!/usr/bin/env pwsh
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SummaryFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $SummaryFile)) {
|
||||
throw "Summary file not found: $SummaryFile"
|
||||
}
|
||||
|
||||
$summary = Get-Content -Raw -Path $SummaryFile | ConvertFrom-Json
|
||||
$metrics = $summary.metrics
|
||||
|
||||
if (-not $metrics) {
|
||||
throw 'Invalid k6 summary file: missing metrics object.'
|
||||
}
|
||||
|
||||
$endpoints = @('overview', 'analytics', 'chat_log', 'users')
|
||||
|
||||
function Get-MetricValue {
|
||||
param(
|
||||
[object]$Metric,
|
||||
[string]$Key
|
||||
)
|
||||
|
||||
if (-not $Metric) {
|
||||
return $null
|
||||
}
|
||||
if (-not $Metric.values) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$value = $Metric.values.$Key
|
||||
if ($null -ne $value) {
|
||||
return [double]$value
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$globalDuration = Get-MetricValue -Metric $metrics.http_req_duration -Key 'p(95)'
|
||||
$globalFailRate = Get-MetricValue -Metric $metrics.http_req_failed -Key 'rate'
|
||||
|
||||
Write-Output "k6 Summary: $SummaryFile"
|
||||
if ($null -ne $globalDuration) {
|
||||
Write-Output ("Global p95 duration (ms): {0:N2}" -f $globalDuration)
|
||||
}
|
||||
if ($null -ne $globalFailRate) {
|
||||
Write-Output ("Global failure rate: {0:P2}" -f $globalFailRate)
|
||||
}
|
||||
Write-Output ''
|
||||
|
||||
$rows = foreach ($endpoint in $endpoints) {
|
||||
$durationMetricName = "http_req_duration{endpoint:$endpoint}"
|
||||
$failMetricName = "http_req_failed{endpoint:$endpoint}"
|
||||
|
||||
$durationP95 = Get-MetricValue -Metric $metrics.$durationMetricName -Key 'p(95)'
|
||||
$failRate = Get-MetricValue -Metric $metrics.$failMetricName -Key 'rate'
|
||||
|
||||
[PSCustomObject]@{
|
||||
Endpoint = $endpoint
|
||||
P95Ms = if ($null -ne $durationP95) { [math]::Round($durationP95, 2) } else { $null }
|
||||
FailurePct = if ($null -ne $failRate) { [math]::Round($failRate * 100, 4) } else { $null }
|
||||
}
|
||||
}
|
||||
|
||||
$rows | Format-Table -AutoSize | Out-String | Write-Output
|
||||
#!/usr/bin/env pwsh
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SummaryFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $SummaryFile)) {
|
||||
throw "Summary file not found: $SummaryFile"
|
||||
}
|
||||
|
||||
$summary = Get-Content -Raw -Path $SummaryFile | ConvertFrom-Json
|
||||
$metrics = $summary.metrics
|
||||
|
||||
if (-not $metrics) {
|
||||
throw 'Invalid k6 summary file: missing metrics object.'
|
||||
}
|
||||
|
||||
$endpoints = @('overview', 'analytics', 'chat_log', 'users')
|
||||
|
||||
function Get-MetricValue {
|
||||
param(
|
||||
[object]$Metric,
|
||||
[string]$Key
|
||||
)
|
||||
|
||||
if (-not $Metric) {
|
||||
return $null
|
||||
}
|
||||
if (-not $Metric.values) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$value = $Metric.values.$Key
|
||||
if ($null -ne $value) {
|
||||
return [double]$value
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$globalDuration = Get-MetricValue -Metric $metrics.http_req_duration -Key 'p(95)'
|
||||
$globalFailRate = Get-MetricValue -Metric $metrics.http_req_failed -Key 'rate'
|
||||
|
||||
Write-Output "k6 Summary: $SummaryFile"
|
||||
if ($null -ne $globalDuration) {
|
||||
Write-Output ("Global p95 duration (ms): {0:N2}" -f $globalDuration)
|
||||
}
|
||||
if ($null -ne $globalFailRate) {
|
||||
Write-Output ("Global failure rate: {0:P2}" -f $globalFailRate)
|
||||
}
|
||||
Write-Output ''
|
||||
|
||||
$rows = foreach ($endpoint in $endpoints) {
|
||||
$durationMetricName = "http_req_duration{endpoint:$endpoint}"
|
||||
$failMetricName = "http_req_failed{endpoint:$endpoint}"
|
||||
|
||||
$durationP95 = Get-MetricValue -Metric $metrics.$durationMetricName -Key 'p(95)'
|
||||
$failRate = Get-MetricValue -Metric $metrics.$failMetricName -Key 'rate'
|
||||
|
||||
[PSCustomObject]@{
|
||||
Endpoint = $endpoint
|
||||
P95Ms = if ($null -ne $durationP95) { [math]::Round($durationP95, 2) } else { $null }
|
||||
FailurePct = if ($null -ne $failRate) { [math]::Round($failRate * 100, 4) } else { $null }
|
||||
}
|
||||
}
|
||||
|
||||
$rows | Format-Table -AutoSize | Out-String | Write-Output
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# summarize-k6.sh — Extract per-endpoint p95 and failure rates from a k6 JSON
|
||||
# summary export. Mirrors scripts/loadtest/summarize-k6.ps1.
|
||||
# Usage: ./scripts/loadtest/summarize-k6.sh <k6-summary.json>
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <k6-summary.json>" >&2
|
||||
exit 2
|
||||
fi
|
||||
summary_file="$1"
|
||||
[[ -f "$summary_file" ]] || { echo "Summary file not found: $summary_file" >&2; exit 1; }
|
||||
|
||||
SUMMARY_FILE="$summary_file" node <<'EOF'
|
||||
const fs = require('fs');
|
||||
const summary = JSON.parse(fs.readFileSync(process.env.SUMMARY_FILE, 'utf8'));
|
||||
const metrics = summary.metrics;
|
||||
if (!metrics) throw new Error('Invalid k6 summary file: missing metrics object.');
|
||||
|
||||
const val = (m, key) => (m && m.values && m.values[key] !== undefined) ? Number(m.values[key]) : undefined;
|
||||
const globalDuration = val(metrics.http_req_duration, 'p(95)');
|
||||
const globalFailRate = val(metrics.http_req_failed, 'rate');
|
||||
|
||||
console.log(`k6 Summary: ${process.env.SUMMARY_FILE}`);
|
||||
if (globalDuration !== undefined) console.log(`Global p95 duration (ms): ${globalDuration.toFixed(2)}`);
|
||||
if (globalFailRate !== undefined) console.log(`Global failure rate: ${(globalFailRate * 100).toFixed(2)}%`);
|
||||
console.log('');
|
||||
|
||||
const endpoints = ['overview', 'analytics', 'chat_log', 'users'];
|
||||
const rows = endpoints.map((ep) => {
|
||||
const dP95 = val(metrics[`http_req_duration{endpoint:${ep}}`], 'p(95)');
|
||||
const fRate = val(metrics[`http_req_failed{endpoint:${ep}}`], 'rate');
|
||||
return {
|
||||
Endpoint: ep,
|
||||
P95Ms: dP95 !== undefined ? dP95.toFixed(2) : 'n/a',
|
||||
FailurePct: fRate !== undefined ? (fRate * 100).toFixed(4) : 'n/a',
|
||||
};
|
||||
});
|
||||
|
||||
const pad = (s, n) => String(s).padEnd(n);
|
||||
console.log(pad('ENDPOINT', 12) + pad('P95_MS', 10) + 'FAILURE_PCT');
|
||||
for (const r of rows) console.log(pad(r.Endpoint, 12) + pad(r.P95Ms, 10) + r.FailurePct);
|
||||
EOF
|
||||
@@ -1,28 +1,28 @@
|
||||
config:
|
||||
target: "{{ $processEnvironment.WS_URL }}"
|
||||
phases:
|
||||
- duration: 60
|
||||
arrivalRate: 10
|
||||
name: warmup-10
|
||||
- duration: 120
|
||||
arrivalRate: 100
|
||||
name: sustain-100
|
||||
- duration: 180
|
||||
arrivalRate: 500
|
||||
name: sustain-500
|
||||
- duration: 240
|
||||
arrivalRate: 1000
|
||||
name: sustain-1000
|
||||
engines:
|
||||
ws: {}
|
||||
scenarios:
|
||||
- engine: ws
|
||||
name: chat-presence-and-send
|
||||
flow:
|
||||
- think: 1
|
||||
- send:
|
||||
text: '{"type":"ping","text":"loadtest"}'
|
||||
- think: 1
|
||||
- send:
|
||||
text: '{"type":"ping","text":"loadtest-2"}'
|
||||
- think: 1
|
||||
config:
|
||||
target: "{{ $processEnvironment.WS_URL }}"
|
||||
phases:
|
||||
- duration: 60
|
||||
arrivalRate: 10
|
||||
name: warmup-10
|
||||
- duration: 120
|
||||
arrivalRate: 100
|
||||
name: sustain-100
|
||||
- duration: 180
|
||||
arrivalRate: 500
|
||||
name: sustain-500
|
||||
- duration: 240
|
||||
arrivalRate: 1000
|
||||
name: sustain-1000
|
||||
engines:
|
||||
ws: {}
|
||||
scenarios:
|
||||
- engine: ws
|
||||
name: chat-presence-and-send
|
||||
flow:
|
||||
- think: 1
|
||||
- send:
|
||||
text: '{"type":"ping","text":"loadtest"}'
|
||||
- think: 1
|
||||
- send:
|
||||
text: '{"type":"ping","text":"loadtest-2"}'
|
||||
- think: 1
|
||||
Reference in new issue
Block a user