Files

8 lines
25 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 122,
"slug": "editorial-2024-08-mechanism-feature-flags",
"title": "Feature flags без рассинхрона: где принимать решение и что считать показом",
"excerpt": "Как разделить серверное решение, клиентское отображение и аналитическое событие, чтобы rollout оставался объяснимым, а временная ветка действительно исчезла.",
"contentHtml": "<p>Новый экран включили для десяти процентов пользователей. Часть из них увидела кнопку, но API продолжил выполнять старую ветку. У другой части браузер показал новый вариант после обновления страницы, хотя сервер ещё отдавал старый. В аналитике при этом появился один общий event exposure. По нему нельзя понять, было ли решение вычислено, дошёл ли ответ до браузера и отрендерился ли экран.</p>\n<p>Такой сбой появляется, когда один флаг становится сразу тремя вещами: правилом доступа, состоянием интерфейса и событием аналитики. Браузер копирует серверное правило, кэш держит старую конфигурацию, а событие отправляется сразу после вычисления. В итоге один пользователь получает разные варианты на соседних запросах, а команда не может восстановить причину.</p>\n<p>Feature flag — это контракт из входов, владельца решения, версии конфигурации, безопасного значения и пути удаления. В статье разберём границу между evaluation, render и business effect на учебном примере. Пример не подключается к реальному провайдеру и не выдаёт его за production-код: его задача — сделать проверяемым сам механизм.</p>\n<h2>Сначала зафиксируйте симптом и границу</h2>\n<table><caption>Диагностика рассинхрона feature flag</caption><thead><tr><th scope=\"col\">Наблюдение</th><th scope=\"col\">Вероятная причина</th><th scope=\"col\">Что проверить</th><th scope=\"col\">Ограниченное действие</th></tr></thead><tbody><tr><td>UI показывает candidate, API выполняет control</td><td>Два слоя независимо оценивают один ключ</td><td>Сверить evaluator, targeting key, variant и config version в одном trace</td><td>Оставить eligibility на сервере и передавать verdict клиенту</td></tr><tr><td>Вариант меняется после входа в аккаунт</td><td>До логина используется anonymous ID, после — account ID</td><td>Построить последовательность ключей одной сессии</td><td>Описать переход или закрепить вариант на операцию</td></tr><tr><td>Есть exposure, но экран не появился</td><td>Событие отправлено на evaluation</td><td>Сопоставить событие с точкой монтирования и ошибкой рендера</td><td>Развести evaluation и render confirmation</td></tr><tr><td>Отключение флага видно не всем</td><td>Кэш, refresh interval или edge отдают старую версию</td><td>Проверить effective config version и возраст ответа</td><td>Задать допустимое окно устаревания и fallback</td></tr></tbody></table>\n<p>Первый вопрос должен звучать не «почему процент работает неправильно?», а «какой слой принял решение и какой факт мы сейчас наблюдаем?». Если ответ невозможно дать по логам и ответу API, сначала добавьте эти поля в контракт. Измерение процента без идентификатора субъекта и версии конфигурации не объясняет распределение.</p>\n<h2>Один authoritative evaluator</h2>\n<p>Evaluator — компонент, который получает ключ флага и контекст и возвращает значение. Для одного бизнес-решения назначьте одного владельца eligibility (допустимости). Сервер может принять защищённые сведения о правах, тарифе или риске. Клиент после этого выбирает только разрешённое представление. Он не должен повторно решать, имеет ли пользователь право на функцию.</p>\n<p>Это не запрещает client-side flags вообще. Клиентский evaluator подходит для публичной настройки, например выбранной темы, если правило не защищает доступ и не влияет на операцию на сервере. Для смешанного правила сервер сначала возвращает безопасный verdict, а клиент может выбрать один из вариантов presentation внутри этого verdict.</p>\n<p>В ответе полезно различать минимум четыре поля:</p>\n<table><caption>Минимальный контракт решения</caption><thead><tr><th scope=\"col\">Поле</th><th scope=\"col\">Смысл</th><th scope=\"col\">Чего поле не доказывает</th></tr></thead><tbody><tr><td><code>flagKey</code></td><td>Какое решение вычисляли</td><td>Что ключ не изменится завтра</td></tr><tr><td><code>variant</code></td><td>Какой вариант вернул evaluator</td><td>Что пользователь его увидел</td></tr><tr><td><code>configVersion</code></td><td>С какой эффективной версией работал evaluator</td><td>Что все сервисы уже используют эту версию</td></tr><tr><td><code>reason</code></td><td>Почему получено значение: targeting, split, cached, default, error и т. п.</td><td>Что провайдер поддерживает каждую причину одинаково</td></tr><tr><td><code>fallback</code></td><td>Что делать при ошибке или отсутствии обязательного контекста</td><td>Что fallback безопасен для любого домена</td></tr></tbody></table>\n<p>OpenFeature называет provider слоем, который разрешает значение, а подробный результат может содержать variant, reason, error code и metadata. Это удобный общий словарь, но не готовая политика вашей системы. Название <code>configVersion</code> в собственном API также не становится глобальной версией всех сервисов: его нужно сопоставлять с журналом публикации.</p>\n<h2>Воспроизводимый evaluator и безопасный default</h2>\n<p>Ниже — самостоятельный пример для Node.js 18 и новее. Он не зависит от SDK: детерминированно распределяет subject по 100 корзинам и возвращает подробный результат. Запустите его в shell как есть. Хеш нужен только для учебного стабильного распределения; он не заменяет защиту персональных данных и не доказывает статистическую значимость эксперимента.</p>\n<pre><code>node --input-type=module &lt;&lt;'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) &lt; 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</code></pre>\n<p>Результат содержит два важных свойства. Один и тот же ключ даёт один и тот же bucket при неизменном flag key, а отсутствие ключа или отказ provider не запускает второй алгоритм: система явно возвращает control и помечает fallback. В рабочем коде fallback выбирают по риску операции. Для декоративного баннера обычно безопасен control, для платежа или миграции данных может понадобиться блокировка операции и ручная диагностика.</p>\n<p>Не передавайте в браузер <code>hasNewCheckoutAccess</code>, внутренний fraud signal или правило вычисления только ради рендера. Браузеру достаточно результата, который разрешён для его доверенной границы. Если provider поддерживает подробную оценку, логируйте минимально нужные поля на сервере: не превращайте targeting context в копию профиля пользователя.</p>\n<h2>Targeting key определяет когорту</h2>\n<p>Процент rollout стабилен только относительно ключа субъекта. Сервер, который хеширует account ID, и браузер, который хеширует cookie ID, распределяют одного человека независимо. После логина ключ может измениться ещё раз. Это не «небольшая погрешность»: изменился субъект, которому принадлежит решение.</p>\n<p>Перед запуском запишите четыре правила: кто является subject, когда появляется targeting key, как проходит anonymous → authenticated и может ли вариант измениться в активной операции. Для чтения новостей пересчёт после логина может быть приемлем. Для оформления заказа, миграции или многошаговой формы вариант часто фиксируют до завершения операции.</p>\n<p>OpenFeature описывает targeting key как строковый идентификатор субъекта и предупреждает, что провайдеры могут требовать его для дробного распределения или адресных правил. Там же есть отдельное предупреждение о персональных данных в evaluation context: провайдер может сериализовать или сохранять контекст. Поэтому используйте устойчивый технический идентификатор или согласованный хеш только после проверки модели угроз и политики хранения. Хеш сам по себе не делает значение анонимным.</p>\n<figure><img src=\"/assets/editorial/2024/feature-flags-2024-rollout-timeline.svg\" alt=\"Поток feature flag: сервер принимает защищённый контекст и targeting key, возвращает variant и configVersion, клиент отображает разрешённый вариант, затем отдельно фиксируются render и бизнес-событие\" loading=\"lazy\" /><figcaption>Схема отделяет решение, рендер и доставку событий. Она учебная: не показывает конкретный SDK, реальный трафик, задержки, размер когорты или результаты эксперимента.</figcaption></figure>\n<h2>Evaluation не равно показ</h2>\n<p>Evaluation означает, что система получила входы и вернула значение. Это ещё не exposure. Если сервер оценил флаг, но запрос завершился ошибкой, экран не показан. Если клиент получил HTML, но компонент не смонтировался, показ также не доказан. Даже подтверждённый рендер не говорит, что человек прочитал или использовал экран.</p>\n<p>Разделите жизненный цикл на четыре факта:</p>\n<ol><li><strong>Evaluation.</strong> Запишите flag key, variant, evaluator, config version и reason. Это ответ на вопрос «какое решение вернулось?».</li><li><strong>Render confirmation.</strong> Отправляйте его из точки, где компонент действительно смонтирован или стал видимым по выбранному критерию. Поле screen должно называть конкретную точку показа.</li><li><strong>Business effect.</strong> Отдельно фиксируйте клик, отправку формы или доменный результат. Событие не должно называться exposure, если оно описывает действие.</li><li><strong>Correlation.</strong> Свяжите записи request ID, subject boundary и безопасным variant. Для повтора доставки используйте idempotency key, но не обещайте exactly-once там, где транспорт даёт at-least-once или не даёт гарантии доставки.</li></ol>\n<p>OpenFeature предоставляет tracking API для связывания последующего действия или состояния приложения с контекстом оценки. Это помогает анализировать влияние флага, но tracking-вызов не превращается автоматически в доказательство рендера. Семантику «виден пользователю» задаёт ваше приложение и его критерий видимости.</p>\n<h2>Кэш и версия: задайте окно устаревания</h2>\n<p>Отключение флага не обязано мгновенно менять каждый процесс. Старое состояние может жить в памяти SDK, edge-кэше, service worker или ответе HTTP. Поэтому для каждого флага задайте допустимое окно stale: сколько времени старый verdict приемлем и что происходит после его истечения.</p>\n<p>В HTTP кэш не должен отдавать stale-ответ без разрешения протокола или явного контракта. Но из этого не следует, что бизнес-флаг обновится мгновенно: ваши refresh interval, локальный кэш и аварийный режим всё равно требуют проектного решения. Возвращайте effective config version, а при диагностике сохраняйте возраст значения и источник кэша.</p>\n<p>При сигнале изменения конфигурации можно запустить refresh или мониторинг. OpenFeature перечисляет provider events, включая configuration changed, error и stale. Эти события говорят о состоянии provider, а не о том, что конкретный пользователь увидел новый экран. При ошибке обновления заранее выберите одно из действий: сохранить последний допустимый вариант на короткое окно, перейти в control или остановить рискованную операцию.</p>\n<pre><code># Пример ручной проверки контракта локального сервиса.\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}'</code></pre>\n<p>Команда не предполагает конкретный продуктовый endpoint: URL и заголовок должны существовать в вашем сервисе. Она показывает воспроизводимый минимум ручной проверки. Для двух последовательных запросов одного subject сравните variant и configVersion; затем выключите provider и проверьте, что response явно перешёл в выбранный fallback.</p>\n<h2>Как проверить отрицательные пути</h2>\n<p>Счастливый путь доказывает только то, что candidate когда-то вернулся. Надёжность видна на отказах. Тестируйте не только процент, но и владельца решения, отсутствие контекста, смену версии и повторную доставку события.</p>\n<ol><li>Зафиксируйте один тестовый targeting key и получите evaluation details. В журнале должны совпасть flag key, variant, reason и config version.</li><li>Уберите targeting key. Проверьте, что система не выбирает случайный cookie и не оценивает тот же policy в браузере.</li><li>Сделайте provider недоступным. Проверьте безопасный default, error code или reason и отсутствие необработанного исключения в пользовательском запросе.</li><li>Измените конфигурацию. Убедитесь, что refresh меняет version в пределах согласованного окна, а старый ответ не выдаётся дольше разрешённого срока.</li><li>Смонтируйте компонент с ошибкой и сравните telemetry. Evaluation может существовать, а render confirmation — отсутствовать; это разные результаты, а не потерянное поле.</li><li>Повторите отправку одного business event. Проверьте дедупликацию downstream по idempotency key и отдельно измерьте потери доставки.</li><li>После завершения rollout удалите ветку, ключ, лишние тесты и документацию. Оставшийся flag без срока пересмотра быстро превращается в постоянную сложность.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Эта модель не выбирает SDK, транспорт аналитики, TTL, процент rollout или способ статистической проверки эксперимента. OpenFeature — vendor-neutral API: его provider может получать данные из разных источников, а конкретная гарантия свежести, хранения контекста и доставки tracking зависит от реализации. RFC 9111 описывает HTTP-кэширование, но не задаёт вашей бизнес-семантике безопасный fallback.</p>\n<p>Учебный SHA-256 evaluator не является доказательством равномерного эксперимента: он показывает детерминированную корзину, но не проверяет качество выборки, SRM, мощность теста или причинность метрики. Не используйте этот фрагмент как замену провайдеру с управлением конфигурацией. Не считайте <code>render confirmation</code> доказательством внимания пользователя и не называйте доставку exactly-once без подтверждённой семантики транспорта.</p>\n<p>Flag готов к rollout, если для одного реального subject команда может без догадок назвать evaluator, targeting key, variant, config version, reason и fallback; показать точку render; воспроизвести отказ provider; увидеть границу stale; связать бизнес-эффект с evaluation. Если правило приходится искать одновременно в сервере, браузере и конфигурационном файле, сначала сократите число владельцев.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://openfeature.dev/specification/sections/evaluation-context/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenFeature Specification: Evaluation Context</a> — требования к targeting key, пользовательским полям контекста и объединению контекстов.</li><li><a href=\"https://openfeature.dev/specification/sections/flag-evaluation/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenFeature Specification: Flag Evaluation API</a> — typed evaluation, default value и evaluation details с variant, reason и error information.</li><li><a href=\"https://openfeature.dev/specification/sections/events/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenFeature Specification: Events</a> — события готовности, ошибки, изменения конфигурации и stale-состояния provider.</li><li><a href=\"https://openfeature.dev/specification/sections/tracking/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenFeature Specification: Tracking</a> — API для связывания последующего действия или состояния приложения с evaluation context.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9111.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9111: HTTP Caching</a> — нормативные правила свежести и выдачи stale-ответов кэшем.</li></ul>"
}