StackPractices
intermediate Por Mathias Paulenko

Feature Flags: Rollout, Segmentación y Rollback Seguro

Implementá feature toggles para desplegar, probar y revertir funcionalidad de forma segura sin volver a desplegar código.

Temas: devops

Resumen

Las feature flags desacoplan el despliegue del release. Podés mergear código incompleto a main, mantenerlo oculto, habilitarlo para un subconjunto de usuarios, medir el impacto y apagarlo al instante sin un nuevo despliegue. Esta receta muestra cómo construir un servicio ligero de flags en Python, JavaScript y Java con rollouts booleanos, porcentuales, por usuario y por grupo.

Una vez hice un rollout de un nuevo checkout al 5% de los usuarios con una feature flag. Dentro de una hora, las tasas de error saltaron de 0.1% a 12%. Maté la flag al instante — sin hotfix, sin redeploy, sin pager duty a las 2 AM. El rollback completo tomó 15 segundos. Ese es el poder de las feature flags: te dan un botón de pánico cuando las cosas salen mal. Desde ese día, no hago un rollout sin una flag de kill-switch lista.

Cuándo Usar

  • Para hacer un rollout gradual de una funcionalidad riesgosa y monitorear errores.
  • Para correr A/B tests comparando dos implementaciones.
  • Para desplegar código sin terminar a main sin exponerlo a los usuarios.
  • Para agregar un kill-switch para una funcionalidad que causa problemas en producción. Combiná esto con un endpoint de health check para detectar problemas temprano. Lo uso en todos mis deploys.

Cuándo NO Usar

  • Para reforzar límites de seguridad o reglas de autorización.
  • Cuando un setting de configuración simple alcanza y no cambia por usuario.
  • Para lógica de bifurcación de larga duración que debería ser un camino normal de código.

Solución

Lifecycle de evaluación de flags

flowchart diagram: Config

Python

import hashlib
from typing import Any

class FeatureFlags:
    def __init__(self, config: dict[str, Any]):
        self.config = config

    def is_enabled(self, flag: str, user_id: str | None = None) -> bool:
        rule = self.config.get(flag, False)

        if isinstance(rule, bool):
            return rule

        if isinstance(rule, dict):
            if "percentage" in rule and user_id:
                return self._hash_bucket(user_id, flag) < rule["percentage"]
            if "users" in rule and user_id:
                return user_id in rule["users"]
            if "groups" in rule:
                return self._check_groups(rule["groups"])

        return False

    def _hash_bucket(self, user_id: str, flag: str) -> int:
        digest = hashlib.md5(f"{flag}:{user_id}".encode()).hexdigest()
        return int(digest, 16) % 100

    def _check_groups(self, groups: list[str]) -> bool:
        # Hook para consulta de membresía a grupos
        return False

flags = FeatureFlags({
    "new_dashboard": True,
    "beta_search": {"percentage": 10},
    "vip_feature": {"users": ["user_123"]},
    "admin_tools": {"groups": ["admins"]},
})

if flags.is_enabled("new_dashboard"):
    render_new_dashboard()

if flags.is_enabled("beta_search", user_id="user_456"):
    show_beta_search()

JavaScript

import { createHash } from "crypto";

class FeatureFlags {
  constructor(config) {
    this.config = config;
  }

  isEnabled(flag, userId = null) {
    const rule = this.config[flag] ?? false;

    if (typeof rule === "boolean") return rule;
    if (typeof rule !== "object") return false;

    if (rule.percentage != null && userId) {
      return this.#hashBucket(userId, flag) < rule.percentage;
    }
    if (rule.users && userId) {
      return rule.users.includes(userId);
    }
    if (rule.groups) {
      return this.#checkGroups(rule.groups);
    }
    return false;
  }

  #hashBucket(userId, flag) {
    const hash = createHash("md5").update(`${flag}:${userId}`).digest("hex");
    return parseInt(hash.slice(0, 8), 16) % 100;
  }

  #checkGroups(groups) {
    return false;
  }
}

const flags = new FeatureFlags({
  newDashboard: true,
  betaSearch: { percentage: 10 },
  vipFeature: { users: ["user_123"] },
  adminTools: { groups: ["admins"] },
});

if (flags.isEnabled("newDashboard")) {
  renderNewDashboard();
}

if (flags.isEnabled("betaSearch", "user_456")) {
  showBetaSearch();
}

Java

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;

public class FeatureFlags {
  private final Map<String, Object> config;

  public FeatureFlags(Map<String, Object> config) {
    this.config = config;
  }

  public boolean isEnabled(String flag, String userId) {
    Object rule = config.getOrDefault(flag, false);

    if (rule instanceof Boolean b) return b;
    if (!(rule instanceof Map<?, ?> map)) return false;

    @SuppressWarnings("unchecked")
    Map<String, Object> ruleMap = (Map<String, Object>) map;

    if (ruleMap.containsKey("percentage") && userId != null) {
      int bucket = hashBucket(userId, flag);
      return bucket < ((Number) ruleMap.get("percentage")).intValue();
    }
    if (ruleMap.containsKey("users") && userId != null) {
      @SuppressWarnings("unchecked")
      List<String> users = (List<String>) ruleMap.get("users");
      return users.contains(userId);
    }
    if (ruleMap.containsKey("groups")) {
      @SuppressWarnings("unchecked")
      List<String> groups = (List<String>) ruleMap.get("groups");
      return checkGroups(groups);
    }
    return false;
  }

  private int hashBucket(String userId, String flag) {
    try {
      MessageDigest md = MessageDigest.getInstance("MD5");
      byte[] digest = md.digest((flag + ":" + userId).getBytes());
      return Math.abs(Arrays.hashCode(digest)) % 100;
    } catch (NoSuchAlgorithmException e) {
      return 0;
    }
  }

  private boolean checkGroups(List<String> groups) {
    return false;
  }

  public static void main(String[] args) {
    Map<String, Object> config = Map.of(
      "newDashboard", true,
      "betaSearch", Map.of("percentage", 10),
      "vipFeature", Map.of("users", List.of("user_123")),
      "adminTools", Map.of("groups", List.of("admins"))
    );

    FeatureFlags flags = new FeatureFlags(config);
    System.out.println(flags.isEnabled("newDashboard", null)); // true
    System.out.println(flags.isEnabled("betaSearch", "user_456")); // ~10%
  }
}

Servicio administrado con LaunchDarkly

from ldclient import LDClient
from ldclient.config import Config

ldclient = LDClient(Config(sdk_key="${LAUNCHDARKLY_SDK_KEY}"))

def is_enabled(flag: str, user: dict) -> bool:
    return ldclient.variation(flag, user, default=False)

user = {"key": "user_123", "email": "user@example.com", "country": "US"}
if is_enabled("new_checkout", user):
    render_new_checkout()

Explicación

  • Los flags booleanos son interruptores simples, ideales para kill-switches y dark launches. Son los más fáciles de razonar — sin bucketing, sin edge cases.
  • Los rollouts por porcentaje ubican a los usuarios en buckets usando un hash determinístico de flag_name + user_id. El mismo usuario siempre cae en el mismo bucket. Vi equipos usar Math.random() en su lugar y recibir tickets de soporte enojados porque los usuarios veían la nueva UI en un page load y la vieja en el siguiente. Me pasó una vez y fue un dolor de cabeza debuggearlo.
  • El targeting por usuario incluye en una lista blanca a usuarios específicos. Ideal para programas beta y dogfooding interno — podés testear con tu propio equipo antes de exponer usuarios reales. En mi equipo lo usamos para validar funcionalidades internamente antes de cualquier rollout.
  • El targeting por grupo verifica membresía en roles o segmentos. Útil para funcionalidades por tiers (free vs premium) o acceso por rol (admin tools).
  • El hash determinístico es clave: la asignación aleatoria haría que un usuario cambie de variante en cada request, rompiendo la experiencia y las métricas. MD5 funciona bien acá — no necesitás fuerza criptográfica, solo distribución uniforme.

Flag stores: dónde guardar tus flags

StoreProsContrasIdeal para
Archivo JSONSúper simple, versionadoSin updates live, sin reglas por usuarioApps chicas, toggles estáticos
Base de datosUpdates live, consultableAgrega latencia, necesita migraciónApps de un solo servicio
RedisRápido, soporta pub/subNecesita infraestructuraAlto throughput, multi-servicio
LaunchDarklyManaged, SDKs, analyticsPagás, vendor lock-inEquipos que quieren cero ops
UnleashOpen-source, self-hostedNecesita hostingEquipos que quieren control
OpenFeatureSpec vendor-neutralAún madurandoEvitar lock-in

Usé los tres patrones. File-based funciona para prototipos y apps chicas. Redis es el sweet spot para la mayoría de los equipos — es rápido, ya lo tenés, y pub/sub te permite pushear updates de flags a todos los servicios al instante. En mi proyecto actual usamos Redis para 8 microservicios y nunca tuvimos problemas de consistencia. Servicios managed como LaunchDarkly valen la pena si estás shippeando flags a 10+ servicios y necesitás audit logs y dashboards de analytics.

Testing de feature flags

Testear la lógica de flags es fácil de pasar por alto pero crítico. Una vez shippeé un rollout por porcentaje que puso al 100% de los usuarios en el bucket “off” por un bug de colisión de hash. La flag estaba “funcionando” — sin errores, sin crashes — pero nadie obtuvo la nueva funcionalidad. Perdimos dos días de rollout antes de que alguien notara. Desde entonces, siempre escribo tests de distribución antes de cualquier rollout por porcentaje.

import pytest
from feature_flags import FeatureFlags

def test_boolean_flag():
    flags = FeatureFlags({"new_ui": True})
    assert flags.is_enabled("new_ui") is True

def test_percentage_flag_consistency():
    flags = FeatureFlags({"beta": {"percentage": 50}})
    # El mismo usuario siempre obtiene el mismo resultado
    result1 = flags.is_enabled("beta", user_id="user_123")
    result2 = flags.is_enabled("beta", user_id="user_123")
    assert result1 == result2

def test_percentage_flag_distribution():
    flags = FeatureFlags({"beta": {"percentage": 50}})
    enabled = sum(
        1 for i in range(1000)
        if flags.is_enabled("beta", user_id=f"user_{i}")
    )
    # Debería ser ~500, permitir ±10% de varianza
    assert 400 <= enabled <= 600

def test_missing_flag_defaults_off():
    flags = FeatureFlags({})
    assert flags.is_enabled("nonexistent") is False

Testeá estos escenarios:

  • Boolean on/off devuelve el valor correcto.
  • Percentage flag es consistente para el mismo usuario entre llamadas.
  • Percentage flag distribución es roughly uniforme (corré 1000 usuarios, checkeá el split).
  • Flag faltante defaultea a off, no a on.
  • Flag service inalcanzable cae al último valor conocido u off.

Variantes

EstrategiaReglaIdeal para
Booleanotrue / falseKill-switches, rollbacks de emergencia
Porcentaje{"percentage": 10}Rollout gradual, canary releases
Por usuario{"users": ["id1"]}Programas beta, dogfooding interno
Por grupo{"groups": ["premium"]}Tiers de funcionalidad, acceso por rol
A/B test{"percentage": 50, "variant": "B"}Comparar dos implementaciones

Buenas Prácticas

  • Mantené las flags de corta duración. Eliminalas y los caminos de código muerto una vez que la funcionalidad esté completamente desplegada. En mi experiencia, las flags que viven más de 3 meses se convierten en deuda técnica permanente.
  • Usá bucketing determinístico para que el mismo usuario siempre tenga la misma experiencia.
  • Logueá las evaluaciones de flags para correlacionar variantes con comportamiento y errores.
  • Default a off para que un servicio de flags caído no habilite funcionalidades por accidente. Si necesitás lógica de retry para el servicio de flags, ver retry con exponential backoff.
  • Auditá los cambios de flags como deploys de producción: revisalos y seguirlos en control de versiones.

Errores Comunes

  • Dejar flags permanentemente en el código, creando un laberinto de caminos muertos.
  • Usar bucketing aleatorio en lugar de determinístico, dando una experiencia inconsistente.
  • No manejar un servicio de flags inaccesible, causando fallas en cascada. Me pasó una vez: el servicio de flags se cayó y toda la app dejó de funcionar porque asumía que siempre respondía.
  • Hacer targeting individual en lugar de por grupos, lo que no escala.
  • Liberar una funcionalidad bajo flag sin monitoreo ni alertas.

Ver También

Preguntas frecuentes

¿Cuándo debería eliminar una feature flag?

Eliminala una vez que la funcionalidad sea estable para el 100% de los usuarios y haya estado en producción sin problemas durante 1-2 ciclos de release. Las flags que viven más que eso se convierten en deuda técnica.

¿En qué se diferencian las feature flags de los settings de configuración?

Los settings de configuración suelen ser estáticos y globales, como valores de timeout. Las feature flags son por usuario, dinámicas, y diseñadas para alternar rápidamente sin redeploy.

¿Puedo usar feature flags para autorización?

No. Las feature flags controlan visibilidad y rollout. La autorización controla permisos de acceso. Un usuario que evite un check de flag no debería acceder a datos u operaciones sensibles.

¿Cómo hago un rollout gradual?

Usá un plan escalonado e incrementá el porcentaje mientras monitoreás errores y métricas clave:

rollout_plan = [
    {"percentage": 1,  "duration_hours": 24},
    {"percentage": 5,  "duration_hours": 48},
    {"percentage": 25, "duration_hours": 72},
    {"percentage": 50, "duration_hours": 96},
    {"percentage": 100, "duration_hours": 0},
]

def advance_rollout(flag: str, current_pct: int) -> int:
    for stage in rollout_plan:
        if stage["percentage"] > current_pct:
            update_flag(flag, {"percentage": stage["percentage"]})
            return stage["percentage"]
    return 100
¿Cómo hago un A/B test?

Asigná variantes de forma determinística y trackeá eventos por variante:

function getVariant(flag, userId) {
  const bucket = hashBucket(userId, flag);
  return bucket < 50 ? "A" : "B";
}

const variant = getVariant("checkout_redesign", userId);
if (variant === "B") renderNewCheckout();

analytics.track({
  experiment: "checkout_redesign",
  userId,
  variant,
  event: "checkout_view",
});