Módulo 4: Monitoring The Launch

Guardrails en vuelo: vigilando cada etapa de la rampa

Descripción

La lección anterior te dio el criterio para leer un resultado de guardrail según cuánta gente está expuesta. Esta lección construye la pieza central del módulo: guardrailWatch(), la función que recorre la rampa completa del módulo 3 —canary 1% → 10% → 50% → 100%— y, en cada etapa, corre guardrailCheck() contra la línea base y decide si la rampa continúa o se detiene. No es una función que reemplace nada de lo que ya construiste: reutiliza guardrailCheck() exactamente como quedó en la guía de métricas, y le agrega una sola idea nueva — la secuencia de etapas, y la disciplina de detenerse en la primera que rompe un guardrail, sin evaluar las siguientes.

Conexión con el módulo. Esta es la función que la lección 1 prometió, y la que el proyecto de la lección 8 va a reutilizar sin cambiarle una línea — el mismo patrón que ya viste en el módulo 2, donde isEnabled() se construyó una vez y se reusó en el proyecto final. Todo lo que sigue en este módulo —cuándo frenar (lección 4), qué mostrar en el dashboard (lección 5), cómo alertar (lección 6), el error budget (lección 7)— da por construida esta función.

Una analogía: el control de vuelo, tramo por tramo

Un vuelo largo no se supervisa con una sola revisión al despegar y otra al aterrizar. La torre de control, y la tripulación misma, revisan el estado del avión en tramos: altitud de crucero alcanzada, combustible dentro de rango, sistemas respondiendo — y si algo se sale de rango en cualquier tramo, la respuesta no es "esperemos a ver si se arregla solo en el siguiente tramo", es atender el problema en ese tramo, antes de seguir avanzando hacia el siguiente punto de la ruta. Nadie sigue subiendo de crucero con una alarma de presión de cabina sonando, "porque ya casi llegamos al siguiente punto de control".

guardrailWatch() es exactamente esa disciplina de tramo por tramo, aplicada al rollout de recommendations. Cada etapa de la rampa —canary, 10%, 50%, 100%— es un tramo. Si un guardrail se rompe en un tramo, la función no sigue evaluando los tramos siguientes como si nada hubiera pasado: se detiene ahí, con el mismo criterio que un piloto que no sigue subiendo de crucero con una alarma activa.

Ejemplo trabajado: guardrailWatch() sobre el rollout real de recommendations

// guardrailCheck: reutilizada, SIN cambios, de la guia de metricas
// (product-metrics-and-experimentation-guide, modulo 4, leccion 6).
function guardrailCheck(before, after, guardrails) {
  const results = guardrails.map((g) => {
    const beforeVal = before[g.metric];
    const afterVal = after[g.metric];
    let broken = false;
    let detail = '';
    if (g.type === 'ceiling') {
      broken = afterVal > g.limit;
      detail = afterVal + ' vs techo ' + g.limit;
    } else if (g.type === 'floor') {
      broken = afterVal < g.limit;
      detail = afterVal + ' vs piso ' + g.limit;
    } else if (g.type === 'maxIncrease') {
      const delta = afterVal - beforeVal;
      broken = delta > g.limit;
      detail = 'delta +' + delta.toFixed(4) + ' vs maximo permitido +' + g.limit;
    }
    return { metric: g.metric, before: beforeVal, after: afterVal, broken, detail };
  });
  const brokenGuardrails = results.filter((r) => r.broken).map((r) => r.metric);
  return { results, anyBroken: brokenGuardrails.length > 0, brokenGuardrails };
}

// guardrailWatch: NUEVA de este modulo. Recorre las etapas de la rampa (M3)
// EN ORDEN y, en cada una, corre guardrailCheck() contra la linea base. En
// cuanto UNA etapa rompe un guardrail, marca HALT y detiene la vigilancia --
// las etapas siguientes de la rampa nunca se alcanzan, y se reportan como
// tales, en vez de evaluarse con datos que en la practica nunca se midieron.
function guardrailWatch(stages, guardrails, baseline) {
  const log = [];
  let halted = false;
  for (const stage of stages) {
    if (halted) {
      log.push({ stage: stage.name, percent: stage.percent, status: 'not reached (ramp halted earlier)' });
      continue;
    }
    if (!stage.metrics) {
      log.push({ stage: stage.name, percent: stage.percent, status: 'not measured yet' });
      continue;
    }
    const check = guardrailCheck(baseline, stage.metrics, guardrails);
    if (check.anyBroken) {
      log.push({ stage: stage.name, percent: stage.percent, status: 'HALT', broken: check.brokenGuardrails, results: check.results });
      halted = true;
    } else {
      log.push({ stage: stage.name, percent: stage.percent, status: 'continue', results: check.results });
    }
  }
  return { log, halted };
}

// El caso: el rollout de recommendations en Mercado, vigilado etapa por
// etapa. La linea base y los cuatro guardrails son EXACTAMENTE los de la
// guia de metricas -- no se redefine nada aqui.
const baseline = {
  checkoutLatencyP95Ms: 650,
  complaintRate: 0.012,
  churnRate: 0.045,
  grossMargin: 0.220,
};

const guardrails = [
  { metric: 'checkoutLatencyP95Ms', type: 'ceiling', limit: 800 },
  { metric: 'complaintRate', type: 'maxIncrease', limit: 0.005 },
  { metric: 'churnRate', type: 'maxIncrease', limit: 0.010 },
  { metric: 'grossMargin', type: 'floor', limit: 0.180 },
];

// La rampa del modulo 3, con las metricas medidas en cada etapa que SI se
// alcanzo. Las etapas de 50% y 100% todavia no tienen metricas porque el
// rollout, en la practica, nunca llego ahi (eso es, precisamente, lo que
// esta funcion va a confirmar).
const rampStages = [
  { name: 'canary', percent: 1, metrics: { checkoutLatencyP95Ms: 680, complaintRate: 0.012, churnRate: 0.045, grossMargin: 0.220 } },
  { name: 'ramp-10', percent: 10, metrics: { checkoutLatencyP95Ms: 910, complaintRate: 0.013, churnRate: 0.045, grossMargin: 0.219 } },
  { name: 'ramp-50', percent: 50, metrics: null },
  { name: 'full', percent: 100, metrics: null },
];

console.log('=== guardrailWatch: vigilando el rollout de recommendations, etapa por etapa ===\n');
const watch = guardrailWatch(rampStages, guardrails, baseline);
watch.log.forEach((entry) => {
  console.log(entry.stage + ' (' + entry.percent + '%): ' + entry.status);
  if (entry.results) {
    entry.results.forEach((r) => {
      console.log('    ' + r.metric.padEnd(22) + String(r.before).padStart(8) + ' -> ' + String(r.after).padStart(8) +
        '  ' + (r.broken ? 'ROTO' : 'OK  ') + '  (' + r.detail + ')');
    });
  }
  if (entry.broken) console.log('    guardrail(s) roto(s): ' + entry.broken.join(', '));
});
console.log('\nRampa detenida: ' + watch.halted);

Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:

=== guardrailWatch: vigilando el rollout de recommendations, etapa por etapa ===

canary (1%): continue
    checkoutLatencyP95Ms       650 ->      680  OK    (680 vs techo 800)
    complaintRate            0.012 ->    0.012  OK    (delta +0.0000 vs maximo permitido +0.005)
    churnRate                0.045 ->    0.045  OK    (delta +0.0000 vs maximo permitido +0.01)
    grossMargin               0.22 ->     0.22  OK    (0.22 vs piso 0.18)
ramp-10 (10%): HALT
    checkoutLatencyP95Ms       650 ->      910  ROTO  (910 vs techo 800)
    complaintRate            0.012 ->    0.013  OK    (delta +0.0010 vs maximo permitido +0.005)
    churnRate                0.045 ->    0.045  OK    (delta +0.0000 vs maximo permitido +0.01)
    grossMargin               0.22 ->    0.219  OK    (0.219 vs piso 0.18)
    guardrail(s) roto(s): checkoutLatencyP95Ms
ramp-50 (50%): not reached (ramp halted earlier)
full (100%): not reached (ramp halted earlier)

Rampa detenida: true

Lee el resultado en orden, porque el orden es exactamente lo que importa. En canary (1%, los 125 compradores del módulo 1), los cuatro guardrails salen OK — y, como confirmó la lección 2, checkoutLatencyP95Ms ya tiene señal confiable en esta etapa (680ms, todavía lejos del techo de 800), así que este OK sí es una confirmación real, no solo ausencia de evidencia. La rampa avanza a 10%. Ahí, checkoutLatencyP95Ms salta a 910ms — el mismo número exacto que la guía de métricas ya había medido en su reporte final—, cruza el techo de 800, y guardrailWatch() marca esa etapa HALT. A partir de ahí, halted queda en true, y las etapas de 50% y 100% aparecen como not reached: la función no inventa datos para esas etapas ni finge que las evaluó — reporta, con precisión, que la rampa nunca llegó ahí.

Fíjate en algo importante: de los cuatro guardrails, solo uno se rompe (checkoutLatencyP95Ms); los otros tres (complaintRate, churnRate, grossMargin) siguen en OK incluso en la etapa donde se activa el HALT. guardrailWatch() no necesita que todos los guardrails se rompan para detener la rampa — con que uno se rompa, alcanza. Es la misma regla que ya viste en guardrailCheck() de la guía de métricas: anyBroken es suficiente, no hace falta unanimidad.

Profundización: por qué el break importa tanto como el if

Vale la pena mirar la mecánica de la función con cuidado, porque la parte que hace el trabajo real no es la condición check.anyBroken — es lo que pasa después: la variable halted se pone en true, y desde ese momento, todas las etapas siguientes del for entran directo al primer if del bucle, sin correr guardrailCheck() ni una sola vez más. Eso no es un detalle de implementación menor: es, literalmente, la diferencia entre un sistema de vigilancia que respeta su propia alarma y uno que la ignora. Si guardrailWatch() siguiera evaluando las etapas de 50% y 100% con datos hipotéticos después de un HALT, estaría simulando un rollout que nunca debió haber avanzado tanto — exactamente el error que la lección 4 va a nombrar con su nombre completo: seguir subiendo la rampa "porque el primario gana", ignorando que un guardrail ya dio la señal de frenar.

Errores comunes

Evaluar todas las etapas de la rampa de una sola vez, sin respetar el orden. Qué pasa: alguien corre guardrailCheck() directamente sobre los datos de la etapa de 10% (o de cualquier etapa) sin haber confirmado primero que las etapas anteriores pasaron limpias. Por qué pasa: si ya tienes los datos de todas las etapas guardados, es tentador revisarlos en cualquier orden, como si fueran filas independientes de una tabla. Cómo detectarlo: si el reporte de guardrails no dice, en ningún lado, "esto se detuvo en la etapa X y no siguió", la vigilancia perdió la secuencia que la hace útil. Cómo corregirlo: guardrailWatch() procesa las etapas en el orden de la rampa, y se detiene apenas encuentra un HALT — el orden es la parte que convierte una lista de resultados en una decisión de "hasta dónde llegamos".

Simular datos para las etapas "not reached" para completar el reporte. Qué pasa: alguien, incómodo con ver etapas vacías en el reporte, rellena ramp-50 y full con valores estimados o proyectados, "para que el dashboard se vea completo". Por qué pasa: un reporte con huecos se siente incompleto, y hay una tentación real de rellenarlo con algo, aunque ese algo no se haya medido nunca. Cómo detectarlo: si algún número en el reporte de guardrails no corresponde a una medición real, sino a una proyección disfrazada de dato, el reporte deja de ser confiable. Cómo corregirlo: not reached es, en sí mismo, información valiosa — dice, con precisión, "la rampa nunca llegó aquí, y eso fue una decisión, no un vacío accidental". No hace falta —ni conviene— rellenarlo con nada más.

Confundir "un solo guardrail roto" con "hay que revisar si el primario compensa." Qué pasa: al ver el HALT en 10%, alguien propone seguir a 50% de todos modos "porque checkoutConversionRate sigue subiendo fuerte, y eso pesa más". Por qué pasa: cuando el número primario se ve bien, es tentador tratar un guardrail roto como un costo aceptable a cambio de esa ganancia, en vez de una señal de frenar. Cómo detectarlo: si la conversación después de un HALT incluye la frase "pero el primario está ganando", sin ninguna mención de diagnosticar la causa del guardrail roto primero. Cómo corregirlo: guardrailWatch(), deliberadamente, no recibe el valor de checkoutConversionRate como input — el guardrail se evalúa solo, sin que el resultado primario pueda "comprar" un permiso para ignorarlo. Esa separación es la disciplina que la lección 4 va a desarrollar a fondo.

Ejercicios

Ejercicio 1 — Cambia el umbral y vuelve a correr. Si el techo de checkoutLatencyP95Ms hubiera sido 950ms en vez de 800ms (los mismos datos: canary 680, 10% en 910), ¿guardrailWatch() seguiría marcando HALT en la etapa de 10%? ¿Hasta dónde llegaría la rampa?

Ver solución

No se rompería, y la rampa llegaría hasta el final. Con limit: 950, la condición de guardrailCheck() evalúa 910 > 950, que es false — el guardrail pasaría OK en la etapa de 10%. Como los otros tres guardrails ya salían OK en los datos originales, y no hay más etapas con datos medidos (ramp-50 y full siguen en metrics: null en este ejemplo), el resultado sería continue en canary y en 10%, y not measured yet en las dos etapas restantes —no HALT en ningún punto—. Este ejercicio confirma, otra vez, que el resultado depende por completo de dónde se fijó el umbral: 800ms detiene la rampa; 950ms la habría dejado avanzar con los mismos datos reales.

Ejercicio 2 — Agrega una quinta etapa. El equipo de Mercado quiere agregar una etapa intermedia entre canary y 10%, llamada 'ramp-5' con percent: 5, con métricas medidas de checkoutLatencyP95Ms: 790 (los otros tres guardrails sin cambios respecto a canary). ¿Dónde se detendría la rampa ahora, y por qué?

Ver solución

Con checkoutLatencyP95Ms: 790, la condición evalúa 790 > 800, que es false — la etapa ramp-5 pasaría como continue, todavía dentro del techo, aunque ya muy cerca (10ms de margen). La rampa seguiría hasta ramp-10, donde 910 > 800 sí rompe el guardrail, y ahí se detendría exactamente igual que en el ejemplo original — solo que ahora con una etapa intermedia extra que muestra la latencia ya subiendo de forma progresiva (650 → 790 → 910) antes de cruzar el techo. Este ejercicio es un buen anticipo de la lección 7: ese margen de apenas 10ms en ramp-5 es, literalmente, presupuesto de error budget casi agotado.

Ejercicio 3 — Explica el not reached a alguien fuera del equipo técnico. En 2-3 frases, sin usar la palabra "HALT" ni "guardrail", explica a alguien del equipo comercial de Mercado por qué el reporte de guardrailWatch() dice "no alcanzada" para las etapas de 50% y 100%, en vez de mostrar algún número ahí.

Ver solución

Un mensaje razonable: "Al llegar al 10% de usuarios expuestos, notamos que la página se estaba tardando más de lo aceptable en cargar, así que decidimos no seguir subiendo el porcentaje hasta resolver eso. Por eso el reporte no tiene números para el 50% ni el 100% — no es que se nos haya olvidado medir, es que, a propósito, todavía no expusimos a esa cantidad de gente al problema que ya detectamos. Cuando resolvamos la lentitud, vamos a retomar el rollout desde ahí, no desde cero."

Resumen y siguiente paso

En esta lección construiste y ejecutaste guardrailWatch(), la función central del módulo: recorre la rampa del módulo 3 en orden, reutiliza guardrailCheck() sin cambios en cada etapa, y se detiene en la primera que rompe un guardrail. Sobre el rollout real de recommendations, el resultado fue preciso: canary pasa limpio (680ms, dentro del techo de 800), 10% rompe el guardrail de latencia (910ms) y activa HALT, y las etapas de 50% y 100% quedan marcadas como nunca alcanzadas — la rampa se detuvo exactamente donde debía, sin avanzar ni un paso más allá del problema.

Antes de avanzar deberías poder: explicar por qué guardrailWatch() deja de evaluar etapas después de un HALT, en vez de seguir revisando el resto de la rampa; identificar, en el resultado, cuál de los cuatro guardrails rompió y por qué los otros tres no bastan para "salvar" la decisión; y anticipar qué pasaría con un umbral distinto sin tener que correr el código de nuevo.

La lección 4 se queda exactamente en este resultado —HALT en 10%— y pregunta qué significa, en la práctica, esa palabra: qué le pasaría a la base de usuarios de Mercado si el equipo decidiera ignorarla y seguir subiendo de todos modos, y qué criterio explícito debería existir, de antemano, para que nadie tenga que decidir "en caliente" si un HALT se respeta o no.

Recursos

  • Google SRE Book, Capítulo 6, "Monitoring Distributed Systems" — sre.google/sre-book/monitoring-distributed-systems. La referencia formal sobre por qué un sistema en producción necesita revisarse en tramos, con las mismas cuatro señales, de forma consistente — la disciplina que guardrailWatch() automatiza. En inglés.
  • LaunchDarkly, "Introducing Guardrail Metrics: best-practice metrics for every release" — launchdarkly.com/blog/introducing-guardrail-metrics. Cómo una plataforma real de guarded rollouts pausa automáticamente un release cuando detecta una regresión, el mismo comportamiento que guardrailWatch() implementa a mano. En inglés.
  • Ronny Kohavi, "Guardrail Metrics for A/B Tests" — linkedin.com/pulse/guardrail-metrics-ab-tests-ronny-kohavi. El artículo de Kohavi sobre guardrails, la base conceptual que esta lección lleva de "un solo chequeo" a "vigilancia continua a lo largo de una rampa". En inglés.