Продуктовые метрики — это приборная панель, а не табло с очками. Сначала команда формулирует, какое полезное изменение в поведении пользователя ожидается, затем записывает события и сравнивает группы. Большое число кликов само по себе ничего не доказывает: важно, кто кликнул, после какого опыта, дошёл ли до ценности и не ухудшилось ли что-то ещё.
Разбираем: Продуктовые метрики и честные A/B-тесты
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
Product analytics связывает вопрос, измеримый event contract, population и период наблюдения. Funnel измеряет переходы между шагами, cohort объединяет пользователей по общему старту, retention показывает возврат к ценному действию, а controlled A/B experiment использует случайное назначение, чтобы оценить причинный эффект изменения. Решение принимают по primary metric вместе с guardrails, quality checks и интервалом правдоподобных эффектов.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
A/B test
Контролируемый эксперимент, который случайно и стабильно назначает units вариантам и сравнивает заранее определённые outcomes.
North Star Metric
Одна ведущая метрика полученной пользователем ценности, связанная с долгосрочным результатом продукта. Это не автоматически revenue, DAU или количество кликов.
Guardrail metric
Защитная метрика, которая не должна недопустимо ухудшиться: error rate, latency, refunds, complaints, unsubscribe или churn.
Event / property
Event фиксирует факт действия во времени, например report_generated. Properties описывают контекст: userId, plan, locale, experiment variant и duration.
Funnel
Последовательность действий и доля population, дошедшая от каждого шага до следующего в заданном порядке и временном окне.
Cohort
Группа пользователей с общим условием или временем старта, например зарегистрировавшиеся в одной неделе или впервые использовавшие AI-search.
Activation
Первый подтверждённый момент, когда новый пользователь получил ключевую ценность, а не просто открыл страницу или зарегистрировался.
Conversion
Доля подходящей population, совершившая целевое действие: converters / eligible participants. Знаменатель является частью определения.
Retention
Доля стартовой cohort, вернувшаяся к осмысленному действию в последующий период. Calendar, rolling и bracket retention отвечают на разные вопросы.
Churn
Потеря клиентов, подписок или recurring revenue за период. User churn и revenue churn нельзя смешивать.
Exposure event
Факт, что experiment unit действительно получил вариант. Assignment без показа не всегда должен входить в triggered analysis.
Randomization unit
Сущность, которую независимо назначают в вариант: user, account, device, session или organization. Unit выбирают по механизму влияния feature.
MDE, sample size и power
MDE — минимальный эффект, который важно обнаружить. Чем он меньше и метрика шумнее, тем больше sample. Power — вероятность обнаружить реальный эффект заданного размера.
Confidence interval (CI)
Интервал оценок эффекта, совместимых с данными и моделью на выбранном уровне. Он показывает неопределённость и полезнее одного p-value.
Statistical significance
Сигнал, что наблюдаемая разница плохо объясняется нулевой гипотезой при выполнении предпосылок. Она не доказывает важность, отсутствие bias или истинность бизнес-гипотезы.
SRM
Sample Ratio Mismatch — фактическое распределение участников по вариантам неправдоподобно отличается от запланированного; часто указывает на баг assignment, exposure или данных.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Начните с решения, а не dashboard
Запишите, какое решение будет принято при росте, отсутствии изменения и ухудшении метрики.
- 02Сформулируйте гипотезу
Укажите population, изменение, ожидаемое поведение, механизм причинного влияния и временное окно.
- 03Назовите primary metric и guardrails
Одна primary metric отвечает на главный вопрос; guardrails ограничивают допустимую цену улучшения.
- 04Опишите event contract
Зафиксируйте имя, business meaning, момент отправки, actor identity, обязательные properties, owner, schema version и deduplication rule.
- 05Проверьте identity и eligibility
Решите, как anonymousId связывается с userId, кто входит в population и как исключаются bots, staff и test accounts.
- 06Снимите baseline
До изменения проверьте volume, variance, сезонность, missing/duplicate events и стабильность определения.
- 07Выберите randomization unit
Назначайте вариант на уровне, где участники не переносят эффект друг на друга; B2B-feature часто требует account, а не user.
- 08Оцените MDE, sample и duration
Заранее задайте practically useful effect, baseline rate, power и error threshold. Короткий тест с малым sample не становится достоверным от красивого процента.
- 09Запишите exposure один раз
Храните assignment стабильно и фиксируйте фактический показ до outcome, не меняя вариант между устройствами или запросами без осознанной причины.
- 10Не принимайте решение при каждом refresh
Следуйте заранее выбранному stopping rule. Постоянное peeking увеличивает вероятность случайной победы.
- 11Проверьте качество до эффекта
Ищите SRM, потери событий, дубли, различия до treatment, поломку guardrails и несогласованные версии feature.
- 12Интерпретируйте размер и неопределённость
Сравните estimate и CI с MDE, проверьте сегменты только по плану, учтите novelty и наблюдайте метрики после rollout.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
North Star не работает одна
Одна агрегированная цифра скрывает качество, distribution и вред. Нужны input metrics, guardrails и разрезы по ключевым populations.
Событие должно означать business fact
Название button_clicked привязано к UI, а report_exported — к устойчивому намерению пользователя. Семантика переживает редизайн лучше селектора кнопки.
Знаменатель определяет метрику
10 покупок из 100 exposed users и 10 из 20 visitors — разные conversion rates. Сохраняйте eligible non-converters через LEFT JOIN.
Retention требует осмысленного return event
Открытие приложения может быть случайным. Для редактора публикаций полезнее повторное сохранение или публикация, если именно они представляют ценность.
Calendar и rolling retention различаются
Day-7 calendar спрашивает о конкретном периоде, rolling — вернулся ли user на седьмой день или позже. Числа нельзя сравнивать без определения.
Assignment и exposure — не одно и то же
User может получить variant в storage, но не открыть экран. Triggered analysis включает реально затронутых, однако trigger должен быть определён до просмотра результата.
Randomization unit следует механизму влияния
Если коллеги одного workspace видят общий документ, randomization по user загрязнит группы. Назначайте весь workspace вместе.
CI важнее ярлыка winner
Интервал может одновременно допускать полезный рост и вред. «Не significant» означает недостаточно доказательств, а не доказанный нулевой эффект.
Peeking меняет частоту ложных побед
Обычный fixed-horizon test предполагает один запланированный анализ. Для непрерывного просмотра нужна последовательная методика или строгий stopping rule.
Multiple testing требует контроля
Если проверить 20 равноправных метрик и много сегментов, случайный winner становится вероятнее. Primary metric и planned slices объявляют заранее.
Novelty может быть временным эффектом
Пользователи исследуют новое или сначала сопротивляются изменению. Эксперимент должен охватывать полный business cycle, а rollout — иметь post-monitoring.
Практическая и статистическая значимость различаются
На огромном sample микроскопический эффект может быть statistical significant, но не окупать разработку, latency или поддержку.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
const day7Retention = returnedOnDay7 / eligibleSignups;
const experiment = {
primary: 'project_published_rate',
guardrails: ['error_rate', 'p95_latency'],
randomizationUnit: 'account_id',
exposureEvent: 'editor_variant_viewed',
};Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
function percentage(value) {
return Number((value * 100).toFixed(2));
}
export function funnel(events, orderedSteps) {
const progressByUser = new Map();
for (const event of events) {
const progress = progressByUser.get(event.userId) ?? 0;
if (event.name === orderedSteps[progress]) {
progressByUser.set(event.userId, progress + 1);
}
}
return orderedSteps.map((step, index) => {
const users = [...progressByUser.values()].filter(
(progress) => progress > index,
).length;
return { step, users };
});
}
export function retention(users, day) {
const eligible = users.filter((user) => user.observedDays >= day);
const returned = eligible.filter((user) => user.activeDays.includes(day));
return {
eligible: eligible.length,
returned: returned.length,
rate: eligible.length ? returned.length / eligible.length : 0,
};
}
export function compareBinaryExperiment(control, variant) {
const controlRate = control.conversions / control.users;
const variantRate = variant.conversions / variant.users;
const absoluteLift = variantRate - controlRate;
const standardError = Math.sqrt(
(controlRate * (1 - controlRate)) / control.users +
(variantRate * (1 - variantRate)) / variant.users,
);
const margin95 = 1.96 * standardError;
const total = control.users + variant.users;
return {
controlRate,
variantRate,
absoluteLift,
confidenceInterval95: [absoluteLift - margin95, absoluteLift + margin95],
allocation: {
control: control.users / total,
variant: variant.users / total,
},
};
}
export async function productMetricsExperiment(emit) {
const events = [
{ userId: 'u1', name: 'signup_completed' },
{ userId: 'u1', name: 'project_created' },
{ userId: 'u1', name: 'project_published' },
{ userId: 'u2', name: 'signup_completed' },
{ userId: 'u2', name: 'project_created' },
{ userId: 'u3', name: 'signup_completed' },
];
const users = [
{ observedDays: 14, activeDays: [0, 1, 7], variant: 'control' },
{ observedDays: 14, activeDays: [0, 3], variant: 'control' },
{ observedDays: 14, activeDays: [0, 7], variant: 'variant' },
{ observedDays: 3, activeDays: [0, 1], variant: 'variant' },
];
emit(
'instrumentation',
'contract',
'События используют стабильные имена, userId и server timestamp',
);
const funnelResult = funnel(events, [
'signup_completed',
'project_created',
'project_published',
]);
emit(
'funnel',
'result',
funnelResult.map(({ step, users }) => `${step}=${users}`).join(' → '),
);
const day7 = retention(users, 7);
emit(
'cohort',
'result',
`D7 retention=${percentage(day7.rate)}%; denominator=${day7.eligible}`,
);
const experiment = compareBinaryExperiment(
{ users: 5_000, conversions: 1_000 },
{ users: 5_100, conversions: 1_096 },
);
const [low, high] = experiment.confidenceInterval95.map(percentage);
emit(
'experiment',
'result',
`Control=${percentage(experiment.controlRate)}%; variant=${percentage(experiment.variantRate)}%; lift CI95=[${low}, ${high}] п.п.`,
);
emit(
'decision',
'guardrail',
'Решение требует заранее выбранной primary metric, guardrails и проверки SRM',
);
return { funnel: funnelResult, retention: day7, experiment };
}
Live trace инструментирован самим приложением: строки и timestamps фиксируются при реальных вызовах emit(...), а названия source/lane задаёт сценарий. Это не profiler V8/libuv и не прямой снимок их внутренних очередей. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Практические шаблоны, которые можно подсмотреть
Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.
Nest: контракт серверного события
Не позволить клиенту подменить actor и отправить неизвестное имя.
const allowedNames = [
'report_created',
'report_published',
] as const;
type ProductEventName = typeof allowedNames[number];
@Injectable()
export class ProductMetricsService {
capture(
actorId: string,
name: ProductEventName,
properties: Record<string, string | number>,
) {
return this.events.insert({
eventId: randomUUID(),
actorId,
name,
properties,
schemaVersion: 1,
});
}
}- as const сохраняет строковые literals вместо общего string.
- ProductEventName становится union разрешённых names.
- Actor приходит отдельным аргументом из auth context.
- Compile-time type не заменяет runtime validation внешнего JSON.
SQL: funnel регистрации
Посчитать, сколько пользователей прошло signup → project → publish за семь дней.
WITH first_steps AS (
SELECT user_id,
min(occurred_at) FILTER (
WHERE event_name = 'signup_completed'
) AS signed_up_at,
min(occurred_at) FILTER (
WHERE event_name = 'project_created'
) AS project_at,
min(occurred_at) FILTER (
WHERE event_name = 'project_published'
) AS published_at
FROM product_events
GROUP BY user_id
)
SELECT
count(*) FILTER (WHERE signed_up_at IS NOT NULL) AS signup,
count(*) FILTER (WHERE project_at >= signed_up_at) AS project,
count(*) FILTER (
WHERE published_at >= project_at
AND published_at < signed_up_at + interval '7 days'
) AS published
FROM first_steps;- WITH создаёт промежуточный набор first_steps.
- min находит первое событие каждого типа.
- FILTER ограничивает rows конкретного aggregate.
- Условия сохраняют порядок и временное окно funnel.
SQL: недельный retention
Измерить возврат к публикации в неделю после activation.
WITH activated AS (
SELECT user_id,
date_trunc('week', min(occurred_at)) AS cohort_week
FROM product_events
WHERE event_name = 'project_published'
GROUP BY user_id
), returned AS (
SELECT DISTINCT a.user_id, a.cohort_week
FROM activated a
JOIN product_events e ON e.user_id = a.user_id
AND e.event_name = 'project_published'
AND e.occurred_at >= a.cohort_week + interval '1 week'
AND e.occurred_at < a.cohort_week + interval '2 weeks'
)
SELECT a.cohort_week,
count(*) AS activated_users,
count(r.user_id) AS retained_users,
count(r.user_id)::numeric / count(*) AS week_1_retention
FROM activated a
LEFT JOIN returned r USING (user_id, cohort_week)
GROUP BY a.cohort_week
ORDER BY a.cohort_week;- Cohort определяется первой публикацией пользователя.
- DISTINCT не даёт частым publishers увеличить числитель.
- LEFT JOIN оставляет non-retained users в знаменателе.
- Определение здесь calendar week-1, а не rolling retention.
SQL: subscription churn
Не смешивать потерянные подписки со всей исторической базой.
SELECT
date_trunc('month', cancelled_at) AS month,
count(*) AS cancelled,
count(*)::numeric /
nullif(max(active_at_month_start), 0) AS user_churn
FROM subscription_cancellations
GROUP BY date_trunc('month', cancelled_at)
ORDER BY month;- Знаменатель — active subscriptions в начале соответствующего периода.
- nullif защищает от деления на ноль.
- Для revenue churn вместо users суммируют потерянный recurring revenue.
Стабильное назначение варианта
Дать одному user один variant без центрального random generator.
function assignVariant(
experimentKey: string,
userId: string,
) {
const bucket = createHash('sha256')
.update(experimentKey + ':' + userId)
.digest()
.readUInt32BE(0) % 10_000;
return bucket < 5_000 ? 'control' : 'treatment';
}- Experiment key разделяет независимые тесты.
- Deterministic hash повторяет assignment на другом Nest instance.
- Production implementation версионирует allocation и хранит exposure.
- Для account-level feature используйте accountId.
SQL: SRM перед чтением результата
Сравнить фактический размер вариантов с ожидаемым allocation.
SELECT variant, count(DISTINCT user_id) AS participants
FROM experiment_exposures
WHERE experiment_key = $1
GROUP BY variant;
-- Для 50/50 ожидаются близкие counts.
-- Статистическую SRM-проверку выполняет
-- experiment service до проверки primary metric.- DISTINCT соответствует randomization unit user.
- Большое отклонение требует расследования, а не корректировки результата вручную.
- Порог зависит от sample и planned allocation; одной визуальной оценки недостаточно.
Карточка эксперимента до запуска
Сделать критерии решения проверяемыми до появления результата.
const checkoutExperiment = {
hypothesis:
'Shorter form increases completed purchases',
unit: 'user',
population: 'authenticated users entering checkout',
primaryMetric: 'purchase within 24h of exposure',
guardrails: ['payment_error_rate', 'refund_rate'],
allocation: { control: 0.5, treatment: 0.5 },
mde: 0.02,
stoppingRule: 'planned sample and full business weeks',
};- MDE 0.02 нужно уточнить: absolute percentage points или relative uplift.
- Guardrails получают допустимые пределы, а не только названия.
- Зафиксированный plan снижает свободу подобрать удобный результат post-hoc.
Как учебная ошибка превращается в инцидент
Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.
Activation растёт из-за события, записанного до бизнес-результата
Команда считает пользователя активированным после создания первого проекта. Controller отправляет событие без await до завершения записи проекта, а повтор запроса создаёт новое событие.
Ошибка БД всё равно оставляет ложную activation, а retry удваивает число событий. Dashboard показывает рост, которого нет в бизнес-данных; по нему команда может принять неверное продуктовое решение.
@Post()
async create(
@CurrentUser() user: User,
@Body() dto: CreateProjectDto,
) {
this.metrics.capture({
eventId: randomUUID(),
name: 'project_created',
userId: user.id,
}); // Promise не ожидается
return this.projects.create(user.id, dto);
}Событие не связано с commit проекта, а randomUUID при каждом retry не позволяет распознать дубль. Метод может вернуть ошибку, хотя analytics уже приняла «успех».
@Injectable()
export class ProjectsService {
constructor(private readonly db: Database) {}
create(userId: string, dto: CreateProjectDto) {
return this.db.transaction(async (tx) => {
const project = await tx.one(
'INSERT INTO projects (user_id, name) ' +
'VALUES ($1, $2) RETURNING id, name',
[userId, dto.name],
);
await tx.query(
'INSERT INTO product_event_outbox ' +
'(dedupe_key, event_name, user_id, properties) ' +
'VALUES ($1, $2, $3, $4::jsonb) ' +
'ON CONFLICT (dedupe_key) DO NOTHING',
[
'project_created:' + project.id,
'project_created',
userId,
JSON.stringify({ projectId: project.id }),
],
);
return project;
});
}
}Project и outbox-event фиксируются одной DB-транзакцией. Стабильный dedupe_key делает повтор безопасным, а publisher отправит событие только после успешного commit.
Что делают непривычные вызовы из обоих фрагментов кода.
@Injectable()- Nest-декоратор помечает класс как provider, который IoC-контейнер может создать и внедрить в controller.
db.transaction(callback)- Открывает транзакцию: либо и project, и outbox-event сохранятся вместе, либо оба изменения будут отменены.
tx.one(sql, values)- Условный repository helper выполняет параметризованный SQL и требует ровно одну возвращённую строку.
$1, $2 и values- PostgreSQL placeholders. Значения передаются отдельно от SQL-текста, а их позиции совпадают с индексами массива начиная с единицы.
RETURNING- Возвращает поля только что записанной строки без отдельного SELECT; здесь нужен id проекта для события.
ON CONFLICT ... DO NOTHING- При повторе уникального dedupe_key база не создаёт вторую строку события.
outbox- Таблица намерений отправить событие. Отдельный worker читает committed строки и надёжно доставляет их в analytics.
A/B-тест «победил» из-за случайного варианта на каждом запросе
Новая checkout-форма выбирается через Math.random() при каждом открытии страницы. Аналитик делит только оформленные заказы по последнему увиденному варианту.
Один пользователь попадает в обе группы, exposure дублируется, а некупившие пользователи отсутствуют в знаменателе. Разница вариантов не имеет причинной интерпретации.
@Get('checkout')
async checkout(@CurrentUser() user: User) {
const variant = Math.random() < 0.5 ? 'A' : 'B';
await this.metrics.capture({
name: 'checkout_exposed',
userId: user.id,
properties: { variant },
});
return this.checkoutPage.render({ variant });
}
// Ошибка анализа: только купившие пользователи
SELECT variant, count(*)
FROM orders
GROUP BY variant;Назначение нестабильно, unit randomization не соблюдается. Запрос считает outcomes без всех exposed participants и поэтому не вычисляет conversion rate.
@Injectable()
export class CheckoutExperiment {
constructor(private readonly db: Database) {}
async getVariant(userId: string) {
const bucket = createHash('sha256')
.update('checkout-v2:' + userId)
.digest()
.readUInt32BE(0) % 10_000;
const variant = bucket < 5_000
? 'control'
: 'treatment';
await this.db.query(
'INSERT INTO experiment_exposures ' +
'(experiment_key, user_id, variant, exposed_at) ' +
'VALUES ($1, $2, $3, now()) ' +
'ON CONFLICT (experiment_key, user_id) DO NOTHING',
['checkout-v2', userId, variant],
);
return variant;
}
}
WITH exposed AS (
SELECT user_id, variant, min(exposed_at) AS exposed_at
FROM experiment_exposures
WHERE experiment_key = 'checkout-v2'
GROUP BY user_id, variant
), converted AS (
SELECT DISTINCT e.user_id
FROM exposed e
JOIN orders o ON o.user_id = e.user_id
AND o.created_at >= e.exposed_at
)
SELECT e.variant,
count(*) AS participants,
count(c.user_id) AS conversions,
count(c.user_id)::numeric / count(*) AS rate
FROM exposed e
LEFT JOIN converted c USING (user_id)
GROUP BY e.variant;Hash стабильно закрепляет userId за одной группой. Уникальный exposure задаёт честный знаменатель, LEFT JOIN сохраняет некупивших, а outcome учитывается только после первого показа варианта.
Что делают непривычные вызовы из обоих фрагментов кода.
createHash(...).update(...)- Детерминированно преобразует experiment key и userId в bytes: одинаковый пользователь получает одинаковый bucket.
readUInt32BE(0) % 10_000- Читает число из первых четырёх bytes hash и переводит его в bucket 0–9999 для процентного rollout.
UNIQUE (experiment_key, user_id)- DB-ограничение не позволяет записать два назначения одного пользователя в одном эксперименте.
WITH exposed AS (...)- CTE даёт имя промежуточному набору и делает знаменатель анализа явным.
LEFT JOIN- Сохраняет каждого exposed user, даже когда подходящего заказа нет; отсутствие order становится non-conversion.
count(c.user_id)::numeric / count(*)- Делит число converters на всех участников группы. ::numeric предотвращает целочисленное деление.
Популярные заблуждения
Миф слева, корректная модель справа.
Больше событий означает больше понимания.
Без owner, схемы и продуктового вопроса события создают противоречивые определения и стоимость хранения.
DAU всегда подходит как North Star.
DAU измеряет присутствие, но не обязательно полученную ценность; метрика должна отражать core outcome конкретного продукта.
Retention — любой повторный визит.
Нужны явно заданные cohort, return action и временное окно.
Если p < 0.05, feature точно полезна.
Проверьте design, SRM, data quality, CI, размер эффекта, guardrails и цену внедрения.
Если significance нет, варианты одинаковы.
Тест мог иметь недостаточную power; CI показывает, какие эффекты данные ещё допускают.
Math.random() при каждом запросе создаёт A/B-тест.
Unit должен получить стабильный assignment, а exposure и outcome — корректно связаться с ним.
Можно остановить эксперимент, как только график стал зелёным.
Незапланированное peeking и stopping повышают вероятность ложноположительного решения.
Сегмент, найденный после теста, уже доказывает эффект.
Post-hoc сегмент является новой гипотезой и требует подтверждения независимыми данными.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Почему North Star требует guardrail metrics?
- Чем event отличается от его properties?
- Что именно входит в знаменатель conversion rate?
- Как cohort и return event определяют retention?
- Чем user churn отличается от revenue churn?
- Почему assignment не всегда равен exposure?
- Как выбрать между user и account как randomization unit?
- Как MDE связан с sample size и power?
- Что confidence interval сообщает сверх ярлыка significant?
- Почему peeking и multiple testing увеличивают ложные победы?
- Что означает SRM и почему результат нельзя читать до его проверки?
- Как transactional outbox защищает продуктовые события?
- Почему один успешный A/B-тест не отменяет post-rollout monitoring?