From 87fd421a1193616e399d5851b1b311ced9285e9f Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Tue, 17 Feb 2026 20:32:08 +0200 Subject: [PATCH 1/4] Add more varied load test profile, refactor instructions to use k6 Docker image --- test/load/README.md | 101 ++++++++++++++++++- test/load/script.js | 232 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 284 insertions(+), 49 deletions(-) diff --git a/test/load/README.md b/test/load/README.md index b8483bc3479b..ee585949f7d7 100644 --- a/test/load/README.md +++ b/test/load/README.md @@ -1,7 +1,100 @@ -### Load testing +# Load testing -This is a simple load test script set up with k6s. If you have a server running locally, you can run the load test with: +This folder contains a [`k6s`](https://grafana.com/docs/k6) load testing script. +Open the script, set input constants (environment under test, site domains, auth cookie, API token), then run the script using -```bash -$ k6 run test/load/script.js +```sh +$ docker run --rm -i grafana/k6 run - 0.5 ? "Mozilla/5.0" : "Mozilla/4.0"} (${Math.random() > 0.5 ? "Macintosh" : "Windows"}; ${Math.random() > 0.5 ? "Intel Mac OS X 10_15_6" : "Windows NT 10.0"}) AppleWebKit/${Math.floor(Math.random() * 1000) + 500}.36 (KHTML, like Gecko) Chrome/${Math.floor(Math.random() * 100) + 50}.0.${Math.floor(Math.random() * 5000) + 1000}.${Math.floor(Math.random() * 500)} Safari/${Math.floor(Math.random() * 1000) + 500}.${Math.floor(Math.random() * 100)} OPR/${Math.floor(Math.random() * 100)}.0.${Math.floor(Math.random() * 5000) + 1000}.${Math.floor(Math.random() * 500)}`, - }, - }; -} +/** INPUTS */ +/** Base URL of the environment under test (env) */ +const baseURL = "http://localhost:8000"; +/** Domain of a site registered in the env */ +const domainHeavy = "dummy.site/heavy"; +/** Domain of a site registered in the env */ +const domainLight = "dummy.site/light"; +/** Valid auth cookie of a user with access to the domains above in the env */ +const internalApiCookie = "_plausible_dev=..."; +/** Valid Stats API token of a user with access to the domains above in the env */ +const externalApiToken = "..."; -export const options = { - scenarios: { - constant_rps: { - executor: "constant-arrival-rate", - rate: 6000, - timeUnit: "1s", - duration: "1m", - preAllocatedVUs: 10000, - maxVUs: 30000, +const endpoints = { + track: { + method: "POST", + name: "/api/event", + getUrl: () => `${baseURL}/api/event`, + getBody: (options) => { + const { name, domain, url, ...rest } = { + name: "pageview", + ...options, + }; + const payload = { + n: name, + u: url ?? `https://${domain}/page`, + d: domain, + ...rest, + }; + return JSON.stringify(payload); + }, + getParams: () => { + const n = () => Math.floor(Math.random() * 255); + const ip = [n() + 1, n(), n(), n()].join("."); + return { + headers: { + "Content-Type": "application/json", + "X-Forwarded-For": ip, + "User-Agent": `${Math.random() > 0.5 ? "Mozilla/5.0" : "Mozilla/4.0"} (${Math.random() > 0.5 ? "Macintosh" : "Windows"}; ${Math.random() > 0.5 ? "Intel Mac OS X 10_15_6" : "Windows NT 10.0"}) AppleWebKit/${Math.floor(Math.random() * 1000) + 500}.36 (KHTML, like Gecko) Chrome/${Math.floor(Math.random() * 100) + 50}.0.${Math.floor(Math.random() * 5000) + 1000}.${Math.floor(Math.random() * 500)} Safari/${Math.floor(Math.random() * 1000) + 500}.${Math.floor(Math.random() * 100)} OPR/${Math.floor(Math.random() * 100)}.0.${Math.floor(Math.random() * 5000) + 1000}.${Math.floor(Math.random() * 500)}`, + }, + }; + }, + checks: { + "is accepted": (res) => res.body === "ok", + "is buffered": (res) => res.headers["X-Plausible-Dropped"] != 1, + }, + }, + internalApi: { + method: "GET", + name: "/api/stats/:domain/pages", + getUrl: ({ domain }) => + `${baseURL}/api/stats/${encodeURIComponent(domain)}/pages?period=all&date=${new Date().toISOString().split("T")[0]}&filters=%5B%5D`, + getParams: () => ({ headers: { Cookie: internalApiCookie } }), + checks: { + "request is successful": (res) => res.status === 200, + }, + }, + externalApi: { + method: "POST", + name: "/api/v2/query", + getUrl: () => `${baseURL}/api/v2/query`, + getBody: ({ domain }) => { + const { site_id, metrics, date_range, ...rest } = { + site_id: domain, + metrics: ["visitors"], + date_range: "all", + }; + + return JSON.stringify({ site_id, metrics, date_range, ...rest }); + }, + getParams: () => ({ + headers: { + Authorization: `Bearer ${externalApiToken}`, + "Content-Type": "application/json", + }, + }), + checks: { + "request is successful": (res) => res.status === 200, + }, + }, + health: { + method: "GET", + name: "/api/system/health/ready", + getUrl: () => `${baseURL}/api/system/health/ready`, + getParams: () => ({}), + checks: { + "request is successful": (res) => res.status === 200, }, }, }; -export default function () { - const res = http.post( - "http://localhost:8000/api/event", - PAYLOAD, - newParams(), +function makeRequest(endpoint, opts = {}) { + const { domain } = opts; + const res = http.request( + endpoint.method, + endpoint.getUrl({ domain }), + endpoint.getBody ? endpoint.getBody({ domain }) : null, + { + tags: { endpoint: endpoint.name, ...(domain && { domain }) }, + ...endpoint.getParams({ domain }), + }, ); - check(res, { - "is accepted": (r) => r.body === "ok", - "is buffered": (r) => r.headers["X-Plausible-Dropped"] !== "1", - }); + check(res, endpoint.checks); } + +export const trackHeavy = () => + makeRequest(endpoints.track, { domain: domainHeavy }); +export const trackLight = () => + makeRequest(endpoints.track, { domain: domainLight }); +export const healthCheck = () => makeRequest(endpoints.health); +export const pagesHeavy = () => + makeRequest(endpoints.internalApi, { domain: domainHeavy }); +export const pagesLight = () => + makeRequest(endpoints.internalApi, { domain: domainLight }); +export const queryHeavy = () => + makeRequest(endpoints.externalApi, { domain: domainHeavy }); +export const queryLight = () => + makeRequest(endpoints.externalApi, { domain: domainLight }); + +const sharedScenarioOptions = { + executor: "constant-arrival-rate", + timeUnit: "1s", + duration: "20s", +}; + +const selectors = [ + `{endpoint: "${endpoints.health.name}"}`, + `{endpoint: "${endpoints.track.name}", domain: "${domainHeavy}"}`, + `{endpoint: "${endpoints.track.name}", domain: "${domainLight}"}`, + `{endpoint: "${endpoints.internalApi.name}", domain: "${domainHeavy}"}`, + `{endpoint: "${endpoints.internalApi.name}", domain: "${domainLight}"}`, + `{endpoint: "${endpoints.externalApi.name}", domain: "${domainHeavy}"}`, + `{endpoint: "${endpoints.externalApi.name}", domain: "${domainLight}"}`, +]; + +export const options = { + scenarios: { + [trackHeavy.name]: { + ...sharedScenarioOptions, + rate: 200, + preAllocatedVUs: 1000, + exec: trackHeavy.name, + }, + [trackLight.name]: { + ...sharedScenarioOptions, + rate: 20, + preAllocatedVUs: 100, + exec: trackLight.name, + }, + [healthCheck.name]: { + ...sharedScenarioOptions, + rate: 1, + preAllocatedVUs: 10, + exec: healthCheck.name, + }, + [pagesHeavy.name]: { + ...sharedScenarioOptions, + rate: 3, + preAllocatedVUs: 50, + exec: pagesHeavy.name, + }, + [pagesLight.name]: { + ...sharedScenarioOptions, + rate: 3, + preAllocatedVUs: 50, + exec: pagesLight.name, + }, + [queryHeavy.name]: { + ...sharedScenarioOptions, + rate: 3, + preAllocatedVUs: 50, + exec: queryHeavy.name, + }, + [queryLight.name]: { + ...sharedScenarioOptions, + rate: 3, + preAllocatedVUs: 50, + exec: queryLight.name, + }, + }, + thresholds: { + ...Object.fromEntries( + selectors.map((selector) => [ + `http_req_duration${selector}`, + ["p(95)<500"], + ]), + ), + ...Object.fromEntries( + selectors.map((selector) => [ + `http_req_failed${selector}`, + ["rate<0.01"], + ]), + ), + }, +}; From a747cf1a60ff39500a131810d6ac3166ff1d3fec Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 18 Feb 2026 10:30:13 +0200 Subject: [PATCH 2/4] Increase rate of requests, refactor --- test/load/script.js | 65 ++++++++++++++++++++++----------------------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/test/load/script.js b/test/load/script.js index 7c216159f98b..a54aca564f31 100644 --- a/test/load/script.js +++ b/test/load/script.js @@ -18,16 +18,15 @@ const endpoints = { method: "POST", name: "/api/event", getUrl: () => `${baseURL}/api/event`, - getBody: (options) => { - const { name, domain, url, ...rest } = { - name: "pageview", - ...options, + getBody: ({ domain }) => { + const { n, d } = { + n: "pageview", + d: domain, }; const payload = { - n: name, - u: url ?? `https://${domain}/page`, - d: domain, - ...rest, + d, + n, + u: `https://${domain}/page/${Math.floor(Math.random() * 100) + 1}`, }; return JSON.stringify(payload); }, @@ -120,63 +119,63 @@ export const queryHeavy = () => export const queryLight = () => makeRequest(endpoints.externalApi, { domain: domainLight }); -const sharedScenarioOptions = { +const scenarioOptions = { executor: "constant-arrival-rate", timeUnit: "1s", duration: "20s", }; const selectors = [ - `{endpoint: "${endpoints.health.name}"}`, - `{endpoint: "${endpoints.track.name}", domain: "${domainHeavy}"}`, - `{endpoint: "${endpoints.track.name}", domain: "${domainLight}"}`, - `{endpoint: "${endpoints.internalApi.name}", domain: "${domainHeavy}"}`, - `{endpoint: "${endpoints.internalApi.name}", domain: "${domainLight}"}`, - `{endpoint: "${endpoints.externalApi.name}", domain: "${domainHeavy}"}`, - `{endpoint: "${endpoints.externalApi.name}", domain: "${domainLight}"}`, + `endpoint: "${endpoints.health.name}"`, + `endpoint: "${endpoints.track.name}", domain: "${domainHeavy}"`, + `endpoint: "${endpoints.track.name}", domain: "${domainLight}"`, + `endpoint: "${endpoints.internalApi.name}", domain: "${domainHeavy}"`, + `endpoint: "${endpoints.internalApi.name}", domain: "${domainLight}"`, + `endpoint: "${endpoints.externalApi.name}", domain: "${domainHeavy}"`, + `endpoint: "${endpoints.externalApi.name}", domain: "${domainLight}"`, ]; export const options = { scenarios: { [trackHeavy.name]: { - ...sharedScenarioOptions, - rate: 200, - preAllocatedVUs: 1000, + ...scenarioOptions, + rate: 500, + preAllocatedVUs: 2000, exec: trackHeavy.name, }, [trackLight.name]: { - ...sharedScenarioOptions, - rate: 20, - preAllocatedVUs: 100, + ...scenarioOptions, + rate: 50, + preAllocatedVUs: 200, exec: trackLight.name, }, [healthCheck.name]: { - ...sharedScenarioOptions, + ...scenarioOptions, rate: 1, preAllocatedVUs: 10, exec: healthCheck.name, }, [pagesHeavy.name]: { - ...sharedScenarioOptions, - rate: 3, + ...scenarioOptions, + rate: 6, preAllocatedVUs: 50, exec: pagesHeavy.name, }, [pagesLight.name]: { - ...sharedScenarioOptions, - rate: 3, + ...scenarioOptions, + rate: 6, preAllocatedVUs: 50, exec: pagesLight.name, }, [queryHeavy.name]: { - ...sharedScenarioOptions, - rate: 3, + ...scenarioOptions, + rate: 6, preAllocatedVUs: 50, exec: queryHeavy.name, }, [queryLight.name]: { - ...sharedScenarioOptions, - rate: 3, + ...scenarioOptions, + rate: 6, preAllocatedVUs: 50, exec: queryLight.name, }, @@ -184,13 +183,13 @@ export const options = { thresholds: { ...Object.fromEntries( selectors.map((selector) => [ - `http_req_duration${selector}`, + `http_req_duration{${selector}}`, ["p(95)<500"], ]), ), ...Object.fromEntries( selectors.map((selector) => [ - `http_req_failed${selector}`, + `http_req_failed{${selector}}`, ["rate<0.01"], ]), ), From c8f9e7a339fc020cef58dcc710a3909b0ae00317 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 18 Feb 2026 10:39:51 +0200 Subject: [PATCH 3/4] Show when external query API request is rate limited --- test/load/script.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/load/script.js b/test/load/script.js index a54aca564f31..0f9db76637bc 100644 --- a/test/load/script.js +++ b/test/load/script.js @@ -76,7 +76,8 @@ const endpoints = { }, }), checks: { - "request is successful": (res) => res.status === 200, + "request is successful (200)": (res) => res.status === 200, + "request is rate limited (429)": (res) => res.status === 429, }, }, health: { From 320edb99b1e021d7ff4cd97b4ba508d69f8db5f3 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 4 Mar 2026 11:59:58 +0200 Subject: [PATCH 4/4] Update script and readme, allow json summary, sensitive values from command line --- test/load/.gitignore | 4 + test/load/README.md | 127 ++++++++++++++++++------------- test/load/script.js | 175 +++++++++++++++++++++++++++++++------------ 3 files changed, 209 insertions(+), 97 deletions(-) create mode 100644 test/load/.gitignore diff --git a/test/load/.gitignore b/test/load/.gitignore new file mode 100644 index 000000000000..64e5f8011735 --- /dev/null +++ b/test/load/.gitignore @@ -0,0 +1,4 @@ +* +!.gitignore +!script.js +!README.md \ No newline at end of file diff --git a/test/load/README.md b/test/load/README.md index ee585949f7d7..f5230f312b27 100644 --- a/test/load/README.md +++ b/test/load/README.md @@ -1,100 +1,127 @@ # Load testing -This folder contains a [`k6s`](https://grafana.com/docs/k6) load testing script. -Open the script, set input constants (environment under test, site domains, auth cookie, API token), then run the script using +This folder contains a [`k6`](https://grafana.com/docs/k6) load testing script. + +## Running the load test + +Open the script, set input constants (environment under test, site domains), then run it: ```sh -$ docker run --rm -i grafana/k6 run - res.headers["X-Plausible-Dropped"] != 1, }, }, - internalApi: { + internalApiPages: { method: "GET", name: "/api/stats/:domain/pages", getUrl: ({ domain }) => `${baseURL}/api/stats/${encodeURIComponent(domain)}/pages?period=all&date=${new Date().toISOString().split("T")[0]}&filters=%5B%5D`, - getParams: () => ({ headers: { Cookie: internalApiCookie } }), + getParams: () => ({ headers: { Cookie: __ENV.AUTH_COOKIE } }), checks: { "request is successful": (res) => res.status === 200, }, }, - externalApi: { + externalApiQuery: { method: "POST", name: "/api/v2/query", getUrl: () => `${baseURL}/api/v2/query`, getBody: ({ domain }) => { const { site_id, metrics, date_range, ...rest } = { site_id: domain, - metrics: ["visitors"], + // increase complexity / load with harder queries + metrics: ["visitors", "percentage"], + filters: [ + [ + "or", + [ + ["contains", "event:page", ["1"]], + ["not", ["contains", "event:page", ["5", "6", "7", "8", "9"]]], + ], + ], + ], date_range: "all", }; @@ -71,7 +77,7 @@ const endpoints = { }, getParams: () => ({ headers: { - Authorization: `Bearer ${externalApiToken}`, + Authorization: `Bearer ${__ENV.STATS_API_TOKEN}`, "Content-Type": "application/json", }, }), @@ -80,7 +86,7 @@ const endpoints = { "request is rate limited (429)": (res) => res.status === 429, }, }, - health: { + healthReadiness: { method: "GET", name: "/api/system/health/ready", getUrl: () => `${baseURL}/api/system/health/ready`, @@ -89,6 +95,15 @@ const endpoints = { "request is successful": (res) => res.status === 200, }, }, + healthLiveness: { + method: "GET", + name: "/api/health", + getUrl: () => `${baseURL}/api/health`, + getParams: () => ({}), + checks: { + "request is successful": (res) => res.status === 200, + }, + }, }; function makeRequest(endpoint, opts = {}) { @@ -110,89 +125,155 @@ export const trackHeavy = () => makeRequest(endpoints.track, { domain: domainHeavy }); export const trackLight = () => makeRequest(endpoints.track, { domain: domainLight }); -export const healthCheck = () => makeRequest(endpoints.health); + +export const readinessCheck = () => makeRequest(endpoints.healthReadiness); +export const livenessCheck = () => makeRequest(endpoints.healthLiveness); + export const pagesHeavy = () => - makeRequest(endpoints.internalApi, { domain: domainHeavy }); + makeRequest(endpoints.internalApiPages, { domain: domainHeavy }); export const pagesLight = () => - makeRequest(endpoints.internalApi, { domain: domainLight }); + makeRequest(endpoints.internalApiPages, { domain: domainLight }); + export const queryHeavy = () => - makeRequest(endpoints.externalApi, { domain: domainHeavy }); + makeRequest(endpoints.externalApiQuery, { domain: domainHeavy }); export const queryLight = () => - makeRequest(endpoints.externalApi, { domain: domainLight }); + makeRequest(endpoints.externalApiQuery, { domain: domainLight }); const scenarioOptions = { executor: "constant-arrival-rate", timeUnit: "1s", - duration: "20s", + duration: "120s", }; +// configuring thresholds changes what stats are shown in the summary at the end const selectors = [ - `endpoint: "${endpoints.health.name}"`, - `endpoint: "${endpoints.track.name}", domain: "${domainHeavy}"`, - `endpoint: "${endpoints.track.name}", domain: "${domainLight}"`, - `endpoint: "${endpoints.internalApi.name}", domain: "${domainHeavy}"`, - `endpoint: "${endpoints.internalApi.name}", domain: "${domainLight}"`, - `endpoint: "${endpoints.externalApi.name}", domain: "${domainHeavy}"`, - `endpoint: "${endpoints.externalApi.name}", domain: "${domainLight}"`, + [ + `endpoint: "${endpoints.healthLiveness.name}"`, + [ + ["http_req_duration", ["p(95)<300"]], + ["http_req_failed", ["rate<0.01"]], + ], + ], + [ + `endpoint: "${endpoints.healthReadiness.name}"`, + [ + ["http_req_duration", ["p(95)<500"]], + ["http_req_failed", ["rate<0.01"]], + ], + ], + [ + `endpoint: "${endpoints.track.name}", domain: "${domainHeavy}"`, + [ + ["http_req_duration", ["p(95)<1500"]], + ["http_req_failed", ["rate<0.01"]], + ], + ], + [ + `endpoint: "${endpoints.track.name}", domain: "${domainLight}"`, + [ + ["http_req_duration", ["p(95)<1500"]], + ["http_req_failed", ["rate<0.01"]], + ], + ], + [ + `endpoint: "${endpoints.internalApiPages.name}", domain: "${domainHeavy}"`, + [ + ["http_req_duration", ["p(95)<3000"]], + ["http_req_failed", ["rate<0.01"]], + ], + ], + [ + `endpoint: "${endpoints.internalApiPages.name}", domain: "${domainLight}"`, + [ + ["http_req_duration", ["p(95)<1500"]], + ["http_req_failed", ["rate<0.01"]], + ], + ], + [ + `endpoint: "${endpoints.externalApiQuery.name}", domain: "${domainHeavy}"`, + [ + ["http_req_duration", ["p(95)<5000"]], + ["http_req_failed", ["rate<0.01"]], + ], + ], + [ + `endpoint: "${endpoints.externalApiQuery.name}", domain: "${domainLight}"`, + [ + ["http_req_duration", ["p(95)<2500"]], + ["http_req_failed", ["rate<0.01"]], + ], + ], ]; export const options = { + // to disable specific requests, comment out those scenarios scenarios: { + [livenessCheck.name]: { + ...scenarioOptions, + rate: 1, + preAllocatedVUs: 100, + exec: livenessCheck.name, + }, + [readinessCheck.name]: { + ...scenarioOptions, + rate: 1, + preAllocatedVUs: 100, + exec: readinessCheck.name, + }, [trackHeavy.name]: { ...scenarioOptions, rate: 500, - preAllocatedVUs: 2000, + preAllocatedVUs: 6000, exec: trackHeavy.name, }, [trackLight.name]: { ...scenarioOptions, rate: 50, - preAllocatedVUs: 200, + preAllocatedVUs: 600, exec: trackLight.name, }, - [healthCheck.name]: { - ...scenarioOptions, - rate: 1, - preAllocatedVUs: 10, - exec: healthCheck.name, - }, [pagesHeavy.name]: { ...scenarioOptions, rate: 6, - preAllocatedVUs: 50, + preAllocatedVUs: 400, exec: pagesHeavy.name, }, [pagesLight.name]: { ...scenarioOptions, rate: 6, - preAllocatedVUs: 50, + preAllocatedVUs: 400, exec: pagesLight.name, }, [queryHeavy.name]: { ...scenarioOptions, - rate: 6, - preAllocatedVUs: 50, + rate: 3, + preAllocatedVUs: 200, exec: queryHeavy.name, }, [queryLight.name]: { ...scenarioOptions, - rate: 6, - preAllocatedVUs: 50, + rate: 3, + preAllocatedVUs: 200, exec: queryLight.name, }, }, thresholds: { ...Object.fromEntries( - selectors.map((selector) => [ - `http_req_duration{${selector}}`, - ["p(95)<500"], - ]), - ), - ...Object.fromEntries( - selectors.map((selector) => [ - `http_req_failed{${selector}}`, - ["rate<0.01"], - ]), + selectors.flatMap(([selector, thresholds]) => + thresholds.map(([metricName, thresholdValues]) => [ + `${metricName}{${selector}}`, + thresholdValues, + ]), + ), ), }, }; + +export function handleSummary(data) { + if (__ENV.JSON_SUMMARY) { + return { + "/tmp/summary.json": JSON.stringify(data), + }; + } + return undefined; // built-in text summary +}