What you'll build
A load test that doesn't just measure latency but enforces it: a
run-level SLO gate that evaluates k6-style expressions (p95<500,
rate<0.05) against the metrics the whole run collected, and exits
non-zero when the budget is blown — so CI goes red instead of merging a
regression.
We'll go in three steps: a baseline HTTP test, an SLO gate in the config's
after: block, and the same gate wired into CI. Everything here is free
and open source — std/thresholds@v1 ships in the perfscale engine on
every plan (v0.8.1+).
Prerequisites
- The
perfscaleCLI (install instructions):npm install -g @perfscale/exe, orperfscale self-updateto be current (thresholds need v0.8.1+ — check withperfscale --version) - Any HTTP endpoint to test. A local
python3 -m http.server 8080in some static directory is enough to follow along.
Step 1 — A baseline run
Thresholds gate on run metrics, so first you need a run that emits some.
Create test.yaml:
steps:
- name: get index
uses: std/http@v1
with:
url: http://127.0.0.1:8080/
method: GET
check:
status: 200
And config.yaml:
vus: 10
duration: 30s
Run it:
perfscale run -f test.yaml -c config.yaml
The end-of-run summary prints the metric families you'll gate on:
http_req_duration (a sample histogram with avg/p50/p95/p99/max),
http_req_failed (a failure-rate metric the runner derives automatically —
every step invocation records a 0/1 sample under <family>_failed), plus
counters like iterations. Note the names in your summary — a gate can
only reference metrics the run actually collected.
One thing to know before moving on: inline check: failures are load-test
feedback — they're printed and counted, but the run still exits 0.
Thresholds are how you turn those numbers into a hard pass/fail.
Step 2 — Add the SLO gate
Gates live in the config file's after: block — they evaluate once,
after every VU has stopped, over the same HDR histograms the summary prints.
Gate numbers match summary numbers, always.
Replace config.yaml:
vus: 10
duration: 30s
after:
- name: slo gate
use: std/thresholds@v1
with:
http_req_duration: ["p95<500", "avg<300", "max<2000"]
http_req_failed: ["rate<0.01"]
severity: fail
message: "checkout SLO"
Run again. If every expression holds, the gate logs PASS and the run exits
0. If one is violated you'll get a summary line like
http_req_duration p95=612ms ≥ 500ms; checkout SLO on stderr — and, because
severity: fail, the run exits non-zero.
The moving parts:
- Expressions are
<agg><op><number>: agg ∈avg,min,max,p50,p90,p95,p99,count,rate; op ∈<,<=,>,>=,==,!=; the number is a plain float, no units. Whitespace is tolerated —p95 < 500parses fine. - Which aggregates apply depends on the metric kind. Sample metrics
(
http_req_duration,grpc_msg_rtt,ws_msg_rtt,db_query_duration, …) take the percentile/avg/min/max aggs;countis the number of samples. Counter metrics take onlycount(final value). Failure metrics (http_req_failed, …) takerate(failed/total in0.0..=1.0) andcount. severitydecides what a violation becomes:fail(default) exits non-zero;warnandinfoprint but exit zero — useful for soft budgets you're still tuning.messageis an optional label (interpolated) appended to the violation summary — put the SLO name there so a red log line tells you which contract broke.
Several gates compose: add more std/thresholds@v1 steps (e.g. a strict
fail gate for hard SLOs, a warn gate for aspirational ones) — the worst
status wins.
Step 3 — Machine-readable results and CI
Each gate's output (available via outputs / __last__) is JSON:
{ "status": "fail",
"message": "http_req_duration p95=612ms ≥ 500ms; checkout SLO",
"violations": [{ "metric": "http_req_duration", "expr": "p95<500", "actual": 612.0 }] }
The same shape lands in the run summary JSON
(perfscale run --summary-export summary.json) under a thresholds field,
so dashboards and pipeline annotations can read gate results without
parsing stdout.
Gating CI is then just "run the test": on GitHub, the
Perfscale/github-action step
installs the pinned CLI and runs your file + config; on GitLab,
include: the templates from
Perfscale/gitlab-ci. A violated
fail gate means a non-zero exit, which means a red job — no wrapper
scripts, no grep over logs. The full wiring (REST API triggers, webhooks
back into CI) is in CI/CD integration.
Errors are loud, not silent
A broken gate never silently passes CI. An unknown metric name, an empty
with:, an unparseable expression, or an aggregate that doesn't apply to
the metric kind — all of these fail the gate and the run with an error
listing the metrics that are present. If a gate is green, the metric
existed, the expression parsed, and the number held.
Next steps
std/thresholds@v1reference — full expression grammar, metric kinds, output shape- Metrics and failure rates —
how
<family>_failedseries are derived - SLO Gates Go Native — the announcement post with the background story