Módulo 8: Proyecto — Prueba de carga de la API de Reservo

4. Los thresholds ligados al SLO

Descripción

El perfil de la lección 3 produjo un p95 de 245 ms bajo stress. Pero un número no es un veredicto: ¿245 ms está bien o mal? La respuesta no la da la técnica, la da el negocio, en forma de un SLO (Service Level Objective) —la promesa de rendimiento que Reservo le hace a sus usuarios—. En esta lección le ponemos al capstone sus thresholds: http_req_duration: ['p(95)<200'], http_req_failed: ['rate<0.01'] y checks: ['rate>0.99'], cada uno atado a un SLO concreto. Vemos de dónde sale cada número (por qué 200 ms y no 150 o 500), por qué el threshold se evalúa sobre la corrida entera como hace k6, y cómo el mismo p95 medido pasa o falla según el SLO que elijas —la prueba de que el umbral es una decisión de negocio, no un detalle técnico—. Y confirmamos el exit code que lo hace un gate: 1 en el gate de Python, 99 en k6.

Conexión con el módulo: esta es la tercera pieza del capstone —el veredicto que convierte las métricas de la lección 3 en un pasa/falla—. Reúsa por entero el módulo 5: qué es un threshold, cómo se escribe en k6, el exit code ≠ 0 que hace fallar el CI, y cómo se elige el umbral a partir del SLO/SLA. Aquí no re-explicamos el mecanismo del exit code; lo usamos para ligar los thresholds del capstone a los SLO de Reservo. La lección 5 leerá a fondo las métricas; la lección 6 ejecutará el gate completo (evaluar + salir con código + exportar). Aquí nos concentramos en los umbrales y en su origen.

La línea de meta que define el negocio, no el corredor

Imagina una carrera. Un corredor cruza la meta en 3 horas 45 minutos. ¿Es un buen tiempo? La pregunta no tiene respuesta hasta que alguien define la línea de corte. Si es la maratón de un club amateur, 3:45 clasifica de sobra. Si es la clasificación para los Juegos Olímpicos, ni de cerca. El mismo tiempo, dos veredictos, según la marca que la competencia decidió exigir. La línea de corte no la pone el corredor mirando su reloj; la pone la organización según lo que la carrera significa. Y una vez puesta, es binaria: cruzaste antes o no.

Un threshold es esa línea de corte, y el SLO es quien la define. Tu prueba mide p(95) = 218 ms —eso es el tiempo del corredor, un hecho—. Si el SLO de Reservo dice "el 95% de las cotizaciones responde en menos de 200 ms", ese p95 falla (cruzó tarde). Si el SLO fuera 300 ms, el mismo p95 pasaría. El número del umbral no sale de la prueba ni de tu intuición técnica; sale de lo que el negocio le prometió al usuario. Por eso un threshold sin un SLO detrás es una línea de meta pintada al azar: hace pasar o fallar builds sin que nadie sepa por qué. Elegir bien el umbral —ligarlo a una promesa real— es lo que hace que el gate proteja algo.

Los thresholds en k6 (contenido)

Aquí están los thresholds del capstone, añadidos al options de la lección 3. Recuerda: contenido rotulado, fiel a la documentación de k6, no ejecutado aquí.

// CONTENIDO (no ejecutado aquí): k6 no está instalado.
// Referencia: grafana.com/docs/k6 (options → thresholds).
export const options = {
  stages: [ /* smoke -> load -> stress -> ramp-down, lección 3 */ ],

  thresholds: {
    // LATENCIA: el 95% de las peticiones bajo 200 ms (el SLO de latencia).
    http_req_duration: ['p(95)<200'],
    // DISPONIBILIDAD: menos del 1% de las peticiones falla (el SLO de errores).
    http_req_failed: ['rate<0.01'],
    // CORRECCIÓN: más del 99% de los checks pasa (las respuestas son correctas bajo carga).
    checks: ['rate>0.99'],
  },
};

Cada threshold es una regla métrica: [expresión], y si cualquiera se rompe, k6 run sale con un código ≠ 0 y el build se pone rojo. Los tres cubren las tres preguntas que una prueba de carga responde:

  • http_req_duration: ['p(95)<200'] — ¿es rápido? El p95 de la latencia (el percentil que representa al usuario de la cola, M3) debe quedar bajo 200 ms. Es el SLO de latencia: la promesa de velocidad.
  • http_req_failed: ['rate<0.01'] — ¿está disponible? Menos del 1% de las peticiones puede fallar. Es el SLO de disponibilidad: la promesa de que el sistema responde. Nota que latencia y disponibilidad son independientes —el sistema puede estar lento (p95 roto) pero disponible (0% de error), como vimos en la lección 3—.
  • checks: ['rate>0.99'] — ¿es correcto? Más del 99% de los check() (status 200, precio correcto, reserva confirmada) debe pasar. Es el SLO de corrección: la promesa de que las respuestas son buenas, no solo rápidas. Un 200 veloz con un precio equivocado rompe este threshold aunque los otros dos pasen.

De dónde sale cada número (el SLO)

Los tres números —200 ms, 1%, 99%— no son arbitrarios; cada uno traduce una promesa de negocio. Así se razona cada uno para Reservo (M5):

  • 200 ms de p95. La investigación de UX dice que por debajo de ~100 ms una respuesta se siente instantánea, y hasta ~200-300 ms el usuario la percibe como fluida; pasado eso, empieza a notar la espera. Reservo promete que cotizar se sienta ágil, así que fija el SLO de latencia en p(95) < 200 ms: el 95% de los usuarios ve el precio en menos de un quinto de segundo. Se elige el p95 (no el promedio) porque el promedio esconde a los usuarios de la cola, que son los que se frustran (M3).
  • 1% de errores. Un SLO de disponibilidad típico para un servicio web se expresa en "nueves": 99% disponible = hasta 1% de error tolerado. Para un flujo de reservas eso ya es generoso (un 1% de cotizaciones fallidas es mucho); muchos servicios apuntan a 99.9%. El capstone usa rate < 0.01 como línea de partida razonable, sabiendo que el negocio podría exigir más.
  • 99% de checks. Bajo carga, aceptamos que una fracción mínima de respuestas pueda salir mal (una condición de carrera rara, un timeout), pero exigimos que casi todas sean correctas: rate > 0.99. Si más del 1% de las cotizaciones devuelve un precio equivocado, algo está roto en la lógica bajo concurrencia, y eso debe fallar el build.

La lección de fondo: el umbral es una decisión de negocio disfrazada de número técnico. Cambiar 200 por 300 no es un ajuste de configuración; es cambiar lo que le prometes al usuario. Por eso el SLO se acuerda con el negocio, no lo inventa quien escribe la prueba.

Por qué sobre la corrida entera (como k6)

Un detalle importante que hereda el capstone de M4: los thresholds de k6 se evalúan sobre todas las peticiones de la corrida completa (smoke + load + stress + ramp-down agregadas), no sobre una etapa suelta. El p(95)<200 mira el p95 de todas las peticiones juntas. Como las fases de menor carga (smoke, load) aportan peticiones rápidas, el p95 agregado sale un poco más bajo que el p95 del pico solo —pero si el stress es lo bastante malo, el agregado igual cruza el umbral—. El generador de Python hace exactamente esto: mide el p95 por etapa (para leer la forma, lección 3) y el p95 de la corrida entera, y evalúa el threshold sobre este último, para ser fiel a cómo k6 decide.

El mismo p95, dos veredictos (ejecutado)

Para probar que el umbral es lo que manda, separemos medir de juzgar. La corrida ya escribió sus métricas en un results.json (eso es la lección 6); aquí un pequeño evaluador las lee y las juzga contra un SLO. Es el mismo patrón de M5: el veredicto es una función de las métricas y del umbral.

# check_thresholds.py — lee un results.json y lo juzga contra el SLO (M5).
# Uso: python3.14 check_thresholds.py <results.json> [p95_limit_ms]
import json, sys

report = json.load(open(sys.argv[1]))
P95_LIMIT = float(sys.argv[2]) if len(sys.argv) > 2 else report["slo"]["p95_ms"]
agg = report["aggregate"]
rows = [
    ("http_req_duration: p(95)", agg["p95"] < P95_LIMIT),
    ("http_req_failed:   rate",  agg["error_rate"] < report["slo"]["error_rate"]),
    ("checks:            rate",  agg["checks_rate"] > report["slo"]["checks_rate"]),
]
# ...imprime la tabla y sale con 0 (todos PASS) o 1 (alguno FAIL).
sys.exit(0 if all(ok for _, ok in rows) else 1)

Primero, la corrida sana (/quote) contra el SLO de 200 ms:

Qué esperar — el p95 agregado (21.72 ms) está muy por debajo de 200; los tres thresholds pasan y sale con código 0:

$ python3.14 check_thresholds.py green.json
SLO: p(95) < 200ms  ·  error < 1%  ·  checks > 99%
corrida: /quote  (80082 peticiones)
------------------------------------------------------------
http_req_duration: p(95)       21.72ms  < 200ms   PASS
http_req_failed:   rate          0.00%  < 1%      PASS
checks:            rate        100.00%  > 99%     PASS
------------------------------------------------------------
VEREDICTO: PASS  (exit code 0)
$ echo $?
0

Ahora la corrida degradada (/quote_cpu) contra el mismo SLO de 200 ms:

Qué esperar — el p95 agregado (218.51 ms) cruza el umbral; el threshold de latencia falla y sale con código 1:

$ python3.14 check_thresholds.py red.json
SLO: p(95) < 200ms  ·  error < 1%  ·  checks > 99%
corrida: /quote_cpu  (9000 peticiones)
------------------------------------------------------------
http_req_duration: p(95)      218.51ms  < 200ms   FAIL
http_req_failed:   rate          0.00%  < 1%      PASS
checks:            rate        100.00%  > 99%     PASS
------------------------------------------------------------
VEREDICTO: FAIL  (exit code 1)
$ echo $?
1

Y ahora la prueba de que el umbral es lo que manda: las mismas métricas de la corrida degradada, pero contra un SLO más laxo de 300 ms:

Qué esperar — con la línea de corte en 300 ms, el p95 de 218.51 ahora pasa; el mismo número, veredicto opuesto, solo porque cambió la promesa:

$ python3.14 check_thresholds.py red.json 300
SLO: p(95) < 300ms  ·  error < 1%  ·  checks > 99%
corrida: /quote_cpu  (9000 peticiones)
------------------------------------------------------------
http_req_duration: p(95)      218.51ms  < 300ms   PASS
http_req_failed:   rate          0.00%  < 1%      PASS
checks:            rate        100.00%  > 99%     PASS
------------------------------------------------------------
VEREDICTO: PASS  (exit code 0)
$ echo $?
0

El mismo p95 medido (218.51 ms) falla contra un SLO de 200 ms y pasa contra uno de 300 ms. La prueba no cambió, las métricas no cambiaron —cambió la línea de corte, y con ella el veredicto y el exit code—. Eso demuestra que el umbral es una decisión de negocio: elegir 200 en vez de 300 es elegir prometer un quinto de segundo en vez de casi un tercio. Ponerlo al azar es pintar la meta donde caiga.

El exit code que lo hace un gate

El veredicto solo detiene un deploy si se traduce a un código de salida (M5). El gate de Python sale con 1 cuando un threshold falla (lo viste arriba: $? = 1 en la corrida roja). k6 hace lo mismo con un detalle: cuando uno o más thresholds fallan, k6 run sale con el código 99 (ThresholdsHaveFailed), reservado para "la prueba corrió bien pero no cumplió los umbrales". Para el pipeline la diferencia entre 1 y 99 no importa: ambos son ≠ 0, así que el paso falla y el deploy se bloquea. Un threshold de rendimiento roto reprueba el build exactamente como un test unitario roto: los dos salen con código ≠ 0.

Errores comunes

Elegir el umbral al azar (o copiarlo de un tutorial). Qué pasa: se pone p(95)<500 porque "sonaba razonable" o porque estaba en un ejemplo, sin preguntar qué promete el negocio. Por qué pasa: es más fácil inventar un número que acordar un SLO. Cómo detectarlo: si nadie puede decir por qué 500 y no 200, el umbral es arbitrario. Cómo corregirlo: liga cada threshold a un SLO concreto —una promesa de latencia, disponibilidad o corrección que el negocio hace al usuario—. Un gate con un umbral arbitrario protege algo arbitrario; uno ligado al SLO protege la promesa real.

Poner solo el threshold de latencia y olvidar error y checks. Qué pasa: se vigila http_req_duration y se da por buena una corrida rápida, aunque el 5% de las peticiones falle o devuelva precios rotos. Por qué pasa: la latencia es la métrica estrella y roba la atención. Cómo detectarlo: si tus thresholds solo tienen una regla, cubres una sola de las tres preguntas. Cómo corregirlo: pon los tres —latencia, disponibilidad y corrección—. Un sistema rápido que falla o miente no es un sistema bueno; los tres thresholds juntos son el veredicto completo.

Confundir el p95 del pico con el p95 que evalúa el threshold. Qué pasa: se ve que el stress dio p95 = 245 ms y se espera que el threshold reporte 245, pero k6 (y el gate) reportan el agregado (218). Por qué pasa: se olvida que el threshold mira la corrida entera, no una etapa. Cómo detectarlo: si tu p95 "del threshold" no coincide con el del pico, es porque el agregado incluye las fases rápidas. Cómo corregirlo: recuerda que el threshold evalúa todas las peticiones juntas; el p95 por etapa es para leer la forma, el agregado es para el veredicto. Ambos son reales; miden cosas distintas (M4).

Ejercicios

Ejercicio 1 — ¿Pasa o falla? Para una corrida con p95 agregado = 180 ms, tasa de error = 0.4% y checks = 99.6%, di si cada threshold pasa y cuál es el veredicto global, contra el SLO p(95)<200, rate<0.01, checks>0.99.

Ver solución
  • http_req_duration: p(95)<200: 180 < 200 → PASS.
  • http_req_failed: rate<0.01: 0.4% = 0.004 < 0.01 → PASS.
  • checks: rate>0.99: 99.6% = 0.996 > 0.99 → PASS.

Los tres pasan → veredicto PASS, exit code 0. La corrida cumple el SLO en las tres dimensiones: es rápida, disponible y correcta. El deploy queda autorizado.

Ejercicio 2 — El mismo número, dos negocios. Una corrida mide p95 = 250 ms. (a) ¿Pasa contra un SLO de p(95)<200? (b) ¿Contra uno de p(95)<300? (c) ¿Qué le dirías al equipo que quiere subir el SLO a 300 solo para que la prueba deje de fallar?

Ver solución
  • (a) Falla (250 ≥ 200). El p95 cruzó la línea de 200 ms.
  • (b) Pasa (250 < 300). El mismo número, bajo una promesa más laxa.
  • (c) Subir el SLO a 300 para que la prueba pase no arregla el rendimiento: cambia la promesa que se le hace al usuario —de "menos de 200 ms" a "menos de 300 ms"—. Si el negocio de verdad puede vivir con 300 ms sin perder usuarios, es una decisión legítima de negocio, acordada con quien responde por la experiencia. Pero si se hace solo para "poner el build en verde", es esconder una regresión bajo la alfombra: el usuario seguirá viviendo los 250 ms, solo que ahora nadie los vigila. El umbral se mueve por una razón de producto, nunca para silenciar una prueba.

Ejercicio 3 — Escribe los thresholds de un SLO más estricto. Reservo endurece su promesa: el 99% de las cotizaciones debe responder en menos de 150 ms, y la disponibilidad sube a 99.9%. Escribe los thresholds de k6 correspondientes (mantén el de checks en 99%).

Ver solución
thresholds: {
  http_req_duration: ['p(99)<150'],  // ahora el p99 (no el p95) bajo 150 ms
  http_req_failed: ['rate<0.001'],   // 99.9% disponible = < 0.1% de error
  checks: ['rate>0.99'],
}

Dos cosas cambiaron. El de latencia pasó de p(95)<200 a p(99)<150: exigir el p99 (no el p95) es más estricto —cubre al 99% de los usuarios, no al 95%— y bajar a 150 ms aprieta el tiempo. El de disponibilidad pasó de rate<0.01 (99%) a rate<0.001 (99.9%): un orden de magnitud menos de errores tolerados. Cada cambio es una promesa más fuerte al usuario, y hace el gate más difícil de pasar —lo cual es correcto si el negocio de verdad se compromete a ese nivel—. (Nota: exigir el p99 en vez del p95 es una decisión distinta a solo bajar el número; el p99 vigila la cola más extrema de la latencia.)

Resumen y siguiente paso

En esta lección le pusiste al capstone su veredicto: los thresholds ligados al SLO. Escribiste los tres en k6 (contenido) —http_req_duration: ['p(95)<200'] (latencia), http_req_failed: ['rate<0.01'] (disponibilidad), checks: ['rate>0.99'] (corrección)— y viste de dónde sale cada número: no de la técnica ni de la intuición, sino de una promesa de negocio (el SLO). Confirmaste que el threshold se evalúa sobre la corrida entera (como k6), y —la lección de fondo— que el mismo p95 de 218.51 ms falla contra un SLO de 200 ms y pasa contra uno de 300 ms: el umbral es una decisión de negocio disfrazada de número, y la línea de corte la define la promesa, no el corredor. Y ataste el veredicto a su exit code: 1 en el gate de Python, 99 en k6, ambos ≠ 0.

Reusaste por entero el módulo 5 (threshold, exit code, elegir el umbral del SLO). Antes de avanzar deberías poder: escribir los tres thresholds de un SLO; explicar por qué el umbral es una decisión de negocio; y decir por qué se evalúa sobre la corrida entera. Lo que sigue, en la lección 5, es leer a fondo las métricas que la corrida produce (p50/p95/p99, RPS, tasa de error) y el resumen de k6 equivalente —los instrumentos que el threshold juzga—.

Recursos

  • k6 — Thresholds — la referencia oficial de cómo se escriben los thresholds (p(95)<200, rate<0.01, checks>0.99) y de que un threshold roto hace salir a k6 run con código ≠ 0. La fuente del contenido de k6 de esta lección.
  • Google SRE Book — Service Level Objectives — el capítulo que explica qué es un SLO y por qué el umbral se deriva de una promesa de negocio, no de la técnica; el fundamento de "de dónde sale cada número".
  • Google SRE Workbook — Implementing SLOs — cómo se eligen en la práctica los objetivos de latencia, disponibilidad y corrección; el criterio detrás de los 200 ms, el 1% y el 99%.
  • k6 — Error codes (código 99) — el código fuente donde k6 define ThresholdsHaveFailed = 99, el exit code que devuelve cuando un threshold falla. La fuente del "99".