Módulo 8: Project Ship Mercados Recommendations

Vigilar la rampa en vivo: el HALT en 10%

Descripción

La lección anterior dejó el flag encendido en el canario (1%, 720ms, dentro del techo) y la rampa completa diseñada hasta el 100%. Ahora toca lo que ningún diseño de rampa, por bueno que sea, puede reemplazar: vigilar en vivo qué pasa realmente cuando el tráfico sube. Esta lección corre guardrailWatch() sobre la rampa completa, con los cuatro guardrails de negocio de la guía de métricas —no solo latencia—, y confirma exactamente dónde y por qué se detiene.

Conexión con el módulo. Esta lección reutiliza guardrailCheck(), guardrailWatch() y errorBudgetTracker() exactamente como quedaron en las lecciones 3 y 7 del módulo 4, sin ningún cambio. La diferencia con la lección anterior de este mismo capstone no es el mecanismo — es el alcance: donde la lección 2 evaluó solo p95Latency en la etapa canary con rolloutPlan() (el patrón simple del módulo 3), esta lección evalúa los cuatro guardrails a la vez —latencia, quejas, churn y margen— en cada etapa con datos reales, con el patrón más completo del módulo 4. Esta lección se detiene exactamente en el momento en que el HALT se confirma; qué hacer con esa confirmación es el trabajo de la lección 4, que sigue después de esta.

Una analogía: el copiloto que lee los instrumentos en cada tramo, no solo al despegar

Un despegue exitoso no garantiza un vuelo seguro completo — es apenas el primer tramo. Un copiloto entrenado no revisa los instrumentos una sola vez, al principio, y después confía en que todo sigue bien; los revisa en cada tramo del vuelo, con la misma atención, porque las condiciones cambian con la altitud, con el clima, con el tiempo transcurrido. Un motor que suena perfecto en la pista puede comportarse distinto a diez mil metros de altura, con más carga y más tiempo de uso continuo.

El canary de la lección anterior fue el despegue: 720ms, dentro del techo, todo en orden. Pero el canary corrió sobre apenas un 1% de la base, durante un tiempo corto — condiciones muy distintas a las que enfrenta el sistema cuando diez veces más tráfico concurrente le pide recomendaciones al mismo motor, al mismo tiempo. Esta lección es el copiloto revisando los instrumentos en el siguiente tramo del vuelo — la etapa de 10% — con la misma atención que en el despegue, no menos.

Ejemplo trabajado: guardrailWatch() sobre la rampa completa

// M8 L03: vigila la rampa completa de recommendations con los CUATRO guardrails de la
// guia de metricas (no solo latencia), etapa por etapa, hasta confirmar donde y por que
// se detiene. guardrailCheck(), guardrailWatch() y errorBudgetTracker() son EXACTAMENTE
// las del modulo 4 (L3, L7), sin ningun cambio.

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 };
}
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 };
}
function errorBudgetTracker(baselineValue, ceiling, stages) {
  const totalBudgetMs = ceiling - baselineValue;
  return stages.map((s) => {
    const consumedMs = s.value - baselineValue;
    const remainingMs = totalBudgetMs - consumedMs;
    const pctConsumed = (consumedMs / totalBudgetMs) * 100;
    return {
      stage: s.name, percent: s.percent, value: s.value, consumedMs, remainingMs, pctConsumed,
      status: remainingMs < 0 ? 'EXHAUSTED' : 'within budget',
    };
  });
}

console.log('=== Parte 1: guardrailWatch sobre la rampa completa ===\n');
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 },
];
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 },
];
const watch = guardrailWatch(rampStages, guardrails, baseline);
watch.log.forEach((entry) => console.log(entry.stage + ' (' + entry.percent + '%): ' + entry.status));

console.log('\n=== Parte 2: el guardrail responsable, con su detalle ===\n');
const haltEntry = watch.log.find((e) => e.status === 'HALT');
const brokenDetail = haltEntry.results.find((r) => r.broken);
console.log('Etapa del HALT: ' + haltEntry.stage + ' (' + haltEntry.percent + '%)');
console.log('Guardrail roto: ' + brokenDetail.metric + '  ' + brokenDetail.before + ' -> ' + brokenDetail.after + '  (' + brokenDetail.detail + ')');

console.log('\n=== Parte 3: error budget consumido en esa etapa ===\n');
const budget = errorBudgetTracker(650, 800, [{ name: haltEntry.stage, percent: haltEntry.percent, value: brokenDetail.after }]);
const b = budget[0];
console.log(b.stage + ' (' + b.percent + '%): consumido=' + b.consumedMs + 'ms (' + b.pctConsumed.toFixed(1) +
  '% del presupuesto de 150ms) | restante=' + b.remainingMs + 'ms | ' + b.status);

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

=== Parte 1: guardrailWatch sobre la rampa completa ===

canary (1%): continue
ramp-10 (10%): HALT
ramp-50 (50%): not reached (ramp halted earlier)
full (100%): not reached (ramp halted earlier)

=== Parte 2: el guardrail responsable, con su detalle ===

Etapa del HALT: ramp-10 (10%)
Guardrail roto: checkoutLatencyP95Ms  650 -> 910  (910 vs techo 800)

=== Parte 3: error budget consumido en esa etapa ===

ramp-10 (10%): consumido=260ms (173.3% del presupuesto de 150ms) | restante=-110ms | EXHAUSTED

Fíjate en algo que la lección 2 de este módulo, con su rolloutPlan() de un solo criterio, no podía mostrar: en el canary, los cuatro guardrails se revisan —no solo latencia—, y los cuatro pasan (continue). En ramp-10, es específicamente checkoutLatencyP95Ms el que rompe —los otros tres (complaintRate, churnRate, grossMargin) se mantienen dentro de rango—, y eso importa: el HALT no es "todo está mal", es "un guardrail específico, con un número específico, cruzó su límite". Las etapas ramp-50 y full nunca se alcanzan, exactamente como se diseñó: guardrailWatch() no sigue evaluando etapas después de un HALT, sin importar que sus datos ya estuvieran definidos en el código (aquí, de hecho, ni siquiera existen — son null, porque nunca se llegó a medirlos).

La Parte 3 traduce el mismo hallazgo a una magnitud: 260ms consumidos contra un presupuesto de 150ms, un 173.3% de sobregasto, con -110ms de saldo negativo. Ese número —no solo la palabra "roto"— es lo que separa un ajuste fino de un problema que necesita rediseño: un guardrail que se excede por un 5% pide un parche; uno que se excede por un 173%, como en este caso, pide investigar la causa raíz antes de seguir subiendo.

Por qué esta lección usa guardrailWatch() y no rolloutPlan()

La lección 2 de este módulo usó rolloutPlan(), con un solo criterio (p95Latency <= 800), para confirmar que el canary avanzaba. Esta lección usa guardrailWatch(), con cuatro guardrails a la vez. La diferencia no es cosmética — refleja dos preguntas distintas que un lanzamiento real necesita contestar en momentos distintos. rolloutPlan(), con su advanceIf() simple, es la herramienta correcta cuando ya sabes exactamente qué criterio importa y solo necesitas decidir avanzar o no. guardrailWatch(), con su lista completa de guardrails, es la herramienta correcta cuando necesitas vigilar todo lo que podría romperse, no solo lo que ya sospechas que va a romperse — exactamente la situación de un rollout real, donde un problema de latencia podría, en teoría, aparecer junto con un aumento de quejas o una caída de margen, y el equipo necesita saber cuál de esos, si alguno, está pasando de verdad.

En el caso de recommendations, resulta que solo la latencia se rompe — pero el equipo no lo sabía de antemano. Vigilar los cuatro guardrails y confirmar que tres se mantienen sanos es, en sí mismo, información valiosa: descarta la hipótesis de que el problema sea más amplio que un cuello de botella técnico específico.

Errores comunes

Revisar solo el guardrail que ya se sospechaba roto, ignorando los otros tres. Qué pasa: alguien, sabiendo de antemano (por la guía de métricas) que la latencia es el problema, corre guardrailCheck() únicamente sobre checkoutLatencyP95Ms, sin incluir complaintRate, churnRate ni grossMargin en la lista de guardrails. Por qué pasa: ya se conoce el resultado esperado, y verificar los otros tres se siente como trabajo innecesario cuando "ya sabemos cuál es el problema". Cómo detectarlo: si el arreglo guardrails de tu código tiene menos de cuatro elementos, no estás vigilando todo lo que la guía de métricas identificó como relevante para este lanzamiento. Cómo corregirlo: como en la Parte 1 de esta lección, la lista completa de guardrails se vigila siempre, incluso cuando ya sospechas cuál va a romperse — la confirmación de que los otros tres siguen sanos es, en sí misma, parte del reporte.

Confundir el HALT de ramp-10 con un fallo del canary. Qué pasa: alguien, al ver el resultado, concluye que "el canary también falló", mezclando las dos etapas en una sola conclusión. Por qué pasa: ambas etapas pertenecen a la misma rampa, y es fácil generalizar el resultado de una etapa posterior hacia atrás. Cómo detectarlo: si tu resumen del resultado no distingue explícitamente entre canary: continue y ramp-10: HALT, la etapa exacta del problema se perdió en la comunicación. Cómo corregirlo: como muestra la Parte 1, cada etapa tiene su propio estatus — el canary pasó limpio con los cuatro guardrails sanos; el problema apareció específicamente cuando el tráfico subió a 10%, no antes. Esa distinción importa porque apunta a la causa: algo relacionado con volumen o concurrencia, no con la feature en sí misma en cualquier escala.

Reportar el HALT sin el error budget, dejando la magnitud del problema sin cuantificar. Qué pasa: alguien comunica "la latencia se rompió en 10%" sin agregar el dato de la Parte 3 —cuánto se excedió el presupuesto—, dejando ambigua la gravedad real del problema. Por qué pasa: "roto" ya suena como información suficiente, y calcular el error budget parece un paso adicional opcional. Cómo detectarlo: si tu reporte no puede contestar "¿por cuánto se excedió?", falta la magnitud que distingue un ajuste menor de un problema serio. Cómo corregirlo: como en la Parte 3 de esta lección, siempre acompaña un HALT con el error budget consumido — 173.3% de sobregasto comunica, de inmediato, que esto no es un caso límite discutible.

Ejercicios

Ejercicio 1 — ¿Qué hubiera pasado si solo la latencia hubiera empeorado levemente? Supón que, en ramp-10, checkoutLatencyP95Ms hubiera medido 790ms en vez de 910ms (todavía dentro del techo de 800ms), con el resto de los datos iguales. Ejecuta mentalmente guardrailWatch() con ese cambio. ¿La rampa se detiene en algún punto? ¿Qué error budget mostraría la Parte 3 para esa etapa?

Ver solución

Con checkoutLatencyP95Ms: 790, la condición afterVal > g.limit (790 > 800) es false — el guardrail de latencia no se rompe, y como los otros tres guardrails ya estaban sanos en los datos originales, ramp-10 pasaría a continue. La rampa seguiría evaluando ramp-50 y full — pero como esos dos siguen con metrics: null en este ejercicio, guardrailWatch() los marcaría como not measured yet (no HALT ni NOT_REACHED), esperando datos reales antes de decidir. El error budget en ramp-10 sería consumedMs: 790 - 650 = 140, contra un presupuesto de 150 — un 93.3% consumido, remainingMs: 10, status: 'within budget' — dentro del límite, pero con muy poco margen restante, una señal de que valdría la pena vigilar de cerca antes de confiar en que la siguiente etapa también se sostiene.

Ejercicio 2 — Aplica el patrón a un guardrail distinto. El equipo de logística de Mercado vigila su algoritmo de tiempos de entrega con un guardrail de errorRate (tipo ceiling, límite 0.04) junto a los mismos tres guardrails de negocio (complaintRate, churnRate, grossMargin, sin cambios). En su etapa de 10%, miden errorRate: 0.038; los otros tres guardrails se mantienen iguales al baseline. ¿guardrailWatch() reporta continue o HALT en esa etapa?

Ver solución

continue. Con type: 'ceiling' y limit: 0.04, la condición de ruptura es afterVal > g.limit, es decir, 0.038 > 0.04, que es false — el guardrail no se rompe, aunque esté cerca del límite. Con los otros tres guardrails también sanos (iguales al baseline, sin ningún incremento que exceda su maxIncrease ni caiga bajo su floor), check.anyBroken sería false para esa etapa, y guardrailWatch() continuaría a la siguiente. Vale la pena notar que "cerca del límite pero sin romperlo" (0.038 contra 0.04) es una señal de alerta razonable para el equipo, aunque el código, correctamente, no la trate como un HALT — esa distinción entre "cerca" y "roto" es, precisamente, para qué sirve tener un límite numérico explícito en vez de un juicio impreciso.

Ejercicio 3 — Comunica el HALT con las tres partes juntas. Escribe el mensaje (80-120 palabras) que enviarías al canal de incidentes del equipo de Mercado en el momento exacto en que guardrailWatch() confirma el HALT en ramp-10. Incluye: la etapa, el guardrail responsable con sus tres números (antes, después, techo), y el error budget consumido.

Ver solución

Un mensaje posible: "🔴 HALT confirmado — rollout de recommendations, etapa ramp-10 (10% de la base). checkoutLatencyP95Ms pasó de 650ms a 910ms, por encima del techo de 800ms que fijamos antes de lanzar. Los otros tres guardrails (quejas, churn, margen) siguen sanos. Error budget: 260ms consumidos contra un presupuesto de 150ms — 173.3% de sobregasto, saldo negativo de -110ms. La rampa no avanza a 50% ni a 100% hasta resolver esto. Necesitamos decidir en los próximos minutos: ¿revertimos o investigamos con la exposición actual?" El mensaje da la etapa exacta, los tres números del guardrail, la magnitud del error budget, y plantea de inmediato la pregunta que la siguiente lección contesta.

Resumen y siguiente paso

En esta lección vigilaste la rampa completa de recommendations con guardrailWatch() sobre los cuatro guardrails de negocio: el canary pasa limpio (680ms, los cuatro sanos), pero ramp-10 se detiene con HALT cuando checkoutLatencyP95Ms rompe el techo (910ms contra 800ms), consumiendo un 173.3% del presupuesto de error disponible. Las etapas ramp-50 y full nunca se alcanzan — exactamente lo que la rampa está diseñada para hacer frente a un guardrail roto.

Antes de avanzar deberías poder: explicar la diferencia entre usar rolloutPlan() (un criterio) y guardrailWatch() (varios guardrails a la vez); y calcular a mano, dado un valor de checkoutLatencyP95Ms, si una etapa pasaría o rompería el techo de 800ms.

La lección 4 toma esta confirmación —HALT en ramp-10, 25,000 compradores expuestos, guardrail crítico roto— y contesta la pregunta que el mensaje del ejercicio 3 dejó abierta: ¿revertir, o arreglar hacia adelante? Y, tomada la decisión, ¿qué tan rápido respondió realmente el equipo?

Recursos

  • Google SRE Book, Capítulo 6, "Monitoring Distributed Systems" — sre.google/sre-book/monitoring-distributed-systems. El marco de referencia sobre vigilar sistemas con señales completas, no una sola métrica aislada — la base de por qué esta lección usa cuatro guardrails a la vez. En inglés.
  • Google SRE Book, Capítulo 3, "Embracing Risk" — sre.google/sre-book/embracing-risk. El capítulo sobre error budgets que sostiene la Parte 3 de esta lección: cuánto margen quedaba, y cuánto se excedió. En inglés.
  • LaunchDarkly, "Introducing Guardrail Metrics: best-practice metrics for every release" — launchdarkly.com/blog/introducing-guardrail-metrics. Cómo la industria automatiza, en plataformas reales, la disciplina de vigilar varios guardrails a la vez durante un rollout. En inglés.