
Запускаем две версии: V1 (старая) и V2 (новая). Малую долю трафика (например, 1–2%) направляем на V2, сравниваем метрики ошибок, задержки, конверсию. Если хуже порога — выключаем V2.
Плюсы: быстрый сигнал. Минусы: нужна балансировка и сравнимые метрики.
Одна кодовая база, поведение выбирается флагом. Сначала 1%, затем 5%, 10%, 25%, 50%, 100%. Важно «приклеивать» пользователя к варианту, чтобы тот не прыгал между старыми и новыми ветками.
Плюсы: просто внедрить, дешево. Минусы: нужен стабильный механизм распределения.
Включаем функцию для безопасных сегментов: сотрудники компании, тестовые аккаунты, один регион, один тариф. Удобно для b2b: можно раскатить на один тестовый тенант.
Плюсы: минимум риска. Минусы: может искажать метрики, если сегмент нетипичен.
Нужны:
Ниже — готовый минимальный модуль фичефлагов с процентной раскаткой и стабильным распределением по FNV‑1a. Код самодостаточен и готов к использованию.
// file: featureFlags.ts
import crypto from 'crypto';
export type FlagRule = {
includeTenants?: string[];
excludeTenants?: string[];
includeUsers?: string[];
excludeUsers?: string[];
percentage?: number; // 0..100
stickiness?: 'userId' | 'tenantId' | 'sessionId' | 'none';
};
export type Flag = {
key: string;
enabled: boolean; // общий выключатель (килл-свитч)
rules?: FlagRule[]; // оцениваются сверху вниз
owner?: string; // владелец флага
expiresAt?: string; // ISO дата, когда флаг нужно удалить
description?: string;
};
export type FlagContext = {
userId?: string;
tenantId?: string;
sessionId?: string;
region?: string;
plan?: string;
};
export type FlagStore = {
getAll(): Flag[];
getByKey(key: string): Flag | undefined;
};
export class InMemoryFlagStore implements FlagStore {
private flags: Map<string, Flag> = new Map();
constructor(initial: Flag[] = []) {
initial.forEach(f => this.flags.set(f.key, f));
}
setAll(flags: Flag[]) {
this.flags.clear();
flags.forEach(f => this.flags.set(f.key, f));
}
getAll(): Flag[] { return Array.from(this.flags.values()); }
getByKey(key: string): Flag | undefined { return this.flags.get(key); }
}
// Простая реализация FNV-1a 32-bit
function fnv1a32(input: string): number {
let hash = 0x811c9dc5; // offset basis
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
// Преобразуем в беззнаковое 32-битное
return hash >>> 0;
}
function inPercentBucket(seed: string, percentage: number): boolean {
if (percentage <= 0) return false;
if (percentage >= 100) return true;
const hash = fnv1a32(seed);
const bucket = hash % 100; // 0..99
return bucket < Math.floor(percentage);
}
function pickStickiness(ctx: FlagContext, s: FlagRule['stickiness']): string {
switch (s) {
case 'userId': return ctx.userId ?? '';
case 'tenantId': return ctx.tenantId ?? '';
case 'sessionId': return ctx.sessionId ?? '';
case 'none': return crypto.randomUUID(); // нежелательно, нестабильно
default: return ctx.userId ?? ctx.tenantId ?? '';
}
}
export class FlagEvaluator {
constructor(private store: FlagStore) {}
isEnabled(key: string, ctx: FlagContext = {}): boolean {
const flag = this.store.getByKey(key);
if (!flag) return false; // по умолчанию безопасно выключено
if (!flag.enabled) return false;
const rules = flag.rules ?? [];
if (rules.length === 0) return true; // просто включено
for (const rule of rules) {
if (rule.includeTenants && rule.includeTenants.length > 0) {
if (!ctx.tenantId || !rule.includeTenants.includes(ctx.tenantId)) {
continue; // правило не подходит
}
}
if (rule.excludeTenants && rule.excludeTenants.length > 0) {
if (ctx.tenantId && rule.excludeTenants.includes(ctx.tenantId)) {
continue; // исключённый тенант — правило не подходит
}
}
if (rule.includeUsers && rule.includeUsers.length > 0) {
if (!ctx.userId || !rule.includeUsers.includes(ctx.userId)) {
continue;
}
}
if (rule.excludeUsers && rule.excludeUsers.length > 0) {
if (ctx.userId && rule.excludeUsers.includes(ctx.userId)) {
continue;
}
}
const pct = rule.percentage ?? 100;
const stickiness = pickStickiness(ctx, rule.stickiness ?? 'userId');
const seed = `${key}:${stickiness}`; // стабильное распределение по контексту
if (inPercentBucket(seed, pct)) {
return true; // первое подошедшее правило включает фичу
}
// если не попали в процент — проверяем следующее правило
}
return false; // ни одно правило не включило фичу
}
}
// Пример начальной конфигурации
export const defaultFlags: Flag[] = [
{
key: 'search_new_ranker',
enabled: true,
description: 'Новый алгоритм ранжирования поиска',
owner: 'team-search',
rules: [
{ includeTenants: ['internal'], percentage: 100, stickiness: 'userId' },
{ percentage: 10, stickiness: 'userId' }
]
},
{
key: 'checkout_kill_switch',
enabled: true,
description: 'Килл‑свитч чекаута: при проблемах выключаем новый путь',
owner: 'team-payments',
rules: [ { percentage: 0 } ] // всегда выключено по умолчанию
}
];
// file: server.ts
import express from 'express';
import { InMemoryFlagStore, FlagEvaluator, defaultFlags } from './featureFlags';
const app = express();
app.use(express.json());
const store = new InMemoryFlagStore(defaultFlags);
const evaluator = new FlagEvaluator(store);
// Утилита для извлечения контекста из запроса
function getContext(req: express.Request) {
return {
userId: (req.headers['x-user-id'] as string) || undefined,
tenantId: (req.headers['x-tenant-id'] as string) || undefined,
sessionId: (req.headers['x-session-id'] as string) || undefined,
region: (req.headers['x-region'] as string) || undefined,
plan: (req.headers['x-plan'] as string) || undefined,
};
}
// Пример маршрута поиска с новым ранкером под флагом
app.get('/search', async (req, res) => {
const ctx = getContext(req);
const useNew = evaluator.isEnabled('search_new_ranker', ctx);
const query = (req.query.q as string) || '';
let results: Array<{ id: string; score: number }> = [];
if (useNew) {
results = await newRankerSearch(query);
} else {
results = await legacySearch(query);
}
res.json({ query, useNew, results });
});
// Пример «килл‑свитча» для нового чекаута
app.post('/checkout', async (req, res) => {
const ctx = getContext(req);
const blocked = evaluator.isEnabled('checkout_kill_switch', ctx);
if (blocked) {
return res.status(503).json({ message: 'Новый чекаут временно выключен. Повторите позже.' });
}
// обычная логика чекаута
res.json({ ok: true });
});
async function legacySearch(q: string) {
// Имитация поиска
return [ { id: 'A', score: 0.8 }, { id: 'B', score: 0.5 } ];
}
async function newRankerSearch(q: string) {
// Имитация нового алгоритма
return [ { id: 'B', score: 0.9 }, { id: 'A', score: 0.6 } ];
}
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
Как раскатывать: меняйте процент в конфиге search_new_ranker с 10 на 25, 50, 100 и перезагружайте конфиг (см. ниже про динамические обновления) — клиенты стабильно останутся в своих «бакетах» по userId.
Если нужно менять флаги без рестарта, держите конфиг в Redis и слушайте Pub/Sub канал для обновлений.
// file: flagsRedis.ts
import { createClient } from 'redis';
import { InMemoryFlagStore, Flag } from './featureFlags';
export async function attachRedisUpdates(store: InMemoryFlagStore) {
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
const sub = createClient({ url: redisUrl });
const data = createClient({ url: redisUrl });
await sub.connect();
await data.connect();
// Начальная загрузка
const raw = await data.get('feature_flags:v1');
if (raw) {
const parsed = JSON.parse(raw) as Flag[];
store.setAll(parsed);
console.log(`Loaded ${parsed.length} flags from Redis`);
}
// Слушаем обновления
await sub.subscribe('feature_flags:update', (message) => {
try {
const parsed = JSON.parse(message) as Flag[];
store.setAll(parsed);
console.log(`Updated flags from Pub/Sub: ${parsed.length}`);
} catch (e) {
console.error('Failed to parse flags update:', e);
}
});
}
Теперь можно обновлять ключ feature_flags:v1 и публиковать новое значение в канал feature_flags:update — сервис подхватит флаги на лету.
Что смотреть во время раскатки:
Пример метрик Prometheus для флага:
// file: metrics.ts
import client from 'prom-client';
export const registry = new client.Registry();
client.collectDefaultMetrics({ register: registry });
export const flagDecision = new client.Counter({
name: 'feature_flag_decision_total',
help: 'Решения по фичефлагам',
labelNames: ['flag', 'enabled'] as const,
});
registry.registerMetric(flagDecision);
// Использование: flagDecision.inc({ flag: 'search_new_ranker', enabled: String(useNew) });
Подсказка: увеличивайте счетчик при каждом вызове флага, и вы увидите реальное соотношение трафика.
Итог: фичефлаги — дешевый и действенный способ управлять риском и скоростью поставки ценности. Начните с минимального набора (хранилище + evaluator + метрики + килл‑свитч) и дисциплины по удалению старых флагов. Это даст ощутимый эффект уже через несколько итераций раскатки.