ГЛАВА 26
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: Продуктовые метрики и честные A/B-тесты

Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.

СНАЧАЛА ПРОСТЫМИ СЛОВАМИ

Продуктовые метрики — это приборная панель, а не табло с очками. Сначала команда формулирует, какое полезное изменение в поведении пользователя ожидается, затем записывает события и сравнивает группы. Большое число кликов само по себе ничего не доказывает: важно, кто кликнул, после какого опыта, дошёл ли до ценности и не ухудшилось ли что-то ещё.

ТЕХНИЧЕСКАЯ ОСНОВА

Product analytics связывает вопрос, измеримый event contract, population и период наблюдения. Funnel измеряет переходы между шагами, cohort объединяет пользователей по общему старту, retention показывает возврат к ценному действию, а controlled A/B experiment использует случайное назначение, чтобы оценить причинный эффект изменения. Решение принимают по primary metric вместе с guardrails, quality checks и интервалом правдоподобных эффектов.

Зачем это знатьAI Product Engineer должен не только быстро выпустить feature, но и доказать, что она помогает. Без корректной телеметрии команда оптимизирует шум; без guardrails ускоряет одну часть продукта ценой ошибок или churn; без честного эксперимента путает сезонность и самоотбор с эффектом кода.
ГДЕ ВЫПОЛНЯЕТСЯ РАБОТА
01PRODUCT QUESTIONгипотеза · решение
02EVENT CONTRACTevent · actor · properties
03ANALYTICS STOREevents · cohorts · exposure
04DECISIONprimary · guardrails · CI
01 · СЛОВАРЬ

Термины этого эксперимента

Сначала поймите слова — затем порядок выполнения.

01

A/B test

Контролируемый эксперимент, который случайно и стабильно назначает units вариантам и сравнивает заранее определённые outcomes.

02

North Star Metric

Одна ведущая метрика полученной пользователем ценности, связанная с долгосрочным результатом продукта. Это не автоматически revenue, DAU или количество кликов.

03

Guardrail metric

Защитная метрика, которая не должна недопустимо ухудшиться: error rate, latency, refunds, complaints, unsubscribe или churn.

04

Event / property

Event фиксирует факт действия во времени, например report_generated. Properties описывают контекст: userId, plan, locale, experiment variant и duration.

05

Funnel

Последовательность действий и доля population, дошедшая от каждого шага до следующего в заданном порядке и временном окне.

06

Cohort

Группа пользователей с общим условием или временем старта, например зарегистрировавшиеся в одной неделе или впервые использовавшие AI-search.

07

Activation

Первый подтверждённый момент, когда новый пользователь получил ключевую ценность, а не просто открыл страницу или зарегистрировался.

08

Conversion

Доля подходящей population, совершившая целевое действие: converters / eligible participants. Знаменатель является частью определения.

09

Retention

Доля стартовой cohort, вернувшаяся к осмысленному действию в последующий период. Calendar, rolling и bracket retention отвечают на разные вопросы.

10

Churn

Потеря клиентов, подписок или recurring revenue за период. User churn и revenue churn нельзя смешивать.

11

Exposure event

Факт, что experiment unit действительно получил вариант. Assignment без показа не всегда должен входить в triggered analysis.

12

Randomization unit

Сущность, которую независимо назначают в вариант: user, account, device, session или organization. Unit выбирают по механизму влияния feature.

13

MDE, sample size и power

MDE — минимальный эффект, который важно обнаружить. Чем он меньше и метрика шумнее, тем больше sample. Power — вероятность обнаружить реальный эффект заданного размера.

14

Confidence interval (CI)

Интервал оценок эффекта, совместимых с данными и моделью на выбранном уровне. Он показывает неопределённость и полезнее одного p-value.

15

Statistical significance

Сигнал, что наблюдаемая разница плохо объясняется нулевой гипотезой при выполнении предпосылок. Она не доказывает важность, отсутствие bias или истинность бизнес-гипотезы.

16

SRM

Sample Ratio Mismatch — фактическое распределение участников по вариантам неправдоподобно отличается от запланированного; часто указывает на баг assignment, exposure или данных.

02 · МЕХАНИКА

Что происходит по шагам

Каждый шаг соответствует наблюдаемому состоянию runtime.

  1. 01
    Начните с решения, а не dashboard

    Запишите, какое решение будет принято при росте, отсутствии изменения и ухудшении метрики.

  2. 02
    Сформулируйте гипотезу

    Укажите population, изменение, ожидаемое поведение, механизм причинного влияния и временное окно.

  3. 03
    Назовите primary metric и guardrails

    Одна primary metric отвечает на главный вопрос; guardrails ограничивают допустимую цену улучшения.

  4. 04
    Опишите event contract

    Зафиксируйте имя, business meaning, момент отправки, actor identity, обязательные properties, owner, schema version и deduplication rule.

  5. 05
    Проверьте identity и eligibility

    Решите, как anonymousId связывается с userId, кто входит в population и как исключаются bots, staff и test accounts.

  6. 06
    Снимите baseline

    До изменения проверьте volume, variance, сезонность, missing/duplicate events и стабильность определения.

  7. 07
    Выберите randomization unit

    Назначайте вариант на уровне, где участники не переносят эффект друг на друга; B2B-feature часто требует account, а не user.

  8. 08
    Оцените MDE, sample и duration

    Заранее задайте practically useful effect, baseline rate, power и error threshold. Короткий тест с малым sample не становится достоверным от красивого процента.

  9. 09
    Запишите exposure один раз

    Храните assignment стабильно и фиксируйте фактический показ до outcome, не меняя вариант между устройствами или запросами без осознанной причины.

  10. 10
    Не принимайте решение при каждом refresh

    Следуйте заранее выбранному stopping rule. Постоянное peeking увеличивает вероятность случайной победы.

  11. 11
    Проверьте качество до эффекта

    Ищите SRM, потери событий, дубли, различия до treatment, поломку guardrails и несогласованные версии feature.

  12. 12
    Интерпретируйте размер и неопределённость

    Сравните estimate и CI с MDE, проверьте сегменты только по плану, учтите novelty и наблюдайте метрики после rollout.

03 · КОНТЕКСТ

Где результат требует оговорки

Эти детали объясняют, почему похожий код иногда даёт другой trace.

01

North Star не работает одна

Одна агрегированная цифра скрывает качество, distribution и вред. Нужны input metrics, guardrails и разрезы по ключевым populations.

02

Событие должно означать business fact

Название button_clicked привязано к UI, а report_exported — к устойчивому намерению пользователя. Семантика переживает редизайн лучше селектора кнопки.

03

Знаменатель определяет метрику

10 покупок из 100 exposed users и 10 из 20 visitors — разные conversion rates. Сохраняйте eligible non-converters через LEFT JOIN.

04

Retention требует осмысленного return event

Открытие приложения может быть случайным. Для редактора публикаций полезнее повторное сохранение или публикация, если именно они представляют ценность.

05

Calendar и rolling retention различаются

Day-7 calendar спрашивает о конкретном периоде, rolling — вернулся ли user на седьмой день или позже. Числа нельзя сравнивать без определения.

06

Assignment и exposure — не одно и то же

User может получить variant в storage, но не открыть экран. Triggered analysis включает реально затронутых, однако trigger должен быть определён до просмотра результата.

07

Randomization unit следует механизму влияния

Если коллеги одного workspace видят общий документ, randomization по user загрязнит группы. Назначайте весь workspace вместе.

08

CI важнее ярлыка winner

Интервал может одновременно допускать полезный рост и вред. «Не significant» означает недостаточно доказательств, а не доказанный нулевой эффект.

09

Peeking меняет частоту ложных побед

Обычный fixed-horizon test предполагает один запланированный анализ. Для непрерывного просмотра нужна последовательная методика или строгий stopping rule.

10

Multiple testing требует контроля

Если проверить 20 равноправных метрик и много сегментов, случайный winner становится вероятнее. Primary metric и planned slices объявляют заранее.

11

Novelty может быть временным эффектом

Пользователи исследуют новое или сначала сопротивляются изменению. Эксперимент должен охватывать полный business cycle, а rollout — иметь post-monitoring.

12

Практическая и статистическая значимость различаются

На огромном sample микроскопический эффект может быть statistical significant, но не окупать разработку, latency или поддержку.

01
Теория

Сначала разберитесь, какие части Node участвуют в выполнении.

02
Упрощённый код

Затем уберите служебные детали и рассмотрите только главную идею.

03
Runtime-код

После этого сопоставьте модель с кодом, который создаёт live trace.

04 · Упрощённый код

Минимальная модель без служебного кода

NestJS + PostgreSQL · учебный фрагментTypeScript + SQL
const day7Retention = returnedOnDay7 / eligibleSignups;

const experiment = {
  primary: 'project_published_rate',
  guardrails: ['error_rate', 'p95_latency'],
  randomizationUnit: 'account_id',
  exposureEvent: 'editor_variant_viewed',
};
05 · Runtime-код

Полный код, который выполняет сценарий

Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.

ФАКТИЧЕСКИЙ SOURCE

Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.

src/product-metrics-lab.js
сценарий116 строк
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-поток открытым до завершения сценария.

06 · РЕЦЕПТЫ

Практические шаблоны, которые можно подсмотреть

Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.

01

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.
02

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.
03

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.
04

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.
05

Стабильное назначение варианта

Дать одному 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.
06

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; одной визуальной оценки недостаточно.
07

Карточка эксперимента до запуска

Сделать критерии решения проверяемыми до появления результата.

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.
07 · PRODUCTION-КЕЙСЫ

Как учебная ошибка превращается в инцидент

Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.

КЕЙС 01

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.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

Продуктовая метрика начинается с корректного бизнес-события. Event delivery должна выдерживать ошибку, retry и конкуренцию; иначе статистическая точность поверх неверных данных бессмысленна.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • Число project_created больше числа реально созданных первых проектов.
  • Activation меняется вместе с частотой HTTP retries.
  • В событиях встречаются projectId, которых нет в primary database.
КЕЙС 02

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 предотвращает целочисленное деление.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

A/B-тест требует стабильной единицы рандомизации, единственного assignment, корректного exposure и заранее определённого окна outcome. После этого всё равно проверяют SRM, confidence interval, guardrails и практический размер эффекта.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • Один user_id присутствует и в control, и в treatment.
  • Соотношение групп заметно отличается от запланированных 50/50.
  • Conversion посчитана от числа orders, а не от exposed users.
  • Эффект исчезает после первых дней из-за novelty effect.
08 · НЕ ПЕРЕПУТАЙТЕ

Популярные заблуждения

Миф слева, корректная модель справа.

МИФ

Больше событий означает больше понимания.

НА САМОМ ДЕЛЕ

Без 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 сегмент является новой гипотезой и требует подтверждения независимыми данными.

09 · САМОПРОВЕРКА

Ответьте своими словами

Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.

  1. Почему North Star требует guardrail metrics?
  2. Чем event отличается от его properties?
  3. Что именно входит в знаменатель conversion rate?
  4. Как cohort и return event определяют retention?
  5. Чем user churn отличается от revenue churn?
  6. Почему assignment не всегда равен exposure?
  7. Как выбрать между user и account как randomization unit?
  8. Как MDE связан с sample size и power?
  9. Что confidence interval сообщает сверх ярлыка significant?
  10. Почему peeking и multiple testing увеличивают ложные победы?
  11. Что означает SRM и почему результат нельзя читать до его проверки?
  12. Как transactional outbox защищает продуктовые события?
  13. Почему один успешный A/B-тест не отменяет post-rollout monitoring?