Feature flags bez technického dlhu: Automatická detekcia stale flags
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ú
- Jednoduché pridanie - 5 minút práce
- Bolestivé odstránenie - code review, testovanie, koordinácia
- Strach z rollbacku - “čo ak to budeme ešte potrebovať?”
- 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:
- Ownership - každý flag má zodpovedného
- Expiry - každý flag má dátum expirácie
- Metrics - sleduj používanie runtime
- Automation - automatické removal PR pre stale flags
- 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
- CI/CD pre monorepo - Integrácia flag checks do pipeline
- Architektúra ako kód - ADR pre feature flag policies
Súvisiace články
Architectural Linting: Automatizovaná ochrana proti spaghetti kódu
Ako vynútiť architektonické pravidlá v CI/CD. Dependency Cruiser pre JS/TS, ArchUnit pre Java a praktické príklady konfigurácie.
Kubernetes rollout bez výpadku DB: Ako zastaviť PostgreSQL connection storm
Reprodukovateľný lab na demonštráciu connection stormu pri K8s rolloutoch. PgBouncer, preStop hooks a jitter - praktické riešenia s benchmarkmi.
Prestaňte mockovať databázu: Integračné testy v ére Testcontainers
Prečo mocky klamú a ako Testcontainers zmení váš prístup k testovaniu. Praktické príklady, CI setup a stratégie izolácie dát.
Zero-downtime migrácie PostgreSQL: Expand/Contract, backfill a rollback stratégie
Praktický playbook pre bezpečné databázové migrácie v produkcii. Od expand/contract patternu cez online indexy až po monitoring a rollback.
Citujte tento článok
Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.