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

7. `abortOnFail` y thresholds por escenario

Descripción

El formato corto que has usado hasta ahora —http_req_duration: ["p(95)<200"]— cubre la mayoría de los casos, pero deja dos necesidades reales sin atender. La primera: cuando una app ya está claramente rota a los diez segundos de una prueba de treinta, seguir martillándola es desperdicio —querrías cortar temprano—. La segunda: no todas las partes de un sistema merecen el mismo umbral —una cotización síncrona necesita un p95 estricto, pero un endpoint de reportes pesados tolera más—, así que querrías un SLO distinto para cada parte. Esta lección cubre las dos herramientas de k6 que las resuelven: abortOnFail (con delayAbortEval), del formato largo, para abortar en cuanto un umbral crítico se rompe; y los thresholds por tag / por escenario, para ponerle a cada parte del sistema su propia regla. Ambas son contenido de k6 (rotulado), y ambas tienen su espejo ejecutable en Python: un gate multi-objetivo que evalúa varios endpoints, cada uno con su umbral, y que aborta de verdad cuando el crítico se rompe —con su código de salida real—.

Conexión con el módulo: la lección 5 te enseñó a elegir un umbral con criterio; esta te enseña a aplicar umbrales distintos a partes distintas, que es la consecuencia natural de que cada parte tenga un SLO distinto. abortOnFail conecta con los perfiles de carga (módulo 4): abortar temprano evita gastar una prueba larga en un sistema ya reprobado. Es la última lección de técnica del módulo antes del mini-proyecto (lección 8), que reúne todo. El pipeline de CI donde estos gates viven a fondo es el módulo 7.

El árbitro que detiene la pelea

En el boxeo hay una regla que protege al peleador: el TKO (nocaut técnico). Si un boxeador está recibiendo una golpiza sin defenderse, el árbitro no espera a que suene la campana del último round —detiene la pelea ahí mismo—. No tiene sentido dejar que sigan doce rounds cuando el resultado ya es evidente y continuar solo hace daño. El árbitro corta temprano porque el veredicto ya está claro y prolongarlo es puro desperdicio (y riesgo).

abortOnFail es ese árbitro. Una prueba de carga de treinta minutos contra un sistema que ya reventó a los dos minutos no aporta información nueva —solo consume recursos, alarga el pipeline y sigue castigando a un servicio que ya sabemos que falla—. Con abortOnFail, k6 detiene la prueba en cuanto un umbral crítico se evalúa como roto, como el árbitro que para la pelea. La segunda herramienta de la lección —los thresholds por escenario— es como tener reglas distintas según la categoría del combate: un peso pluma y un peso pesado no se juzgan con el mismo criterio, y /quote y /quote_cpu no se gatean con el mismo umbral.

abortOnFail: cortar temprano

abortOnFail es una propiedad del formato largo de los thresholds (el que viste de pasada en la lección 3). En vez de una regla como string, escribes un objeto:

// CONTENIDO (no ejecutado aqui): abortOnFail y delayAbortEval.
// Ver grafana.com/docs/k6/latest/using-k6/thresholds/
export const options = {
  vus: 50,
  duration: "5m",   // una prueba larga...
  thresholds: {
    http_req_duration: [
      {
        threshold: "p(95)<200",   // la regla de siempre
        abortOnFail: true,        // ...pero aborta en cuanto se rompa
        delayAbortEval: "10s",    // tras esperar 10s a que junte muestras
      },
    ],
  },
};

Tres propiedades:

  • threshold — la regla, igual que en el formato corto ("p(95)<200").
  • abortOnFail: true — si durante la prueba ese umbral se evalúa como roto, k6 aborta la prueba entera en ese momento, sin esperar a que terminen los 5 minutos. Ahorra tiempo y recursos cuando la app ya reprobó.
  • delayAbortEval: "10s" — espera este tiempo antes de empezar a evaluar el umbral para abortar. Es importante: al arrancar, con pocas muestras, un p95 puede verse feo por puro ruido (recuerda del módulo 3 que los percentiles necesitan muchos datos para ser estables). delayAbortEval le da a la prueba unos segundos para juntar muestras antes de tomar la decisión de cortar, evitando abortos por un pico inicial engañoso.

Un detalle operativo que conviene saber (de la documentación de k6): cuando k6 corre en su modo cloud, los thresholds se evalúan cada 60 segundos, así que un abortOnFail puede tardar hasta un minuto en dispararse. En local es más inmediato. En ambos casos, la idea es la misma que el TKO: no prolongar una prueba cuyo veredicto ya es rojo.

Thresholds por tag y por escenario

La segunda herramienta resuelve que no todo el sistema merece el mismo umbral. k6 te deja poner un threshold sobre una sub-métrica filtrada por un tag, con la sintaxis metrica{tag:valor}. El caso más común es distinguir tipos de tráfico:

// CONTENIDO (no ejecutado aqui): thresholds por tag. Ver grafana.com/docs/k6
thresholds: {
  // Las peticiones etiquetadas como API: p95 estricto.
  "http_req_duration{type:api}": ["p(95)<200"],
  // Las etiquetadas como contenido estatico: p95 mas holgado.
  "http_req_duration{type:staticContent}": ["p(95)<500"],
},

Para que eso funcione, en el script etiquetas cada petición con ese tag:

// CONTENIDO (no ejecutado aqui): etiquetar una peticion para el threshold por tag.
http.post(url, payload, { headers, tags: { type: "api" } });

k6 además añade tags de sistema automáticamente, y uno de ellos es scenario —el nombre del escenario que ejecutó la petición (los escenarios son la forma de k6 de correr varios perfiles de carga a la vez; su anatomía completa es del módulo 6)—. Eso te permite thresholds por escenario sin etiquetar a mano:

// CONTENIDO (no ejecutado aqui): threshold por escenario (tag de sistema 'scenario').
thresholds: {
  // El escenario 'quotes' (trafico interactivo) tiene SLO estricto.
  "http_req_duration{scenario:quotes}": ["p(95)<200"],
  // El escenario 'reports' (trabajos pesados) tiene SLO holgado.
  "http_req_duration{scenario:reports}": ["p(95)<3000"],
},

La idea es la de la lección 5 llevada a la práctica: cada parte del sistema tiene su propio SLO —anclado en su propio daño al usuario— y por tanto su propio umbral. Un gate único con un solo umbral para todo, o sería demasiado estricto para el reporte pesado, o demasiado holgado para la cotización interactiva. Los thresholds por escenario dan a cada parte la regla que le corresponde.

El espejo ejecutable: un gate multi-objetivo

Construyamos el equivalente en Python, que sí corre. gate_multi.py evalúa varios blancos, cada uno con su propio umbral de p95, y soporta un flag abort_on_fail por blanco: si un blanco crítico rompe su umbral, corta de inmediato sin seguir midiendo los demás.

def main():
    # Cada blanco tiene SU umbral: /quote es interactivo (SLO estricto),
    # /quote_cpu es un endpoint pesado (SLO mas holgado). Umbrales por-escenario.
    targets = [
        ("/quote",     600, 20,  200, False),  # SLO estricto para el rapido
        ("/quote_cpu", 600, 20,  400, True),   # SLO holgado, pero critico: aborta
    ]

    all_pass = True
    for path, total, concurrency, p95_limit, abort_on_fail in targets:
        latencies, error_rate = measure(path, total, concurrency)
        p95 = q(latencies, 95)
        passed = p95 < p95_limit
        status = "PASS" if passed else "FAIL"
        flag = "  [abortOnFail]" if abort_on_fail else ""
        print(f"{path:<12} p(95)={p95:7.2f}ms  umbral<{p95_limit}ms  -> {status}{flag}")
        if not passed:
            all_pass = False
            if abort_on_fail:
                print(f"  ABORT: umbral critico de {path} roto -> corto la prueba ya")
                sys.exit(1)     # el espejo de abortOnFail: cortar temprano

    sys.exit(0 if all_pass else 1)

Cada blanco es una tupla (path, total, concurrency, p95_limit, abort_on_fail). Fíjate en que /quote lleva umbral 200 ms (estricto, es interactivo) y /quote_cpu lleva 400 ms (holgado, es pesado) —umbrales por escenario, cada uno según su SLO—. Y /quote_cpu lleva abort_on_fail=True: si rompe su umbral, el sys.exit(1) corta ahí mismo, sin seguir. Es el espejo exacto de abortOnFail de k6.

Corriéndolo: todos pasan (carga liviana)

Con carga liviana (20 concurrentes en ambos), los dos endpoints cumplen su umbral respectivo. Salida real:

Qué esperar/quote bien bajo su umbral estricto de 200 ms; /quote_cpu bien bajo su umbral holgado de 400 ms; el gate pasa con código 0:

$ python3.14 gate_multi.py http://127.0.0.1:PORT
/quote       p(95)=   7.23ms  umbral<200ms  -> PASS
/quote_cpu   p(95)=  41.21ms  umbral<400ms  -> PASS  [abortOnFail]
$ echo $?
0

Nota que /quote_cpu tiene un p95 más alto (41 ms) que /quote (7 ms) —hace más trabajo—, pero pasa igual, porque su umbral es más holgado a propósito. Con un umbral único de 200 ms para ambos también habría pasado aquí; la diferencia se ve cuando el pesado se degrada.

Corriéndolo: el crítico se rompe y aborta

Ahora subimos la carga de /quote_cpu a 200 concurrentes (carga pesada). Su p95 rompe el umbral crítico de 400 ms, y como lleva abortOnFail, el gate corta ahí mismo. Salida real:

Qué esperar/quote pasa; /quote_cpu rompe su umbral crítico y el gate aborta de inmediato con código 1, sin seguir:

$ python3.14 gate_multi_abort.py http://127.0.0.1:PORT
/quote       p(95)=   6.78ms  umbral<200ms  -> PASS
/quote_cpu   p(95)= 506.79ms  umbral<400ms  -> FAIL  [abortOnFail]
  ABORT: umbral critico de /quote_cpu roto -> corto la prueba ya
$ echo $?
1

Ahí está el árbitro deteniendo la pelea: /quote pasó su umbral estricto (6.78 ms < 200), pero /quote_cpu rompió el suyo (506.79 ms ≥ 400), y como era crítico (abortOnFail), el gate salió con código 1 sin evaluar nada más. En una prueba larga de verdad, ese corte temprano ahorra minutos de martillar un sistema que ya reprobó. Y fíjate en la lección de la 5 encarnada aquí: los dos endpoints se juzgaron, cada uno con su umbral —200 para el interactivo, 400 para el pesado—, no con una regla única que sería injusta para uno de los dos.

Errores comunes

Abortar sin delayAbortEval y cortar por un pico inicial. Qué pasa: se pone abortOnFail: true sin retraso, y la prueba aborta en el segundo 1 porque el p95 con tres muestras se vio feo. Por qué pasa: los percentiles con pocos datos son inestables (módulo 3), y abortOnFail los evalúa desde el arranque. Cómo detectarlo: abortos que ocurren siempre en los primeros segundos, sin degradación real. Cómo corregirlo: añade delayAbortEval (p. ej. "10s") para darle tiempo a juntar muestras antes de decidir cortar.

Un umbral único para partes con SLOs distintos. Qué pasa: se gatea todo el sistema con p(95)<200, y el endpoint de reportes pesados falla siempre aunque su latencia sea perfectamente aceptable para un trabajo asíncrono. Por qué pasa: se ignora que cada parte tiene su propio daño al usuario (lección 5). Cómo detectarlo: un endpoint que "siempre falla el gate" pero cuya latencia nadie considera un problema real. Cómo corregirlo: pon thresholds por tag/escenario —{scenario:reports} con umbral holgado, {scenario:quotes} con umbral estricto—, cada uno anclado en su propio SLO.

Etiquetar mal y que el threshold por tag no aplique a nada. Qué pasa: se escribe "http_req_duration{type:api}": ["p(95)<200"] pero ninguna petición lleva tags: { type: "api" }, así que el threshold se evalúa sobre cero muestras y da un resultado engañoso (o no aplica). Por qué pasa: el threshold por tag y el etiquetado de la petición están desincronizados. Cómo detectarlo: un threshold por tag que nunca falla ni con la app rota, o que k6 reporta sin datos. Cómo corregirlo: asegúrate de que el tags: en http.post(...) use exactamente el mismo nombre y valor que el {tag:valor} del threshold. (Los tags de sistema como scenario no necesitan esto: k6 los añade solo.)

Ejercicios

Ejercicio 1 — Escribe el formato largo con abort. Escribe el threshold de k6 (formato largo) para: el p95 bajo 300 ms, que aborte la prueba si se rompe, tras esperar 15 segundos a juntar muestras.

Ver solución
thresholds: {
  http_req_duration: [
    {
      threshold: "p(95)<300",
      abortOnFail: true,
      delayAbortEval: "15s",
    },
  ],
},

threshold es la regla, abortOnFail: true hace que corte temprano si se rompe, y delayAbortEval: "15s" espera 15 s antes de evaluar para no cortar por un pico inicial con pocas muestras.

Ejercicio 2 — Un SLO por escenario. Tienes dos escenarios: checkout (el usuario paga y espera en pantalla) y nightly_export (un volcado de datos que corre de madrugada, nadie espera). Escribe los thresholds por escenario, eligiendo un umbral defendible para cada uno, y justifica en una frase cada elección.

Ver solución
thresholds: {
  // Checkout: interactivo y critico, el usuario espera mirando -> SLO estricto.
  "http_req_duration{scenario:checkout}": ["p(95)<300"],
  // Export nocturno: asincrono, nadie espera -> SLO muy holgado.
  "http_req_duration{scenario:nightly_export}": ["p(95)<10000"],
},
  • checkout: umbral estricto (p. ej. 300 ms, incluso el p99) porque es síncrono y crítico —un pago lento frustra y pierde ventas—.
  • nightly_export: umbral holgado (p. ej. 10 s) porque es asíncrono y de madrugada —unos segundos no dañan a nadie, y gatearlo estricto sería malgastar esfuerzo—.

El umbral de cada uno sale de su propio daño al usuario (lección 5), no de un número único para todo.

Ejercicio 3 — ¿Por qué abortó? En la corrida gate_multi_abort.py, /quote mostró PASS y /quote_cpu mostró FAIL [abortOnFail] con exit code 1. (a) ¿Por qué el gate salió con 1 si /quote pasó? (b) ¿Qué habría pasado si /quote_cpu no tuviera abort_on_fail? (c) ¿Qué ahorra el abort en una prueba larga?

Ver solución
  • (a) Porque el veredicto global es un Y lógico: /quote_cpu rompió su umbral (506.79 ≥ 400), y una sola regla rota reprueba todo el gate → sys.exit(1). Que /quote pasara no compensa.
  • (b) Sin abort_on_fail, el gate habría marcado /quote_cpu como FAIL pero habría seguido evaluando los blancos restantes (si hubiera más) antes de salir con código 1 al final. El veredicto sería el mismo (falla), pero sin cortar temprano —seguiría midiendo—.
  • (c) Ahorra el tiempo y los recursos de seguir martillando un sistema que ya reprobó: en una prueba de minutos u horas, cortar en cuanto el veredicto es claro (como el TKO del árbitro) evita gastar la prueba entera y acelera el feedback del pipeline.

Resumen y siguiente paso

Dos refinamientos sobre el gate básico. abortOnFail (formato largo: { threshold, abortOnFail, delayAbortEval }) hace que k6 corte la prueba en cuanto un umbral crítico se rompe —el árbitro que detiene la pelea—, con delayAbortEval para esperar a juntar muestras y no abortar por un pico inicial engañoso. Los thresholds por tag/escenario ("http_req_duration{scenario:quotes}": ["p(95)<200"]) le dan a cada parte del sistema su propio umbral, anclado en su propio SLO —la lección 5 aplicada por partes—. Lo construiste ejecutable en gate_multi.py: dos endpoints, /quote con umbral estricto (200 ms) y /quote_cpu con umbral holgado (400 ms) y abortOnFail; con carga liviana ambos pasan (exit 0), y cuando /quote_cpu se degrada bajo carga pesada rompe su umbral crítico y el gate aborta de verdad (exit 1), sin seguir.

Antes de avanzar deberías poder: escribir un threshold de formato largo con abortOnFail y delayAbortEval; escribir thresholds por escenario con umbrales defendibles para cada parte; y explicar qué ahorra abortar temprano. Lo que sigue, en la lección 8, es el mini-proyecto: reúnes todo el módulo —levantas Reservo con /quote_cpu, mides bajo dos cargas, aplicas evaluate_thresholds (verde con la liviana, rojo con la pesada, con su exit code) y escribes el bloque thresholds de k6 equivalente como contenido—. El gate de rendimiento, de punta a punta, con tus manos.

Recursos