Módulo 8: Project Ship Mercados Recommendations

Re-lanzar, y verificar que el lift aguanta

Descripción

Todo lo anterior converge aquí. El motor v2 está validado en shadow mode (90.0% de acuerdo global y 84.6% en el segmento cold-start, por encima de los dos umbrales de modelMigrationDecision()). El timeout con fallback del postmortem ya se implementó. La rampa sigue siendo la misma que diseñó la lección 2 de este módulo. Esta lección re-lanza recommendations desde el canary, sube toda la rampa con rolloutPlan() — esta vez con la latencia arreglada — y hace algo que ninguna lección anterior de esta guía pudo hacer todavía: verificar, varias semanas después, si el +18.75% de lift original se sostiene, o si era solo el efecto de la novedad.

Conexión con el módulo. Esta lección reutiliza rolloutPlan() exactamente como quedó en el módulo 3, corriéndolo sobre la misma rampa de cuatro etapas con datos de latencia ya corregidos. Reutiliza también noveltyCheck() tal como quedó en la lección 7 del módulo 6, sin cambiarle una línea: compara el lift medido en la primera semana después del re-lanzamiento contra el de varias semanas después, para distinguir un efecto real y durable de un pico temporal de curiosidad que se desinfla con el tiempo.

Una analogía: el segundo despegue, con el avión ya revisado

Vuelve, una última vez, a la analogía del vuelo que abrió este módulo. El primer intento de despegue —el rollout original— se detuvo a mitad de pista cuando los instrumentos confirmaron una falla real: la turbulencia de latencia. El equipo de tierra investigó, encontró la causa exacta, y la corrigió — no con una promesa vaga de "va a estar mejor", sino con una pieza específica reemplazada (v1 por v2) y probada en tierra antes de volver a intentarlo (el shadow mode de la lección anterior).

Este es el segundo despegue. Y esta vez, hay algo más que verificar además de si el avión despega sin problemas: ¿el destino al que llega realmente vale la pena, o solo parecía prometedor la primera vez que alguien lo vio? Un pasajero que se emociona con un destino nuevo la primera semana, y pierde el interés a la cuarta, no es la misma señal que un pasajero que sigue eligiendo ese destino mes tras mes. El noveltyCheck() de esta lección es esa segunda verificación — no solo "¿llegamos bien?", sino "¿vale la pena seguir yendo?".

Ejemplo trabajado: re-lanzamiento limpio y verificación de durabilidad

// M8 L07: re-lanza recommendations con v2 (fix de latencia validado en la leccion
// anterior) sobre la misma rampa del modulo 3, y verifica con noveltyCheck() si el
// lift de +18.75% se sostiene semanas despues, o si era solo efecto de novedad.
// rolloutPlan() es EXACTAMENTE la del modulo 3. noveltyCheck() es EXACTAMENTE la
// del modulo 6, leccion 7 -- mismo umbral (50%), mismo decayPct como porcentaje,
// misma forma de entrada y de retorno, sin ningun cambio.

function rolloutPlan(stages) {
  const results = [];
  let halted = false;
  for (const s of stages) {
    if (halted) { results.push({ ...s, decision: 'NOT_REACHED' }); continue; }
    const decision = s.advanceIf(s.measured) ? 'ADVANCE' : 'HOLD';
    results.push({ ...s, decision });
    if (decision === 'HOLD') halted = true;
  }
  return results;
}
function noveltyCheck(weeklyLift) {
  const first = weeklyLift[0];
  const last = weeklyLift[weeklyLift.length - 1];
  const decayPct = Math.round(((first - last) / first) * 1000) / 10;
  const verdict = decayPct > 50 ? 'NOVELTY (se desvanece)' : 'HOLDS (se sostiene)';
  return { weeklyLift, first, last, decayPct, verdict };
}

console.log('=== Parte 1: rolloutPlan -- re-lanzamiento con v2 + fix de latencia ===\n');
const ceiling = 800;
const relaunchStages = [
  { percent: 0.01, label: 'canary 1%', measured: { p95Latency: 705 }, advanceIf: (m) => m.p95Latency <= ceiling },
  { percent: 0.10, label: 'rollout 10%', measured: { p95Latency: 740 }, advanceIf: (m) => m.p95Latency <= ceiling },
  { percent: 0.50, label: 'rollout 50%', measured: { p95Latency: 765 }, advanceIf: (m) => m.p95Latency <= ceiling },
  { percent: 1.00, label: 'rollout 100%', measured: { p95Latency: 778 }, advanceIf: (m) => m.p95Latency <= ceiling },
];
const relaunchResult = rolloutPlan(relaunchStages);
relaunchResult.forEach((s) => console.log(s.label.padEnd(14) + 'p95=' + s.measured.p95Latency + 'ms -> ' + s.decision));

console.log('\n=== Parte 2: noveltyCheck -- el lift de +18.75% aguanta 4 semanas despues? ===\n');
const weeklyLift = [0.1875, 0.1810, 0.1755, 0.1740];
weeklyLift.forEach((lift, i) => console.log('semana ' + (i + 1) + ': +' + (lift * 100).toFixed(2) + '%'));
const novelty = noveltyCheck(weeklyLift);
console.log('\ndecay = (' + (novelty.first * 100).toFixed(2) + '% - ' + (novelty.last * 100).toFixed(2) + '%) / ' + (novelty.first * 100).toFixed(2) + '% = ' + novelty.decayPct + '%');
console.log(novelty.verdict);

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

=== Parte 1: rolloutPlan -- re-lanzamiento con v2 + fix de latencia ===

canary 1%     p95=705ms -> ADVANCE
rollout 10%   p95=740ms -> ADVANCE
rollout 50%   p95=765ms -> ADVANCE
rollout 100%  p95=778ms -> ADVANCE

=== Parte 2: noveltyCheck -- el lift de +18.75% aguanta 4 semanas despues? ===

semana 1: +18.75%
semana 2: +18.10%
semana 3: +17.55%
semana 4: +17.40%

decay = (18.75% - 17.40%) / 18.75% = 7.2%
HOLDS (se sostiene)

La Parte 1 es la diferencia más visible con el primer intento de lanzamiento: las cuatro etapas avanzan limpio, ADVANCE en cada una, hasta llegar al 100% de la base de Mercado. El mismo criterio exacto (p95Latency <= 800) que detuvo la rampa en 910ms durante el primer intento ahora se cumple en las cuatro etapas —705ms, 740ms, 765ms, 778ms—, con el motor v2 sosteniendo la latencia por debajo del techo incluso en la etapa de mayor volumen. Nota que la latencia sube un poco en cada etapa (más tráfico concurrente, algo esperable), pero se mantiene con margen respecto al techo en las cuatro — a diferencia del primer intento, donde el margen desapareció por completo en la segunda etapa.

La Parte 2 contesta una pregunta que ninguna lección anterior de esta guía completa pudo contestar todavía: ¿el +18.75% original era un efecto real, o el entusiasmo pasajero de ver algo nuevo? El lift baja, semana a semana —18.75%18.10%17.55%17.40%—, lo cual es normal: algo de decaimiento inicial es casi siempre esperable cuando una parte del lift viene de la novedad de ver algo nuevo por primera vez. Lo que importa es la magnitud de esa caída: un 7.2% de decaimiento sobre el lift original, muy por debajo del umbral de 50% que noveltyCheck() usa para distinguir "efecto de novedad que se desinfla" de "efecto real que se sostiene" — el mismo umbral, sin ningún ajuste, que la lección 7 del módulo 6 fijó de antemano. El veredicto es HOLDS (se sostiene): la ganancia de recommendations no depende de que sea nuevo — sigue funcionando cuando dejó de serlo.

Por qué esta verificación no existía en ningún módulo anterior

Vale la pena notar por qué noveltyCheck() aparece justo aquí, y no antes. Cada pieza anterior de esta guía —blastRadius(), isEnabled(), rolloutPlan(), guardrailWatch(), rollbackDecision()— trabaja con datos de un momento específico: el estado del sistema en el instante en que se mide. noveltyCheck() es distinto: necesita datos de varias semanas, porque la pregunta que contesta —¿esto se sostiene?— no tiene sentido con una sola medición. Un lanzamiento puede pasar todos los guardrails de latencia, todos los criterios de la rampa, y aun así resultar, semanas después, en un efecto que se desinfla — eso no sería un fallo de ninguna de las piezas anteriores, sería, simplemente, un tipo de riesgo distinto que ninguna de ellas está diseñada para detectar. Por eso el ciclo completo de un lanzamiento no termina en "llegó al 100% sin romper nada" — termina cuando alguien vuelve, semanas después, y confirma que el resultado sigue siendo real.

Errores comunes

Declarar el lanzamiento exitoso apenas se alcanza el 100%, sin esperar a medir la durabilidad. Qué pasa: el equipo celebra el resultado de la Parte 1 —cuatro ADVANCE seguidos— y considera el trabajo terminado ahí, sin programar ninguna medición de seguimiento semanas después. Por qué pasa: llegar al 100% sin ningún HALT se siente como la línea de meta, y es fácil olvidar que el valor real del experimento —el lift sostenido— todavía no se ha confirmado a esa escala de tiempo. Cómo detectarlo: si nadie tiene programada una revisión del lift varias semanas después del re-lanzamiento, la pregunta de la Parte 2 de esta lección se va a quedar sin contestar. Cómo corregirlo: como en esta lección, un rollout limpio hasta el 100% es una condición necesaria, pero no suficiente — el ciclo se cierra con noveltyCheck(), no antes.

Interpretar cualquier caída semana a semana como evidencia de que el efecto no era real. Qué pasa: alguien ve que el lift bajó de 18.75% a 17.40% entre la semana 1 y la 4, y concluye —incorrectamente— que "el resultado se está cayendo, esto no va a durar". Por qué pasa: cualquier número que baja se siente, intuitivamente, como una mala señal, sin comparar la magnitud de esa caída contra un umbral razonable. Cómo detectarlo: si la conclusión sobre durabilidad se basa en "el número bajó" sin calcular el porcentaje de decaimiento ni compararlo contra ningún umbral, la lectura es incompleta. Cómo corregirlo: como calcula noveltyCheck(), lo que importa no es si el lift bajó —casi siempre baja un poco—, sino cuánto: un 7.2% de decaimiento, muy por debajo del 50% que marcaría una preocupación real, es consistente con un efecto durable, no con uno que se está desinflando.

Re-lanzar con la rampa original sin re-verificar el flag y sus criterios. Qué pasa: alguien asume que, como el flag y la rampa ya se diseñaron en la lección 2 de este módulo, no hace falta revisarlos de nuevo antes de este re-lanzamiento — simplemente se reactiva recommendationsFlag.enabled = true y se sube directo. Por qué pasa: el trabajo de diseño ya está hecho, y repetirlo se siente redundante. Cómo detectarlo: si el re-lanzamiento no incluye una nueva pasada por el canary con datos reales del motor v2 —como hace la Parte 1 de esta lección, con p95=705ms en la primera etapa—, se está asumiendo que el comportamiento del sistema con el modelo nuevo es idéntico al que se midió con el modelo viejo, sin verificarlo. Cómo corregirlo: como en esta lección, el re-lanzamiento vuelve a correr la rampa completa desde el canary, con datos medidos de verdad sobre el sistema ya corregido — no se salta ninguna etapa solo porque la rampa "ya se probó una vez" con un motor distinto.

Ejercicios

Ejercicio 1 — Calcula el decay con un caso de novedad real. Supón que, para otra feature de Mercado (un banner de descuento por tiempo limitado), el lift medido fue: semana 1, 22%; semana 4, 9%. Calcula el decayPct con la fórmula de noveltyCheck(), y determina el veredicto.

Ver solución

decayPct = Math.round(((0.22 - 0.09) / 0.22) * 1000) / 10 = 59.1. Como 59.1 > 50, el veredicto sería NOVELTY (se desvanece) — el lift original probablemente estaba inflado por la curiosidad inicial de ver un banner nuevo, y ese efecto se desinfló en gran parte hacia la semana 4. Este resultado sería consistente con la intuición: un banner de "tiempo limitado" es, por diseño, un tipo de feature más propensa a generar un pico de atención inicial que no se sostiene, a diferencia de una mejora funcional como recommendations, que sigue siendo útil aunque deje de ser nueva.

Ejercicio 2 — Diseña una quinta semana de verificación. El equipo de Mercado quiere agregar una semana 5 a este seguimiento, para confirmar que el lift se mantiene estable más allá de la cuarta semana. Si la semana 5 midiera un lift de 17.20%, ¿cómo cambiaría el resultado de noveltyCheck() si se agregara ese dato al arreglo weeklyLift?

Ver solución

Con weeklyLift extendido a cinco elementos, noveltyCheck() seguiría comparando weeklyLift[0] (semana 1, 18.75%) contra weeklyLift[weeklyLift.length - 1] — que ahora sería la semana 5 (17.20%), no la semana 4. El nuevo decayPct sería Math.round(((0.1875 - 0.1720) / 0.1875) * 1000) / 10 = 8.3, todavía muy por debajo del 50, así que el veredicto seguiría siendo HOLDS (se sostiene). Este ejercicio confirma que noveltyCheck(), tal como está escrito, siempre compara la primera y la última medición del arreglo que recibe — agregar más semanas intermedias no cambia la lógica, solo actualiza cuál es "la última" medición disponible.

Ejercicio 3 — Cierra el ciclo por escrito. Escribe el mensaje (100-150 palabras) que le enviarías al equipo ejecutivo de Mercado, cuatro semanas después del re-lanzamiento, confirmando que recommendations está en producción al 100% y que el resultado se sostiene. Incluye: el estado del rollout, la latencia con el fix aplicado, y el resultado de noveltyCheck().

Ver solución

Un mensaje posible: "recommendations está en producción al 100% de nuestra base de compradores desde hace cuatro semanas, sin ningún incidente adicional. La migración al motor v2 resolvió el problema de latencia que detuvo el primer intento: p95Latency se mantiene entre 705ms y 778ms en las cuatro etapas de la rampa, siempre por debajo de nuestro techo de 800ms, con margen. Sobre el resultado de negocio: el lift de conversión en checkout, que arrancó en +18.75% la primera semana, se mantiene en +17.40% cuatro semanas después — una caída de apenas 7.2%, dentro de lo esperable como ajuste inicial y muy lejos de indicar que el efecto era solo curiosidad pasajera. El resultado es real y durable. Este cierra el ciclo completo del lanzamiento: detectamos el problema a tiempo, lo corregimos con evidencia, y confirmamos que el valor se sostiene." El mensaje separa el estado técnico (rollout, latencia) del resultado de negocio (lift durable), con los números exactos de ambos.

Resumen y siguiente paso

En esta lección re-lanzaste recommendations con el motor v2 ya validado: la rampa completa avanza limpio hasta el 100%, con la latencia siempre por debajo del techo de 800ms en las cuatro etapas. Confirmaste, con noveltyCheck(), que el lift de +18.75% se sostiene cuatro semanas después (+17.40%, un decaimiento de apenas 7.2%) — el resultado es durable, no un efecto de novedad que se desinfla.

Antes de avanzar deberías poder: explicar por qué noveltyCheck() necesita datos de varias semanas, a diferencia del resto de las piezas de esta guía; y calcular el decayPct a mano dados dos valores de lift.

Con las siete piezas de M1 a M7 corridas, en orden, sobre el mismo lanzamiento —desde el radio de impacto hasta la verificación de durabilidad—, la lección 8 junta todo en un solo documento: el plan de lanzamiento completo, la corrida end-to-end en un único script de Node, y el cierre de esta guía y de todo el ecosistema de Product Engineering.

Recursos

  • Ron Kohavi, Diane Tang y Ya Xu, Trustworthy Online Controlled Experimentsexperimentguide.com. Incluye una discusión detallada sobre el efecto de novedad y por qué medir un resultado solo en la primera semana puede ser engañoso. En inglés.
  • Google SRE Workbook, Capítulo 16, "Canarying Releases" — sre.google/workbook/canarying-releases. La referencia formal del rollout gradual, ahora aplicada por segunda vez en esta guía sobre el mismo caso, con el problema ya corregido. En inglés.