Módulo 2: Feature Flags
Exposición gradual: el mismo flag, un porcentaje de usuarios
Descripción
Hasta ahora, el flag de recommendations era binario: enabled: true lo mostraba a todos, enabled: false no lo mostraba a nadie. El registro de la lección 3 ya tenía un campo rolloutPercent: 10 esperando — pero nada, todavía, lo convertía en una decisión real. Esta lección construye esa pieza: isEnabled(userId, flag), la función central de todo este módulo, que decide, comprador por comprador, si ve recommendations, usando un porcentaje en vez de un interruptor de todo o nada. Es la lección que vas a ejecutar más veces en el resto de la guía.
Conexión con el módulo. Esta es la lección donde el flag deja de ser un interruptor simple y se convierte en la herramienta que hace posible controlar el radio de impacto del módulo 1 en código real: blastRadius() mostraba cuánta gente conviene exponer primero (125 de 250,000, con un canary al 1%); isEnabled() es lo que de verdad logra que solo esa fracción, y ninguna más, vea la feature. La lección 5 le agrega el apagado de emergencia a esta misma función.
Una analogía: la lista de invitados, no el todo-o-nada de la puerta
Un evento privado con capacidad limitada no funciona con un solo guardia que decide "hoy entra todo el mundo" o "hoy no entra nadie" — funciona con una lista de invitados: un criterio fijo (¿tu nombre está en la lista de esta noche?) que cualquier guardia, en cualquier puerta, aplica exactamente igual. Si tu nombre está en la lista, entras siempre — no depende de qué guardia te revisó ni de qué tan cansado esté esa noche. Si el organizador decide ampliar la lista para la próxima fecha, la lista crece, pero sigue siendo la misma clase de decisión: consultar el nombre, no tirar una moneda al aire en la puerta.
isEnabled(userId, flag) es esa lista, calculada matemáticamente en vez de escrita a mano. En lugar de guardar 25,000 nombres en un archivo, calcula, para cada userId, un número fijo entre 0 y 99 —su posición en la "lista"— y lo compara contra rolloutPercent. El resultado es exactamente lo que necesitas de una lista de invitados real: el mismo comprador obtiene siempre la misma respuesta, sin importar cuántas veces visite la página ni qué servidor de Mercado atienda su request.
Ejemplo trabajado: isEnabled() sobre diez compradores de Mercado
Vamos a construir la función completa: un hash determinista del userId (nunca Math.random() — eso rompería exactamente la propiedad que necesitamos) módulo 100, comparado contra rolloutPercent:
// isEnabled: decide si un usuario ve una feature, usando un hash deterministico
// del userId (nunca Math.random -- eso rompe la estabilidad, que es el punto
// central de esta leccion) modulo 100, comparado contra rolloutPercent (0-100).
function hashUserId(userId) {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = (hash * 31 + userId.charCodeAt(i)) % 100;
}
return hash;
}
function isEnabled(userId, flag) {
if (!flag.enabled) return false; // kill switch: enabled=false apaga a TODOS, sin importar rolloutPercent (leccion 5)
const bucket = hashUserId(userId + flag.name); // salt con el nombre del flag: mismo user, distinto bucket por flag
return bucket < flag.rolloutPercent;
}
const recommendationsFlag = { name: 'recommendations', enabled: true, rolloutPercent: 10 };
const buyers = ['buyer-ana', 'buyer-bruno', 'buyer-carla', 'buyer-diego', 'buyer-elena',
'buyer-fabio', 'buyer-gina', 'buyer-hugo', 'buyer-irene', 'buyer-julio'];
console.log('=== isEnabled sobre 10 compradores nombrados (rolloutPercent=10) ===\n');
buyers.forEach((id) => {
console.log(id.padEnd(14) + '-> ' + isEnabled(id, recommendationsFlag));
});
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== isEnabled sobre 10 compradores nombrados (rolloutPercent=10) ===
buyer-ana -> false
buyer-bruno -> false
buyer-carla -> false
buyer-diego -> false
buyer-elena -> false
buyer-fabio -> false
buyer-gina -> false
buyer-hugo -> false
buyer-irene -> true
buyer-julio -> false
De diez compradores, exactamente uno —buyer-irene— ve la feature. Con un rolloutPercent de 10, esperarías, en una muestra tan chica, algo cerca de 1 de cada 10 — y eso es justo lo que salió, aunque con solo diez personas cualquier resultado entre 0 y 2 sería igual de razonable estadísticamente. Para confirmar el porcentaje con más confianza, hace falta una muestra más grande.
Verificando las dos propiedades que importan: el porcentaje, y la estabilidad
Sigamos con el mismo archivo, agregando dos verificaciones más sobre la misma función, sin cambiarle una sola línea:
console.log('\n=== Estabilidad: misma llamada, 3 veces, para buyer-ana ===');
console.log([isEnabled('buyer-ana', recommendationsFlag), isEnabled('buyer-ana', recommendationsFlag), isEnabled('buyer-ana', recommendationsFlag)]);
// bulk check over 1000 synthetic users
let count = 0;
const total = 1000;
for (let i = 0; i < total; i++) {
const id = 'user-' + String(i).padStart(4, '0');
if (isEnabled(id, recommendationsFlag)) count++;
}
console.log('\n=== Chequeo estadistico sobre ' + total + ' usuarios sinteticos (rolloutPercent=10) ===');
console.log(count + ' de ' + total + ' ven la feature = ' + (count / total * 100).toFixed(1) + '%');
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== Estabilidad: misma llamada, 3 veces, para buyer-ana ===
[ false, false, false ]
=== Chequeo estadistico sobre 1000 usuarios sinteticos (rolloutPercent=10) ===
98 de 1000 ven la feature = 9.8%
Las dos verificaciones prueban cosas distintas, y las dos importan. La primera —llamar isEnabled('buyer-ana', recommendationsFlag) tres veces seguidas y obtener false las tres— confirma la estabilidad: no hay ningún componente de azar en la función; el mismo userId con el mismo flag produce, siempre, la misma respuesta, sin importar cuántas veces se llame ni en qué momento. La segunda —correr la función sobre 1,000 usuarios sintéticos y contar cuántos caen en true— confirma el porcentaje: 9.8%, muy cerca del 10% configurado en rolloutPercent. Con una muestra de 1,000 en vez de 10, el resultado se acerca mucho más al porcentaje teórico, exactamente como esperarías de cualquier distribución razonablemente uniforme.
Por qué el hash, y por qué nunca Math.random()
La pieza que hace posible la estabilidad es hashUserId(): una función que toma una cadena de texto (userId + flag.name) y produce siempre el mismo número entre 0 y 99, sin ningún componente aleatorio — multiplica un acumulador por 31, le suma el código de cada carácter, y aplica módulo 100 en cada paso. No hace falta entender la aritmética exacta para confiar en la propiedad que importa: la misma entrada de texto siempre produce la misma salida numérica. Eso es, literalmente, lo opuesto de lo que haría Math.random(), que genera un número distinto en cada llamada, sin memoria de las llamadas anteriores.
Fíjate también en el detalle de userId + flag.name dentro del hash, en vez de usar solo userId. Ese "salting" con el nombre del flag existe para que el mismo comprador no caiga siempre en el mismo bucket para todos los flags de Mercado — buyer-irene puede estar en el 10% que ve recommendations y, al mismo tiempo, fuera del 50% que ve checkoutVariantB, porque cada flag calcula su propio hash con su propio nombre incluido. Sin ese detalle, un comprador que cayera en el 10% de un flag caería, automáticamente, en el 10% de absolutamente todos los flags de Mercado — una correlación que ningún equipo querría, y que rompería la validez de cualquier experimento corriendo en paralelo.
Una última propiedad, solo para que la notes hoy sin profundizar todavía: si rolloutPercent sube de 10 a 50, los buckets no se recalculan — buyer-irene (bucket menor a 10) sigue estando adentro, y compradores nuevos con bucket entre 10 y 49 se suman, sin que nadie que ya veía la feature deje de verla. Esa propiedad es la que hace posible, en principio, subir un porcentaje sin "reiniciar" a nadie — pero diseñar la rampa completa, con sus etapas y sus criterios para subir, es exactamente el trabajo del módulo 3, no de esta lección.
Errores comunes
Usar Math.random() para decidir quién ve la feature. Qué pasa: alguien escribe Math.random() < flag.rolloutPercent / 100 en vez de un hash del userId, y la función "funciona" en el sentido de que, en agregado, cerca del porcentaje correcto de requests obtiene true — pero cada request tira un dado nuevo, sin memoria de las anteriores. Por qué pasa: Math.random() es la forma más corta de escribir "dame verdadero X% de las veces", y para una sola llamada aislada da el resultado esperado. Cómo detectarlo: pídele a la función que decida dos veces para el mismo userId, en momentos distintos — si las dos respuestas pueden diferir, el bug ya está confirmado. Cómo corregirlo: como en el ejemplo de esta lección, la decisión tiene que depender únicamente de datos que no cambian entre llamadas (userId, flag.name) — nunca de una fuente de números aleatorios sin memoria.
Asignación no determinista: el mismo comprador ve la feature un día sí y otro no. Qué pasa: es la consecuencia directa del error anterior, vista desde la experiencia del usuario — buyer-irene ve el carrusel de recommendations en su visita de la mañana, y en la tarde, en la misma sesión, ya no está — sin que nadie haya cambiado el flag. Por qué pasa: cualquier fuente de aleatoriedad en la decisión (Math.random(), la hora del día, qué servidor atendió el request) produce este parpadeo, aunque el porcentaje agregado se vea correcto en un dashboard. Cómo detectarlo: quejas de soporte del tipo "vi algo ayer y hoy ya no está" son la señal más clara — y también rompe cualquier intento de medir el efecto de la feature, porque un usuario que salta entre control y variant invalida el experimento que la corre. Cómo corregirlo: el chequeo de estabilidad de esta lección —llamar isEnabled() varias veces para el mismo userId y confirmar que la respuesta no cambia— debería ser una prueba automatizada obligatoria antes de confiar en cualquier lógica de rollout por porcentaje.
Un flag booleano global cuando la situación pedía un porcentaje por usuario. Qué pasa: el equipo quiere exponer recommendations "solo un poco" para probar con cuidado, pero como el flag original de la lección 2 solo tenía enabled: true/false, la única forma de "ir despacio" termina siendo prender el flag por una hora y apagarlo, en vez de exponerlo de forma estable a una fracción real de usuarios. Por qué pasa: un flag booleano es más simple de razonar, y si nadie construyó todavía la lógica de rolloutPercent, "prender y apagar rápido" se siente como la única palanca disponible. Cómo detectarlo: si la estrategia de "ir despacio" de un equipo depende de prender y apagar el flag en vez de fijar un porcentaje estable, es señal de que falta esta lección. Cómo corregirlo: isEnabled(userId, flag) con rolloutPercent es exactamente la herramienta correcta para este caso — un porcentaje fijo y estable, no un interruptor que parpadea.
Ejercicios
Ejercicio 1 — Calcula el bucket a mano. Sin ejecutar código, usa la lógica de hashUserId() para razonar: si bucket('buyer-x' + 'recommendations') diera, por ejemplo, 7, y rolloutPercent es 10, ¿isEnabled() devuelve true o false? ¿Y si rolloutPercent fuera 5?
Ver solución
Con bucket = 7 y rolloutPercent = 10: 7 < 10 es true — el comprador ve la feature. Con rolloutPercent = 5: 7 < 5 es false — el mismo comprador, con el mismo bucket calculado, ya no la ve. Este ejercicio ilustra que el bucket de un usuario no cambia cuando cambia rolloutPercent —sigue siendo 7, siempre—; lo que cambia es el umbral contra el que se compara. Es la misma propiedad de monotonicidad que se mencionó al final de la sección de profundización: subir el porcentaje solo puede sumar usuarios que antes no calificaban, nunca quitar a los que ya calificaban.
Ejercicio 2 — Predicción con dos flags. buyer-irene tiene bucket menor a 10 para recommendations (por eso isEnabled('buyer-irene', recommendationsFlag) da true con rolloutPercent: 10). Si Mercado activa un flag nuevo, checkoutVariantB, con rolloutPercent: 50, ¿es seguro asumir que buyer-irene también va a ver ese segundo flag? ¿Por qué sí o por qué no, según el diseño de hashUserId()?
Ver solución
No es seguro asumirlo — de hecho, no hay ninguna relación garantizada entre los dos resultados. hashUserId() calcula el bucket con userId + flag.name, así que el bucket de 'buyer-irene' + 'recommendations' y el de 'buyer-irene' + 'checkoutVariantB' son, en la práctica, dos cálculos independientes que producen números distintos y sin correlación entre sí. Ese es precisamente el propósito del "salting" con el nombre del flag que se explicó en la lección: evitar que un mismo comprador caiga sistemáticamente adentro o afuera de todos los flags de Mercado a la vez.
Ejercicio 3 — Diseña la prueba de estabilidad. Escribe, en un par de líneas de pseudocódigo o JavaScript, una función checkStability(userId, flag, times) que llame isEnabled(userId, flag) la cantidad de veces indicada por times y devuelva true solo si todas las respuestas fueron idénticas.
Ver solución
function checkStability(userId, flag, times) {
const results = [];
for (let i = 0; i < times; i++) results.push(isEnabled(userId, flag));
return results.every((r) => r === results[0]);
}
console.log(checkStability('buyer-irene', recommendationsFlag, 10)); // true
Esta función es, en esencia, una versión automatizada del chequeo de estabilidad que ya viste en el ejemplo de esta lección —llamar varias veces y comparar—, generalizada para cualquier cantidad de repeticiones. En un sistema real, una prueba como esta correría como parte de la suite de tests automatizados de cualquier implementación de feature flags, precisamente para atrapar el error de "asignación no determinista" antes de que llegue a producción.
Resumen y siguiente paso
En esta lección construiste isEnabled(userId, flag), la función central de este módulo: un hash determinista del userId (nunca Math.random()), módulo 100, comparado contra rolloutPercent. Confirmaste dos propiedades sobre el caso de Mercado —a un rolloutPercent de 10, el 9.8% de una muestra de 1,000 usuarios sintéticos ve recommendations, y buyer-ana, consultado tres veces, obtiene siempre la misma respuesta—. Esas dos propiedades, porcentaje correcto y estabilidad garantizada, son exactamente lo que un rollout gradual necesita del mecanismo que lo sostiene.
Antes de avanzar deberías poder: explicar por qué un hash determinista logra estabilidad y Math.random() no; ejecutar isEnabled() mentalmente dado un bucket y un rolloutPercent; y explicar por qué cada flag necesita su propio "salt" en el hash, en vez de compartir el bucket de un usuario entre todos los flags.
La lección 5 le agrega a esta misma función la pieza que falta para una emergencia real: el kill switch, que tiene que ganarle a rolloutPercent sin importar su valor, apagando a todos al instante.
Recursos
- LaunchDarkly, "What Is Progressive Delivery All About?" — launchdarkly.com/blog/what-is-progressive-delivery-all-about. Describe el rollout por porcentaje como la técnica central de la entrega progresiva —exponer una feature a una fracción específica de usuarios antes de decidir el resto—, el tema completo de esta lección. En inglés.
- Google SRE Workbook, Capítulo 16, "Canarying Releases" — sre.google/workbook/canarying-releases. Documenta frameworks de feature flags/experimentos como el mecanismo que separa el lanzamiento de una feature del release binario, con exposición fraccionada como la de
isEnabled(). En inglés.