Produces one artifact: a load-test plan in which every scenario has a name, a load shape, an injection model, and a pass/fail line traceable to a stated SLO.
What this skill owns, and what it does not
| Owned here | Owned elsewhere |
|---|---|
| Which scenarios exist, and why each one exists | Nothing |
| The load shape each scenario applies (profile, ramp, plateau, injection model) | Nothing |
| The pass/fail line each scenario carries, expressed as a threshold derived from an SLO | Nothing |
| The error budget each threshold is sized against | Nothing |
| Runner-specific configuration: executor blocks, protocol setup, feeders, distributed workers, result stores | A per-tool wrapper for k6, Gatling, JMeter, or Locust |
| Aggregating verdicts from several runners into one CI go / no-go | A multi-runner CI gate |
| Finding which commit caused a regression after the plan is already running | A bisection workflow |
The axis is time: this skill runs before a runner is chosen. Its output names metrics, shapes, and numbers in prose and tables, not in a runner's syntax. One short threshold snippet appears below purely to show what the numbers turn into; everything else stays declarative.
Step 1 - Derive named scenarios from SLOs
For every SLO in the input:
One SLO can yield several scenarios. Split whenever the boundary conditions differ: the same p95 target at average traffic and at peak traffic are two scenarios, because they need different load shapes and can fail independently.
| SLO | Governs | Scenario |
|---|---|---|
| p95 latency < 300 ms at peak load | POST /api/checkout (20% of mix) | checkout-latency-peak |
| p95 latency < 100 ms at average load | GET /api/orders (60% of mix) | orders-latency-average |
| Error rate < 0.1% under a 3x spike | All endpoints, mix preserved | spike-error-budget |
| Availability 99.9% over a four-week window | All endpoints, mix preserved | soak-availability |
Preserve the traffic mix inside each scenario. A scenario that sends 100% of its requests to the endpoint under test measures that endpoint in isolation, not in the contention it actually experiences.
Step 2 - Assign a load profile
Six canonical profiles, per the Grafana k6 test types guide (opens in new window). Every scenario from Step 1 gets exactly one.
| Profile | Load level | Duration | What it answers | Shape |
|---|---|---|---|---|
| Smoke | Minimal load | Seconds to a couple of minutes | Does the script itself work at minimal load? | Flat, one or two users |
| Average-load | Average production load | 5 to 60 min (k6 test types (opens in new window)) | Do we meet the SLO on a normal day? | Ramp up, plateau, ramp down |
| Stress | Above the expected average | 5 to 60 min (k6 test types (opens in new window)) | How much headroom is there when load exceeds the average? | Ramp above average, plateau |
| Soak | Average production load | Hours (k6 test types (opens in new window)) | Does it still meet the SLO after hours? | Slow ramp, long plateau |
| Spike | Very high, brief | A few minutes (k6 test types (opens in new window)) | Do we survive a sudden, short, massive surge? | Near-instant surge, short hold, drop |
| Breakpoint | Increasing until failure | Until the system breaks (k6 test types (opens in new window)) | Where is the capacity ceiling? | Continuous ramp, no plateau |
Selection rules that follow from the definitions:
Step 3 - Choose the injection model per scenario
Each scenario also declares one of two workload models. This is the most frequently mis-stated concept in load-test planning, so state it precisely.
Closed model. A fixed population of virtual users, each finishing its current request before starting the next: "The next iteration doesn't start until the previous one finishes" (k6 open and closed models (opens in new window)). The consequence that matters for planning: throughput is coupled to latency. When the service slows down, offered load falls with it, so the test quietly stops applying the load you specified - k6 names this effect coordinated omission.
Open model. New iterations arrive at a rate you specify, independent of how long previous ones take, so "the response times of the target system no longer influence the load on the target system" (k6 open and closed models (opens in new window)). A degrading service keeps receiving the same arrival rate, so queues build the way they would in production.
For how k6 and Gatling name their open and closed executors, and the Gatling "users means open, not closed" trap, see references/runners.md.
Decision rule for the plan:
Step 4 - Derive threshold expressions from the SLO
Every scenario except breakpoint carries at least one threshold, and every threshold is a restatement of an SLO. Per the k6 thresholds documentation (opens in new window), a threshold expression has the form <aggregation_method> <operator> <value>, is evaluated against the metric collected during the run, and produces a non-zero exit code when it fails.
Mechanical translation:
| SLO statement | Metric | Expression |
|---|---|---|
| p95 latency < 300 ms | Request duration trend | p(95)<300 (k6 thresholds (opens in new window)) |
| p99 latency < 500 ms | Request duration trend | p(99)<500 |
| Error rate < 0.1% | Request failure rate | rate<0.001 (k6 thresholds (opens in new window)) |
| Average latency < 150 ms | Request duration trend | avg<150 (k6 thresholds (opens in new window)) |
| At least 500 completed checkouts per run | Counter | count>=500 (k6 thresholds (opens in new window)) |
Two rules keep the thresholds honest:
Scope each threshold to the endpoints its SLO governs. A whole-run p95 is dominated by whichever endpoint carries the most traffic, so a slow low-volume checkout hides behind a fast high-volume list call. Thresholds can be attached to a tagged subset of requests, written as metric_name{tag_name:tag_value}, for example http_req_duration{type:API} (k6 thresholds (opens in new window)). The plan states the tag per scenario; the implementer wires it.
Say per scenario whether a breach aborts the run. The long form of a threshold supports abortOnFail: true to stop execution on failure and delayAbortEval to postpone evaluation until enough data has accumulated, given as a relative time string such as '10s' (k6 thresholds (opens in new window)):
thresholds: {
http_req_duration: [{ threshold: 'p(95)<300', abortOnFail: true, delayAbortEval: '10s' }],
http_req_failed: ['rate<0.001'],
}Abort when continuing costs something and teaches nothing:
Do not abort when the failure itself is the measurement: spike recovery (you want to see whether latency returns to baseline after the surge), stress scenarios exploring headroom, and breakpoint runs, which have no threshold to abort on at all.
Step 5 - Size the error budget and calibrate the soak
An error budget is what the SLO leaves over: "the error budget is 100% minus the SLO". The Google SRE Workbook's worked case: a 99.9% SLO on a service receiving 3 million requests over a four-week period allows a budget of 3,000 (0.1%) errors (Implementing SLOs (opens in new window)).
The formula the plan uses:
budget_events = (1 - SLO_target) x total_events_in_windowCalibrate the soak in three steps.
Every threshold in the plan should now be traceable: SLO, then window, then event count, then budget, then the number in the expression. A threshold that cannot be traced back this way is a guess.
Output format
One Markdown document:
## Load-test plan: <service> (<date>)
### SLO inventory
| SLO | Metric | Window | Current baseline | Source |
### Scenario matrix
| Scenario | SLO governed | Profile | Injection model | Target rate or concurrency | Duration |
### Profile definitions
For each scenario:
- Ramp: <start> to <peak> over <T>
- Plateau: hold <peak> for <T>
- Ramp-down: <peak> to 0 over <T>
- Injection model: open (arrivals/sec) | closed (concurrency), and why
- Traffic mix applied: <endpoint: weight, ...>
### Thresholds
| Scenario | Metric | Scope tag | Expression | Abort on fail | SLO it restates |
### Error-budget derivation
| SLO | Window | Total events | Budget events | Soak allowance | Burn-rate cap |
### Open questions
Anything the plan assumed rather than knew: peak RPS, growth rate, whether an
SLO is agreed or aspirational.Worked example
A full worked example - a checkout service's four SLOs turned into a six-scenario matrix with per-scenario thresholds, each traceable back to its SLO - is in references/runners.md.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Thresholds picked as round numbers ("p95 < 500 ms sounds fast") | Passes while the SLO is breached, or fails while it is met | Derive every number from an SLO and record the derivation (Steps 4 and 5) |
| One mega-scenario covering all endpoints | A breach tells you the service is slow, not which endpoint | One scenario per SLO boundary condition, thresholds scoped by tag |
| Closed model used for an overload scenario | Throughput falls with latency, so the intended load is never applied and the run looks healthier than production would (k6 open and closed models (opens in new window)) | Open model whenever the target is a rate |
| Only spike and stress scenarios | Misses slow degradation; extended-period reliability needs an extended-period run (k6 test types (opens in new window)) | A soak scenario for any service with an availability SLO |
| Breakpoint run against production | It is designed to reach the capacity limit, which means designed to break the service (k6 test types (opens in new window)) | Isolated environment only, and no threshold attached |
| Choosing the load tool inside the plan | Tool choice depends on stack, CI, skills, and budget, none of which the SLO document contains | Leave the plan runner-agnostic; decide the tool afterwards |
| Writing the plan directly as runner code | The plan stops being reviewable by the people who own the SLOs | Tables first, syntax later |
Limitations
Reference material for slo-load-test-plan: how the open/closed distinction appears in the two most common runners, and a full worked example turning a set of SLOs into a scenario matrix. The plan itself stays runner-agnostic; this file is where the runner-specific names and the end-to-end example live.
Open vs closed in the two most common runners
| Runner | Open model | Closed model |
|---|---|---|
| k6 | constant-arrival-rate, ramping-arrival-rate executors (k6 open and closed models (opens in new window)) | constant-vus and the other non-arrival-rate executors (k6 open and closed models (opens in new window)) |
| Gatling | injectOpen with atOnceUsers, rampUsers, constantUsersPerSec, rampUsersPerSec, stressPeakUsers (Gatling injection (opens in new window)) | injectClosed with constantConcurrentUsers, rampConcurrentUsers, incrementConcurrentUsers (Gatling injection (opens in new window)) |
Note the trap in the Gatling column: rampUsers and atOnceUsers are open-model blocks that inject a number of users over a window, while the closed-model blocks are the *ConcurrentUsers family, which hold a level of concurrency in the system (Gatling injection (opens in new window)). "Users" in a profile name does not mean closed.
Worked example
Input: a checkout service. SLOs are p95 < 300 ms on POST /api/checkout at peak, p95 < 100 ms on GET /api/orders at average, error rate < 0.1% under a 3x spike, and 99.9% availability over four weeks. Traffic mix is GET /api/orders 60%, POST /api/checkout 20%, everything else 20%. Peak is 300 RPS, average 100 RPS, with a 3x spike on sale days.
Resulting scenario matrix:
| Scenario | SLO governed | Profile | Injection | Target | Duration |
|---|---|---|---|---|---|
smoke-mix | none (gate) | Smoke | Open | 1 arrival/sec | 60 s |
orders-latency-average | p95 < 100 ms on orders | Average-load | Open | 100 arrivals/sec | 30 min |
checkout-latency-peak | p95 < 300 ms on checkout | Stress | Open | 300 arrivals/sec | 30 min |
spike-error-budget | error rate < 0.1% | Spike | Open | 900 arrivals/sec | 3 min |
soak-availability | 99.9% over four weeks | Soak | Open | 300 arrivals/sec | 4 h |
capacity-ceiling | none (capacity finding) | Breakpoint | Open | ramp to failure | until failure |
Thresholds:
| Scenario | Expression | Scope | Abort | Traceable to |
|---|---|---|---|---|
orders-latency-average | p(95)<100 | orders tag | no | orders latency SLO |
checkout-latency-peak | p(95)<300 | checkout tag | no | checkout latency SLO |
spike-error-budget | rate<0.001 | all | no (recovery is the measurement) | spike error-rate SLO |
soak-availability | rate<0.001 | all | yes, delayAbortEval: '10s' | 99.9% availability, pro-rata over 4.32M requests = 4,320 failures |
capacity-ceiling | none | n/a | n/a | capacity finding, not an SLO |
Every load number is open-model arrivals per second, because every SLO here is stated per request rather than per session.