{ "index": 90, "slug": "editorial-2025-07-practice-product-metrics", "title": "Метрики продукта для инженера: от изменения к проверяемому решению", "excerpt": "Рост conversion сам по себе не доказывает пользу релиза. Разбираем контракт события, cohort, denominator, атрибуцию и guardrail на воспроизводимом примере, который останавливает расчёт при неполных данных.", "contentHtml": "
Представим изменение checkout: форма должна открываться без дополнительного ожидания. На следующий день conversion выросла с 42% до 47%, и команда готовит rollout. Но при проверке выясняется, что после релиза часть событий checkout_opened перестала отправляться, а повторное открытие теперь считается иначе. Пользователи не обязательно стали чаще подтверждать заказ. Изменился способ подсчёта.
Это не задача про красивый дашборд. Инженеру нужно установить, что именно поменялось, кого сравнивают, какое действие считается результатом и что может отменить локальный выигрыш. Если одного звена нет, число следует пометить как непригодное для решения, а не превращать пропуск в нулевую конверсию.
Тезис: продуктовая метрика — это контракт между изменением и решением. В контракте заранее указаны событие, cohort (сравниваемая группа), период, population (множество пользователей или попыток), числитель, denominator (знаменатель) и guardrail — показатель побочного риска. Такой контракт не доказывает причинность сам по себе, но делает ошибку измерения видимой до релиза.
Слово «conversion» не говорит, какое действие нужно совершить. Один и тот же термин может означать подтверждение формы, создание заказа или успешную оплату. Поэтому начните с решения: rollout, hold (пауза до проверки), rollback или сбор дополнительных данных.
Для рассматриваемого checkout формулировка может быть такой: «Разрешить увеличение доли treatment после того, как доля подтверждений среди пользователей, открывших checkout, не ниже control, а доля ошибок рендера не выросла». Здесь есть вариант изменения, основной outcome и ограничитель риска. Фраза «ускорим экран и поднимем conversion» оставляет все три части неопределёнными.
Разделите технический сигнал и пользовательский результат. Время ответа API описывает один вызов, но не сообщает, дождался ли пользователь экрана и завершил ли действие. OpenTelemetry разделяет traces, metrics и logs: путь запроса, измерение во времени и запись события. Это граница диагностики, а не готовая product metric.
Хорошая гипотеза помещается в одну проверяемую цепочку: изменение → наблюдаемый шаг → outcome → guardrail → решение. Для checkout это выглядит так:
checkout_opened после фактического отображения checkout.checkout_confirmed.render_failed на базе открывших checkout не растёт относительно control.Ключевой вопрос здесь — что является единицей анализа. Если продукт оценивает людей, denominator состоит из уникальных пользователей. Если важна каждая попытка оплаты, единицей становится попытка с отдельным идентификатором. Нельзя считать пользователей в числителе и попытки в знаменателе: такая дробь выглядит точной, но отвечает не на тот вопрос.
Также зафиксируйте окно измерения. Cohort, собранный по текущему значению feature flag, может быть неверным: флаг успели переключить после показа старой версии. Надёжнее сохранить назначенный вариант в событии экспозиции или в неизменяемом контексте попытки. Атрибуция — правило, связывающее outcome с реально увиденным вариантом.
Имя button_click почти ничего не говорит о результате. Для решения нужны имя события, версия схемы, обезличенный идентификатор субъекта, cohort, период и идентификатор попытки. Не отправляйте в telemetry email, номер карты или свободный текст пользователя.
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.subjectId позволяет убрать повторную доставку одного события. requestId связывает открытие, подтверждение и ошибку одной попытки. cohort нужно записывать в момент назначения варианта, а не вычислять задним числом по текущему флагу. period задаёт окно сравнения и помогает не смешать данные разных версий схемы.
Trace ID связывает запросы между сервисами, а product request ID задаёт единицу расчёта; подменять их можно только после явного решения.
Отсутствие обязательного поля — это не «неизвестный пользователь» и не нулевая конверсия. Это отдельный статус качества данных. При нём расчёт должен вернуть hold с причиной, которую можно найти в логах и исправить.
Для локального учебного сравнения зададим одну базу: уникальные пары subjectId + requestId, у которых есть checkout_opened. Тогда для каждого cohort считаем:
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Это не универсальное определение conversion. Платёжный продукт может считать только подтверждённый заказ, успешное списание или завершённую сессию. Важна не выбранная формула, а её неизменность между вариантами и наличие источника для каждого множества.
| Поле | Что фиксируем | Что сломается без него | Проверка |
|---|---|---|---|
| Outcome | checkout_confirmed, а не любой клик | Локальный сигнал примут за завершённое действие | Сверить событие с бизнес-операцией |
| Cohort | Вариант, назначенный до действия | Control и treatment смешаются | Сравнить assignment с событием экспозиции |
| Period | Единое окно и часовой пояс | Варианты будут сравниваться в разные дни | Проверить границы окна и версию схемы |
| Denominator | Одна база открывших checkout | Рост дроби появится из-за пропавших открытий | Посчитать уникальные ключи до деления |
| Attribution | Связь outcome с той же попыткой | Старое или чужое подтверждение попадёт в результат | Проверить пару subjectId + requestId |
| Guardrail | Ошибка рендера на той же базе | Локальный выигрыш скроет ухудшение | Сопоставить риск с заранее заданным порогом |
Если один пользователь открыл checkout три раза, выбор между «один пользователь» и «три попытки» должен быть сделан до просмотра результата. Для продуктовой воронки чаще нужна одна единица на пользователя; для надёжности платёжного вызова может быть важна каждая попытка. Оба решения допустимы в разных задачах, но их нельзя молча смешивать.
Ниже — самостоятельный скрипт Node.js без внешних пакетов. Сохраните его в файл metrics-check.mjs и выполните node metrics-check.mjs. В наборе есть повторное событие открытия: оно не увеличивает denominator. Все строки вымышлены и нужны только для проверки алгоритма.
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) => event.subjectId + ':' + event.requestId;\n\nfunction evaluate(input) {\n const invalid = input.find((event) =>\n required.some((field) => !event[field])\n );\n if (invalid) return { status: 'hold', reason: 'missing-required-field' };\n\n const periods = new Set(input.map((event) => event.period));\n if (periods.size !== 1) return { status: 'hold', reason: 'mixed-period' };\n\n const cohorts = [...new Set(input.map((event) => event.cohort))];\n const metrics = cohorts.map((cohort) => {\n const inCohort = input.filter((event) => event.cohort === cohort);\n const opened = new Set(inCohort.filter((event) => event.name === 'checkout_opened').map(key));\n const confirmed = new Set(inCohort.filter((event) => event.name === 'checkout_confirmed').map(key));\n const failed = new Set(inCohort.filter((event) => event.name === 'render_failed').map(key));\n const confirmedAfterOpen = [...confirmed].filter((item) => opened.has(item)).length;\n const failedAfterOpen = [...failed].filter((item) => 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));Ожидаемый результат для основного набора: у control opened: 2 и conversion: 0.5; у treatment те же opened: 2 и conversion: 0.5, но renderFailureRate: 0.5. Это не основание для rollout: guardrail показывает риск, а маленький искусственный набор не даёт статистического вывода.
Теперь добавьте к массиву событие без requestId и снова запустите команду:
events.push({\n name: 'checkout_confirmed',\n subjectId: 'u-5',\n cohort: 'treatment',\n period: '2025-07-14'\n});Результат должен стать {\\\"status\\\":\\\"hold\\\",\\\"reason\\\":\\\"missing-required-field\\\"}. Такой отрицательный путь важнее подстановки нуля: он сообщает, что дробь нельзя интерпретировать, пока не восстановлена атрибуция. В production дополнительно нужны дедупликация на уровне хранилища, обработка запаздывающих событий, политика хранения и контроль доступа к данным.
Когда число изменилось сразу после релиза, сначала ищите разрыв в измерении. Следующая таблица задаёт короткий маршрут расследования.
| Симптом | Рабочая гипотеза | Проверка | Действие |
|---|---|---|---|
| Conversion выросла сразу | Пропали открытия или изменился фильтр включения | Сравнить множества уникальных opened до и после | Поставить hold и восстановить общий denominator |
| Есть confirm, но нет варианта | Потеряна атрибуция при отправке события | Найти assignment и requestId для каждой попытки | Исключить неподтверждённые строки и исправить контракт |
| Treatment лучше control, но окна разные | Смешаны period или часовые пояса | Сверить границы периода и версию схемы | Пересобрать оба cohort в одном окне |
| Outcome растёт вместе с отказами | Guardrail не участвовал в решении | Посчитать render failure на базе opened | Остановить rollout и проверить технический путь |
| На графике появился ноль | Пустые данные выданы за нулевой результат | Проверить статус загрузки и stop reason | Разделить «нет данных» и «значение равно нулю» |
| Повторный запуск даёт другое число | Дубликаты или плавающее окно | Сверить ключ дедупликации и зафиксированный period | Повторить расчёт после исправления входа |
Рядом с числом храните размер cohort, numerator, denominator, долю пропусков, версию схемы и время построения: это помогает отличить изменение поведения от сбоя pipeline.
eligible-for-human-review или hold, но rollout, rollback и интерпретацию бизнес-результата утверждает ответственный человек.Эта схема делает расчёт проверяемым, но не превращает его в доказательство причинности. Она не проверяет случайное распределение, статистическую мощность, длительность эффекта, сезонность, interference между пользователями и корректность самого бизнес-события. Для таких вопросов нужен отдельный дизайн эксперимента и статистическая проверка.
Нельзя считать отсутствие события нулевым результатом: оно может означать сбой клиента, блокировку сети, задержку доставки или изменение схемы. Нельзя сравнивать cohort, собранные разными правилами, даже если итоговые проценты выглядят рядом. Нельзя выдавать рост локальной conversion за рост выручки, удовлетворённости или удержания без связи с соответствующими исходами.
Один guardrail не описывает всю цену изменения. Для оплаты это могут быть отказы, незавершённые операции и обращения в поддержку; для регистрации — повторная отправка и доступность; для медленного интерфейса — время до полезного состояния и ошибки клиента. Выбирайте ограничители по реальному риску, а не по тому, какой график уже есть.
Если персональные данные попадают в событие, прежде чем расширять сбор, нужны правила минимизации, доступа и срока хранения. Пример выше использует вымышленные технические идентификаторы и не является готовым шаблоном политики приватности.
Проверка готова, когда другой инженер может по записи решения восстановить гипотезу, единицу анализа, cohort, период, numerator, denominator, атрибуцию, guardrail и владельца. Для каждого числа известен источник событий. Для каждого hold есть воспроизводимый вход и понятное действие по исправлению.
Минимум — контракт схемы, локальный прогон, положительный пример, отрицательные проверки, сравнение outcome с guardrail и ограничения. Microsoft Research показывает на инфраструктурных A/B-тестах, почему одной серверной latency недостаточно: изменения в сети и backend могут усилить задержку на пользовательском пути, а ошибки телеметрии способны испортить ранние scorecard. Это аргумент в пользу нескольких независимых сигналов, а не готовый порог для любого продукта.
Итоговое правило простое: сначала доказать, что число считается одинаково, затем обсуждать, что оно означает. Если связь между изменением и outcome оборвалась, честный результат — hold с причиной, а не точный процент без смысла.