Späť na blog

Feature flags bez technického dlhu: Automatická detekcia stale flags

|
| feature-flags, devops, tech-debt, automation, ci-cd

Zabudnuty feature flag je tichy, az kym nezacne kusat. “Ten flag tam nechaj, možno sa ešte bude hodiť.” O tri roky neskôr má kód 200+ feature flags, z ktorých polovica nikto nevie čo robí a druhá polovica je permanentne zapnutá v produkcii už 18 mesiacov.

Feature flags sú výborný nástroj pre progresívne rollout-y a A/B testy. Ale bez disciplíny sa stávajú parazitmi, ktorí znásobujú komplexitu kódu a zatemňujú logiku.

Testované na: TypeScript/Node.js codebases, LaunchDarkly a Unleash. Princípy platia pre akýkoľvek flag system.

Prečo feature flags nekontrolovane rastú

  1. Jednoduché pridanie - 5 minút práce
  2. Bolestivé odstránenie - code review, testovanie, koordinácia
  3. Strach z rollbacku - “čo ak to budeme ešte potrebovať?”
  4. Chýbajúci ownership - kto je zodpovedný za cleanup?

Symptómy problému

// Kód po 3 rokoch
if (featureFlags.isEnabled('new_checkout_flow')) {  // "new" z 2021
  if (featureFlags.isEnabled('checkout_v2_improvements')) {  // override?
    if (featureFlags.isEnabled('payment_retry_logic')) {  // bug fix?
      // Ktorá kombinácia je vlastne produkčný stav?
    }
  }
}

Framework pre Flag Lifecycle Management

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   CREATE    │────▶│   ACTIVE    │────▶│   STALE     │
│  + owner    │     │  + metrics  │     │  + warning  │
│  + expiry   │     │  + usage    │     │  + removal  │
└─────────────┘     └─────────────┘     └─────────────┘


                                        ┌─────────────┐
                                        │   REMOVED   │
                                        │  + cleanup  │
                                        │  + PR       │
                                        └─────────────┘

Krok 1: Povinné Metadáta pri Vytvorení

Flag Schema

interface FeatureFlag {
  key: string;
  description: string;
  owner: string;           // Slack handle alebo team
  createdAt: Date;
  expiresAt: Date;         // Povinné!
  type: 'release' | 'experiment' | 'ops' | 'permission';
  jiraTicket?: string;     // Link na feature
  removalPR?: string;      // Automaticky vyplnené
}

CI Gate pre Nové Flagy

# .github/workflows/feature-flag-check.yml
name: Feature Flag Validation

on:
  pull_request:
    paths:
      - 'src/flags/**'
      - '**/feature-flags.json'

jobs:
  validate-flags:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Validate flag metadata
        run: |
          node scripts/validate-flags.js
// scripts/validate-flags.js
const flags = require('../src/flags/feature-flags.json');

const errors = [];

for (const [key, flag] of Object.entries(flags)) {
  if (!flag.owner) {
    errors.push(`${key}: missing owner`);
  }
  if (!flag.expiresAt) {
    errors.push(`${key}: missing expiresAt`);
  }
  if (new Date(flag.expiresAt) < new Date()) {
    errors.push(`${key}: already expired, should be removed`);
  }
  if (!flag.description || flag.description.length < 10) {
    errors.push(`${key}: description too short`);
  }
}

if (errors.length > 0) {
  console.error('Flag validation failed:');
  errors.forEach(e => console.error(`  - ${e}`));
  process.exit(1);
}

console.log('All flags valid');

Krok 2: Runtime Metrics

Tracking Flag Evaluations

import { Counter, Gauge } from 'prom-client';

const flagEvaluations = new Counter({
  name: 'feature_flag_evaluations_total',
  help: 'Total number of flag evaluations',
  labelNames: ['flag_key', 'variation']
});

const flagLastEvaluation = new Gauge({
  name: 'feature_flag_last_evaluation_timestamp',
  help: 'Timestamp of last evaluation',
  labelNames: ['flag_key']
});

class InstrumentedFlagClient {
  constructor(private client: FlagClient) {}

  isEnabled(key: string, context?: Context): boolean {
    const result = this.client.isEnabled(key, context);

    flagEvaluations.inc({
      flag_key: key,
      variation: String(result)
    });

    flagLastEvaluation.set({ flag_key: key }, Date.now());

    return result;
  }
}

Prometheus Alert pre Nepoužívané Flagy

# prometheus/rules/feature-flags.yml
groups:
  - name: feature-flags
    rules:
      - alert: StaleFeatureFlag
        expr: |
          (time() - feature_flag_last_evaluation_timestamp) > 2592000  # 30 dní
        for: 1h
        labels:
          severity: warning
        annotations:
          summary: "Feature flag {{ $labels.flag_key }} not evaluated for 30+ days"
          description: "Consider removing this flag from codebase"

      - alert: FlagAlwaysSameVariation
        expr: |
          count by (flag_key) (
            count_over_time(feature_flag_evaluations_total[30d])
          ) == 1
        for: 1h
        labels:
          severity: info
        annotations:
          summary: "Feature flag {{ $labels.flag_key }} always returns same value"

Krok 3: Statická Analýza Kódu

Scanner: Flagy v Kóde vs Registry

// scripts/scan-flags.ts
import * as ts from 'typescript';
import * as glob from 'glob';
import * as fs from 'fs';

interface FlagUsage {
  flagKey: string;
  file: string;
  line: number;
}

function findFlagsInCode(sourceFile: ts.SourceFile): FlagUsage[] {
  const usages: FlagUsage[] = [];

  function visit(node: ts.Node) {
    // Match: featureFlags.isEnabled('flag_key')
    if (
      ts.isCallExpression(node) &&
      ts.isPropertyAccessExpression(node.expression) &&
      node.expression.name.text === 'isEnabled' &&
      node.arguments.length > 0 &&
      ts.isStringLiteral(node.arguments[0])
    ) {
      const flagKey = node.arguments[0].text;
      const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());

      usages.push({
        flagKey,
        file: sourceFile.fileName,
        line: line + 1
      });
    }

    ts.forEachChild(node, visit);
  }

  visit(sourceFile);
  return usages;
}

async function main() {
  // 1. Scan všetkých TS/JS súborov
  const files = glob.sync('src/**/*.{ts,tsx,js,jsx}');
  const allUsages: FlagUsage[] = [];

  for (const file of files) {
    const content = fs.readFileSync(file, 'utf-8');
    const sourceFile = ts.createSourceFile(
      file,
      content,
      ts.ScriptTarget.Latest,
      true
    );
    allUsages.push(...findFlagsInCode(sourceFile));
  }

  // 2. Načítaj registry (LaunchDarkly API / local config)
  const registry = await fetchFlagRegistry();

  // 3. Porovnaj
  const usedKeys = new Set(allUsages.map(u => u.flagKey));
  const registeredKeys = new Set(Object.keys(registry));

  const inCodeNotRegistry = [...usedKeys].filter(k => !registeredKeys.has(k));
  const inRegistryNotCode = [...registeredKeys].filter(k => !usedKeys.has(k));

  console.log('Flags in code but not in registry:', inCodeNotRegistry);
  console.log('Flags in registry but not in code:', inRegistryNotCode);

  // 4. Output pre CI
  if (inCodeNotRegistry.length > 0) {
    console.error('ERROR: Undefined flags used in code');
    process.exit(1);
  }

  return { usages: allUsages, orphaned: inRegistryNotCode };
}

Krok 4: Automatické Removal PR

Codemod Script

// scripts/remove-flag.ts
import * as ts from 'typescript';
import * as fs from 'fs';
import { execSync } from 'child_process';

interface RemovalConfig {
  flagKey: string;
  finalValue: boolean;  // Hodnota na ktorú nahradiť
  dryRun?: boolean;
}

function removeFlag(config: RemovalConfig) {
  const { flagKey, finalValue, dryRun } = config;

  // Nájdi všetky výskyty
  const files = execSync('grep -rl "' + flagKey + '" src/', { encoding: 'utf-8' })
    .split('\n')
    .filter(Boolean);

  for (const file of files) {
    const content = fs.readFileSync(file, 'utf-8');
    const sourceFile = ts.createSourceFile(
      file,
      content,
      ts.ScriptTarget.Latest,
      true
    );

    const transformer: ts.TransformerFactory<ts.SourceFile> = (context) => {
      return (rootNode) => {
        function visit(node: ts.Node): ts.Node {
          // Nahraď featureFlags.isEnabled('key') → true/false
          if (
            ts.isCallExpression(node) &&
            ts.isPropertyAccessExpression(node.expression) &&
            node.expression.name.text === 'isEnabled' &&
            node.arguments.length > 0 &&
            ts.isStringLiteral(node.arguments[0]) &&
            node.arguments[0].text === flagKey
          ) {
            return finalValue
              ? ts.factory.createTrue()
              : ts.factory.createFalse();
          }

          return ts.visitEachChild(node, visit, context);
        }

        return ts.visitNode(rootNode, visit) as ts.SourceFile;
      };
    };

    const result = ts.transform(sourceFile, [transformer]);
    const printer = ts.createPrinter();
    const newContent = printer.printFile(result.transformed[0]);

    if (dryRun) {
      console.log(`Would update ${file}`);
    } else {
      fs.writeFileSync(file, newContent);
    }

    result.dispose();
  }
}

// Optimalizácia: odstráň dead code po nahradení
function removeDeadCode(files: string[]) {
  // Po nahradení flagov:
  // if (true) { ... } → ...
  // if (false) { ... } → (remove)

  for (const file of files) {
    execSync(`npx eslint --fix ${file}`, { stdio: 'inherit' });
  }
}

GitHub Action pre Auto-Removal

# .github/workflows/stale-flag-removal.yml
name: Stale Flag Removal

on:
  schedule:
    - cron: '0 9 * * 1'  # Každý pondelok 9:00

jobs:
  find-stale-flags:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Find expired flags
        id: stale
        run: |
          node scripts/find-stale-flags.js > stale-flags.json
          echo "flags=$(cat stale-flags.json | jq -r '.[] | .key' | head -1)" >> $GITHUB_OUTPUT

      - name: Create removal PR
        if: steps.stale.outputs.flags != ''
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          FLAG_KEY="${{ steps.stale.outputs.flags }}"
          BRANCH="remove-flag-${FLAG_KEY}"

          git checkout -b $BRANCH

          # Spusti codemod
          node scripts/remove-flag.js --key=$FLAG_KEY --value=true

          # Commit a push
          git add .
          git commit -m "chore: remove stale feature flag ${FLAG_KEY}"
          git push origin $BRANCH

          # Vytvor PR
          gh pr create \
            --title "Remove stale feature flag: ${FLAG_KEY}" \
            --body "$(cat <<EOF
          ## Automatic Stale Flag Removal

          Flag \`${FLAG_KEY}\` has been identified as stale:
          - Last evaluation: > 30 days ago
          - Expiry date: passed
          - Always returns: true

          This PR removes the flag and replaces all usages with \`true\`.

          **Review checklist:**
          - [ ] Dead code was correctly removed
          - [ ] No side effects from removal
          - [ ] Tests pass
          EOF
          )"

Dashboard: Flag Health Overview

// pages/api/flag-health.ts
import { prisma } from '@/lib/prisma';

export async function GET() {
  const flags = await prisma.featureFlag.findMany({
    include: { evaluations: true }
  });

  const health = flags.map(flag => {
    const lastEval = flag.evaluations[0]?.timestamp;
    const daysSinceEval = lastEval
      ? Math.floor((Date.now() - lastEval.getTime()) / 86400000)
      : null;

    const daysUntilExpiry = Math.floor(
      (new Date(flag.expiresAt).getTime() - Date.now()) / 86400000
    );

    return {
      key: flag.key,
      owner: flag.owner,
      status: getStatus(daysSinceEval, daysUntilExpiry),
      lastEvaluation: lastEval,
      expiresAt: flag.expiresAt,
      evaluationCount30d: flag.evaluations.length,
      alwaysSameValue: new Set(flag.evaluations.map(e => e.result)).size === 1
    };
  });

  return Response.json(health);
}

function getStatus(daysSinceEval: number | null, daysUntilExpiry: number) {
  if (daysUntilExpiry < 0) return 'expired';
  if (daysSinceEval === null) return 'never-used';
  if (daysSinceEval > 30) return 'stale';
  if (daysUntilExpiry < 14) return 'expiring-soon';
  return 'healthy';
}

Production Checklist

## Feature Flag Hygiene Checklist

### Pri vytvorení flagu
- [ ] Owner je definovaný (team alebo individual)
- [ ] Expiry date je nastavený (max 90 dní pre release flags)
- [ ] Description vysvetľuje PREČO flag existuje
- [ ] Link na JIRA/Linear ticket

### Týždenný review
- [ ] Dashboard check: žiadne expired flags
- [ ] Metrics check: žiadne unused flags (30d)
- [ ] Ownership check: žiadne orphaned flags

### Pri odstránení
- [ ] Codemod nahradí flag správnou hodnotou
- [ ] Dead code je odstránený
- [ ] Testy prejdú
- [ ] Flag je odstránený z registry
- [ ] PR je reviewed

### Monitoring
- [ ] Alert na expired flags
- [ ] Alert na unused flags (30d)
- [ ] Metric: počet aktívnych flags
- [ ] Metric: priemerný vek flags

Záver

Feature flags sú výkonný nástroj, ale vyžadujú disciplínu. Kľúčové princípy:

  1. Ownership - každý flag má zodpovedného
  2. Expiry - každý flag má dátum expirácie
  3. Metrics - sleduj používanie runtime
  4. Automation - automatické removal PR pre stale flags
  5. Visibility - dashboard pre celý team

Bez týchto opatrení sa feature flags stávajú technickým dlhom. S nimi sú mocným nástrojom pre safe delivery.

FAQ

Aká je ideálna životnosť feature flagu?

  • Release flags: 2-4 týždne (po úspešnom rollout remove)
  • Experiment flags: podľa A/B test duration + buffer
  • Ops flags: podľa potreby, ale review každé 3 mesiace
  • Permission flags: môžu byť permanentné (ale review)

Čo ak flag potrebujem dlhšie?

Extend expiry s jasným zdôvodnením. Ak flag žije > 6 mesiacov, pravdepodobne by mal byť config alebo permission, nie feature flag.

Ako handlovať flags v testoch?

Mock flag client v unit testoch. V integration testoch použi test-specific flag values. Nikdy nehardkóduj produkčné flag values do testov.


Súvisiace články

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "Feature flags bez technického dlhu: Automatická detekcia stale flags". https://www.michal-drozd.com/sk/blog/feature-flags-stale-detection/ (Publikované 4. apríla 2025).