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

6. El gate de threshold y exportar a JSON

Descripción

Hasta aquí, medir y juzgar fueron pasos separados. En esta lección los juntamos en el gate completo: una sola corrida que mide las métricas, las evalúa contra los thresholds, exporta el resultado a un results.json, y sale con un código que un pipeline lee (0 = PASS, 1 = FAIL). Es la pieza que convierte la prueba en algo automatizable. La corremos de verdad en sus dos caras: verde contra el build sano (/quote, p95 bajo el SLO, exit 0) y rojo contra el motor de precios pesado (/quote_cpu, el stress cruza el p95, exit 1). Vemos el JSON exportado de la corrida roja y comprobamos, con una cadena de shell, cómo ese exit code autoriza o bloquea el deploy sin que nadie mire un número.

Conexión con el módulo: esta lección une dos módulos en la pieza que faltaba. Del módulo 5, el exit code que le da dientes al veredicto (0/1, el idioma del pipeline). Del módulo 7, exportar las métricas a un archivo (la libreta de la corrida, que la lección 7 subirá como artifact y que un chequeo de regresión podría comparar). Aquí no re-explicamos el mecanismo del exit code ni por qué se exporta; los usamos para cerrar el gate del capstone. La lección 7 pondrá este gate en un load.yml de CI; la 8 lo entregará completo. Este es el corazón ejecutable de la prueba.

El torniquete del metro

En la entrada del metro hay un torniquete que hace tres cosas en un instante. Lee tu tarjeta (¿tiene saldo?), decide (pasa / no pasa), y actúa físicamente: si tienes saldo, las aspas giran y avanzas; si no, se traban y te detienes. No hay un guardia interpretando tu cara ni escuchando tus razones —el torniquete traduce el saldo a un movimiento mecánico que la siguiente persona en la fila respeta—. Y en algún lado queda el registro: la máquina anota tu paso, para que después alguien pueda revisar cuánta gente entró.

El gate del capstone es ese torniquete. Lee las métricas de la corrida, decide contra los thresholds (pasa / falla), y actúa con un exit code que el pipeline respeta: 0 y las aspas giran (el deploy avanza), 1 y se traban (el deploy se bloquea). No hay un humano interpretando el p95; el número hace girar o trabar. Y el registro es el results.json que exporta: la anotación de qué midió esa corrida, para revisarla después, adjuntarla al build o compararla con corridas futuras. Leer, decidir, actuar, registrar —las cuatro cosas, en un comando—.

El gate completo (la corrida ejecutable)

Este es el corazón de loadtest.py: después de correr las etapas y agregar las métricas, evalúa los tres thresholds, exporta el reporte y sale con el código del veredicto. Es la evaluate de M5 más el export de M7, unidos:

# Fragmento de loadtest.py — el gate: evaluar, exportar, salir con código.
# (El SLO viene de la lección 4; las métricas agregadas, de la lección 5.)
agg = summarize(whole, whole_wall)              # métricas de la corrida entera
rows = [
    ("http_req_duration: p(95) < 200ms", agg["p95"] < P95_LIMIT_MS),
    ("http_req_failed:   rate < 1.00%",  agg["error_rate"] < ERROR_LIMIT),
    ("checks:            rate > 99.00%", agg["checks_rate"] > CHECKS_MIN),
]
passed = all(ok for _, ok in rows)

# EXPORTAR (M7): la libreta de la corrida, para el artifact y las comparaciones.
report = {"scenario": "quote->book", "quote_path": QUOTE_PATH,
          "slo": {"p95_ms": P95_LIMIT_MS, "error_rate": ERROR_LIMIT, "checks_rate": CHECKS_MIN},
          "aggregate": agg, "stages": per_stage, "passed": passed}
with open(OUT_JSON, "w") as f:
    json.dump(report, f, indent=2)

# SALIR CON CÓDIGO (M5): el idioma que el pipeline entiende.
if passed:
    print("GATE: PASS  (exit code 0)"); sys.exit(0)   # las aspas giran
else:
    print("GATE: FAIL  (exit code 1)"); sys.exit(1)   # se traban

Fíjate en el orden: exporta primero, sale después. El results.json se escribe pase lo que pase —lo necesitas tanto si la corrida pasó como si falló, porque el registro de una corrida fallida es justo el que quieres revisar—. Y el sys.exit es lo último: convierte el booleano passed en el número que el pipeline lee. True → 0, False → 1.

El gate verde (build sano)

Corremos la prueba completa contra /quote, el endpoint canónico rápido —el build sano—:

Qué esperar — el p95 de la corrida entera queda muy bajo el SLO; los tres thresholds pasan; se exporta el JSON y sale con código 0:

$ python3.14 loadtest.py http://127.0.0.1:PORT /quote green.json
PRUEBA DE CARGA — escenario cotizar->reservar contra /quote
perfil: smoke(5) -> load(20) -> stress(80) VUs
--------------------------------------------------------------------------
etapa     VUs    reqs      RPS      p50      p95      p99   error   checks
--------------------------------------------------------------------------
smoke       5   23452   5861.3     0.79     1.19     1.43   0.00%  100.00%
load       20   26240   5243.1     3.61     5.97     7.34   0.00%  100.00%
stress     80   31340   5208.5    14.54    25.34    31.27   0.00%  100.00%
--------------------------------------------------------------------------

THRESHOLDS (evaluados sobre la corrida entera — como k6)
--------------------------------------------------------------------------
THRESHOLD                             MEDIDO                RESULTADO
http_req_duration: p(95) < 200ms      p(95) = 21.37ms       PASS
http_req_failed:   rate < 1.00%       rate  = 0.00%         PASS
checks:            rate > 99.00%      rate  = 100.00%       PASS
--------------------------------------------------------------------------
metricas exportadas -> green.json
GATE: PASS  (exit code 0)
$ echo $?
0

Con el build sano, hasta la etapa de stress (80 VUs) da un p95 de 25.34 ms, y el agregado 21.37 ms —muy lejos de los 200 ms del SLO—. Los tres thresholds pasan, el gate sale con 0, y el pipeline tiene luz verde para desplegar.

El gate rojo (motor de precios pesado)

Ahora la misma prueba contra /quote_cpu, el motor de precios que hace trabajo de CPU:

Qué esperar — bajo el stress el p95 cruza el SLO; el threshold de latencia falla; se exporta el JSON igual y sale con código 1:

$ python3.14 loadtest.py http://127.0.0.1:PORT /quote_cpu red.json
PRUEBA DE CARGA — escenario cotizar->reservar contra /quote_cpu
perfil: smoke(5) -> load(20) -> stress(80) VUs
--------------------------------------------------------------------------
etapa     VUs    reqs      RPS      p50      p95      p99   error   checks
--------------------------------------------------------------------------
smoke       5    2336    583.3     9.07    14.15    17.07   0.00%  100.00%
load       20    2920    582.2    34.42    57.98    62.07   0.00%  100.00%
stress     80    3526    584.3   135.97   239.77   254.80   0.00%  100.00%
--------------------------------------------------------------------------

THRESHOLDS (evaluados sobre la corrida entera — como k6)
--------------------------------------------------------------------------
THRESHOLD                             MEDIDO                RESULTADO
http_req_duration: p(95) < 200ms      p(95) = 221.89ms      FAIL
http_req_failed:   rate < 1.00%       rate  = 0.00%         PASS
checks:            rate > 99.00%      rate  = 100.00%       PASS
--------------------------------------------------------------------------
metricas exportadas -> red.json
GATE: FAIL  (exit code 1)
$ echo $?
1

Aquí está la degradación completa, atrapada. Bajo smoke (p95 14.15) y load (p95 57.98) el sistema cumple el SLO; es la etapa de stress (p95 239.77) la que lo rompe, y el p95 agregado (221.89 ms) cruza los 200 ms. El threshold de latencia falla, el gate sale con 1, y el pipeline tiene luz roja. Fíjate en que —igual que en M5— el exit code es 1 aunque dos de los tres thresholds pasaron: una regla rota basta para el FAIL, y el FAIL basta para el 1.

El JSON exportado (la libreta de la corrida)

Cada corrida escribió su results.json. Este es el de la corrida roja —el registro que un pipeline subiría como artifact y que un chequeo de regresión podría comparar (M7)—:

$ cat red.json
{
  "scenario": "quote->book",
  "quote_path": "/quote_cpu",
  "slo": {
    "p95_ms": 200.0,
    "error_rate": 0.01,
    "checks_rate": 0.99
  },
  "aggregate": {
    "reqs": 8782,
    "rps": 583.5,
    "p50": 41.24,
    "p95": 221.89,
    "p99": 249.04,
    "error_rate": 0.0,
    "checks_rate": 1.0
  },
  "stages": [
    { "stage": "smoke",  "vus": 5,  "reqs": 2336, "p95": 14.15,  "error_rate": 0.0, "checks_rate": 1.0 },
    { "stage": "load",   "vus": 20, "reqs": 2920, "p95": 57.98,  "error_rate": 0.0, "checks_rate": 1.0 },
    { "stage": "stress", "vus": 80, "reqs": 3526, "p95": 239.77, "error_rate": 0.0, "checks_rate": 1.0 }
  ],
  "passed": false
}

(Se muestra abreviado; el archivo real lleva también rps, p50 y p99 por etapa, y las duraciones.) El JSON guarda todo lo que necesitas después de que la terminal se cierre: el SLO contra el que se juzgó, las métricas agregadas y por etapa, y el veredicto ("passed": false). Es la libreta de la corrida —el chef anotando el plato de hoy para poder compararlo con el de mañana (M7)—. Sin exportar, la corrida se evapora al cerrar la ventana; con el JSON, queda un registro que se adjunta al build, se compara con el histórico y se investiga.

El exit code que autoriza o bloquea el deploy (ejecutado)

El exit code solo importa si algo lo lee. Sin un pipeline entero, el propio shell tiene los operadores que CI usa por dentro: A && B corre B solo si A tuvo éxito (código 0); A || B corre B solo si A falló. Simulemos "corre la prueba, y solo si pasa, autoriza el deploy":

Qué esperar — con el build sano el && deja pasar (deploy autorizado); con el motor pesado el && se corta y el || dispara el bloqueo. Salida real:

$ python3.14 loadtest.py http://127.0.0.1:PORT /quote green.json > /dev/null \
    && echo "CI: deploy autorizado" || echo "CI: deploy BLOQUEADO"
CI: deploy autorizado

$ python3.14 loadtest.py http://127.0.0.1:PORT /quote_cpu red.json > /dev/null \
    && echo "CI: deploy autorizado" || echo "CI: deploy BLOQUEADO"
CI: deploy BLOQUEADO

De punta a punta: el build sano pasó el gate → código 0 → el && autorizó el deploy; el motor pesado falló el gate → código 1 → el && se cortó y el deploy quedó bloqueado. Ningún humano miró un p95. El exit code —el torniquete— hizo todo el trabajo. Un pipeline de CI real hace exactamente esto, solo que el "paso siguiente" es el deploy de verdad; el load.yml que lo expresa es la lección 7.

Errores comunes

Salir siempre con 0 (el gate decorativo). Qué pasa: el script imprime "FAIL" en rojo pero termina normalmente, con código 0. El pipeline ve el 0 y despliega igual. Por qué pasa: se confunde comunicarle al humano (el print) con comunicarle a la máquina (el sys.exit). Cómo detectarlo: corre el gate en un caso que debería fallar y haz echo $?; si dice 0, el gate es decorativo. Cómo corregirlo: la rama de fallo debe llamar a sys.exit(1) (o ≠ 0). El print es para el humano; el exit code es para el pipeline (M5).

Exportar solo si la corrida pasa. Qué pasa: se escribe el JSON dentro de la rama de éxito, así que las corridas fallidas no dejan registro. Por qué pasa: parece que "solo importa guardar lo bueno". Cómo detectarlo: si no tienes el results.json de la corrida que falló, no puedes investigar por qué falló. Cómo corregirlo: exporta antes de salir, pase lo que pase. El registro de una corrida fallida es el más valioso —es el que revisas para encontrar el cuello de botella—. En el gate del capstone, el json.dump va antes del sys.exit, siempre.

Creer que el && del shell es el CI. Qué pasa: alguien ve la cadena ... && echo autorizado || echo bloqueado y cree que eso es "el pipeline". Por qué pasa: la cadena imita la lógica del CI. Cómo detectarlo: aquí no hay git/gh; es una simulación en shell de lo que un pipeline hace. Cómo corregirlo: entiende que el shell demuestra el mecanismo (un exit code que corta o deja pasar el paso siguiente); el load.yml de la lección 7 es la forma industrial del mismo mecanismo, como contenido. Ambos usan el mismo idioma: el código de salida.

Ejercicios

Ejercicio 1 — Predice el $? y el deploy. Para cada corrida, di qué imprime echo $? y si la cadena && deploy || bloqueado despliega. (a) /quote con los tres thresholds en verde. (b) /quote_cpu con el p95 en rojo y los otros dos en verde. (c) Una corrida con error = 3% (sobre el 1%) pero p95 y checks en verde.

Ver solución
  • (a) $? = 0, despliega (los tres PASS → gate PASS → 0 → el && deja pasar).
  • (b) $? = 1, no despliega (una regla rota basta para el FAIL → 1 → el && se corta, el || bloquea).
  • (c) $? = 1, no despliega (el threshold http_req_failed: rate<0.01 falla con 3% de error, aunque latencia y checks pasen; una sola regla rota → FAIL → 1 → bloqueo).

En los tres, el gate mira las tres reglas y una rota es suficiente para el 1.

Ejercicio 2 — ¿Por qué exportar antes de salir? El gate escribe el results.json justo antes del sys.exit. (a) ¿Qué pasaría si el json.dump estuviera dentro del if passed: (solo la rama de éxito)? (b) ¿Por qué el JSON de una corrida fallida es especialmente valioso?

Ver solución
  • (a) Las corridas que fallan no dejarían archivo: el sys.exit(1) de la rama de fallo se ejecutaría sin haber escrito nada. Justo las corridas que más quieres investigar (las rojas) se perderían.
  • (b) Porque el JSON de una corrida fallida es la evidencia de la degradación: guarda el p95 que cruzó, en qué etapa, con qué carga, contra qué SLO. Es lo que subes al build como artifact, lo que compartes con el equipo, y lo que comparas contra el baseline para localizar cuándo empezó el problema. Una corrida verde confirma que todo está bien; una roja documenta qué se rompió —y esa documentación solo existe si exportaste antes de salir—.

Ejercicio 3 — Encadena gate y deploy real. Escribe una línea de shell que corra el gate contra /quote_cpu y, solo si pasa, ejecute un ./deploy.sh (ficticio); si el gate falla, que imprima "deploy bloqueado por rendimiento" sin desplegar. ¿Qué operador expresa "solo si pasa"?

Ver solución
python3.14 loadtest.py "http://127.0.0.1:$(cat PORT)" /quote_cpu results.json \
    && ./deploy.sh \
    || echo "deploy bloqueado por rendimiento"

El operador && expresa "solo si pasa": corre ./deploy.sh únicamente si el gate salió con código 0. Como la corrida contra /quote_cpu falla el threshold de p95 y sale con 1, el && se corta (no despliega) y el || dispara el mensaje de bloqueo. Es la misma lógica que un pipeline de CI aplica entre el paso de la prueba de carga y el de deploy —el load.yml de la lección 7 la escribe en la sintaxis de GitHub Actions—.

Resumen y siguiente paso

En esta lección cerraste el gate completo: una corrida que mide, evalúa contra los thresholds, exporta el resultado a results.json, y sale con exit code (0 = PASS, 1 = FAIL) —leer, decidir, actuar, registrar, como el torniquete del metro—. Lo corriste en sus dos caras: verde contra el build sano /quote (p95 agregado 21.37 ms, exit 0) y rojo contra el motor pesado /quote_cpu (el stress lleva el p95 a 239.77 ms, agregado 221.89 ms, exit 1). Viste el JSON exportado de la corrida roja —la libreta que guarda el SLO, las métricas y el veredicto—, y comprobaste con la cadena && deploy || bloqueado que el exit code autoriza o bloquea el deploy sin que nadie mire un número.

Uniste el módulo 5 (el exit code) y el módulo 7 (exportar). Antes de avanzar deberías poder: explicar por qué se exporta antes de salir; distinguir un gate real de uno decorativo; y encadenar gate y deploy con &&. Lo que sigue, en la lección 7, es poner este gate en un pipeline: el .github/workflows/load.yml como contenido, donde el threshold es el gate que bloquea el deploy —y cuándo conviene dispararlo—.

Recursos