{ "index": 122, "slug": "editorial-2024-08-mechanism-feature-flags", "title": "Feature flags без рассинхрона: где принимать решение и что считать показом", "excerpt": "Как разделить серверное решение, клиентское отображение и аналитическое событие, чтобы rollout оставался объяснимым, а временная ветка действительно исчезла.", "contentHtml": "
Новый экран включили для десяти процентов пользователей. Часть из них увидела кнопку, но API продолжил выполнять старую ветку. У другой части браузер показал новый вариант после обновления страницы, хотя сервер ещё отдавал старый. В аналитике при этом появился один общий event exposure. По нему нельзя понять, было ли решение вычислено, дошёл ли ответ до браузера и отрендерился ли экран.
\nЦена такой ошибки растёт после первого успешного релиза. Команда тратит время на поиск слоя, который изменил вариант. В защищённый клиентский контекст могут попасть лишние сведения. Сегмент получает разные правила на соседних запросах. После эксперимента остаются две ветки кода, старый ключ конфигурации и тесты, которые поддерживают уже несуществующее решение.
\nFeature flag — не просто boolean. Это контракт из входов, владельца решения, версии конфигурации, безопасного значения и пути удаления. Главный принцип прост: один слой владеет eligibility, а остальные получают только тот результат, который им нужен. Evaluation, render и business effect нужно считать разными фактами.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| UI показывает новый вариант, API выполняет старый | Сервер и браузер независимо вычисляют один flag | Сравнить evaluator, targeting key и config version в одном запросе | Оставить eligibility на сервере; клиенту передавать verdict и version |
| Один пользователь меняет вариант после логина | До логина используется anonymous ID, после логина — account ID | Построить timeline ключа для одной сессии | Описать переход ключа или закрепить вариант на время сессии |
| Exposure есть, но экран не показан | Событие отправляется сразу после evaluation | Сопоставить событие с точкой рендера и ошибками UI | Разделить evaluation record и render confirmation |
| Отключение флага не меняет всех клиентов сразу | Клиент держит кэш или обновляет конфигурацию по расписанию | Проверить effective config version и границу refresh | Задать допустимое окно устаревания и fallback |
Оценка флага получает context. В него могут входить идентификатор субъекта, приложение, окружение, локаль и другие признаки. Не каждый такой признак можно передавать в браузер. Право доступа, индивидуальная цена, fraud signal и внутреннее состояние аккаунта должны оставаться за доверенной границей. Клиенту нужен ответ о доступности функции, а не правило, по которому сервер его получил.
\nПубличный layout preference или локаль часто подходят для client-side решения. Это верно только тогда, когда они уже доступны клиенту и не скрывают защищённую политику. Если один flag зависит и от локали, и от права доступа, его нельзя безопасно перенести в браузер целиком. Сервер может вычислить eligibility, а клиент — выбрать разрешённое представление внутри полученного контракта.
\nEvaluator отвечает на вопрос: какой вариант вернуть для данного flag key и context. В архитектуре должен быть один владелец этого ответа. Если сервер и браузер повторяют одно правило, они должны либо использовать один явно согласованный контракт, либо считаться независимыми решениями с отдельными названиями. Скрытая копия правила почти всегда приводит к расхождению.
\nПример ниже учебный. Он не подключается к provider, не читает реальную конфигурацию и не отправляет события. В нём показана граница: сервер принимает защищённый вход, возвращает уже принятое решение и версию, а клиент не переоценивает право доступа.
\ntype FlagDecision = {\n variant: 'control' | 'candidate';\n enabled: boolean;\n configVersion: string;\n};\n\nfunction resolveCheckoutBanner(input: {\n accountId: string;\n hasNewCheckoutAccess: boolean;\n}): FlagDecision {\n return {\n variant: input.hasNewCheckoutAccess ? 'candidate' : 'control',\n enabled: input.hasNewCheckoutAccess,\n configVersion: 'checkout-banner-v3',\n };\n}\n\nconst decision = resolveCheckoutBanner({\n accountId: 'account-42',\n hasNewCheckoutAccess: false,\n});\n\n// Browser receives the result, not hasNewCheckoutAccess or the rule.\nrenderBanner({ enabled: decision.enabled, variant: decision.variant });\nВ реальной системе значение accountId не нужно отправлять в клиент только ради отображения. Поле configVersion помогает объяснить результат и сопоставить его с журналом. Его нельзя считать глобальным доказательством: другой сервис или кэш может работать с другой версией.
Отрицательный путь важнее счастливого. Если provider недоступен, context не содержит обязательного ключа или версия конфигурации устарела сверх допустимого окна, система должна вернуть явно выбранный fallback. Не следует молча вычислять тот же flag вторым алгоритмом в браузере. Иначе отказ превращается в незаметное изменение аудитории.
\nПроцент rollout стабилен только относительно ключа. Сервер может распределять пользователей по account ID, а браузер — по cookie ID. Тогда один человек получит разные варианты. После входа ключ может измениться снова. Это не мелкая деталь хеширования. Это изменение субъекта, которому принадлежит решение.
\nЗафиксируйте четыре вещи: кто является subject, когда появляется его ключ, как проходит переход anonymous → authenticated и нужно ли закреплять вариант на активную сессию. Если конфигурация меняется, решите, может ли следующий запрос пересчитать когорту. Для некоторых экранов это допустимо. Для оплаты, миграции данных или последовательного сценария может потребоваться pinning до конца операции.
\nНе включайте персональные данные в context без необходимости. Провайдер может сериализовать или сохранять его для таргетинга. Используйте стабильный идентификатор или заранее определённый хеш, если это соответствует модели угроз и правилам хранения. Хеш сам по себе не делает данные безопасными.
\nEvaluation означает, что evaluator получил входы и вернул вариант или fallback. Это технический факт вычисления. Exposure candidate означает, что приложение подготовило запись о выбранном варианте. Render confirmation означает, что клиент дошёл до конкретной точки показа. Business effect означает отдельное действие пользователя или доменный результат. Эти события нельзя сливать в одно поле exposure.
| Факт | Минимальные поля | Чего он не доказывает |
|---|---|---|
| Evaluation | flag key, variant, evaluator, config version | Что UI отрендерился |
| Exposure candidate | subject boundary, variant, idempotency key | Что событие доставлено ровно один раз |
| Render confirmation | screen, display point, client timestamp | Что пользователь прочитал экран |
| Business effect | Доменное действие и его собственный contract | Что действие вызвал только flag |
Слово exactly-once здесь опасно. Повторная отправка, таймаут, падение consumer и повторный запуск страницы создают разные сценарии. Если аналитике нужна дедупликация, downstream должен получить устойчивый idempotency key и окно хранения ключей. Если нужно знать факт рендера, отправляйте событие в точке рендера. Даже это не доказывает, что пользователь увидел или использовал результат.
\nСерверное решение подходит, когда flag зависит от защищённых данных или должно одинаково влиять на API и UI. Недостаток — сетевой путь и возможность увидеть старый результат из кэша. Его компенсируют явная версия, короткий контракт ответа и проверяемый fallback.
\nКлиентское решение подходит для уже публичной конфигурации, например локали или разрешённой темы. Недостаток — контекст и правила становятся частью доверенной границы браузера. Нельзя использовать этот вариант для скрытого entitlement только потому, что так быстрее собрать интерфейс.
\nРазделённое решение полезно, когда сервер отвечает за eligibility, а клиент выбирает только presentation. Например, сервер возвращает candidate и версию, а клиент решает, какой из двух разрешённых layout показать. Граница должна быть написана рядом с контрактом. Иначе presentation постепенно начнёт повторять policy.
Отключение flag не обязано мгновенно менять каждый клиент. Кэш, refresh interval, service worker, edge и повторная загрузка страницы создают окно устаревшего состояния. Поэтому контракт должен отвечать на вопрос: какая версия вернулась этому evaluator и сколько времени такой результат допустим.
\nЕсли UI получает вариант с сервера, не заставляйте браузер заново применять скрытое targeting rule. Передавайте verdict, variant и config version. Если provider сообщает об изменении конфигурации, это событие помогает запустить refresh или диагностику, но само по себе не является бизнес-exposure. При ошибке обновления используйте заранее выбранное поведение: сохранить последний допустимый вариант, перейти на control или заблокировать опасную операцию. Выбор зависит от риска функции.
\nЭта модель не выбирает конкретный SDK, TTL кэша, транспорт событий или процент rollout. Она не доказывает, что provider доступен, токен клиента ограничен или эксперимент статистически значим. Актуальная документация OpenFeature описывает provider, evaluation context и provider events как части API и жизненного цикла. Она не задаёт вашей команде SLA свежести, политику персональных данных или семантику product exposure.
\nУчебный код выше не является production-рецептом. В боевой системе отдельно проверяют авторизацию, валидацию context, обработку ошибок, наблюдаемость и совместимость версий. Не переносите строку checkout-banner-v3 в рабочую конфигурацию без определения владельца и пути удаления.
Критерий готовности проверяемый. Для одного реального flag возьмите один subject и один запрос. По логам и ответу назовите evaluator, targeting key, variant, config version и fallback. Затем покажите, где возникло evaluation, где произошёл render и как consumer обработал повтор события. Если любой ответ требует догадки или поиска правила в двух слоях, контракт ещё не готов.
\n