Перейти к основному содержимому

0065. Периоды активности вариантов A/B-теста и сбор CTR по окнам

Копия ADR из репозитория: backend/docs/adr/0065-ab-test-variant-periods.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-09-28
  • Связанные задачи: OP-143, OP-144, OP-145, OP-146 (5.14)

Контекст и проблема​

AbTestMetricsService.collect() (OP-144) нигде не вызывался, кроме unit-тестов: POST /ab-tests/:id/analyze читал пустую ab_test_variant_metrics и почти всегда отдавал insufficient-data. Метрики считаются из витрины рекламы ad_campaign_stats (синк автобиддера), а она подневная и привязана к nmId карточки, а не к варианту. Карточка WB показывает один вариант за раз, поэтому без знания «какой вариант когда стоял» все варианты получали бы одинаковые суммы показов/кликов.

Рассмотренные варианты​

  1. Окна задаёт клиент (collect(variantId, from, to) через API) — просто, но фронт должен помнить историю переключений; ошибки окна → двойной счёт.
  2. Периоды активности на бэкенде (ab_test_variant_periods) — бэкенд сам фиксирует моменты активации/переключения и собирает метрики по ним.
  3. Разнесение по дням на лету в analyze без хранения — нельзя показать промежуточный CTR и нет аудита переключений.

Решение​

Вариант 2.

  • Таблица ab_test_variant_periods (ab_test_id, variant_id, started_at, ended_at NULL = активен). Частичный уникальный индекс — не более одного открытого периода на тест; смена периода — под pessimistic_write строки теста.
  • ab_test_variant_metrics.period_id (FK, UNIQUE): одна строка метрик на период, повторный сбор открытого периода сдвигает period_end (upsert по period_id), а не плодит пересекающиеся окна, которые анализ сложил бы дважды.
  • Окно периода — дни MSK (витрина WB в MSK). День переключения целиком отходит новому варианту: окно закрытого периода с преемником заканчивается накануне дня ended_at; последний период (закрыт stop/analyze) включает день закрытия; открытый — до «сегодня». Период внутри одного дня (активирован и переключён в тот же день) полного дня не содержит — метрик нет.
  • Жизненный цикл:
    • start открывает первый период: body.variantId → контрольный → первый созданный;
    • POST /ab-tests/:id/variants/:variantId/activate — переключение: закрыть текущий период + собрать его, открыть новый (повтор для активного — идемпотентен);
    • stop — закрыть открытый период + собрать;
    • analyze — закрыть открытый период, пересобрать все периоды, затем расчёт.
  • Cron 25 */3 * * * (Europe/Moscow) через реестр расписаний (OP-42), очередь ab-test-metrics: пересобирает периоды всех running-тестов (витрина рекламы догружается с задержкой — закрытые окна тоже уточняются). Одно глобальное расписание — новые тесты подхватываются без рестарта.
  • GET /ab-tests/:id/periods — периоды с метриками для UI.

Последствия​

  • Положительные: CTR честно разнесён по вариантам; analyze получает данные; есть история переключений для аудита и графиков.
  • Отрицательные / компромиссы: точность — сутки (день переключения целиком у нового варианта; утро дня старта теста засчитывается первому варианту). Витрина — только рекламный трафик (ad_campaign_stats), органика не учтена. Реальную смену фото на карточке переключение не делает — это отдельный эндпоинт apply (ADR-0062).
  • Дальше: при появлении воронки карточки (nm-report) — перейти на полный трафик.