who_need_help/load/k6/production-readonly.js

229 lines
5.9 KiB
JavaScript

import { check, sleep } from "k6";
import http from "k6/http";
import exec from "k6/execution";
import { Counter } from "k6/metrics";
import { WebSocket } from "k6/websockets";
function required(name) {
const value = __ENV[name];
if (value === undefined || value === "") {
throw new Error(`${name} is required`);
}
return value;
}
function nonNegativeInteger(name) {
const value = Number(required(name));
if (!Number.isInteger(value) || value < 0) {
throw new Error(`${name} must be a non-negative integer`);
}
return value;
}
function positiveInteger(name) {
const value = nonNegativeInteger(name);
if (value === 0) {
throw new Error(`${name} must be a positive integer`);
}
return value;
}
function nonNegativeNumber(name) {
const value = Number(required(name));
if (!Number.isFinite(value) || value < 0) {
throw new Error(`${name} must be a non-negative number`);
}
return value;
}
const baseUrl = required("BASE_URL").replace(/\/+$/, "");
if (baseUrl !== "https://whoneedhelp.com") {
throw new Error("The production read-only profile is pinned to https://whoneedhelp.com");
}
const httpVus = nonNegativeInteger("HTTP_VUS");
const websocketVus = nonNegativeInteger("WS_VUS");
if (httpVus + websocketVus === 0) {
throw new Error("At least one HTTP or WebSocket VU is required");
}
const duration = required("DURATION");
const httpThinkSeconds = nonNegativeNumber("HTTP_THINK_SECONDS");
const websocketHoldMs = positiveInteger("WS_HOLD_MS");
const websocketConnectTimeoutMs = positiveInteger("WS_CONNECT_TIMEOUT_MS");
// Phoenix 1.8's browser client sends a heartbeat every 30 seconds. Keep the
// production probe aligned with that behavior so a long-lived socket is not
// mistaken for an idle client and closed by the server.
const websocketHeartbeatIntervalMs = 30000;
const websocketUrl =
baseUrl.replace(/^http/, "ws") + "/live/websocket?vsn=2.0.0";
// This allowlist is intentionally compiled into the read-only profile. It
// contains GET-only public pages and the readiness endpoint. Authentication,
// registration, support submission, email delivery, and every mutating route
// are outside this file.
const publicRoutes = [
{ path: "/", endpoint: "home" },
{ path: "/safety", endpoint: "safety" },
{ path: "/privacy", endpoint: "privacy" },
{ path: "/terms", endpoint: "terms" },
{ path: "/users/log-in", endpoint: "login_page" },
{ path: "/users/register", endpoint: "registration_page" },
{ path: "/healthz/ready", endpoint: "readiness" },
];
const websocketOpened = new Counter("wnh_readonly_websocket_opened");
const websocketErrors = new Counter("wnh_readonly_websocket_errors");
const heartbeatReplies = new Counter("wnh_readonly_heartbeat_replies");
const scenarios = {};
const thresholds = {};
if (httpVus > 0) {
scenarios.public_http = {
executor: "constant-vus",
exec: "publicHttp",
vus: httpVus,
duration,
};
thresholds.checks = [{ threshold: "rate==1", abortOnFail: true }];
thresholds.http_req_failed = [{ threshold: "rate==0", abortOnFail: true }];
}
if (websocketVus > 0) {
scenarios.phoenix_websocket = {
executor: "constant-vus",
exec: "phoenixWebsocket",
vus: websocketVus,
duration,
};
thresholds.wnh_readonly_websocket_errors = [
{ threshold: "count==0", abortOnFail: true },
];
}
export const options = {
discardResponseBodies: true,
scenarios,
thresholds,
};
export function publicHttp() {
const route = publicRoutes[exec.scenario.iterationInTest % publicRoutes.length];
const response = http.get(`${baseUrl}${route.path}`, {
redirects: 0,
tags: { endpoint: route.endpoint, safety_scope: "production_readonly" },
headers: { "User-Agent": "WhoNeedHelp-production-readonly-load/1" },
});
check(response, {
[`GET ${route.path} returned 200`]: (candidate) => candidate.status === 200,
});
sleep(httpThinkSeconds);
}
export function phoenixWebsocket() {
const socket = new WebSocket(websocketUrl, [], {
tags: {
endpoint: "phoenix_live_socket",
safety_scope: "production_readonly",
},
headers: {
Origin: baseUrl,
"User-Agent": "WhoNeedHelp-production-readonly-load/1",
},
});
let opened = false;
let closeTimer;
let heartbeatTimer;
let heartbeatSequence = 0;
let pendingHeartbeatRef;
const sendHeartbeat = () => {
pendingHeartbeatRef = `${__VU}-${__ITER}-${heartbeatSequence}`;
heartbeatSequence += 1;
socket.send(
JSON.stringify([null, pendingHeartbeatRef, "phoenix", "heartbeat", {}]),
);
};
const scheduleHeartbeat = () => {
const nextHeartbeatAtMs =
heartbeatSequence * websocketHeartbeatIntervalMs;
if (nextHeartbeatAtMs >= websocketHoldMs) {
return;
}
heartbeatTimer = setTimeout(() => {
sendHeartbeat();
scheduleHeartbeat();
}, websocketHeartbeatIntervalMs);
};
const connectTimer = setTimeout(() => {
if (!opened) {
websocketErrors.add(1);
socket.close();
}
}, websocketConnectTimeoutMs);
socket.addEventListener("open", () => {
opened = true;
clearTimeout(connectTimer);
websocketOpened.add(1);
sendHeartbeat();
scheduleHeartbeat();
closeTimer = setTimeout(() => socket.close(), websocketHoldMs);
});
socket.addEventListener("message", (event) => {
let frame;
try {
frame = JSON.parse(event.data);
} catch (_error) {
return;
}
if (
Array.isArray(frame) &&
frame[1] === pendingHeartbeatRef &&
frame[2] === "phoenix" &&
frame[3] === "phx_reply" &&
frame[4]?.status === "ok"
) {
heartbeatReplies.add(1);
}
});
socket.addEventListener("error", () => {
websocketErrors.add(1);
clearTimeout(connectTimer);
socket.close();
});
socket.addEventListener("close", () => {
clearTimeout(connectTimer);
if (closeTimer !== undefined) {
clearTimeout(closeTimer);
}
if (heartbeatTimer !== undefined) {
clearTimeout(heartbeatTimer);
}
});
}