{ "index": 122, "slug": "editorial-2024-08-mechanism-feature-flags", "title": "Feature flags без рассинхрона: где принимать решение и что считать показом", "excerpt": "Как разделить серверное решение, клиентское отображение и аналитическое событие, чтобы rollout оставался объяснимым, а временная ветка действительно исчезла.", "contentHtml": "
Новый экран включили для десяти процентов пользователей. Часть из них увидела кнопку, но API продолжил выполнять старую ветку. У другой части браузер показал новый вариант после обновления страницы, хотя сервер ещё отдавал старый. В аналитике при этом появился один общий event exposure. По нему нельзя понять, было ли решение вычислено, дошёл ли ответ до браузера и отрендерился ли экран.
\nТакой сбой появляется, когда один флаг становится сразу тремя вещами: правилом доступа, состоянием интерфейса и событием аналитики. Браузер копирует серверное правило, кэш держит старую конфигурацию, а событие отправляется сразу после вычисления. В итоге один пользователь получает разные варианты на соседних запросах, а команда не может восстановить причину.
\nFeature flag — это контракт из входов, владельца решения, версии конфигурации, безопасного значения и пути удаления. В статье разберём границу между evaluation, render и business effect на учебном примере. Пример не подключается к реальному провайдеру и не выдаёт его за production-код: его задача — сделать проверяемым сам механизм.
\n| Наблюдение | Вероятная причина | Что проверить | Ограниченное действие |
|---|---|---|---|
| UI показывает candidate, API выполняет control | Два слоя независимо оценивают один ключ | Сверить evaluator, targeting key, variant и config version в одном trace | Оставить eligibility на сервере и передавать verdict клиенту |
| Вариант меняется после входа в аккаунт | До логина используется anonymous ID, после — account ID | Построить последовательность ключей одной сессии | Описать переход или закрепить вариант на операцию |
| Есть exposure, но экран не появился | Событие отправлено на evaluation | Сопоставить событие с точкой монтирования и ошибкой рендера | Развести evaluation и render confirmation |
| Отключение флага видно не всем | Кэш, refresh interval или edge отдают старую версию | Проверить effective config version и возраст ответа | Задать допустимое окно устаревания и fallback |
Первый вопрос должен звучать не «почему процент работает неправильно?», а «какой слой принял решение и какой факт мы сейчас наблюдаем?». Если ответ невозможно дать по логам и ответу API, сначала добавьте эти поля в контракт. Измерение процента без идентификатора субъекта и версии конфигурации не объясняет распределение.
\nEvaluator — компонент, который получает ключ флага и контекст и возвращает значение. Для одного бизнес-решения назначьте одного владельца eligibility (допустимости). Сервер может принять защищённые сведения о правах, тарифе или риске. Клиент после этого выбирает только разрешённое представление. Он не должен повторно решать, имеет ли пользователь право на функцию.
\nЭто не запрещает client-side flags вообще. Клиентский evaluator подходит для публичной настройки, например выбранной темы, если правило не защищает доступ и не влияет на операцию на сервере. Для смешанного правила сервер сначала возвращает безопасный verdict, а клиент может выбрать один из вариантов presentation внутри этого verdict.
\nВ ответе полезно различать минимум четыре поля:
\n| Поле | Смысл | Чего поле не доказывает |
|---|---|---|
flagKey | Какое решение вычисляли | Что ключ не изменится завтра |
variant | Какой вариант вернул evaluator | Что пользователь его увидел |
configVersion | С какой эффективной версией работал evaluator | Что все сервисы уже используют эту версию |
reason | Почему получено значение: targeting, split, cached, default, error и т. п. | Что провайдер поддерживает каждую причину одинаково |
fallback | Что делать при ошибке или отсутствии обязательного контекста | Что fallback безопасен для любого домена |
OpenFeature называет provider слоем, который разрешает значение, а подробный результат может содержать variant, reason, error code и metadata. Это удобный общий словарь, но не готовая политика вашей системы. Название configVersion в собственном API также не становится глобальной версией всех сервисов: его нужно сопоставлять с журналом публикации.
Ниже — самостоятельный пример для Node.js 18 и новее. Он не зависит от SDK: детерминированно распределяет subject по 100 корзинам и возвращает подробный результат. Запустите его в shell как есть. Хеш нужен только для учебного стабильного распределения; он не заменяет защиту персональных данных и не доказывает статистическую значимость эксперимента.
\nnode --input-type=module <<'NODE'\nimport { createHash } from 'node:crypto';\n\nconst flagKey = 'checkout-banner';\nconst configVersion = 'checkout-banner-v3';\nconst rolloutPercent = 10;\n\nfunction bucket(targetingKey) {\n const digest = createHash('sha256')\n .update(flagKey + ':' + targetingKey)\n .digest();\n return digest.readUInt32BE(0) % 100;\n}\n\nfunction evaluate({ targetingKey, providerReady }) {\n if (!providerReady || !targetingKey) {\n return {\n flagKey,\n variant: 'control',\n reason: providerReady ? 'missing-targeting-key' : 'error',\n configVersion,\n fallback: true,\n };\n }\n\n const inRollout = bucket(targetingKey) < rolloutPercent;\n return {\n flagKey,\n variant: inRollout ? 'candidate' : 'control',\n reason: 'split',\n configVersion,\n fallback: false,\n };\n}\n\nfor (const input of [\n { targetingKey: 'user-001', providerReady: true },\n { targetingKey: 'user-002', providerReady: true },\n { targetingKey: '', providerReady: true },\n { targetingKey: 'user-001', providerReady: false },\n]) {\n console.log(JSON.stringify({ input, result: evaluate(input) }));\n}\nNODE\nРезультат содержит два важных свойства. Один и тот же ключ даёт один и тот же bucket при неизменном flag key, а отсутствие ключа или отказ provider не запускает второй алгоритм: система явно возвращает control и помечает fallback. В рабочем коде fallback выбирают по риску операции. Для декоративного баннера обычно безопасен control, для платежа или миграции данных может понадобиться блокировка операции и ручная диагностика.
\nНе передавайте в браузер hasNewCheckoutAccess, внутренний fraud signal или правило вычисления только ради рендера. Браузеру достаточно результата, который разрешён для его доверенной границы. Если provider поддерживает подробную оценку, логируйте минимально нужные поля на сервере: не превращайте targeting context в копию профиля пользователя.
Процент rollout стабилен только относительно ключа субъекта. Сервер, который хеширует account ID, и браузер, который хеширует cookie ID, распределяют одного человека независимо. После логина ключ может измениться ещё раз. Это не «небольшая погрешность»: изменился субъект, которому принадлежит решение.
\nПеред запуском запишите четыре правила: кто является subject, когда появляется targeting key, как проходит anonymous → authenticated и может ли вариант измениться в активной операции. Для чтения новостей пересчёт после логина может быть приемлем. Для оформления заказа, миграции или многошаговой формы вариант часто фиксируют до завершения операции.
\nOpenFeature описывает targeting key как строковый идентификатор субъекта и предупреждает, что провайдеры могут требовать его для дробного распределения или адресных правил. Там же есть отдельное предупреждение о персональных данных в evaluation context: провайдер может сериализовать или сохранять контекст. Поэтому используйте устойчивый технический идентификатор или согласованный хеш только после проверки модели угроз и политики хранения. Хеш сам по себе не делает значение анонимным.
\nEvaluation означает, что система получила входы и вернула значение. Это ещё не exposure. Если сервер оценил флаг, но запрос завершился ошибкой, экран не показан. Если клиент получил HTML, но компонент не смонтировался, показ также не доказан. Даже подтверждённый рендер не говорит, что человек прочитал или использовал экран.
\nРазделите жизненный цикл на четыре факта:
\nOpenFeature предоставляет tracking API для связывания последующего действия или состояния приложения с контекстом оценки. Это помогает анализировать влияние флага, но tracking-вызов не превращается автоматически в доказательство рендера. Семантику «виден пользователю» задаёт ваше приложение и его критерий видимости.
\nОтключение флага не обязано мгновенно менять каждый процесс. Старое состояние может жить в памяти SDK, edge-кэше, service worker или ответе HTTP. Поэтому для каждого флага задайте допустимое окно stale: сколько времени старый verdict приемлем и что происходит после его истечения.
\nВ HTTP кэш не должен отдавать stale-ответ без разрешения протокола или явного контракта. Но из этого не следует, что бизнес-флаг обновится мгновенно: ваши refresh interval, локальный кэш и аварийный режим всё равно требуют проектного решения. Возвращайте effective config version, а при диагностике сохраняйте возраст значения и источник кэша.
\nПри сигнале изменения конфигурации можно запустить refresh или мониторинг. OpenFeature перечисляет provider events, включая configuration changed, error и stale. Эти события говорят о состоянии provider, а не о том, что конкретный пользователь увидел новый экран. При ошибке обновления заранее выберите одно из действий: сохранить последний допустимый вариант на короткое окно, перейти в control или остановить рискованную операцию.
\n# Пример ручной проверки контракта локального сервиса.\n# Ожидайте в JSON flagKey, variant, configVersion, reason и fallback.\nset -eu\ncurl --fail-with-body --silent --show-error \\\n -H 'X-Debug-Subject: user-001' \\\n 'http://localhost:3000/api/flags/checkout-banner' \\\n | jq '{flagKey, variant, configVersion, reason, fallback}'\nКоманда не предполагает конкретный продуктовый endpoint: URL и заголовок должны существовать в вашем сервисе. Она показывает воспроизводимый минимум ручной проверки. Для двух последовательных запросов одного subject сравните variant и configVersion; затем выключите provider и проверьте, что response явно перешёл в выбранный fallback.
\nСчастливый путь доказывает только то, что candidate когда-то вернулся. Надёжность видна на отказах. Тестируйте не только процент, но и владельца решения, отсутствие контекста, смену версии и повторную доставку события.
\nЭта модель не выбирает SDK, транспорт аналитики, TTL, процент rollout или способ статистической проверки эксперимента. OpenFeature — vendor-neutral API: его provider может получать данные из разных источников, а конкретная гарантия свежести, хранения контекста и доставки tracking зависит от реализации. RFC 9111 описывает HTTP-кэширование, но не задаёт вашей бизнес-семантике безопасный fallback.
\nУчебный SHA-256 evaluator не является доказательством равномерного эксперимента: он показывает детерминированную корзину, но не проверяет качество выборки, SRM, мощность теста или причинность метрики. Не используйте этот фрагмент как замену провайдеру с управлением конфигурацией. Не считайте render confirmation доказательством внимания пользователя и не называйте доставку exactly-once без подтверждённой семантики транспорта.
Flag готов к rollout, если для одного реального subject команда может без догадок назвать evaluator, targeting key, variant, config version, reason и fallback; показать точку render; воспроизвести отказ provider; увидеть границу stale; связать бизнес-эффект с evaluation. Если правило приходится искать одновременно в сервере, браузере и конфигурационном файле, сначала сократите число владельцев.
\n