Feature flags
Use feature flags for gradual enablement, A/B tests, and emergency shutoff; align naming, defaults, auditing, and cleanup across code and operations.
The skill should distinguish release toggles, experiment flags, and operational kill switches, and define evaluation context: per user, tenant, percentage, or environment—without blocking hot paths on remote synchronous fetches.
Record who may change flags, whether changes need approval, sync with config services or vendors like LaunchDarkly, and test obligations for legacy code paths when off—so “temporary” toggles do not stay on forever.
Types and evaluation context
Tie flags to release flow: new behavior defaults off; during gray rollout watch errors and latency; after full rollout set a removal date or tech-debt ticket, and have the agent flag stale toggles in PRs.
- Defaults must degrade safely when config is unavailable (usually conservative: new behavior off).
- Do not hide unfinished auth, payments, or other compliance paths behind flags.
- Document production-critical flags and owners.
// LaunchDarkly Node.js SDK integration example
import * as ld from '@launchdarkly/node-server-sdk';
const client = ld.init(process.env.LD_SDK_KEY!);
await client.waitForInitialization();
// Evaluation context: user + organization + environment
const context: ld.LDContext = {
kind: 'multi',
user: { key: userId, email: user.email, plan: user.plan },
org: { key: orgId, tier: org.tier },
};
// Release toggle: new feature defaults off
const newCheckout = await client.variation(
'billing.new-checkout-flow', // flag key (normalized naming)
context,
false // fallback default (when config service unreachable)
);
if (newCheckout) {
return handleNewCheckout(cart);
} else {
return handleLegacyCheckout(cart); // old path must be kept until flag is removed
}
// Ops toggle (Kill Switch): emergency shutoff, no redeploy needed
// Set the flag to false in the console/API to take effect immediately
// Unleash Node.js SDK integration example (open-source alternative)
import { initialize } from 'unleash-client';
const unleash = initialize({
url: 'https://unleash.internal/api/',
appName: 'myapp',
customHeaders: { Authorization: process.env.UNLEASH_TOKEN! },
});
// Flag type examples
// release: control new feature launch
const isNewUI = unleash.isEnabled('myapp.new-dashboard-ui', {
userId: String(user.id), // bucket by user ID (stable hash)
});
// experiment: A/B test, returns variant value
const variant = unleash.getVariant('myapp.checkout-button-color', {
userId: String(user.id),
});
const buttonColor = variant.enabled ? variant.payload?.value : 'blue';
// ops: operational control, differentiated by environment
const rateLimitEnabled = unleash.isEnabled('ops.strict-rate-limit');
Progressive rollout
From internal/canary to percentage or bucketed full rollout: each step needs observable metrics and a rollback plan. Evaluation should use stable context (user id, tenant, request traits) so users do not see random flicker.
[ Define flag: default off, namespace, owner ]
│
▼
┌─────────────┐ Internal / fixed allowlist; validate behavior and fallback
│ Small enable │
└─────────────┘
│
▼
┌─────────────┐ Percentage or buckets; watch errors, latency, funnels
│ Widen gray │──── Issues: lower % or flip off (see Kill switch)
└─────────────┘
│
▼
┌─────────────┐ After full on: removal date or ticket; delete dead paths
│ Stabilize │
└─────────────┘
When generating call sites, spell out the off-path behavior, cache/batch consistency effects, and conflicts with experiment deduplication.
Kill switch
Operational switches cut new paths during incidents or overload: changes must be auditable; propagation delay must be known (client cache TTL, CDN, regions); verify core journeys still work when off.
[ Alert / human: need immediate mitigation ]
│
▼
┌─────────────┐ Set flag off (or safe default) in config or console
│ Turn off new │──── Log actor, ticket, timestamp
└─────────────┘
│
▼
┌─────────────┐ Wait for cache/instances; spot-check key journeys
│ Propagate │
└─────────────┘
│
▼
┌─────────────┐ Stay off until root cause fixed; decide permanent removal
│ Incident │
└─────────────┘
- Critical paths must not be “new implementation only”—no 500s or blank pages after kill.
- Align with on-call runbooks: which flag maps to which capability, who can change production.
Governance, defaults, and cleanup
Unify prefixes (team/domain), environment, and layering (default vs override) so one concept does not sprawl into many keys. In PR review: permanent if-chains, and tests for both off and on paths.
- Long-lived flags need a business owner and quarterly review; experiments need expiry.
- In CI: lint or report on stale comments or TODOs (
FLAG_REMOVE_BY).
// Setting feature flag state in tests (Jest example)
import { FeatureFlagClient } from '../lib/feature-flags';
// Mock the entire SDK client
jest.mock('../lib/feature-flags');
const mockClient = FeatureFlagClient as jest.Mocked<typeof FeatureFlagClient>;
describe('Checkout with new-checkout-flow flag', () => {
beforeEach(() => jest.clearAllMocks());
it('uses new version when flag=ON', async () => {
mockClient.prototype.isEnabled.mockResolvedValue(true);
const result = await processCheckout(cart);
expect(result.flow).toBe('new');
});
it('falls back to legacy when flag=OFF (fallback path must be tested)', async () => {
mockClient.prototype.isEnabled.mockResolvedValue(false);
const result = await processCheckout(cart);
expect(result.flow).toBe('legacy');
});
it('uses default value false when config service is unavailable', async () => {
mockClient.prototype.isEnabled.mockRejectedValue(new Error('timeout'));
const result = await processCheckout(cart);
expect(result.flow).toBe('legacy'); // degraded default value
});
});
Flag key normalization
Turn draft names into stable keys: lowercase, hyphen-separated words, only a-z, 0-9, ., - (works with most SDKs and config stores). Input below is normalized; empty after stripping means an invalid key.
Rules: trim → lowercase → spaces/underscores to a single - → strip invalid chars → collapse - → trim leading/trailing -.
- Normalized key
If empty after normalization, no valid key remains; teams often add a fixed prefix (e.g. billing.) for domain scoping.
---
name: feature-flags
description: Feature flag types, SDK integration, testing, and lifecycle governance
version: 2.0
---
# Flag types and naming conventions
- release: team.feature-name (default off; remove after full rollout)
- experiment: team.exp-button-color (expiry date; paired with stats analysis)
- ops: ops.strict-rate-limit (kill switch; changes must be audited)
- permission: billing.enterprise-only (per-role / per-tenant authorization)
# SDK evaluation best practices
- Evaluation context: user.key + org.key + env (ensures stable bucketing)
- Fallback default: conservatively disable new behavior when config is unreachable
- Hot paths: use local cache (TTL 30s) to avoid blocking on synchronous remote calls
- Forbidden: calling remote SDK at high frequency inside loops or ORM hooks
# Lifecycle management
- Creation: add FLAG_OWNER and FLAG_REMOVE_BY comments in the PR
- Activation: gray rollout → observe metrics → advance percentage
- Cleanup: submit PR to remove flag code and SDK calls within 30 days of full rollout
- CI lint: check for flag calls past their FLAG_REMOVE_BY date
# Test both paths
- flag=true path: verify new feature behavior
- flag=false path: fallback / legacy path must have its own test cases
- SDK unavailable: confirm default value behavior matches expectations