Module 5: Thresholds Pass Fail And Slos

3. k6's thresholds: p95, error, and checks

Overview

You already have the threshold made by hand in Python: evaluate_thresholds, three rules and a logical AND. This lesson shows you how exactly the same thing is written in k6, with its options.thresholds block —the industrial form, the one you'd use in production—. Since k6 isn't installed in this environment, all the JavaScript and the summary in this lesson go as labeled content, faithful to k6's official documentation; the numbers that appear are consistent with what your Python generator actually measured. You'll see the thresholds block for the three key metrics —http_req_duration: ['p(95)<200'], http_req_failed: ['rate<0.01'], checks: ['rate>0.99']—, learn to read each one in a sentence, meet the short and long formats, and map each k6 threshold to the Python rule you already wrote. By the end, k6's thresholds block won't be a tutorial incantation: it'll be your evaluate_thresholds, written more compactly.

Connection to the module: lesson 2 built the threshold in Python (executed); this one translates it to k6 (content). It's a one-to-one bridge: the same triad of rules, the same logical AND, the same pass/fail. The consequence of a k6 threshold failing —that k6 run exits with code 99 and CI fails— is lesson 4. Here we stay on the declaration: how the rule is written in k6's language and how its result is read in the summary. The checks metric one of the thresholds watches is produced with check(), whose complete anatomy is module 6; here we treat it as just another metric to put a limit on.

The same contract, in two languages

When a Mexican company and a German one sign the same contract, there are two documents —one in Spanish, one in German— that say the same thing with identical obligations. They're not two different agreements: it's one agreement in two languages. If you read one, you understand the other, because the clauses correspond one to one.

Your evaluate_thresholds in Python and k6's options.thresholds block are that contract in two languages. They say the same —"the p95 below 200 ms, the error below 1%, the checks above 99%"— with identical obligations —a broken rule fails everything—. Module 2 already showed you that a k6 script is your Python generator industrialized; the thresholds extend that parallelism to the verdict. What in Python is a list of tuples and an all_pass, in k6 is a thresholds object and an exit code; but the clause is the same. That's why, if you understand the Python version (which you executed), you understand the k6 one (which here is content): you're just reading the other copy of the same contract.

The thresholds block, clause by clause

This is how a threshold is declared in k6. It goes inside export const options, in a thresholds key, which is an object where each key is a metric and each value is an array of rules (strings). Labeled content:

// CONTENT (not run here): k6's thresholds block.
// See grafana.com/docs/k6/latest/using-k6/thresholds/
export const options = {
  vus: 50,
  duration: "30s",
  thresholds: {
    // The latency p95 must be below 200 ms.
    http_req_duration: ["p(95)<200"],
    // Less than 1% of requests may fail (status >= 400, timeout, etc.).
    http_req_failed: ["rate<0.01"],
    // More than 99% of the check()s must pass (correctness under load).
    checks: ["rate>0.99"],
  },
};

Read it clause by clause, and you'll recognize the three sentences from lesson 2:

  • http_req_duration: ["p(95)<200"] — "the latency p95 must be below 200 ms". http_req_duration is k6's latency metric (module 3); p(95) is its 95th percentile; <200 is the limit (in milliseconds, the metric's default unit). It's identical to your p95 < p95_limit_ms in Python, with p95_limit_ms = 200.
  • http_req_failed: ["rate<0.01"] — "the failed-request rate must be below 1%". http_req_failed is a rate metric (a value between 0 and 1); rate<0.01 demands that less than one hundredth of the requests fail. It's your error_rate < 0.01.
  • checks: ["rate>0.99"] — "the correct-checks rate must be above 99%". checks accumulates how many of your check() verifications passed; rate>0.99 demands that more than 99% did. It's your checks_rate > 0.99.

Three keys, three rules, the same triad you evaluated in Python. And the same severity: if any of the three fails, the whole test fails —k6, like your all_pass, applies a logical AND between thresholds—.

The syntax of the expressions

Each rule is a string of the form aggregation operator value. The aggregation says which summary of the metric to look at:

  • For trend metrics (latencies) you can use avg, min, max, med, p(90), p(95), p(99), p(99.9)... Almost always you'll want a percentile, not avg (module 3: the average lies).
  • For rate metrics (like http_req_failed and checks) you use rate, which is the proportion between 0 and 1.
  • For counters you use count.

The operator is <, <=, >, >=, ==, or !=, and the value is a number (in the metric's unit: milliseconds for durations, proportion for rates). You can put several rules on the same metric in the array —all must be met—:

// CONTENT (not run here). Several rules on the same metric:
thresholds: {
  // The p95 below 200 ms AND the p99 below 500 ms: BOTH must be met.
  http_req_duration: ["p(95)<200", "p(99)<500"],
},

That's exactly the "fourth threshold" you added in the lesson-2 exercise, but declarative: k6 evaluates the two rules and both have to pass.

Reading the verdict in the summary

When k6 finishes, its summary shows each threshold with a green ✓ (passed) or a red ✗ (failed), next to the metric. This is what the summary of a run that passes would look like —labeled content, with numbers consistent with the light load Python actually measured—:

// CONTENT (not run here): shape of the k6 run summary. See grafana.com/docs/k6
     ✓ http_req_duration..............: p(95)=9.74ms   (threshold: p(95)<200)
     ✓ http_req_failed................: 0.00%          (threshold: rate<0.01)
     ✓ checks.........................: 100.00%        (threshold: rate>0.99)

     checks.........................: 100.00%  ✓ 20000    ✗ 0
     http_req_duration..............: avg=4.1ms  min=2.6ms  med=3.8ms  max=61ms  p(90)=6.9ms  p(95)=9.74ms
     http_req_failed................: 0.00%    ✓ 0        ✗ 20000
     http_reqs......................: 20000    666.6/s
     iterations.....................: 20000    666.6/s
     vus............................: 50       min=50     max=50

The three green ✓ at the top are the verdict: the three thresholds passed, the test passes. And this is what it would look like if the latency fails —under heavy load, the p95 crosses the limit—:

// CONTENT (not run here): shape of the summary with a broken threshold. See grafana.com/docs/k6
     ✗ http_req_duration..............: p(95)=246.96ms (threshold: p(95)<200)
     ✓ http_req_failed................: 0.00%          (threshold: rate<0.01)
     ✓ checks.........................: 100.00%        (threshold: rate>0.99)

     checks.........................: 100.00%  ✓ 20000    ✗ 0
     http_req_duration..............: avg=110ms  min=3ms   med=95ms  max=540ms p(90)=210ms p(95)=246.96ms
     http_req_failed................: 0.00%    ✓ 0        ✗ 20000
     http_reqs......................: 20000    650.1/s

The red ✗ next to http_req_duration is the factory's red light: that threshold wasn't met (246.96 ≥ 200). The other two are still green, but it doesn't matter —one ✗ fails the whole test, and in lesson 4 you'll see it also makes k6 run exit with code 99—. You'll recognize the numbers: they're the same ones your Python gate produced (p95 of 9.74 ms under light load, 246.96 ms under heavy load), because k6 and your generator measure the same thing.

The mapping, side by side

Here's the contract in its two languages, clause by clause. The Python column is what you executed in lesson 2; the k6 one is the content of this lesson:

ThresholdPython (evaluate_thresholds, executed)k6 (options.thresholds, content)
Latency p95q(latencies, 95) < 200http_req_duration: ["p(95)<200"]
Error rateerror_rate < 0.01http_req_failed: ["rate<0.01"]
Checks ratechecks_rate > 0.99checks: ["rate>0.99"]
Global verdictall_pass (logical AND)✓/✗ per threshold; one ✗ fails
Consequencesys.exit(1) (lesson 4)k6 run exits with 99 (lesson 4)

The correspondence is exact, line by line. The only real difference is in the last row —the mechanism of the consequence—, and it's lesson 4. Everything else is the same contract: three rules on the same three metrics, with the same rule that a single broken one fails everything. When you write k6 thresholds in a real project, you'll be writing your evaluate_thresholds in k6's language.

The long format (a preview)

Everything above uses the short format: each rule is a string ("p(95)<200"). k6 also has a long format, where each rule is an object with extra properties:

// CONTENT (not run here): long format. See grafana.com/docs/k6
thresholds: {
  http_req_duration: [
    { threshold: "p(95)<200", abortOnFail: true, delayAbortEval: "10s" },
  ],
},

The long format adds abortOnFail (abort the test as soon as the threshold breaks, without finishing) and delayAbortEval (wait a while before evaluating, to gather samples). You don't need them yet —the short format covers 90% of cases— but it's worth knowing they exist; we develop them in lesson 7. For now, stick with the short format: it's the one that maps cleanly to your evaluate_thresholds.

Common mistakes

Putting the threshold on avg instead of a percentile. What happens: someone writes http_req_duration: ["avg<200"] and the test passes even though the tail is horrible. Why it happens: avg looks like "the latency" but hides the tail (module 3). How to detect it: if your latency rule says avg, you're gating the metric that lies. How to fix it: use p(95) or p(99) —the percentile that describes the suffering user, which is what the SLO protects—.

Confusing the rate unit (0.01 isn't 1). What happens: someone writes http_req_failed: ["rate<1"] meaning "less than 1%," but rate<1 means "less than 100%" —a limit that almost never fails—. Why it happens: they think in percent (1%) but the metric is a proportion (0.01). How to detect it: if your error threshold never fails even with the app broken, check the unit. How to fix it: use the proportion —1% is 0.01, 0.1% is 0.001—; rate<0.01 is "less than 1%".

Believing this lesson's k6 summary ran here. What happens: someone cites the ✓/✗ or the summary's numbers as "what the guide measured." Why it happens: the summary looks very real and its numbers match Python's (on purpose). How to detect it: k6 isn't installed; every k6 block is labeled as content, and the executed stuff always comes with a python3.14 ... command. How to fix it: remember that k6's ✓/✗ is the official form of the verdict; the verdict that actually ran is the GATE: PASS/FAIL of your Python gate.

Exercises

Exercise 1 — Write the block. Write k6's options.thresholds block (short format) for these three rules: the latency p99 below 500 ms; less than 0.5% errors; more than 95% correct checks.

See solution
thresholds: {
  http_req_duration: ["p(99)<500"],
  http_req_failed: ["rate<0.005"],   // 0.5% = 0.005
  checks: ["rate>0.95"],             // 95% = 0.95
},

Mind the units: 0.5% is 0.005 (not 0.5), and 95% is 0.95 (not 95). The rates go as a proportion between 0 and 1.

Exercise 2 — Read the summary. A k6 summary shows ✗ http_req_failed: 3.20% (threshold: rate<0.01) and ✓ http_req_duration: p(95)=120ms (threshold: p(95)<200). (a) Which threshold failed and why? (b) Does the test pass or fail? (c) What does it tell you that the latency is green but the error red?

See solution
  • (a) http_req_failed failed: the measured rate (3.20% = 0.032) isn't less than the threshold (0.01 = 1%). The red ✗ marks it.
  • (b) It fails. A single broken threshold fails the whole test (logical AND), even though the latency is green.
  • (c) That the app responds fast but badly: 96.8% of the requests that did work did so with good latency (p95=120 ms), but 3.2% failed entirely. An excellent latency doesn't compensate for a high error rate —a fast response that returns a 500 served no one (module 3)—. That's why both thresholds exist and both must pass.

Exercise 3 — Short vs long. Rewrite this threshold from the short format to the long format, adding abortOnFail: true: http_req_duration: ["p(95)<200"]. What do you gain by aborting on failure?

See solution
http_req_duration: [
  { threshold: "p(95)<200", abortOnFail: true },
],

You gain cutting early: if during the test the p95 already exceeds 200 ms, k6 doesn't wait to finish the 30 seconds —it aborts as soon as the threshold is evaluated as broken—. That saves time and resources when the app is already clearly failing: there's no point in continuing to hammer a system that already failed. (It's developed in lesson 7; delayAbortEval serves to give it a few seconds for samples to accumulate before deciding.)

Summary and next step

k6 declares thresholds in options.thresholds, an object where each key is a metric and each value an array of rules. The three key rules are http_req_duration: ["p(95)<200"] (the p95 below 200 ms), http_req_failed: ["rate<0.01"] (less than 1% error), and checks: ["rate>0.99"] (more than 99% correct checks) —the same triad you evaluated in Python, in k6's language—. Each rule is aggregation operator value; almost always you want a percentile (p(95)), not avg; and the rates go as a proportion (1% is 0.01). In the summary, each threshold comes out with a green ✓ or a red ✗, and a single ✗ fails the whole test (logical AND), just like your all_pass.

The mapping is one to one: q(latencies,95)<200http_req_duration:["p(95)<200"], error_rate<0.01http_req_failed:["rate<0.01"], checks_rate>0.99checks:["rate>0.99"]. It's the same contract in two languages —Python's, which you executed, and k6's, which here is labeled content—.

Before moving on you should be able to: write a thresholds block for given rules, minding the units; read a k6 summary and say which threshold failed and whether the test passes; and map each k6 threshold to its rule in evaluate_thresholds. What comes next, in lesson 4, is the consequence we've been postponing: what happens mechanically when a threshold fails. The answer is a nonzero exit codesys.exit(1) in Python, 99 in k6— and it's what turns the verdict into a gate a CI pipeline respects. There the pass/fail gets teeth.

Resources

  • k6 — Thresholds — the official reference for the options.thresholds block: the syntax of the expressions, the short and long formats, and how they're shown in the summary. The source of all the k6 content in this lesson.
  • k6 — Built-in metrics — what http_req_duration, http_req_failed, and checks are, the metrics the thresholds are put on. Confirms their names and units.
  • k6 — check() — how the checks metric the third threshold watches is produced; its in-depth use for verifying correctness under load is module 6.
  • statistics.quantiles — Python documentation — the p95 computation in Python that corresponds to k6's p(95) aggregation. The same percentile, computed by hand.