Módulo 5: Thresholds — pasa/falla y SLOs

3. Los thresholds de k6: p95, error y checks

Descripción

Ya tienes el threshold hecho a mano en Python: evaluate_thresholds, tres reglas y un Y lógico. Esta lección te muestra cómo se escribe exactamente lo mismo en k6, con su bloque options.thresholds —la forma industrial, la que usarías en producción—. Como k6 no está instalado en este entorno, todo el JavaScript y el resumen de esta lección van como contenido rotulado, fieles a la documentación oficial de k6; los números que aparecen son coherentes con lo que tu generador de Python midió de verdad. Vas a ver el bloque thresholds para las tres métricas clave —http_req_duration: ['p(95)<200'], http_req_failed: ['rate<0.01'], checks: ['rate>0.99']—, aprender a leer cada uno en una frase, conocer el formato corto y el largo, y mapear cada threshold de k6 a la regla de Python que ya escribiste. Al terminar, el bloque thresholds de k6 no será un conjuro de un tutorial: será tu evaluate_thresholds, escrita más compacta.

Conexión con el módulo: la lección 2 construyó el threshold en Python (ejecutado); esta lo traduce a k6 (contenido). Es un puente uno a uno: la misma tríada de reglas, el mismo Y lógico, el mismo pasa/falla. La consecuencia de que un threshold de k6 falle —que k6 run salga con código 99 y el CI falle— es la lección 4. Aquí nos quedamos en la declaración: cómo se escribe la regla en el lenguaje de k6 y cómo se lee su resultado en el resumen. La métrica checks que uno de los thresholds vigila se produce con check(), cuya anatomía completa es el módulo 6; aquí la tratamos como una métrica más a la que ponerle umbral.

El mismo contrato, en dos idiomas

Cuando una empresa mexicana y una alemana firman el mismo contrato, hay dos documentos —uno en español, otro en alemán— que dicen lo mismo con obligaciones idénticas. No son dos acuerdos distintos: es un acuerdo en dos idiomas. Si lees uno, entiendes el otro, porque las cláusulas se corresponden una a una.

Tu evaluate_thresholds en Python y el bloque options.thresholds de k6 son ese contrato en dos idiomas. Dicen lo mismo —"el p95 bajo 200 ms, el error bajo 1%, los checks sobre 99%"— con obligaciones idénticas —una regla rota reprueba todo—. El módulo 2 ya te mostró que un script de k6 es tu generador de Python industrializado; los thresholds extienden ese paralelismo al veredicto. Lo que en Python es una lista de tuplas y un all_pass, en k6 es un objeto thresholds y un exit code; pero la cláusula es la misma. Por eso, si entiendes la versión de Python (que ejecutaste), entiendes la de k6 (que aquí es contenido): solo estás leyendo la otra copia del mismo contrato.

El bloque thresholds, cláusula por cláusula

Así se declara un threshold en k6. Va dentro de export const options, en una clave thresholds, que es un objeto donde cada clave es una métrica y cada valor es un arreglo de reglas (strings). Contenido rotulado:

// CONTENIDO (no ejecutado aqui): el bloque thresholds de k6.
// Ver grafana.com/docs/k6/latest/using-k6/thresholds/
export const options = {
  vus: 50,
  duration: "30s",
  thresholds: {
    // El p95 de la latencia debe estar por debajo de 200 ms.
    http_req_duration: ["p(95)<200"],
    // Menos del 1% de las peticiones puede fallar (status >= 400, timeout, etc.).
    http_req_failed: ["rate<0.01"],
    // Mas del 99% de los check() debe pasar (correccion bajo carga).
    checks: ["rate>0.99"],
  },
};

Léelo cláusula por cláusula, y reconocerás las tres frases de la lección 2:

  • http_req_duration: ["p(95)<200"] — "el p95 de la latencia debe estar por debajo de 200 ms". http_req_duration es la métrica de latencia de k6 (módulo 3); p(95) es su percentil 95; <200 es el umbral (en milisegundos, la unidad por defecto de la métrica). Es idéntica a tu p95 < p95_limit_ms en Python, con p95_limit_ms = 200.
  • http_req_failed: ["rate<0.01"] — "la tasa de peticiones fallidas debe estar por debajo del 1%". http_req_failed es una métrica de tasa (un valor entre 0 y 1); rate<0.01 exige que menos de una centésima de las peticiones falle. Es tu error_rate < 0.01.
  • checks: ["rate>0.99"] — "la tasa de checks correctos debe estar por encima del 99%". checks acumula cuántas de tus verificaciones check() pasaron; rate>0.99 exige que más del 99% lo hicieran. Es tu checks_rate > 0.99.

Tres claves, tres reglas, la misma tríada que evaluaste en Python. Y la misma severidad: si cualquiera de las tres falla, la prueba entera falla —k6, como tu all_pass, aplica un Y lógico entre thresholds—.

La sintaxis de las expresiones

Cada regla es un string con la forma agregación operador valor. La agregación dice qué resumen de la métrica mirar:

  • Para métricas de tendencia (latencias) puedes usar avg, min, max, med, p(90), p(95), p(99), p(99.9)... Casi siempre querrás un percentil, no avg (módulo 3: el promedio miente).
  • Para métricas de tasa (como http_req_failed y checks) usas rate, que es la proporción entre 0 y 1.
  • Para contadores usas count.

El operador es <, <=, >, >=, == o !=, y el valor es un número (en la unidad de la métrica: milisegundos para duraciones, proporción para tasas). Puedes poner varias reglas sobre la misma métrica en el arreglo —todas deben cumplirse—:

// CONTENIDO (no ejecutado aqui). Varias reglas sobre la misma metrica:
thresholds: {
  // El p95 bajo 200 ms Y el p99 bajo 500 ms: las DOS deben cumplirse.
  http_req_duration: ["p(95)<200", "p(99)<500"],
},

Eso es exactamente el "cuarto threshold" que añadiste en el ejercicio de la lección 2, pero declarativo: k6 evalúa las dos reglas y ambas tienen que pasar.

Leer el veredicto en el resumen

Cuando k6 termina, su resumen muestra cada threshold con una ✓ verde (pasó) o una ✗ roja (falló), junto a la métrica. Así se vería el resumen de una corrida que pasa —contenido rotulado, con números coherentes con la carga liviana que Python midió de verdad—:

// CONTENIDO (no ejecutado aqui): forma del resumen de k6 run. Ver grafana.com/docs/k6
     ✓ http_req_duration..............: p(95)=9.74ms   (umbral: p(95)<200)
     ✓ http_req_failed................: 0.00%          (umbral: rate<0.01)
     ✓ checks.........................: 100.00%        (umbral: 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

Las tres ✓ verdes de arriba son el veredicto: los tres thresholds pasaron, la prueba pasa. Y así se vería si la latencia falla —bajo carga pesada, el p95 cruza el umbral—:

// CONTENIDO (no ejecutado aqui): forma del resumen con un threshold roto. Ver grafana.com/docs/k6
     ✗ http_req_duration..............: p(95)=246.96ms (umbral: p(95)<200)
     ✓ http_req_failed................: 0.00%          (umbral: rate<0.01)
     ✓ checks.........................: 100.00%        (umbral: 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

La ✗ roja junto a http_req_duration es la luz roja de la fábrica: ese threshold no se cumplió (246.96 ≥ 200). Las otras dos siguen en verde, pero da igual —una ✗ hace fallar la prueba entera, y en la lección 4 verás que además hace que k6 run salga con código 99—. Reconocerás los números: son los mismos que tu gate de Python produjo (p95 de 9.74 ms en la carga liviana, 246.96 ms en la pesada), porque k6 y tu generador miden lo mismo.

El mapeo, lado a lado

Aquí está el contrato en sus dos idiomas, cláusula por cláusula. La columna de Python es lo que ejecutaste en la lección 2; la de k6 es el contenido de esta lección:

ThresholdPython (evaluate_thresholds, ejecutado)k6 (options.thresholds, contenido)
Latencia p95q(latencies, 95) < 200http_req_duration: ["p(95)<200"]
Tasa de errorerror_rate < 0.01http_req_failed: ["rate<0.01"]
Tasa de checkschecks_rate > 0.99checks: ["rate>0.99"]
Veredicto globalall_pass (Y lógico)✓/✗ por threshold; una ✗ reprueba
Consecuenciasys.exit(1) (lección 4)k6 run sale con 99 (lección 4)

La correspondencia es exacta, línea por línea. La única diferencia real está en la última fila —el mecanismo de la consecuencia—, y es la lección 4. Todo lo demás es el mismo contrato: tres reglas sobre las mismas tres métricas, con la misma regla de que una sola rota reprueba todo. Cuando escribas thresholds de k6 en un proyecto real, estarás escribiendo tu evaluate_thresholds en el idioma de k6.

El formato largo (un adelanto)

Todo lo anterior usa el formato corto: cada regla es un string ("p(95)<200"). k6 también tiene un formato largo, donde cada regla es un objeto con propiedades extra:

// CONTENIDO (no ejecutado aqui): formato largo. Ver grafana.com/docs/k6
thresholds: {
  http_req_duration: [
    { threshold: "p(95)<200", abortOnFail: true, delayAbortEval: "10s" },
  ],
},

El formato largo añade abortOnFail (abortar la prueba en cuanto el threshold se rompe, sin terminar) y delayAbortEval (esperar un tiempo antes de evaluar, para juntar muestras). No los necesitas todavía —el formato corto cubre el 90% de los casos— pero conviene saber que existen; los desarrollamos en la lección 7. Por ahora, quédate con el formato corto: es el que mapea limpio con tu evaluate_thresholds.

Errores comunes

Poner el threshold sobre avg en vez de un percentil. Qué pasa: alguien escribe http_req_duration: ["avg<200"] y la prueba pasa aunque la cola sea horrible. Por qué pasa: avg parece "la latencia" pero esconde la cola (módulo 3). Cómo detectarlo: si tu regla de latencia dice avg, estás gateando la métrica que miente. Cómo corregirlo: usa p(95) o p(99) —el percentil que describe al usuario que sufre, que es lo que la SLO protege—.

Confundir la unidad de la tasa (0.01 no es 1). Qué pasa: alguien escribe http_req_failed: ["rate<1"] queriendo decir "menos del 1%", pero rate<1 significa "menos del 100%" —un umbral que casi nunca falla—. Por qué pasa: se piensa en porcentaje (1%) pero la métrica es una proporción (0.01). Cómo detectarlo: si tu threshold de error nunca falla ni con la app rota, revisa la unidad. Cómo corregirlo: usa la proporción —1% es 0.01, 0.1% es 0.001—; rate<0.01 es "menos del 1%".

Creer que el resumen de k6 de esta lección se ejecutó aquí. Qué pasa: alguien cita las ✓/✗ o los números del resumen como "lo que midió la guía". Por qué pasa: el resumen se ve muy real y sus números coinciden con los de Python (a propósito). Cómo detectarlo: k6 no está instalado; todo bloque de k6 está rotulado como contenido, y lo ejecutado siempre viene con un comando python3.14 .... Cómo corregirlo: recuerda que la ✓/✗ de k6 es la forma oficial del veredicto; el veredicto que de verdad corrió es el GATE: PASS/FAIL de tu gate en Python.

Ejercicios

Ejercicio 1 — Escribe el bloque. Escribe el bloque options.thresholds de k6 (formato corto) para estas tres reglas: el p99 de la latencia bajo 500 ms; menos del 0.5% de errores; más del 95% de checks correctos.

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

Ojo con las unidades: 0.5% es 0.005 (no 0.5), y 95% es 0.95 (no 95). Las tasas van como proporción entre 0 y 1.

Ejercicio 2 — Lee el resumen. Un resumen de k6 muestra ✗ http_req_failed: 3.20% (umbral: rate<0.01) y ✓ http_req_duration: p(95)=120ms (umbral: p(95)<200). (a) ¿Qué threshold falló y por qué? (b) ¿La prueba pasa o falla? (c) ¿Qué te dice que la latencia esté verde pero el error rojo?

Ver solución
  • (a) Falló http_req_failed: la tasa medida (3.20% = 0.032) no es menor que el umbral (0.01 = 1%). La ✗ roja lo marca.
  • (b) Falla. Un solo threshold roto reprueba la prueba entera (Y lógico), aunque la latencia esté verde.
  • (c) Que la app responde rápido pero mal: el 96.8% de las peticiones que sí funcionaron lo hicieron con buena latencia (p95=120 ms), pero un 3.2% falló del todo. Una latencia excelente no compensa una tasa de error alta —una respuesta veloz que devuelve un 500 no le sirvió a nadie (módulo 3)—. Por eso ambos thresholds existen y ambos deben pasar.

Ejercicio 3 — Corto vs largo. Reescribe este threshold del formato corto al formato largo, añadiéndole abortOnFail: true: http_req_duration: ["p(95)<200"]. ¿Qué gana al abortar en fallo?

Ver solución
http_req_duration: [
  { threshold: "p(95)<200", abortOnFail: true },
],

Gana cortar temprano: si durante la prueba el p95 ya supera 200 ms, k6 no espera a terminar los 30 segundos —aborta en cuanto el threshold se evalúa como roto—. Eso ahorra tiempo y recursos cuando la app ya está claramente fallando: no tiene sentido seguir martillando un sistema que ya reprobó. (Se desarrolla en la lección 7; delayAbortEval sirve para darle unos segundos a que se acumulen muestras antes de decidir.)

Resumen y siguiente paso

k6 declara los thresholds en options.thresholds, un objeto donde cada clave es una métrica y cada valor un arreglo de reglas. Las tres reglas clave son http_req_duration: ["p(95)<200"] (el p95 bajo 200 ms), http_req_failed: ["rate<0.01"] (menos del 1% de error) y checks: ["rate>0.99"] (más del 99% de checks correctos) —la misma tríada que evaluaste en Python, en el idioma de k6—. Cada regla es agregación operador valor; casi siempre quieres un percentil (p(95)), no avg; y las tasas van como proporción (1% es 0.01). En el resumen, cada threshold sale con una ✓ verde o una ✗ roja, y una sola ✗ reprueba la prueba entera (Y lógico), igual que tu all_pass.

El mapeo es uno a uno: 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"]. Es el mismo contrato en dos idiomas —el de Python que ejecutaste y el de k6 que aquí es contenido rotulado—.

Antes de avanzar deberías poder: escribir un bloque thresholds para reglas dadas, cuidando las unidades; leer un resumen de k6 y decir qué threshold falló y si la prueba pasa; y mapear cada threshold de k6 a su regla en evaluate_thresholds. Lo que sigue, en la lección 4, es la consecuencia que hemos venido postergando: qué pasa mecánicamente cuando un threshold falla. La respuesta es un código de salida distinto de cerosys.exit(1) en Python, 99 en k6— y es lo que convierte el veredicto en un gate que un pipeline de CI respeta. Ahí el pasa/falla cobra dientes.

Recursos

  • k6 — Thresholds — la referencia oficial del bloque options.thresholds: la sintaxis de las expresiones, el formato corto y el largo, y cómo se muestran en el resumen. La fuente de todo el contenido de k6 de esta lección.
  • k6 — Métricas incorporadas — qué son http_req_duration, http_req_failed y checks, las métricas sobre las que se ponen los thresholds. Confirma sus nombres y unidades.
  • k6 — check() — cómo se produce la métrica checks que el tercer threshold vigila; su uso a fondo para verificar corrección bajo carga es el módulo 6.
  • statistics.quantiles — documentación de Python — el cálculo del p95 en Python que corresponde a la agregación p(95) de k6. El mismo percentil, calculado a mano.