Módulo 7: Analizar resultados y CI
8. Mini-proyecto: exportar y detectar una regresión
Descripción
Este mini-proyecto junta con tus manos toda la mitad de analizar del módulo, de principio a fin, y la conecta con la de automatizar. Vas a levantar la API de Reservo con el endpoint /quote_slow declarado, correr dos cargas —una baseline contra el endpoint rápido /quote y una degradada contra el lento /quote_slow—, exportar cada una a un results.json, y correr un chequeo de regresión que compara el p95 de las dos y falla con un código de salida porque el rendimiento empeoró más del umbral. Como cierre, escribes el .github/workflows/load.yml de CI (contenido) que automatizaría todo esto. Al terminar tendrás el recorrido completo ejecutado: producir el resultado, guardarlo, compararlo, atrapar la regresión con exit code, y saber cómo se pondría en un pipeline.
Conexión con el módulo: este es el capstone del módulo 7. Usa el servidor de la lección 1 (con /quote_slow), el exportador de la lección 3, el chequeo de regresión de la lección 4, y el load.yml de la lección 6. Es la síntesis: si haces este recorrido solo, dominas la mitad de análisis del módulo. Lo que sigue es el módulo 8, el capstone de toda la guía, donde esto se integra en una prueba de carga completa de Reservo (de smoke a stress, con thresholds y checks).
El encargo
Eres responsable del rendimiento de la API de Reservo. Un compañero abrió un cambio que toca el endpoint de cotización, y quieres asegurarte de que no degradó el rendimiento antes de que se mergee. Tu tarea:
- Levantar Reservo con
/quote_slow(que modela el endpoint después del cambio malo). - Medir el baseline (el rendimiento de antes, golpeando
/quote) y exportarlo. - Medir la corrida actual (el rendimiento de después, golpeando
/quote_slow) y exportarla. - Correr un chequeo de regresión que compare los dos p95 y falle si empeoró más de +20%.
- Escribir el
load.ymlque automatizaría esta comprobación en CI.
Entregas: los dos results.json, la salida del chequeo con su exit code, y el YAML.
Paso 1 — Levantar el blanco con /quote_slow
Usa el servidor canónico de Reservo con el añadido de este módulo (la constante SLOW_DELAY_S, el import time, y la rama de /quote_slow en do_POST; el código completo está en la lección 1). Arráncalo en segundo plano y lee el puerto que eligió (recuerda: puerto 0, el SO asigna uno libre).
Qué esperar — el servidor imprime su puerto y responde correcto tanto en /quote (rápido) como en /quote_slow (lento), los dos con el número-ancla 7500:
$ python3.14 reservo_server.py &
Reservo escuchando en http://127.0.0.1:PORT
$ curl -s -X POST http://127.0.0.1:PORT/quote \
-H 'Content-Type: application/json' -d '{"room":"Focus","tier":"basic","hours":3}'
{"price_cents": 7500}
$ curl -s -X POST http://127.0.0.1:PORT/quote_slow \
-H 'Content-Type: application/json' -d '{"room":"Focus","tier":"basic","hours":3}'
{"price_cents": 7500}
Los dos endpoints devuelven lo mismo (7500): la lógica es idéntica. La diferencia está solo en el tiempo —/quote_slow espera un retardo fijo antes de responder—, que es exactamente lo que hace de él una regresión de rendimiento pura (velocidad, no corrección).
Paso 2 y 3 — Medir y exportar las dos cargas
Con el exportador de la lección 3 (load_and_export.py), lanza las dos corridas con los mismos parámetros (600 peticiones, concurrencia 30) para que la única variable sea el endpoint. La primera es el baseline (/quote), la segunda la actual (/quote_slow). Salida real:
Qué esperar — la baseline con p95 de milisegundos; la degradada con p95 mucho mayor por el retardo; cada una escribe su JSON:
$ python3.14 load_and_export.py http://127.0.0.1:PORT /quote 600 30 results_baseline.json baseline
[baseline] /quote 600 req concurrencia 30
rps=5449.6 error_rate=0.00% checks=100.00%
p50=4.7ms p95=6.66ms p99=19.91ms max=22.63ms
-> escrito results_baseline.json
$ python3.14 load_and_export.py http://127.0.0.1:PORT /quote_slow 600 30 results_actual.json actual
[actual] /quote_slow 600 req concurrencia 30
rps=538.1 error_rate=0.00% checks=100.00%
p50=54.74ms p95=61.27ms p99=73.62ms max=75.82ms
-> escrito results_actual.json
Y los artefactos en disco, listos para comparar. Salida real:
Qué esperar — dos JSON resumen; fíjate en el contraste de p95 (6.66 vs 61.27 ms) y de rps (5449.6 vs 538.1):
$ cat results_baseline.json results_actual.json
{
"label": "baseline",
"endpoint": "/quote",
"requests": 600,
"concurrency": 30,
"duration_s": 0.11,
"rps": 5449.6,
"error_rate": 0.0,
"checks_rate": 1.0,
"latency_ms": {
"min": 2.7,
"p50": 4.7,
"p95": 6.66,
"p99": 19.91,
"max": 22.63,
"avg": 5.29
}
}{
"label": "actual",
"endpoint": "/quote_slow",
"requests": 600,
"concurrency": 30,
"duration_s": 1.115,
"rps": 538.1,
"error_rate": 0.0,
"checks_rate": 1.0,
"latency_ms": {
"min": 46.29,
"p50": 54.74,
"p95": 61.27,
"p99": 73.62,
"max": 75.82,
"avg": 54.73
}
}
Los dos con error_rate: 0.0 y checks_rate: 1.0: la corrección se mantuvo. Es una regresión de puro rendimiento.
Paso 4 — Detectar la regresión (con exit code)
Ahora el chequeo de regresión de la lección 4 (check_regression.py), comparando el baseline con la actual, con un umbral de +20%. Salida real:
Qué esperar — el p95 subió de 6.66 a 61.27 ms (+820%), muy por encima del +20%: REGRESIÓN y exit code 1:
$ python3.14 check_regression.py results_baseline.json results_actual.json 20
CHEQUEO DE REGRESION DE RENDIMIENTO (p95)
--------------------------------------------------------
baseline (baseline) : p95 = 6.66 ms
actual ( actual) : p95 = 61.27 ms
cambio : +54.61 ms (+820.0%)
umbral permitido : +20.0%
--------------------------------------------------------
REGRESION: el p95 subio de 6.66 ms a 61.27 ms (+820.0%, supera +20.0%)
RESULTADO: FAIL (exit code 1)
$ echo $?
1
El chequeo atrapó la regresión y salió con código 1. Comprueba también que no salta con una falsa alarma: comparar el baseline contra otra corrida del mismo endpoint rápido debe dar PASS (exit 0). Salida real:
Qué esperar — dos corridas equivalentes dan p95 casi idénticos (+0.8%, dentro del margen): PASS y exit code 0:
$ python3.14 check_regression.py results_baseline.json results_baseline2.json 20
CHEQUEO DE REGRESION DE RENDIMIENTO (p95)
--------------------------------------------------------
baseline (baseline) : p95 = 6.66 ms
actual (baseline2) : p95 = 6.71 ms
cambio : +0.05 ms (+0.8%)
umbral permitido : +20.0%
--------------------------------------------------------
OK: el p95 no empeoro mas del umbral (+0.8% <= +20.0%)
RESULTADO: PASS (exit code 0)
$ echo $?
0
Y el exit code decidiendo el deploy, como haría CI:
Qué esperar — con la regresión, el || se dispara y el deploy queda bloqueado:
$ python3.14 check_regression.py results_baseline.json results_actual.json 20 > /dev/null \
&& echo "DEPLOY: autorizado" \
|| echo "DEPLOY: BLOQUEADO (exit code $?)"
DEPLOY: BLOQUEADO (exit code 1)
Ese es el resultado central del proyecto: detectaste una regresión de rendimiento comparando dos corridas, y el veredicto salió como un exit code real que un pipeline usaría para bloquear el deploy.
Paso 5 — El load.yml de CI (contenido)
Como cierre, escribe el workflow que automatizaría esta comprobación. Es contenido rotulado —aquí no se ejecuta git/gh—, y combina las dos herramientas del módulo: k6 run con thresholds (el gate absoluto vs SLO) y el chequeo de regresión (el gate relativo vs baseline):
# CONTENIDO (no ejecutado aqui): .github/workflows/load.yml
# Prueba de carga de Reservo en CI: gate absoluto (k6) + gate de regresion.
# Ver grafana.com/docs/k6 y docs.github.com/actions.
name: load-test
on:
schedule:
- cron: "0 3 * * *" # nightly (leccion 7: no en cada PR)
release:
types: [published] # pre-release: gate del lanzamiento
workflow_dispatch: # on-demand
jobs:
load:
runs-on: ubuntu-latest
steps:
- name: Traer el codigo
uses: actions/checkout@v4
- name: Arrancar la API de Reservo (el blanco)
run: |
python3 reservo_server.py &
sleep 2
- name: Instalar k6
uses: grafana/setup-k6-action@v1
- name: Correr la prueba de carga (thresholds = gate absoluto vs SLO)
run: k6 run --out json=results.json load_test.js
# Un threshold roto -> k6 sale con 99 -> el step falla -> deploy bloqueado.
- name: Descargar el baseline de la ultima corrida
uses: actions/download-artifact@v4
with:
name: k6-results-baseline
path: baseline/
- name: Chequeo de regresion (gate relativo vs baseline)
run: |
python3 check_regression.py baseline/results.json results.json 20
# p95 empeoro > 20% respecto al baseline -> exit 1 -> deploy bloqueado.
- name: Guardar el JSON de esta corrida como artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: k6-results
path: results.json
Fíjate en los dos gates encadenados: primero k6 run (¿cumple el SLO absoluto?), luego el chequeo de regresión (¿empeoró vs el baseline?). Cualquiera de los dos que falle pone el job en rojo y bloquea el deploy —las dos redes de seguridad de la lección 4, en un solo pipeline—. Y el on: con la cadencia correcta de la lección 7 (nightly + pre-release + on-demand, nunca pull_request).
Rúbrica
Evalúa tu entrega contra estos criterios:
| Criterio | Cumple si… |
|---|---|
| El blanco corre | Reservo levanta con /quote_slow declarado; los dos endpoints devuelven 7500 (misma lógica). |
| Dos cargas medidas | Baseline (/quote) y actual (/quote_slow) con los mismos parámetros (misma N, misma concurrencia); la única variable es el endpoint. |
| Exportación real | Cada corrida escribió un results.json con p50/p95/p99, rps, error_rate, checks_rate. Los archivos existen en disco. |
| Regresión detectada | El chequeo comparó los p95, reportó "REGRESIÓN: el p95 subió de X a Y" y salió con exit code 1; echo $? mostró 1. |
| Sin falsa alarma | Comparar el baseline contra otra corrida equivalente dio PASS (exit 0): el margen absorbe el ruido. |
| Corrección mantenida | Las dos corridas tienen error_rate: 0.0 y checks_rate: 1.0: es una regresión de velocidad, no de resultado. |
| CI como contenido | El load.yml está rotulado como contenido, con los dos gates (k6 absoluto + regresión relativa) y la cadencia correcta (schedule/release/workflow_dispatch, sin pull_request). |
| Honestidad del entorno | Lo ejecutado (Python) se cita con su comando y su exit code; k6 y el YAML van rotulados como contenido; nunca se ejecutó git/gh. |
Errores comunes
Medir baseline y actual con parámetros distintos. Qué pasa: se corre el baseline con concurrencia 30 y la actual con 50, y la diferencia de p95 mezcla el efecto del endpoint con el de la carga. Por qué pasa: se cambian dos variables a la vez. Cómo detectarlo: si baseline y actual no comparten N y concurrencia, la comparación no es limpia. Cómo corregirlo: mismos parámetros en las dos corridas; la única diferencia debe ser el endpoint (el "cambio de código" que pruebas).
Olvidar el caso PASS. Qué pasa: solo se prueba que el chequeo falla con la regresión, sin verificar que no salta cuando no hay cambio. Por qué pasa: se asume que un chequeo que atrapa lo malo está completo. Cómo detectarlo: si nunca probaste dos corridas equivalentes, no sabes si tu umbral es tan estricto que da falsos rojos. Cómo corregirlo: verifica los dos casos —FAIL con la regresión, PASS con corridas equivalentes—; un gate que falla siempre es tan inútil como uno que nunca falla.
Presentar el load.yml como si hubiera corrido. Qué pasa: se muestra el YAML sin rótulo, como si el pipeline se hubiera ejecutado. Por qué pasa: se pierde la honestidad del entorno. Cómo detectarlo: aquí no se ejecuta git/gh ni hay k6 instalado; el YAML es contenido. Cómo corregirlo: rotula el YAML como contenido; lo ejecutado es el Python, con su comando y su echo $?.
Ejercicios
Ejercicio 1 — Corre el recorrido completo. Levanta Reservo con /quote_slow, corre las dos cargas (mismos parámetros), expórtalas, y corre el chequeo de regresión. (a) ¿Qué exit code dio el chequeo? (b) ¿Cuánto empeoró el p95, en % ? (c) ¿Las dos corridas mantuvieron la corrección (error 0, checks 100%)?
Ver solución
- (a) Exit code 1: el chequeo detectó la regresión (el p95 empeoró muy por encima del +20%).
echo $?imprime1. - (b) En la corrida de referencia, de 6.66 a 61.27 ms: +820% (se multiplicó por ~9.2). Tus números exactos variarán, pero el orden de magnitud es el mismo: el retardo fijo de
/quote_slow(~45 ms) domina sobre los ~5 ms del baseline. - (c) Sí: las dos tienen
error_rate: 0.0ychecks_rate: 1.0. La lógica es idéntica (mismo7500); solo cambió el tiempo. Regresión de rendimiento pura.
Ejercicio 2 — Ajusta el umbral. Corriste el chequeo con +20% y falló. (a) ¿Con qué umbral dejaría de fallar la comparación baseline-vs-actual? (b) ¿Sería buena idea subir el umbral hasta que pase? (c) ¿Qué umbral tiene sentido para el caso PASS (baseline vs baseline2, +0.8%)?
Ver solución
- (a) Como el p95 subió +820%, tendrías que poner un umbral mayor a 820% para que la comparación baseline-vs-actual pase. Un umbral así de absurdo deja pasar cualquier regresión.
- (b) No. Subir el umbral hasta que el gate pase es "arreglar" la prueba en vez de la app: silencia la alarma en lugar de atender la regresión. El umbral se elige por lo que el negocio tolera (un 10–25% típico), no para que el build se ponga verde.
- (c) Cualquier umbral por encima de +0.8% (el ruido natural) deja pasar el caso PASS. Un +20% da margen de sobra para el ruido y sigue atrapando una degradación real (que suele ser de decenas o cientos de por ciento). Ese equilibrio —holgado ante el ruido, estricto ante la regresión— es el correcto.
Ejercicio 3 — Extiende el pipeline. El load.yml tiene dos gates (k6 absoluto y regresión relativa). (a) ¿Qué pasa si el k6 run pasa el SLO pero el chequeo de regresión falla? (b) ¿Por qué el upload-artifact lleva if: always()? (c) ¿Por qué el on: no incluye pull_request?
Ver solución
- (a) El job falla igual y el deploy se bloquea. Los dos gates están encadenados: cualquiera que salga con exit code ≠ 0 pone el job en rojo. Que el SLO absoluto se cumpla no rescata al build si el rendimiento se degradó respecto al baseline. (Es justo el caso de la corrida degradada: 61 ms pasa el SLO de 200 ms pero es una regresión de 9x.)
- (b) Para subir el
results.jsonaunque un gate haya fallado —que es cuando más lo necesitas para investigar—. Por defecto, un step no corre si el anterior falló;if: always()lo fuerza a correr siempre. - (c) Porque la prueba de carga es lenta, cara, ruidosa y necesita un entorno estable (lección 7): no va en cada PR. Su cadencia es nightly + pre-release + on-demand.
Resumen y siguiente paso
En este mini-proyecto hiciste, con tus manos y ejecutado de verdad, todo el ciclo de análisis del módulo: levantaste Reservo con /quote_slow, mediste dos cargas con los mismos parámetros (baseline contra /quote, actual contra /quote_slow), exportaste cada una a un results.json, y corriste un chequeo de regresión que atrapó que el p95 empeoró de 6.66 a 61.27 ms y falló con exit code 1 —mientras que dos corridas equivalentes dieron PASS—. Confirmaste que era una regresión de puro rendimiento (corrección intacta: error 0, checks 100%), y escribiste el load.yml de CI (contenido) con los dos gates encadenados —el absoluto de k6 y el relativo de regresión— y la cadencia correcta.
Con esto cierras el módulo 7 completo: producir una prueba (M2–M6), analizarla (leer, exportar, detectar la regresión, localizar el cuello de botella) y automatizarla (el pipeline y su cadencia). Lo que sigue es el módulo 8, el capstone de toda la guía: una prueba de carga completa de Reservo —de smoke a load a stress, con stages, thresholds y checks del flujo cotizar→reservar— que integra todo lo que aprendiste, incluido el análisis y el CI de este módulo.
Recursos
- k6 — Running k6 in CI — la referencia del
load.ymly de cómo el exit code dek6 rungatea el pipeline; la base del paso 5. - k6 — Results output — cómo se exporta el resultado de una corrida (
--out), el análogo delresults.jsonque produjiste en los pasos 2 y 3. sys.exit— documentación de Python — el mecanismo con el que el chequeo de regresión devuelve 0 (PASS) o 1 (FAIL), el exit code real que viste conecho $?.- GitHub Actions —
download-artifact/upload-artifact— cómo el pipeline guarda elresults.jsonde una corrida y descarga el baseline de otra para compararlos; la pieza que conecta la exportación con el chequeo de regresión en CI.