name: ring:load-testing-with-k6 description: "Load-testing with k6 for the LerianStudio/k6 Palantir platform: scaffolds product.yaml, smoke/load/stress/soak scenarios, a helper client, builds the webpack bundle, and verifies a local k6 run. Use when new API/gRPC endpoints or throughput-path changes need SLO validation under load, or a Palantir CI load gate is required. Skip when no network-facing endpoints are affected or changes are config-only or non-code."
k6 Load Testing (Palantir Platform)
When to use
- After integration testing passes
- Before production deploy of performance-sensitive changes
- New API endpoints or significant throughput-path changes
- Need to validate SLOs under load (latency, error rate, throughput)
- CI pipeline requires load test gate via Palantir
Skip when
- Task is documentation-only, configuration-only, or non-code
- No HTTP/gRPC endpoints affected by the change
- Changes limited to static assets, configs, or non-runtime code
- Service has no network-facing interface
Related
Complementary: ring:implementing-tasks, ring:reviewing-code
This skill generates k6 load tests following the Lerian k6 platform conventions. Tests are structured for execution via Palantir (Self-Service Testing) and are bundled by webpack into self-contained scripts deployed to EKS via k6-operator.
Reference repository: LerianStudio/k6 — specifically platform/ directory.
Block conditions:
- Test script missing
handleSummaryexport = FAIL (Palantir can't collect results) scenario.yamlparam names don't match__ENVvars in test.js = FAIL- Test doesn't read VUS/DURATION from
__ENV= FAIL - No
checkResponse()from shared utils = FAIL - Missing
product.yaml= FAIL
Step 1: Validate Input
Required:
product— product name in lowercase (e.g.,midaz,tracer,reporter,matcher)endpoints— list of endpoints to test, each with method, path, and optional payloadbase_port— local dev port for the product (e.g., 3000 for midaz, 4020 for tracer)
Optional:
scenario_types— which scenarios to generate (default:[smoke, load, stress])auth_type—bearer(default, usesshared/auth.js) |api-key|noneapi_key_header— header name for API key auth (default:X-API-Key)custom_thresholds— override default thresholdsexisting_product— if true, extend existing product directory
Step 2: Understand the Platform Structure
All test code lives under platform/ in the LerianStudio/k6 repo:
platform/
├── products/{product}/
│ ├── product.yaml # Product metadata (read by Palantir)
│ ├── helpers/
│ │ └── client.js # HTTP client for this product's API
│ └── scenarios/
│ └── {scenario}/
│ ├── scenario.yaml # Catalog metadata (read by Palantir)
│ └── test.js # k6 test script (webpack entry point)
├── shared/
│ ├── auth.js # getAuthHeaders(), authenticate()
│ ├── utils.js # checkResponse(), sleepWithJitter(), defaultHandleSummary()
│ └── palantir/ # SDK for complex scenarios (fixtures, runtime)
│ ├── index.js # scenario(), fixture(), createTestExports()
│ ├── runtime.js # Builds k6 exports from config
│ ├── scenario.js # Declarative scenario config builder
│ └── templates.js # Built-in test type templates (smoke/quick/full/breakpoint/soak)
├── dist/ # Webpack output (git-ignored)
├── build.js # Bundler entry point
├── webpack.config.js # Auto-discovers products/*/scenarios/*/test.js
├── config.yaml # Platform-level test catalog metadata
└── package.json
Two Patterns for Writing Tests
Pattern A: Simple client (recommended for most tests)
Product helpers/client.js provides get(), post(), patch(), del() scoped to
the product's base URL. Scenarios import the client and shared/utils.js directly.
Used by: smoke, load, stress, soak scenarios for midaz, console, pix.
Pattern B: Palantir SDK (for complex scenarios with fixtures)
For scenarios that need declarative fixture setup (create rules, limits, etc.), sanity checks, and built-in metric tracking, use the Palantir SDK:
import { scenario, fixture, createTestExports } from '../../../../shared/palantir/index.js';
Used by: tracer scenarios (pass-through, denied-by-limit, denied-by-rule, complex-approval).
Choose Pattern A unless the product requires setup fixtures (rules, limits, etc.) that must be created and activated before load can run.
Step 3: Create Product Files
3a. product.yaml
Create platform/products/{product}/product.yaml:
product: {product}
description: "{Product description} - performance tests"
base_url_env: {PRODUCT}_BASE_URL
defaults:
thresholds:
http_req_duration: ["p(95)<500", "p(99)<1000"]
http_req_failed: ["rate<0.01"]
env:
API_VERSION: "v1"
tags:
- {product}
- {relevant-tags}
3b. helpers/client.js
Create platform/products/{product}/helpers/client.js:
For bearer auth (most products):
import http from 'k6/http';
import { getAuthHeaders } from '../../../shared/auth.js';
const BASE_URL = __ENV.{PRODUCT}_BASE_URL || __ENV.TARGET_URL || 'http://localhost:{base_port}';
const API_VERSION = __ENV.API_VERSION || 'v1';
export function apiUrl(path) {
return `${BASE_URL}/${API_VERSION}${path}`;
}
export function get(path, params = {}) {
const { headers: extraHeaders, ...restParams } = params;
return http.get(apiUrl(path), {
...restParams,
headers: { ...getAuthHeaders(), ...extraHeaders },
});
}
export function post(path, body, params = {}) {
const { headers: extraHeaders, ...restParams } = params;
return http.post(apiUrl(path), JSON.stringify(body), {
...restParams,
headers: { ...getAuthHeaders(), ...extraHeaders },
});
}
export function patch(path, body, params = {}) {
const { headers: extraHeaders, ...restParams } = params;
return http.patch(apiUrl(path), JSON.stringify(body), {
...restParams,
headers: { ...getAuthHeaders(), ...extraHeaders },
});
}
export function del(path, params = {}) {
const { headers: extraHeaders, ...restParams } = params;
return http.del(apiUrl(path), null, {
...restParams,
headers: { ...getAuthHeaders(), ...extraHeaders },
});
}
For API key auth (e.g., tracer):
import http from 'k6/http';
const BASE_URL = __ENV.{PRODUCT}_BASE_URL || __ENV.TARGET_URL || 'http://localhost:{base_port}';
const API_VERSION = __ENV.API_VERSION || 'v1';
function getHeaders() {
const headers = { 'Content-Type': 'application/json' };
const apiKey = __ENV.{PRODUCT}_API_KEY;
if (apiKey) {
headers['{api_key_header}'] = apiKey;
}
return headers;
}
export function apiUrl(path) {
return `${BASE_URL}/${API_VERSION}${path}`;
}
// ... same get/post/patch/del pattern with getHeaders() ...
export function readiness() {
return http.get(`${BASE_URL}/health`, {
tags: { name: '{product}_readiness' },
});
}
Key rules for the client:
__ENV.TARGET_URLis the primary URL injected by Palantir SST — always include as fallback- Product-specific env var (
{PRODUCT}_BASE_URL) allows override in multi-product environments - Never hardcode auth credentials — read from
__ENV
Step 4: Create Scenario Files
4a. scenario.yaml (per scenario)
Create platform/products/{product}/scenarios/{type}/scenario.yaml:
name: "{Scenario Display Name}"
description: "{What this scenario validates}"
type: {smoke|load|stress|soak|functional}
tags: [{type}, {relevant-tags}]
defaults:
vus: {default_vus}
duration: "{default_duration}"
parallelism: 1
params:
- name: VUS
label: "Virtual Users"
type: number
default: "{default_vus}"
description: "Number of concurrent virtual users"
- name: DURATION
label: "Test Duration"
type: string
default: "{default_duration}"
description: "How long the test runs (e.g. 1m, 5m, 30s)"
Rules:
- Every
params[].nameMUST match a__ENV.XXXvariable read in test.js typemust be one of: smoke, load, stress, soak, breakpoint, capacity, functionaldefaultsdefine what Palantir pre-fills in the form
Default values per scenario type
| Type | VUs | Duration | Thresholds |
|---|---|---|---|
| smoke | 3-5 | 1m | p(95)<500 |
| load | 50 | 10m | p(95)<300, p(99)<500 |
| stress | 100-200 | 5m | p(95)<500, p(99)<1000 |
| soak | 30 | 30m-2h | p(95)<300, p(99)<500 |
4b. test.js (Pattern A — Simple)
Create platform/products/{product}/scenarios/{type}/test.js:
Smoke test:
import { sleep } from 'k6';
import { get } from '../../helpers/client.js';
import { checkResponse, sleepWithJitter, defaultHandleSummary } from '../../../../shared/utils.js';
export const options = {
vus: __ENV.VUS ? parseInt(__ENV.VUS) : 5,
duration: __ENV.DURATION || '1m',
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
// Health/readiness check
const healthRes = get('/health');
checkResponse(healthRes, 200, 'health check');
// Representative API calls for this product
const res = get('/{resource}');
checkResponse(res, 200, 'list {resource}');
sleep(sleepWithJitter(1));
}
// MANDATORY: Palantir results collection
export { defaultHandleSummary as handleSummary };
Load test (with ramp-up stages):
import { sleep } from 'k6';
import { get, post } from '../../helpers/client.js';
import { checkResponse, sleepWithJitter, randomString, defaultHandleSummary } from '../../../../shared/utils.js';
const VUS = __ENV.VUS ? parseInt(__ENV.VUS) : 50;
const DURATION = __ENV.DURATION || '10m';
const RAMP_UP = __ENV.RAMP_UP || '2m';
export const options = {
stages: [
{ duration: RAMP_UP, target: VUS },
{ duration: DURATION, target: VUS },
{ duration: '1m', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<300', 'p(99)<500'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
// Product-specific API flow
// Example: list → get → create cycle
const listRes = get('/{resource}');
checkResponse(listRes, 200, 'list {resource}');
if (listRes.status === 200) {
const items = JSON.parse(listRes.body);
if (items.items && items.items.length > 0) {
const id = items.items[0].id;
const detailRes = get(`/{resource}/${id}`);
checkResponse(detailRes, 200, 'get {resource}');
}
}
sleep(sleepWithJitter(0.5, 0.3));
}
export { defaultHandleSummary as handleSummary };
Stress test:
import { sleep } from 'k6';
import { get, post } from '../../helpers/client.js';
import { checkResponse, sleepWithJitter, randomString, defaultHandleSummary } from '../../../../shared/utils.js';
const VUS = __ENV.VUS ? parseInt(__ENV.VUS) : 100;
const DURATION = __ENV.DURATION || '5m';
const RAMP_UP = __ENV.RAMP_UP || '1m';
export const options = {
stages: [
{ duration: RAMP_UP, target: VUS },
{ duration: DURATION, target: VUS },
{ duration: '30s', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'],
http_req_failed: ['rate<0.02'],
},
};
export default function () {
// Higher-intensity flow: mixed reads + writes
// Adapt to product's critical path
sleep(sleepWithJitter(0.3, 0.2));
}
export { defaultHandleSummary as handleSummary };
4c. test.js (Pattern B — Palantir SDK with Fixtures)
For products that need fixture setup (like Tracer with rules/limits):
import { scenario, fixture, createTestExports } from '../../../../shared/palantir/index.js';
import * as client from '../../helpers/client.js';
const config = scenario({
name: '{Scenario Name}',
fixtures: [
fixture.rule({
expression: '{CEL expression}',
action: 'ALLOW',
description: '{rule description}',
}),
fixture.limit({
limitType: 'DAILY',
maxAmount: 999000000,
description: '{limit description}',
}),
],
sanity: {
expectedDecision: 'ALLOW',
},
thresholds: {
'http_req_duration': ['p(95)<500', 'p(99)<1000'],
'txn_correctness_rate': ['rate>0.99'],
'txn_error_rate': ['rate<0.01'],
},
});
function buildPayload() {
return {
// Product-specific payload
};
}
function checks(body) {
return {
'decision is ALLOW': () => body.decision === 'ALLOW',
// Product-specific checks
};
}
const test = createTestExports({ config, client, buildPayload, checks });
export const options = test.options;
export const setup = test.setup;
export default test.default;
export const handleSummary = test.handleSummary;
Step 5: Build and Verify
cd platform
npm install # first time only
npm run build # webpack bundles all scenarios
Verify bundle was created:
ls -la dist/{product}/
# Expected: {scenario}.bundle.js for each scenario
Verify bundle runs locally:
# With product running locally
k6 run dist/{product}/smoke.bundle.js
# Override target URL
k6 run -e TARGET_URL=http://localhost:{port} dist/{product}/smoke.bundle.js
Step 6: Mandatory Checklist
Before marking complete, verify ALL items:
-
products/{product}/product.yamlexists withbase_url_envand default thresholds -
products/{product}/helpers/client.jsexists withTARGET_URLfallback - At least
scenarios/smoke/exists with bothscenario.yamlandtest.js - Every
scenario.yamlparamnamematches a__ENV.XXXintest.js - Every
test.jsexportshandleSummary(re-exportdefaultHandleSummary) - Every
test.jsreadsVUSandDURATIONfrom__ENV - Every
test.jsdefinesthresholdsinoptions - Every
test.jsusescheckResponse()fromshared/utils.js -
npm run buildsucceeds and produces bundles indist/{product}/ - Bundle runs locally with
k6 run dist/{product}/smoke.bundle.js
Environment Variables Reference
Injected by Palantir SST (available in all tests)
| Variable | Description |
|---|---|
TARGET_URL |
Base URL of the product under test |
VUS |
Number of virtual users |
DURATION |
Test duration (e.g., 30s, 5m) |
ENVIRONMENT_ID |
SST environment UUID |
K6_TESTID |
Test run UUID (for Grafana filtering) |
Authentication (from shared/auth.js)
| Variable | Description |
|---|---|
AUTH_TOKEN |
Bearer token (takes priority) |
AUTH_USER / AUTH_PASS |
Basic auth credentials |
AUTH_URL |
OAuth token endpoint |
AUTH_CLIENT_ID |
OAuth client ID |
AUTH_CLIENT_SECRET |
OAuth client secret |
Shared Utilities (from shared/utils.js)
| Function | Description |
|---|---|
checkResponse(res, status?, label?) |
Asserts status + duration <5s, tracks custom_error_rate and custom_request_duration |
sleepWithJitter(base?, jitter?) |
Returns base + random(0, jitter) — avoids thundering herd |
defaultHandleSummary(data) |
Writes summary JSON to /tmp/summary.json + stdout markers for SST collection |
randomString(length?) |
Random alphanumeric string |
Output Report
## Load Test Summary
| Metric | Value |
|--------|-------|
| Result | PASS |
| Product | {product} |
| Scenarios Created | smoke, load, stress |
| Pattern | A (Simple client) / B (Palantir SDK) |
## Files Created
| File | Purpose |
|------|---------|
| `platform/products/{product}/product.yaml` | Product metadata |
| `platform/products/{product}/helpers/client.js` | HTTP client |
| `platform/products/{product}/scenarios/smoke/scenario.yaml` | Smoke catalog |
| `platform/products/{product}/scenarios/smoke/test.js` | Smoke test |
| `platform/products/{product}/scenarios/load/scenario.yaml` | Load catalog |
| `platform/products/{product}/scenarios/load/test.js` | Load test |
## Palantir Integration
- Bundle path: `dist/{product}/{scenario}.bundle.js`
- Build verified: ✅
- Local run verified: ✅ (smoke @ localhost:{port})
## Next Steps
- Push to `LerianStudio/k6` repository
- Verify in Palantir UI: product appears in catalog with all scenarios
- Run smoke test via SST to validate end-to-end flow