Saltar al contenido

Implementación de feature flags a gran escala

Construye feature flags con lanzamientos graduales, segmentación, pruebas A/B y kill switches, con un ciclo de vida que evita la deuda técnica.

5 min de lectura
Panel que muestra los estados de los feature flags con lanzamientos por porcentaje, reglas de segmentación de usuarios y controles de interruptores de emergencia

Los feature flags separan el despliegue del lanzamiento. Puedes desplegar código a producción en cualquier momento, pero la funcionalidad solo se activa para los usuarios cuando activas el flag. Esto convierte el despliegue de un evento arriesgado en una operación rutinaria, y transforma los lanzamientos de eventos de todo o nada en implementaciones graduales y medibles que puedes revertir en segundos.

Pero los feature flags que no se gestionan se convierten en deuda técnica. Cada flag es una bifurcación en tu código que duplica las rutas de prueba. Los equipos que adoptan flags sin una gestión del ciclo de vida terminan con miles de flags obsoletos, código imposible de comprender y flags "temporales" que llevan tres años en producción. El sistema debe diseñarse desde el primer día considerando la limpieza como una prioridad de primer nivel.

Arquitectura de evaluación de flags

El núcleo de un sistema de feature flags es el motor de evaluación: dados una clave de flag, un contexto de usuario y un conjunto de reglas, ¿el flag debe estar activado o desactivado?

tstypescript
// Core flag evaluation types
interface FlagContext {
  userId: string;
  email?: string;
  country?: string;
  plan?: string;
  attributes: Record<string, string | number | boolean>;
}
 
interface FlagRule {
  conditions: FlagCondition[];
  percentage?: number;
  variant?: string;
}
 
interface FlagCondition {
  attribute: string;
  operator: 'eq' | 'neq' | 'contains' | 'gt' | 'lt' | 'in';
  value: string | number | string[];
}
 
interface FeatureFlag {
  key: string;
  enabled: boolean;
  rules: FlagRule[];
  defaultVariant: string;
  killSwitch: boolean;
  owner: string;
  expiresAt?: Date;
}
 
// ❌ Naive flag check: just a boolean
function isFeatureEnabled(flagKey: string): boolean {
  return flags[flagKey] === true; // No targeting, no gradual rollout
}
 
// ✅ Full evaluation with targeting and percentage rollout
class FlagEvaluator {
  evaluate(flag: FeatureFlag, context: FlagContext): FlagResult {
    // Kill switch overrides everything
    if (flag.killSwitch) {
      return { enabled: false, variant: 'off', reason: 'kill-switch' };
    }
 
    // Global toggle
    if (!flag.enabled) {
      return { enabled: false, variant: flag.defaultVariant, reason: 'disabled' };
    }
 
    // Evaluate rules in order — first match wins
    for (const rule of flag.rules) {
      if (this.matchesConditions(rule.conditions, context)) {
        if (rule.percentage !== undefined) {
          const hash = this.consistentHash(flag.key, context.userId);
          if (hash <= rule.percentage) {
            return { enabled: true, variant: rule.variant ?? 'on', reason: 'rule-match' };
          }
        } else {
          return { enabled: true, variant: rule.variant ?? 'on', reason: 'rule-match' };
        }
      }
    }
 
    return { enabled: false, variant: flag.defaultVariant, reason: 'no-match' };
  }
 
  // Consistent hashing: same user always gets same result
  private consistentHash(flagKey: string, userId: string): number {
    const input = `${flagKey}:${userId}`;
    let hash = 0;
    for (let i = 0; i < input.length; i++) {
      hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
    }
    return (Math.abs(hash) % 100) + 1;
  }
 
  private matchesConditions(
    conditions: FlagCondition[],
    context: FlagContext
  ): boolean {
    return conditions.every((condition) => {
      const value = context.attributes[condition.attribute]
        ?? (context as Record<string, unknown>)[condition.attribute];
 
      switch (condition.operator) {
        case 'eq': return value === condition.value;
        case 'neq': return value !== condition.value;
        case 'contains': return String(value).includes(String(condition.value));
        case 'in': return Array.isArray(condition.value) && condition.value.includes(String(value));
        case 'gt': return Number(value) > Number(condition.value);
        case 'lt': return Number(value) < Number(condition.value);
        default: return false;
      }
    });
  }
}

Patrones de lanzamiento gradual

El verdadero poder de los feature flags está en el lanzamiento gradual: empiezas con el 1% de los usuarios, observas las tasas de error, aumentas al 10%, vuelves a observar, y así sucesivamente hasta llegar al 100%.

tstypescript
// Progressive rollout configuration
interface RolloutPlan {
  flagKey: string;
  stages: RolloutStage[];
  currentStage: number;
  metrics: RolloutMetrics;
}
 
interface RolloutStage {
  percentage: number;
  durationHours: number;
  autoAdvance: boolean;
  rollbackConditions: {
    errorRateThreshold: number;
    latencyP99Threshold: number;
  };
}
 
// Example rollout plan for a new checkout flow
const checkoutRollout: RolloutPlan = {
  flagKey: 'new-checkout-flow',
  currentStage: 0,
  stages: [
    {
      percentage: 1,
      durationHours: 24,
      autoAdvance: false,  // Manual review first
      rollbackConditions: {
        errorRateThreshold: 0.05,
        latencyP99Threshold: 3000,
      },
    },
    {
      percentage: 10,
      durationHours: 48,
      autoAdvance: true,
      rollbackConditions: {
        errorRateThreshold: 0.02,
        latencyP99Threshold: 2000,
      },
    },
    {
      percentage: 50,
      durationHours: 72,
      autoAdvance: true,
      rollbackConditions: {
        errorRateThreshold: 0.01,
        latencyP99Threshold: 1500,
      },
    },
    {
      percentage: 100,
      durationHours: 0,
      autoAdvance: false,
      rollbackConditions: {
        errorRateThreshold: 0.01,
        latencyP99Threshold: 1500,
      },
    },
  ],
  metrics: {} as RolloutMetrics,
};

Gestión del ciclo de vida de los flags

La diferencia entre un sistema de flags útil y una pesadilla en el código base: todo flag debe tener un responsable y una fecha de expiración.

tstypescript
// Flag with lifecycle metadata
interface ManagedFlag extends FeatureFlag {
  createdAt: Date;
  createdBy: string;
  expiresAt: Date;       // When should this flag be removed?
  lastEvaluated?: Date;  // Is anyone still checking this flag?
  category: FlagCategory;
  jiraTicket: string;    // Link to cleanup ticket
}
 
type FlagCategory =
  | 'release'       // Temporary: remove after full rollout
  | 'experiment'    // Temporary: remove after A/B test concludes
  | 'ops'           // Semi-permanent: kill switches, circuit breakers
  | 'permission';   // Permanent: gating features by plan/role
 
// Automated stale flag detection
class FlagHealthChecker {
  async findStaleFlags(flags: ManagedFlag[]): Promise<StaleReport[]> {
    const now = new Date();
    const reports: StaleReport[] = [];
 
    for (const flag of flags) {
      const issues: string[] = [];
 
      // Expired flags that haven't been cleaned up
      if (flag.expiresAt < now && flag.category !== 'ops') {
        issues.push(
          `Expired ${this.daysSince(flag.expiresAt)} days ago`
        );
      }
 
      // Flags at 100% rollout that should be removed
      if (
        flag.category === 'release' &&
        flag.enabled &&
        flag.rules.length === 0
      ) {
        const age = this.daysSince(flag.createdAt);
        if (age > 30) {
          issues.push(
            `At 100% for ${age} days — remove flag and dead code`
          );
        }
      }
 
      // Flags that haven't been evaluated recently
      if (
        flag.lastEvaluated &&
        this.daysSince(flag.lastEvaluated) > 14
      ) {
        issues.push('Not evaluated in 14 days — possibly dead code');
      }
 
      if (issues.length > 0) {
        reports.push({
          flag: flag.key,
          owner: flag.owner,
          issues,
        });
      }
    }
 
    return reports;
  }
 
  private daysSince(date: Date): number {
    return Math.floor(
      (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)
    );
  }
}

Pruebas con feature flags

Los feature flags multiplican tu matriz de pruebas. Sin disciplina, terminas sin probar a fondo ni el estado activado ni el desactivado.

tstypescript
// ❌ Testing without considering flag states
describe('checkout', () => {
  it('should process payment', async () => {
    // Which checkout flow does this test?
    // If the flag is on in the test environment, it tests new flow
    // If off, it tests old flow
    // Nobody knows which one CI is testing
    const result = await processCheckout(order);
    expect(result.status).toBe('success');
  });
});
 
// ✅ Explicit flag state testing
describe('checkout', () => {
  describe('with new-checkout-flow enabled', () => {
    beforeEach(() => {
      flagService.override('new-checkout-flow', true);
    });
 
    afterEach(() => {
      flagService.clearOverrides();
    });
 
    it('should use stripe payment intent API', async () => {
      const result = await processCheckout(order);
      expect(stripeClient.createPaymentIntent).toHaveBeenCalled();
    });
  });
 
  describe('with new-checkout-flow disabled', () => {
    beforeEach(() => {
      flagService.override('new-checkout-flow', false);
    });
 
    afterEach(() => {
      flagService.clearOverrides();
    });
 
    it('should use legacy charge API', async () => {
      const result = await processCheckout(order);
      expect(stripeClient.createCharge).toHaveBeenCalled();
    });
  });
});

Evaluación de flags en el cliente

En las aplicaciones frontend, la evaluación de flags debe ser rápida (sin llamadas de red en cada renderizado) y consistente (sin parpadeos entre estados).

tstypescript
// Client-side flag SDK pattern
class FeatureFlagClient {
  private flags: Map<string, FlagResult> = new Map();
  private listeners: Map<string, Set<() => void>> = new Map();
 
  async initialize(context: FlagContext): Promise<void> {
    // Fetch all flags once on initialization
    const response = await fetch('/api/flags', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(context),
    });
    const flags = await response.json();
 
    for (const [key, value] of Object.entries(flags)) {
      this.flags.set(key, value as FlagResult);
    }
 
    // Stream updates for real-time flag changes
    this.connectStream(context);
  }
 
  isEnabled(flagKey: string): boolean {
    return this.flags.get(flagKey)?.enabled ?? false;
  }
 
  // React hook integration
  onFlagChange(flagKey: string, callback: () => void): () => void {
    if (!this.listeners.has(flagKey)) {
      this.listeners.set(flagKey, new Set());
    }
    this.listeners.get(flagKey)!.add(callback);
    return () => this.listeners.get(flagKey)?.delete(callback);
  }
 
  private connectStream(context: FlagContext): void {
    const source = new EventSource(
      `/api/flags/stream?userId=${context.userId}`
    );
    source.onmessage = (event) => {
      const update = JSON.parse(event.data);
      this.flags.set(update.key, update.value);
      this.listeners.get(update.key)?.forEach((cb) => cb());
    };
  }
}

Puntos clave

Los feature flags separan el despliegue del lanzamiento: despliega código en cualquier momento y activa las funcionalidades de forma gradual mediante lanzamientos basados en porcentajes que empiezan en el 1%, avanzan al 10% y al 50% con verificaciones automáticas de métricas, y se revierten al instante si la tasa de errores o la latencia superan los umbrales establecidos. Todo feature flag necesita metadatos de ciclo de vida: un responsable, una fecha de expiración, una categoría (release, experiment, ops, permission) y un ticket de limpieza vinculado; sin esta disciplina, los flags obsoletos se acumulan hasta formar un código base donde nadie sabe qué rutas de código están realmente activas. Usa hashing consistente para los lanzamientos por porcentaje, de modo que el mismo usuario obtenga siempre el mismo estado de flag en todas las solicitudes; esto evita experiencias inconsistentes y hace que los errores reportados por los usuarios sean reproducibles al comprobar qué variante resuelve el hash de ese usuario. Prueba explícitamente ambos estados del flag en tu batería de pruebas sobrescribiendo los flags en la configuración de pruebas, en lugar de depender de los valores predeterminados del entorno, porque un flag que funciona activado pero falla desactivado (o viceversa) terminará llegando a producción cuando cambie el lanzamiento.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX