{ "index": 89, "slug": "editorial-2025-07-mechanism-product-metrics", "title": "Рост conversion не равен улучшению продукта: проверяем denominator и guardrail", "excerpt": "Практический способ проверить продуктовую метрику до решения: зафиксировать событие, population, cohort, attribution, окно и denominator, затем сопоставить сигнал с guardrail и остановить вывод при разрыве данных.", "readingMinutes": 9, "contentHtml": "
На дашборде treatment показывает conversion 52%, а control — 48%. Команда готовит выпуск. На разборе выясняется, что в treatment считали уникальных открывших экран, а в control — все строки события. Часть подтверждений пришла без связи с вариантом. Проценты выглядят убедительно, но сравнивают разные множества.
\nЦена ошибки — не только неверный график. Команда может раскатить изменение, которого пользователь не заметил, потерять доверие к аналитике и потратить следующий спринт на поиск причины. Если одновременно выросли ошибки рендера, задержка или отмены, локальный рост conversion скрывает ущерб.
\nМетрика становится основанием для инженерного решения только вместе с контрактом измерения. В нём явно записаны субъект, событие успеха, population, cohort, attribution, окно, numerator, denominator и guardrail. При нарушении контракта результат получает статус hold: неполные данные нельзя выдавать за нулевой эффект или за победу варианта.
Conversion — это не свойство экрана или кнопки, а дробь над выбранной population. До запроса к хранилищу ответьте на пять вопросов: кого считаем, какое событие открывает воронку, какое событие считается успехом, к какому варианту относим субъекта и в каком окне ждём результат.
\nВ этой статье учебный контракт такой: subject — обезличенный идентификатор пользователя, population — субъекты с checkout_opened, numerator — субъекты с checkout_confirmed, единица счёта — один субъект. Оба события должны относиться к одному cohort и дню. Поэтому формула выглядит так:
conversion = unique(subject where event = checkout_confirmed)\n / unique(subject where event = checkout_opened)\nЕсли один субъект нажал кнопку трижды, три строки могут быть полезны для диагностики повторов, но не должны превращать одного человека в трёх участников знаменателя. Если вопрос другой — например, «сколько подтверждений на тысячу попыток» — это допустимая другая метрика. Её нельзя молча сравнивать с пользовательской conversion.
\nПервый источник разрыва — смена единицы счёта. Запрос по строкам события может показать рост после того, как клиент начал отправлять повторный checkout_confirmed. Запрос по уникальным субъектам этот повтор уберёт. Оба запроса технически корректны, но отвечают на разные вопросы.
Второй источник — несовместимые population. Если знаменатель treatment строится по открывшим checkout, а знаменатель control — по всем посетителям, разница отражает состав групп, а не поведение продукта. В отчёте рядом с каждой долей должны быть абсолютные значения: numerator, denominator, число уникальных субъектов и число сырых строк.
Третий источник — неверная attribution, то есть привязка outcome к exposure и варианту. Подтверждение без subject, exposure_id или времени нельзя надёжно приписать treatment. При отсутствии связи система должна возвращать причину missing-attribution, а не выбирать вариант по последнему известному значению.
Событие само по себе тоже имеет контракт. В официальной спецификации OpenTelemetry событие — это именованное происшествие с временем возникновения и структурированными атрибутами; динамические идентификаторы не должны попадать в имя события. Для продуктовой аналитики это означает практическое правило: имя вроде checkout_confirmed остаётся стабильным, а subject, exposure_id и cohort хранятся отдельными полями. Спецификация не определяет вашу conversion, поэтому остальные поля нужно согласовать в проекте.
Сохраните следующий фрагмент как metrics-example.mjs и запустите командой node metrics-example.mjs. Набор намеренно мал: в нём видны дедупликация субъектов и отдельный guardrail. Числа учебные и не описывают production-трафик.
const events = [\n { event: 'checkout_opened', subject: 'u-1', cohort: 'control' },\n { event: 'checkout_confirmed', subject: 'u-1', cohort: 'control' },\n { event: 'checkout_opened', subject: 'u-2', cohort: 'control' },\n { event: 'render_failed', subject: 'u-2', cohort: 'control' },\n { event: 'checkout_opened', subject: 'u-3', cohort: 'treatment' },\n { event: 'checkout_confirmed', subject: 'u-3', cohort: 'treatment' },\n { event: 'checkout_confirmed', subject: 'u-3', cohort: 'treatment' },\n { event: 'checkout_opened', subject: 'u-4', cohort: 'treatment' },\n];\n\nfunction uniqueSubjects(eventName, cohort) {\n return new Set(\n events\n .filter((item) => item.event === eventName && item.cohort === cohort)\n .map((item) => item.subject),\n );\n}\n\nfunction ratio(numerator, denominator) {\n if (denominator === 0) return { status: 'hold', reason: 'empty-denominator' };\n return { status: 'ok', value: numerator / denominator };\n}\n\nfor (const cohort of ['control', 'treatment']) {\n const opened = uniqueSubjects('checkout_opened', cohort);\n const confirmed = uniqueSubjects('checkout_confirmed', cohort);\n const failed = uniqueSubjects('render_failed', cohort);\n const failedAfterOpen = [...failed].filter((subject) => opened.has(subject)).length;\n\n console.log(cohort, {\n conversion: ratio(\n [...confirmed].filter((subject) => opened.has(subject)).length,\n opened.size,\n ),\n renderFailure: ratio(failedAfterOpen, opened.size),\n openedSubjects: opened.size,\n confirmedSubjects: confirmed.size,\n });\n}\nОжидаемый результат: в каждой группе conversion равна 1 / 2 = 0.5. В control guardrail рендера тоже равен 1 / 2, а в treatment — 0. Повторное подтверждение u-3 не меняет conversion, потому что множество удаляет дубликат. Обратите внимание: этот код не проверяет, что вариант был назначен случайно, и не доказывает причинный эффект.
Перед расчётом в рабочем отчёте полезно хранить не только число, но и описание запроса:
\n{\n \"name\": \"checkout_confirmation_rate\",\n \"unit\": \"unique_subject\",\n \"population\": \"checkout_opened\",\n \"outcome\": \"checkout_confirmed\",\n \"cohortKey\": \"checkout_v2\",\n \"period\": \"2025-07-14T00:00:00Z/2025-07-15T00:00:00Z\",\n \"attribution\": \"same subject and exposure_id\",\n \"guardrails\": [\"render_failure_rate\", \"cancel_rate\"]\n}\nТакой объект не является универсальным стандартом. Это минимальный проектный шаблон, который делает запрос проверяемым через ревью, повторный запуск и сравнение версий схемы.
\nAttribution нужно определить до просмотра результата. Для короткого checkout можно требовать тот же subject, связанный exposure_id и outcome после exposure. Для отложенной покупки понадобится другое окно и, возможно, серверное событие. Нельзя переносить правило из одного продукта в другой только потому, что названия событий совпадают.
События должны различать время, когда действие произошло, и время, когда его приняла аналитическая система. Задержка доставки может сделать вчерашнее окно неполным. Практический отчёт поэтому содержит event_time, received_at и дату среза. Пока данные ещё догружаются, статус отчёта — pending, а не «конверсия равна нулю».
Проверяйте и границы окна: включается ли начало, исключается ли конец, что делать с часовыми поясами, когда субъект открыл экран до полуночи, а подтвердил после неё. Одна и та же граница должна применяться treatment и control. Смешанное окно — причина пересобрать выборку.
\nSuccess metric отвечает на вопрос о желаемом результате. Local metric показывает ближайший шаг. Guardrail ограничивает цену улучшения: ошибки рендера, отмены, задержку или обращение в поддержку. Guardrail — не украшение отчёта, а условие, при котором рост success metric перестаёт быть приемлемым.
\n| Симптом | Что проверить | Безопасное действие |
|---|---|---|
| Conversion выросла сразу после изменения tracking | Число субъектов, сырые строки, deduplication и долю доставки каждого события до и после изменения | Остановить интерпретацию и восстановить прежнее определение denominator |
| Treatment и control имеют неожиданное соотношение | Exposure, распределение вариантов, пропуски и задержку событий | Не объявлять победителя; проверить sample-ratio и pipeline |
| Outcome не содержит cohort или exposure_id | Схему события и цепочку от exposure до результата | Вернуть hold: missing-attribution |
| Local metric растёт вместе с ошибками | Guardrail на той же population и в том же окне | Сравнить с заранее заданным порогом и привлечь владельца риска |
| События пришли после закрытия окна | Разницу между event_time и received_at | Пометить отчёт pending или пересобрать окно |
Порог guardrail задают до чтения результата и связывают с владельцем риска. Формулировка «ошибок стало больше» не годится: нужны числитель, знаменатель, окно и действие при нарушении. Если допустимый порог неизвестен, отчёт может показать наблюдение, но не должен сам объявлять выпуск безопасным.
\nПоложительный путь означает, что расчёт прошёл проверки и может попасть к человеку, принимающему решение. Он не означает, что изменение уже доказанно улучшает продукт. Отрицательный путь возвращает конкретную причину и сохраняет выборку для исправления.
\nfunction inspect(report) {\n const reasons = [];\n\n if (!report.attribution) reasons.push('missing-attribution');\n if (report.denominator !== 'unique-opened-subjects') {\n reasons.push('wrong-denominator');\n }\n if (report.periods.length !== 1) reasons.push('mixed-period');\n if (report.dataQuality !== 'ok') reasons.push('data-quality');\n if (report.guardrailRate > report.guardrailLimit) {\n reasons.push('guardrail-breached');\n }\n\n return reasons.length === 0\n ? { status: 'eligible-for-human-review', reasons: [] }\n : { status: 'hold', reasons };\n}\n\n// Инспектор проверяет условия расчёта.\n// Он не читает telemetry и не запускает rollout.\nВ отрицательном пути нет подстановки default cohort, усреднения пропущенных событий или выбора удобного denominator. Если период смешан, выборку пересобирают. Если attribution отсутствует, чинят схему или правило связи. Если guardrail нарушен, владелец риска принимает отдельное решение. Функция не должна прятать эти причины в null или зелёный статус.
Controlled rollout помогает разделить эксперимент и доставку: в работе Microsoft Research описаны одновременное сравнение вариантов, поэтапное расширение аудитории, работа с exposed populations, длительностью и pass criteria. Это поддерживает порядок проверки, но не делает учебные числа доказательством и не заменяет статистический дизайн конкретного эксперимента.
\nhold с конкретными причинами.Контракт метрики не доказывает случайное распределение, достаточную мощность, отсутствие сезонности или причинный эффект. Он отвечает на более узкий вопрос: одинаково ли определены данные, на которых построено сравнение. Для причинного вывода нужны подходящий дизайн эксперимента, длительность и статистический анализ.
\nМаленький набор в примере не моделирует реальный трафик. В production дополнительно проверяют идемпотентность отправки, потерю событий в клиенте, задержку очереди, часовые пояса, приватность, retention и доступ к идентификаторам. В финансовых, медицинских и других чувствительных сценариях эти требования нельзя заменить одним полем subject.
Критерий готовности воспроизводим: независимый инженер по записи может восстановить population, numerator, denominator, cohort, период, attribution, качество данных и guardrail. На валидном наборе инспектор возвращает eligible-for-human-review, а на каждом специально испорченном наборе — hold с причиной. Ни один путь не публикует результат и не запускает rollout автоматически.