Files
progcode/editorial/agent-rewrites/090.json
T

8 lines
28 KiB
JSON
Raw 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": 90,
"slug": "editorial-2025-07-practice-product-metrics",
"title": "Метрики продукта для инженера: от изменения к проверяемому решению",
"excerpt": "Рост conversion сам по себе не доказывает пользу релиза. Разбираем контракт события, cohort, denominator, атрибуцию и guardrail на воспроизводимом примере, который останавливает расчёт при неполных данных.",
"contentHtml": "<p>Представим изменение checkout: форма должна открываться без дополнительного ожидания. На следующий день conversion выросла с 42% до 47%, и команда готовит rollout. Но при проверке выясняется, что после релиза часть событий <code>checkout_opened</code> перестала отправляться, а повторное открытие теперь считается иначе. Пользователи не обязательно стали чаще подтверждать заказ. Изменился способ подсчёта.</p><p>Это не задача про красивый дашборд. Инженеру нужно установить, что именно поменялось, кого сравнивают, какое действие считается результатом и что может отменить локальный выигрыш. Если одного звена нет, число следует пометить как непригодное для решения, а не превращать пропуск в нулевую конверсию.</p><p><strong>Тезис:</strong> продуктовая метрика — это контракт между изменением и решением. В контракте заранее указаны событие, cohort (сравниваемая группа), период, population (множество пользователей или попыток), числитель, denominator (знаменатель) и guardrail — показатель побочного риска. Такой контракт не доказывает причинность сам по себе, но делает ошибку измерения видимой до релиза.</p><h2>Сначала назовите решение, а не график</h2><p>Слово «conversion» не говорит, какое действие нужно совершить. Один и тот же термин может означать подтверждение формы, создание заказа или успешную оплату. Поэтому начните с решения: <code>rollout</code>, <code>hold</code> (пауза до проверки), <code>rollback</code> или сбор дополнительных данных.</p><p>Для рассматриваемого checkout формулировка может быть такой: «Разрешить увеличение доли treatment после того, как доля подтверждений среди пользователей, открывших checkout, не ниже control, а доля ошибок рендера не выросла». Здесь есть вариант изменения, основной outcome и ограничитель риска. Фраза «ускорим экран и поднимем conversion» оставляет все три части неопределёнными.</p><p>Разделите технический сигнал и пользовательский результат. Время ответа API описывает один вызов, но не сообщает, дождался ли пользователь экрана и завершил ли действие. OpenTelemetry разделяет traces, metrics и logs: путь запроса, измерение во времени и запись события. Это граница диагностики, а не готовая product metric.</p><figure><img src=\"/assets/editorial/2025/product-metrics-2025-causal-funnel.svg\" alt=\"Схема проверки checkout: техническое изменение ведёт к событиям opened и confirmed, затем к решению; render_failed на той же базе показан отдельным guardrail\" loading=\"lazy\" /><figcaption>Сначала связываем изменение с наблюдаемым действием, затем считаем outcome и guardrail на сопоставимой базе. Схема показывает порядок проверки, но не заменяет экспериментальный дизайн.</figcaption></figure><h2>Разложите гипотезу на контракт</h2><p>Хорошая гипотеза помещается в одну проверяемую цепочку: изменение → наблюдаемый шаг → outcome → guardrail → решение. Для checkout это выглядит так:</p><ul><li><strong>Изменение:</strong> форма в treatment получает данные до показа экрана.</li><li><strong>Наблюдаемый шаг:</strong> клиент отправляет <code>checkout_opened</code> после фактического отображения checkout.</li><li><strong>Outcome:</strong> тот же пользователь и та же попытка дают <code>checkout_confirmed</code>.</li><li><strong>Guardrail:</strong> <code>render_failed</code> на базе открывших checkout не растёт относительно control.</li><li><strong>Решение:</strong> rollout разрешён только после проверки качества событий и заранее заданного порога.</li></ul><p>Ключевой вопрос здесь — что является единицей анализа. Если продукт оценивает людей, denominator состоит из уникальных пользователей. Если важна каждая попытка оплаты, единицей становится попытка с отдельным идентификатором. Нельзя считать пользователей в числителе и попытки в знаменателе: такая дробь выглядит точной, но отвечает не на тот вопрос.</p><p>Также зафиксируйте окно измерения. Cohort, собранный по текущему значению feature flag, может быть неверным: флаг успели переключить после показа старой версии. Надёжнее сохранить назначенный вариант в событии экспозиции или в неизменяемом контексте попытки. Атрибуция — правило, связывающее outcome с реально увиденным вариантом.</p><h2>Событие должно сохранять контекст попытки</h2><p>Имя <code>button_click</code> почти ничего не говорит о результате. Для решения нужны имя события, версия схемы, обезличенный идентификатор субъекта, cohort, период и идентификатор попытки. Не отправляйте в telemetry email, номер карты или свободный текст пользователя.</p><pre><code>const event = {\n name: 'checkout_confirmed',\n schemaVersion: 1,\n subjectId: 'u-17',\n cohort: 'treatment',\n period: '2025-07-14',\n requestId: 'r-204',\n occurredAt: '2025-07-14T10:21:03Z'\n};\n\n// Это пример записи в памяти. Он не отправляет событие\n// и не является результатом работы реального checkout.</code></pre><p><code>subjectId</code> позволяет убрать повторную доставку одного события. <code>requestId</code> связывает открытие, подтверждение и ошибку одной попытки. <code>cohort</code> нужно записывать в момент назначения варианта, а не вычислять задним числом по текущему флагу. <code>period</code> задаёт окно сравнения и помогает не смешать данные разных версий схемы.</p><p>Trace ID связывает запросы между сервисами, а product request ID задаёт единицу расчёта; подменять их можно только после явного решения.</p><p>Отсутствие обязательного поля — это не «неизвестный пользователь» и не нулевая конверсия. Это отдельный статус качества данных. При нём расчёт должен вернуть <code>hold</code> с причиной, которую можно найти в логах и исправить.</p><h2>Denominator важнее красивой дроби</h2><p>Для локального учебного сравнения зададим одну базу: уникальные пары <code>subjectId + requestId</code>, у которых есть <code>checkout_opened</code>. Тогда для каждого cohort считаем:</p><pre><code>conversion(cohort) =\n opened attempts with checkout_confirmed\n /\n unique opened attempts\n\nrenderFailureRate(cohort) =\n opened attempts with render_failed\n /\n unique opened attempts</code></pre><p>Это не универсальное определение conversion. Платёжный продукт может считать только подтверждённый заказ, успешное списание или завершённую сессию. Важна не выбранная формула, а её неизменность между вариантами и наличие источника для каждого множества.</p><div class=\"table-scroll\"><table><caption>Контракт расчёта и типичный риск</caption><thead><tr><th scope=\"col\">Поле</th><th scope=\"col\">Что фиксируем</th><th scope=\"col\">Что сломается без него</th><th scope=\"col\">Проверка</th></tr></thead><tbody><tr><td>Outcome</td><td><code>checkout_confirmed</code>, а не любой клик</td><td>Локальный сигнал примут за завершённое действие</td><td>Сверить событие с бизнес-операцией</td></tr><tr><td>Cohort</td><td>Вариант, назначенный до действия</td><td>Control и treatment смешаются</td><td>Сравнить assignment с событием экспозиции</td></tr><tr><td>Period</td><td>Единое окно и часовой пояс</td><td>Варианты будут сравниваться в разные дни</td><td>Проверить границы окна и версию схемы</td></tr><tr><td>Denominator</td><td>Одна база открывших checkout</td><td>Рост дроби появится из-за пропавших открытий</td><td>Посчитать уникальные ключи до деления</td></tr><tr><td>Attribution</td><td>Связь outcome с той же попыткой</td><td>Старое или чужое подтверждение попадёт в результат</td><td>Проверить пару <code>subjectId + requestId</code></td></tr><tr><td>Guardrail</td><td>Ошибка рендера на той же базе</td><td>Локальный выигрыш скроет ухудшение</td><td>Сопоставить риск с заранее заданным порогом</td></tr></tbody></table></div><p>Если один пользователь открыл checkout три раза, выбор между «один пользователь» и «три попытки» должен быть сделан до просмотра результата. Для продуктовой воронки чаще нужна одна единица на пользователя; для надёжности платёжного вызова может быть важна каждая попытка. Оба решения допустимы в разных задачах, но их нельзя молча смешивать.</p><h2>Воспроизводимый пример с положительным и отрицательным путём</h2><p>Ниже — самостоятельный скрипт Node.js без внешних пакетов. Сохраните его в файл <code>metrics-check.mjs</code> и выполните <code>node metrics-check.mjs</code>. В наборе есть повторное событие открытия: оно не увеличивает denominator. Все строки вымышлены и нужны только для проверки алгоритма.</p><pre><code>const events = [\n { name: 'checkout_opened', subjectId: 'u-1', cohort: 'control', period: '2025-07-14', requestId: 'r-1' },\n { name: 'checkout_opened', subjectId: 'u-1', cohort: 'control', period: '2025-07-14', requestId: 'r-1' },\n { name: 'checkout_confirmed', subjectId: 'u-1', cohort: 'control', period: '2025-07-14', requestId: 'r-1' },\n { name: 'checkout_opened', subjectId: 'u-2', cohort: 'control', period: '2025-07-14', requestId: 'r-2' },\n { name: 'checkout_opened', subjectId: 'u-3', cohort: 'treatment', period: '2025-07-14', requestId: 'r-3' },\n { name: 'checkout_confirmed', subjectId: 'u-3', cohort: 'treatment', period: '2025-07-14', requestId: 'r-3' },\n { name: 'checkout_opened', subjectId: 'u-4', cohort: 'treatment', period: '2025-07-14', requestId: 'r-4' },\n { name: 'render_failed', subjectId: 'u-4', cohort: 'treatment', period: '2025-07-14', requestId: 'r-4' }\n];\n\nconst required = ['subjectId', 'cohort', 'period', 'requestId'];\nconst key = (event) =&gt; event.subjectId + ':' + event.requestId;\n\nfunction evaluate(input) {\n const invalid = input.find((event) =&gt;\n required.some((field) =&gt; !event[field])\n );\n if (invalid) return { status: 'hold', reason: 'missing-required-field' };\n\n const periods = new Set(input.map((event) =&gt; event.period));\n if (periods.size !== 1) return { status: 'hold', reason: 'mixed-period' };\n\n const cohorts = [...new Set(input.map((event) =&gt; event.cohort))];\n const metrics = cohorts.map((cohort) =&gt; {\n const inCohort = input.filter((event) =&gt; event.cohort === cohort);\n const opened = new Set(inCohort.filter((event) =&gt; event.name === 'checkout_opened').map(key));\n const confirmed = new Set(inCohort.filter((event) =&gt; event.name === 'checkout_confirmed').map(key));\n const failed = new Set(inCohort.filter((event) =&gt; event.name === 'render_failed').map(key));\n const confirmedAfterOpen = [...confirmed].filter((item) =&gt; opened.has(item)).length;\n const failedAfterOpen = [...failed].filter((item) =&gt; opened.has(item)).length;\n if (opened.size === 0) return { cohort, status: 'hold', reason: 'empty-denominator' };\n return {\n cohort,\n opened: opened.size,\n confirmed: confirmedAfterOpen,\n conversion: confirmedAfterOpen / opened.size,\n renderFailed: failedAfterOpen,\n renderFailureRate: failedAfterOpen / opened.size\n };\n });\n return { status: 'eligible-for-human-review', period: input[0].period, metrics };\n}\n\nconsole.log(JSON.stringify(evaluate(events), null, 2));</code></pre><p>Ожидаемый результат для основного набора: у control <code>opened: 2</code> и <code>conversion: 0.5</code>; у treatment те же <code>opened: 2</code> и <code>conversion: 0.5</code>, но <code>renderFailureRate: 0.5</code>. Это не основание для rollout: guardrail показывает риск, а маленький искусственный набор не даёт статистического вывода.</p><p>Теперь добавьте к массиву событие без <code>requestId</code> и снова запустите команду:</p><pre><code>events.push({\n name: 'checkout_confirmed',\n subjectId: 'u-5',\n cohort: 'treatment',\n period: '2025-07-14'\n});</code></pre><p>Результат должен стать <code>{\\\"status\\\":\\\"hold\\\",\\\"reason\\\":\\\"missing-required-field\\\"}</code>. Такой отрицательный путь важнее подстановки нуля: он сообщает, что дробь нельзя интерпретировать, пока не восстановлена атрибуция. В production дополнительно нужны дедупликация на уровне хранилища, обработка запаздывающих событий, политика хранения и контроль доступа к данным.</p><h2>Симптом → причина → проверка → действие</h2><p>Когда число изменилось сразу после релиза, сначала ищите разрыв в измерении. Следующая таблица задаёт короткий маршрут расследования.</p><div class=\"table-scroll\"><table><caption>Диагностика метрики после технического изменения</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Рабочая гипотеза</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Conversion выросла сразу</td><td>Пропали открытия или изменился фильтр включения</td><td>Сравнить множества уникальных opened до и после</td><td>Поставить hold и восстановить общий denominator</td></tr><tr><td>Есть confirm, но нет варианта</td><td>Потеряна атрибуция при отправке события</td><td>Найти assignment и requestId для каждой попытки</td><td>Исключить неподтверждённые строки и исправить контракт</td></tr><tr><td>Treatment лучше control, но окна разные</td><td>Смешаны period или часовые пояса</td><td>Сверить границы периода и версию схемы</td><td>Пересобрать оба cohort в одном окне</td></tr><tr><td>Outcome растёт вместе с отказами</td><td>Guardrail не участвовал в решении</td><td>Посчитать render failure на базе opened</td><td>Остановить rollout и проверить технический путь</td></tr><tr><td>На графике появился ноль</td><td>Пустые данные выданы за нулевой результат</td><td>Проверить статус загрузки и stop reason</td><td>Разделить «нет данных» и «значение равно нулю»</td></tr><tr><td>Повторный запуск даёт другое число</td><td>Дубликаты или плавающее окно</td><td>Сверить ключ дедупликации и зафиксированный period</td><td>Повторить расчёт после исправления входа</td></tr></tbody></table></div><p>Рядом с числом храните размер cohort, numerator, denominator, долю пропусков, версию схемы и время построения: это помогает отличить изменение поведения от сбоя pipeline.</p><h2>Порядок проверки перед rollout</h2><ol><li><strong>Назовите решение.</strong> Запишите, какое действие станет допустимым при успехе и что произойдёт при нарушении условия.</li><li><strong>Сформулируйте гипотезу.</strong> Укажите изменение, observable step, outcome и guardrail в одном абзаце.</li><li><strong>Выберите единицу анализа.</strong> Решите, считаются пользователи, сессии или попытки. Запишите это до расчёта.</li><li><strong>Зафиксируйте контракт событий.</strong> Проверьте обязательные поля, версию схемы, момент записи cohort и связь с requestId.</li><li><strong>Соберите одинаковые окна.</strong> Используйте одну timezone, период и правила включения для control и treatment.</li><li><strong>Проверьте denominator.</strong> Посчитайте уникальные ключи и отдельно долю повторных, пропущенных и неподтверждённых событий.</li><li><strong>Посчитайте outcome и guardrail.</strong> Покажите числитель и знаменатель, а не только процент. Сопоставьте риск с заранее заданным порогом.</li><li><strong>Прогоните отрицательные входы.</strong> Проверьте отсутствие requestId, смешанные периоды, пустой denominator и confirm без opened. Для каждого случая нужен отдельный stop reason.</li><li><strong>Передайте решение владельцу.</strong> Код может вернуть <code>eligible-for-human-review</code> или <code>hold</code>, но rollout, rollback и интерпретацию бизнес-результата утверждает ответственный человек.</li></ol><h2>Границы применимости</h2><p>Эта схема делает расчёт проверяемым, но не превращает его в доказательство причинности. Она не проверяет случайное распределение, статистическую мощность, длительность эффекта, сезонность, interference между пользователями и корректность самого бизнес-события. Для таких вопросов нужен отдельный дизайн эксперимента и статистическая проверка.</p><p>Нельзя считать отсутствие события нулевым результатом: оно может означать сбой клиента, блокировку сети, задержку доставки или изменение схемы. Нельзя сравнивать cohort, собранные разными правилами, даже если итоговые проценты выглядят рядом. Нельзя выдавать рост локальной conversion за рост выручки, удовлетворённости или удержания без связи с соответствующими исходами.</p><p>Один guardrail не описывает всю цену изменения. Для оплаты это могут быть отказы, незавершённые операции и обращения в поддержку; для регистрации — повторная отправка и доступность; для медленного интерфейса — время до полезного состояния и ошибки клиента. Выбирайте ограничители по реальному риску, а не по тому, какой график уже есть.</p><p>Если персональные данные попадают в событие, прежде чем расширять сбор, нужны правила минимизации, доступа и срока хранения. Пример выше использует вымышленные технические идентификаторы и не является готовым шаблоном политики приватности.</p><h2>Критерий готовности</h2><p>Проверка готова, когда другой инженер может по записи решения восстановить гипотезу, единицу анализа, cohort, период, numerator, denominator, атрибуцию, guardrail и владельца. Для каждого числа известен источник событий. Для каждого <code>hold</code> есть воспроизводимый вход и понятное действие по исправлению.</p><p>Минимум — контракт схемы, локальный прогон, положительный пример, отрицательные проверки, сравнение outcome с guardrail и ограничения. Microsoft Research показывает на инфраструктурных A/B-тестах, почему одной серверной latency недостаточно: изменения в сети и backend могут усилить задержку на пользовательском пути, а ошибки телеметрии способны испортить ранние scorecard. Это аргумент в пользу нескольких независимых сигналов, а не готовый порог для любого продукта.</p><p>Итоговое правило простое: сначала доказать, что число считается одинаково, затем обсуждать, что оно означает. Если связь между изменением и outcome оборвалась, честный результат — <code>hold</code> с причиной, а не точный процент без смысла.</p><h2>Проверяемые источники</h2><ul><li><a href=\"https://opentelemetry.io/docs/concepts/signals/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry: Signals</a> — официальное описание traces, metrics, logs и baggage; источник различия между видами телеметрии.</li><li><a href=\"https://www.microsoft.com/en-us/research/articles/a-b-testing-infrastructure-changes-at-microsoft-exp/\" target=\"_blank\" rel=\"noopener noreferrer\">Microsoft Research: A/B Testing Infrastructure Changes at Microsoft ExP</a> — официальный разбор инфраструктурных A/B-тестов, guardrail-метрик, итераций и проблем корреляции telemetry.</li><li><a href=\"https://www.microsoft.com/en-us/research/publication/online-experimentation-at-microsoft/\" target=\"_blank\" rel=\"noopener noreferrer\">Microsoft Research: Online Experimentation at Microsoft</a> — официальная публикация о контролируемых экспериментах, рандомизации и границах интерпретации продуктовых изменений.</li><li><a href=\"https://www.w3.org/TR/trace-context/\" target=\"_blank\" rel=\"noopener noreferrer\">W3C Trace Context</a> — стандарт распространения trace-контекста между сервисами; он не заменяет product-level requestId и не задаёт формулу conversion.</li></ul>"
}