Refactor load testing scripts to support Bash and PowerShell
CI / test (push) Successful in 2m50s
CI / docker (push) Successful in 5m12s

- 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:
zwitschi committed 2026-09-02 19:47:34 +02:00
1 parent 9f60087a4c
commit 33b6296c5e
82 files changed
+19682 -21133

No files matched your search

+106 -106
View File
@@ -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);
});
+8 -8
View File
@@ -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
+6
View File
@@ -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" "$@"
+132
View File
@@ -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
+177
View File
@@ -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
View File
@@ -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
+58 -58
View File
@@ -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);
}
+98 -98
View File
@@ -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"
+72
View File
@@ -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"
+115 -115
View File
@@ -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
}
}
}
+54
View File
@@ -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
+68 -68
View File
@@ -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
+43
View File
@@ -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
+28 -28
View File
@@ -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