Saltar al contenido

Feature flags a escala: estrategias para entrega progresiva

Implementa feature flags con despliegues por porcentaje, segmentación, pruebas A/B y kill switches, desacoplando el despliegue del lanzamiento.

4 min de lectura
Un pipeline de despliegue con feature flags controlando porcentajes de tráfico hacia rutas de código nueva y antigua

El despliegue no es el lanzamiento

Desplegar código y lanzar una funcionalidad son dos acciones distintas. El despliegue es una operación técnica: subir artefactos a producción. El lanzamiento es una decisión de negocio: exponer funcionalidad a los usuarios. Los feature flags separan ambas cosas, permitiéndote desplegar código oscuro en cualquier momento y lanzar funcionalidades cuando estés listo, a quien tú elijas.

El contrato del feature flag

Un feature flag es un punto de decisión en tiempo de ejecución. En su forma más simple, es un booleano. A escala, es un motor de reglas que evalúa el contexto del usuario contra reglas de segmentación para devolver un valor.

tstypescript
interface FeatureFlag {
  key: string;
  type: "boolean" | "string" | "number" | "json";
  defaultValue: unknown;
  enabled: boolean;
  rules: TargetingRule[];
  killSwitch: boolean;
}
 
interface TargetingRule {
  priority: number;
  conditions: Condition[];
  percentage: number;
  value: unknown;
}
 
interface Condition {
  attribute: string;
  operator: "eq" | "neq" | "in" | "contains" | "gt" | "lt" | "semver_gt";
  value: unknown;
}
 
interface EvaluationContext {
  userId: string;
  email?: string;
  plan?: string;
  country?: string;
  appVersion?: string;
  customAttributes?: Record<string, unknown>;
}
tstypescript
class FeatureFlagClient {
  private flags: Map<string, FeatureFlag> = new Map();
 
  evaluate<T>(flagKey: string, context: EvaluationContext, fallback: T): T {
    const flag = this.flags.get(flagKey);
 
    // Flag doesn't exist — return fallback
    if (!flag) return fallback;
 
    // Kill switch activated — return default immediately
    if (flag.killSwitch) return flag.defaultValue as T;
 
    // Flag disabled globally — return default
    if (!flag.enabled) return flag.defaultValue as T;
 
    // Evaluate targeting rules in priority order
    const sortedRules = [...flag.rules].sort(
      (a, b) => a.priority - b.priority
    );
 
    for (const rule of sortedRules) {
      if (this.matchesConditions(rule.conditions, context)) {
        if (this.isInPercentage(context.userId, flagKey, rule.percentage)) {
          return rule.value as T;
        }
      }
    }
 
    return flag.defaultValue as T;
  }
 
  private matchesConditions(
    conditions: Condition[],
    context: EvaluationContext
  ): boolean {
    return conditions.every((condition) => {
      const contextValue = this.getAttribute(context, condition.attribute);
      switch (condition.operator) {
        case "eq":
          return contextValue === condition.value;
        case "neq":
          return contextValue !== condition.value;
        case "in":
          return Array.isArray(condition.value) &&
            condition.value.includes(contextValue);
        case "contains":
          return typeof contextValue === "string" &&
            contextValue.includes(condition.value as string);
        default:
          return false;
      }
    });
  }
 
  // Deterministic percentage based on userId + flagKey hash
  private isInPercentage(
    userId: string,
    flagKey: string,
    percentage: number
  ): boolean {
    if (percentage >= 100) return true;
    if (percentage <= 0) return false;
    const hash = this.consistentHash(`${userId}:${flagKey}`);
    return (hash % 100) < percentage;
  }
}

Patrones de lanzamiento progresivo

Empieza con usuarios internos, expande a beta testers, y luego sube a 1%, 5%, 25%, 50%, 100%. Si las métricas empeoran en cualquier etapa, detén o revierte al instante — sin necesidad de desplegar.

tstypescript
// ❌ Big-bang release — all users get the feature at once
function NewCheckout() {
  return <RadicallyDifferentCheckout />;
}
 
// ✅ Progressive rollout with monitoring at each stage
function Checkout() {
  const flags = useFeatureFlags();
  const isNewCheckout = flags.evaluate("new-checkout-flow", {
    userId: user.id,
    plan: user.plan,
    country: user.country,
  }, false);
 
  return isNewCheckout ? <NewCheckoutFlow /> : <CurrentCheckoutFlow />;
}
 
// Rollout configuration evolution:
const rolloutStages: TargetingRule[] = [
  // Stage 1: Internal team only
  {
    priority: 1,
    conditions: [{ attribute: "email", operator: "contains", value: "@ourcompany.com" }],
    percentage: 100,
    value: true,
  },
  // Stage 2: Beta users
  {
    priority: 2,
    conditions: [{ attribute: "plan", operator: "eq", value: "beta" }],
    percentage: 100,
    value: true,
  },
  // Stage 3: 5% of all users
  {
    priority: 3,
    conditions: [],
    percentage: 5,
    value: true,
  },
  // Stage 4: 50% of all users
  // Stage 5: 100% — flag cleaned up
];

Evaluación en servidor vs cliente

Dónde evalúes los flags afecta latencia, seguridad y confiabilidad. La evaluación del lado del servidor mantiene las reglas de segmentación privadas y garantiza consistencia. La evaluación del cliente reduce la latencia para cambios de UI pero expone las configuraciones de los flags.

tstypescript
// Server-side: evaluate on the API, send only the result
// app/api/flags/route.ts
export async function GET(request: Request) {
  const context = await buildContextFromRequest(request);
 
  // Only send evaluated values, never rules or conditions
  const flags = {
    "new-checkout": flagClient.evaluate("new-checkout", context, false),
    "dark-mode": flagClient.evaluate("dark-mode", context, false),
    "pricing-tier": flagClient.evaluate("pricing-tier", context, "standard"),
  };
 
  return Response.json(flags);
}
 
// Client-side: bootstrap with server-evaluated values
function FlagProvider({ children }: { children: React.ReactNode }) {
  const [flags, setFlags] = useState<Record<string, unknown>>({});
 
  useEffect(() => {
    fetch("/api/flags")
      .then((r) => r.json())
      .then(setFlags);
  }, []);
 
  return (
    <FlagContext.Provider value={flags}>
      {children}
    </FlagContext.Provider>
  );
}
 
function useFlag<T>(key: string, fallback: T): T {
  const flags = useContext(FlagContext);
  return (flags[key] as T) ?? fallback;
}

Ciclo de vida del flag y deuda técnica

Los feature flags que viven para siempre se convierten en deuda técnica. Cada flag es una rama en tu código. Establece un ciclo de vida: crear, lanzar, alcanzar el 100%, eliminar el flag y la ruta de código antigua.

tstypescript
interface FlagMetadata {
  key: string;
  owner: string;
  createdAt: string;
  expectedRemovalDate: string;
  purpose: "release" | "experiment" | "ops" | "permission";
  jiraTicket: string;
}
 
// Automated flag staleness detection
function findStaleFlags(
  flags: FlagMetadata[],
  today: Date = new Date()
): FlagMetadata[] {
  return flags.filter((flag) => {
    const removalDate = new Date(flag.expectedRemovalDate);
    return removalDate < today;
  });
}
 
// ESLint rule concept: warn on flags past their removal date
// Flag inventory tracked alongside code
const FLAG_REGISTRY: FlagMetadata[] = [
  {
    key: "new-checkout-flow",
    owner: "payments-team",
    createdAt: "2025-06-01",
    expectedRemovalDate: "2025-08-01",
    purpose: "release",
    jiraTicket: "PAY-1234",
  },
  {
    key: "maintenance-mode",
    owner: "platform-team",
    createdAt: "2025-01-15",
    expectedRemovalDate: "9999-12-31", // Permanent ops flag
    purpose: "ops",
    jiraTicket: "PLAT-500",
  },
];

Monitoreo y observabilidad

Cada evaluación de un flag debería emitir telemetría. Sin ella, no puedes correlacionar los estados de los flags con tasas de error, cambios de latencia o métricas de negocio.

tstypescript
class ObservableFeatureFlagClient extends FeatureFlagClient {
  constructor(private readonly metrics: MetricsClient) {
    super();
  }
 
  evaluate<T>(flagKey: string, context: EvaluationContext, fallback: T): T {
    const start = performance.now();
    const result = super.evaluate(flagKey, context, fallback);
    const duration = performance.now() - start;
 
    this.metrics.increment("feature_flag.evaluation", {
      flag: flagKey,
      result: String(result),
      userId: context.userId,
    });
 
    this.metrics.histogram("feature_flag.evaluation_ms", duration, {
      flag: flagKey,
    });
 
    return result;
  }
}

Conclusiones clave

Los feature flags desacoplan el despliegue del lanzamiento. Despliega código en cualquier momento; lanza funcionalidades a usuarios específicos, en porcentajes específicos, con un interruptor — no con un despliegue. Usa lanzamientos progresivos para limitar el radio de impacto: primero internos, luego beta, después sube los porcentajes con monitoreo en cada etapa.

Evalúa los flags del lado del servidor cuando las reglas de segmentación sean sensibles o la consistencia importe. Rastrea cada flag con metadatos — propietario, propósito, fecha esperada de eliminación — y exige la limpieza para evitar la acumulación de deuda técnica. Emite telemetría en cada evaluación para poder correlacionar los estados de los flags con métricas de producción.

El objetivo no es tener más flags. El objetivo es lanzar con confianza, sabiendo que cualquier funcionalidad nueva puede revertirse en segundos sin tocar el pipeline de despliegue.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX