From cbb624bbaceef9f51b666d8551273beffdaacb8a Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Wed, 12 Aug 2026 18:36:33 +0200 Subject: [PATCH 1/4] perf: add k6 ramp-and-hold spike scenario Signed-off-by: laurentketterle-hub --- tests/k6/plans-spike.js | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/k6/plans-spike.js diff --git a/tests/k6/plans-spike.js b/tests/k6/plans-spike.js new file mode 100644 index 00000000..43cec1c7 --- /dev/null +++ b/tests/k6/plans-spike.js @@ -0,0 +1,59 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Counter, Trend } from 'k6/metrics'; + +// Custom metrics +const planErrors = new Counter('plan_errors'); +const planResponseTimes = new Trend('plan_response_times'); + +// Configuration (overridable via env) +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const API_KEY = __ENV.API_KEY || 'test-key'; + +export const options = { + // Ramp from 10 to 2000 VUs over 5 minutes, hold for 2 minutes, + // then cool down. Focused on the plans read path (GET /api/v1/plans). + stages: [ + { duration: '1m', target: 10 }, // warm-up to 10 VUs + { duration: '5m', target: 2000 }, // ramp 10 -> 2000 (burst) + { duration: '2m', target: 2000 }, // hold at peak + { duration: '1m', target: 0 }, // cool down + ], + thresholds: { + // p99 latency budget for the plans read path + 'plan_response_times': ['p(99)<500'], + 'http_req_duration': ['p(99)<500'], + // Error rate must stay under 1% + 'http_req_failed': ['rate<0.01'], + 'plan_errors': ['count<1'], + // At least 95% of checks must pass + checks: ['rate>0.95'], + }, +}; + +export default function () { + group('Plans Read Path - Spike Test', () => { + const params = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${API_KEY}`, + }, + }; + + const res = http.get(`${BASE_URL}/api/v1/plans`, params); + + const ok = check(res, { + 'status is 200': (r) => r.status === 200, + 'p99 < 500ms': (r) => r.timings.duration < 500, + 'body is not empty': (r) => r.body && r.body.length > 0, + }); + + if (!ok) { + planErrors.add(1); + } + planResponseTimes.add(res.timings.duration); + + // Small think-time to approximate real client pacing. + sleep(1); + }); +} From 7266215b638160b4ea638df3484c91e9909ad008 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Wed, 12 Aug 2026 18:36:35 +0200 Subject: [PATCH 2/4] perf: add k6 ramp-and-hold spike scenario Signed-off-by: laurentketterle-hub --- .github/workflows/k6-spike-test.yml | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/k6-spike-test.yml diff --git a/.github/workflows/k6-spike-test.yml b/.github/workflows/k6-spike-test.yml new file mode 100644 index 00000000..b0aa3c5a --- /dev/null +++ b/.github/workflows/k6-spike-test.yml @@ -0,0 +1,40 @@ +name: K6 Spike Test - Plans + +on: + workflow_dispatch: + inputs: + environment: + description: 'Test environment' + required: true + default: 'staging' + type: choice + options: + - staging + - production + +jobs: + spike-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup k6 + run: | + sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 + echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6-stable.list + sudo apt-get update + sudo apt-get install -y k6 + + - name: Run Spike Test + env: + BASE_URL: ${{ secrets[format('K6_{0}_BASE_URL', github.event.inputs.environment)] }} + API_KEY: ${{ secrets[format('K6_{0}_API_KEY', github.event.inputs.environment)] }} + run: | + k6 run tests/k6/plans-spike.js --out=json=results.json + + - name: Upload JSON Summary + if: always() + uses: actions/upload-artifact@v4 + with: + name: k6-spike-results + path: results.json From 2ab2026a3e1c559acb59c751da8316f4bc89a4f0 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Wed, 12 Aug 2026 18:57:51 +0200 Subject: [PATCH 3/4] feat: feat: k6 load-spike ramp profile for plans read path --- tests/k6/plans-spike.js | 110 +++++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 59 deletions(-) diff --git a/tests/k6/plans-spike.js b/tests/k6/plans-spike.js index 43cec1c7..5d4138bc 100644 --- a/tests/k6/plans-spike.js +++ b/tests/k6/plans-spike.js @@ -1,59 +1,51 @@ -import http from 'k6/http'; -import { check, sleep, group } from 'k6'; -import { Counter, Trend } from 'k6/metrics'; - -// Custom metrics -const planErrors = new Counter('plan_errors'); -const planResponseTimes = new Trend('plan_response_times'); - -// Configuration (overridable via env) -const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; -const API_KEY = __ENV.API_KEY || 'test-key'; - -export const options = { - // Ramp from 10 to 2000 VUs over 5 minutes, hold for 2 minutes, - // then cool down. Focused on the plans read path (GET /api/v1/plans). - stages: [ - { duration: '1m', target: 10 }, // warm-up to 10 VUs - { duration: '5m', target: 2000 }, // ramp 10 -> 2000 (burst) - { duration: '2m', target: 2000 }, // hold at peak - { duration: '1m', target: 0 }, // cool down - ], - thresholds: { - // p99 latency budget for the plans read path - 'plan_response_times': ['p(99)<500'], - 'http_req_duration': ['p(99)<500'], - // Error rate must stay under 1% - 'http_req_failed': ['rate<0.01'], - 'plan_errors': ['count<1'], - // At least 95% of checks must pass - checks: ['rate>0.95'], - }, -}; - -export default function () { - group('Plans Read Path - Spike Test', () => { - const params = { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${API_KEY}`, - }, - }; - - const res = http.get(`${BASE_URL}/api/v1/plans`, params); - - const ok = check(res, { - 'status is 200': (r) => r.status === 200, - 'p99 < 500ms': (r) => r.timings.duration < 500, - 'body is not empty': (r) => r.body && r.body.length > 0, - }); - - if (!ok) { - planErrors.add(1); - } - planResponseTimes.add(res.timings.duration); - - // Small think-time to approximate real client pacing. - sleep(1); - }); -} +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Counter, Trend } from 'k6/metrics'; + +const planErrors = new Counter('plan_errors'); +const planLatency = new Trend('plan_latency'); +const planP99 = new Trend('plan_p99_latency'); + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const API_KEY = __ENV.API_KEY || 'test-key'; +const PLANS_PATH = __ENV.PLANS_PATH || '/api/v1/plans'; + +export const options = { + vus: 10, + stages: [ + { duration: '5m', target: 2000 }, + { duration: '2m', target: 2000 }, + { duration: '1m', target: 0 }, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(99)<1500'], + plan_p99_latency: ['p(99)<1500'], + plan_errors: ['count<1'], + }, +}; + +export default function () { + group('Plans Read - Spike', () => { + const params = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${API_KEY}`, + }, + }; + + const res = http.get(`${BASE_URL}${PLANS_PATH}`, params); + + const ok = check(res, { + 'status is 200': (r) => r.status === 200, + 'p99 < 1500ms': (r) => r.timings.duration < 1500, + 'body is not empty': (r) => r.body && r.body.length > 0, + }); + + if (!ok) planErrors.add(1); + planLatency.add(res.timings.duration); + planP99.add(res.timings.duration); + + sleep(1); + }); +} From c4fad9f3734bc7f1824b6e792e5cb78f24061d53 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Wed, 12 Aug 2026 18:57:52 +0200 Subject: [PATCH 4/4] feat: feat: k6 load-spike ramp profile for plans read path --- .github/workflows/k6-spike-test.yml | 72 +++++++++++++---------------- 1 file changed, 32 insertions(+), 40 deletions(-) diff --git a/.github/workflows/k6-spike-test.yml b/.github/workflows/k6-spike-test.yml index b0aa3c5a..0ae2537b 100644 --- a/.github/workflows/k6-spike-test.yml +++ b/.github/workflows/k6-spike-test.yml @@ -1,40 +1,32 @@ -name: K6 Spike Test - Plans - -on: - workflow_dispatch: - inputs: - environment: - description: 'Test environment' - required: true - default: 'staging' - type: choice - options: - - staging - - production - -jobs: - spike-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup k6 - run: | - sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 - echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6-stable.list - sudo apt-get update - sudo apt-get install -y k6 - - - name: Run Spike Test - env: - BASE_URL: ${{ secrets[format('K6_{0}_BASE_URL', github.event.inputs.environment)] }} - API_KEY: ${{ secrets[format('K6_{0}_API_KEY', github.event.inputs.environment)] }} - run: | - k6 run tests/k6/plans-spike.js --out=json=results.json - - - name: Upload JSON Summary - if: always() - uses: actions/upload-artifact@v4 - with: - name: k6-spike-results - path: results.json +name: k6 Spike Test + +on: + workflow_dispatch: + pull_request: + paths: + - 'tests/k6/**' + - '.github/workflows/k6-spike-test.yml' + +jobs: + spike: + name: Plans read-path spike (10 -> 2000 VUs) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run k6 spike profile + uses: grafana/k6-action@v0.3.1 + with: + filename: tests/k6/plans-spike.js + flags: --summary-export=results/k6-spike-summary.json + env: + BASE_URL: ${{ secrets.STAGING_URL }} + API_KEY: ${{ secrets.STAGING_API_KEY }} + + - name: Publish JSON summary artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: k6-spike-summary + path: results/k6-spike-summary.json + if-no-files-found: warn