Тема
ТЗ — A/B-тесты (cms/ab-testing)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Сплит-тестирование вариантов страниц и блоков поверх ревизий контента ядра. Вариант выбирается стабильно по cookie-бакету ещё до формирования page-cache, чтобы кеш не смешивал варианты между посетителями. Цели берутся из cms/analytics, значимость и автозавершение считаются по накопленной статистике.
- Варианты страниц/блоков как альтернативные ревизии контента ядра
- Стабильный сплит по проценту через cookie-бакет (один посетитель — один вариант всю сессию)
- Выбор варианта на edge/middleware до кеша — исключает утечку варианта B посетителю варианта A
- Ключ page-cache включает идентификатор варианта (сегментация кеша по тесту)
- Цели конверсии берутся из реестра
cms/analytics, без дублирования учёта событий - Расчёт статистической значимости по накопленным данным теста
- Автозавершение теста при достижении значимости или лимита длительности
- Совместимость со scheduled-publishing: тест не переживает публикацию новой ревизии втихую
Зависимости и выключение
requires: ядро (ревизии контента), cms/analytics · suggests: —
Поведение при выключении: все посетители видят контрольный (базовый) вариант контента — активные тесты приостанавливаются без потери накопленной статистики, деградация до обычного показа, не поломка. Внешних платных API нет; единственная «стоимость» — вычислительная (расчёт значимости в очереди, не на горячем пути).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_ab_tests | id, name, subject_type, subject_id, status, split_percent, started_at, ends_at, lock_version | тест на странице/блоке |
cms_ab_variants | id, test_id, code, revision_id, is_control | варианты теста, ссылка на ревизию ядра |
cms_ab_assignments | id, test_id, visitor_token, variant_id, assigned_at | стабильное закрепление посетителя за вариантом |
cms_ab_conversions | id, test_id, variant_id, goal_code, visitor_token, converted_at | зафиксированные конверсии по цели cms/analytics |
FK test_id, variant_id, revision_id — constrained() + index(); status — PHP Enum; уникальный индекс (test_id, visitor_token) в cms_ab_assignments гарантирует стабильность закрепления; cms_ab_conversions — журнальная таблица, кандидат на BRIN по converted_at; lock_version на cms_ab_tests — конкурентная правка теста.
ПДн-паспорт: visitor_token — обезличенный идентификатор, ПДн не хранится ни в одной из четырёх таблиц. Ретеншн: cms_ab_assignments/cms_ab_conversions — журналы завершённого теста хранятся как исторические данные (полезны для ретроспективы), явного TTL нет по умолчанию — при накоплении множества завершённых тестов рекомендуется архивация через общую политику ретеншна журналов ядра.
Входные и выходные данные
Входы:
| Источник | Поля | Чем валидируется |
|---|---|---|
| Форма создания теста (Filament) | name, subject_type, subject_id, split_percent, variants[], goal_code | FormRequest, subject_type — whitelist поддерживаемых сущностей ядра, goal_code — сверяется с реестром cms/analytics |
| Публичный запрос страницы (посетитель) | cookie-бакет / его отсутствие | middleware: детерминированный хэш visitor_token → бакет 0–99, без пользовательского ввода |
Событие цели конверсии (cms/analytics) | goal_code, context | контракт события AnalyticsGoalTriggered, слушатель проверяет активное закрепление посетителя за тестом |
| Команда/API завершения теста | test_id | ab-testing.manage, FormRequest |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| Посетитель | вариант контента (ревизия) | Blade-рендер выбранной ревизии, идентификатор варианта — в ключе кеша |
| Filament admin | список тестов, дашборд значимости | keyset-JSON через API |
| page-cache ядра | измерение variant_id для ключа кеша | внутренний контракт кеша (не API) |
| Подписчики событий | AbTestVariantAssigned, AbTestConversionRecorded, AbTestFinished | payload по таблице ниже |
Настройки (группа ab-testing)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
ab-testing.enabled | bool | true | да | Включение сплит-тестирования |
ab-testing.default_split_percent | int | 50 | да | Дефолтный процент сплита для новых тестов |
ab-testing.significance_threshold | float | 0.95 | нет | Порог статистической значимости для автозавершения |
ab-testing.max_duration_days | int | 30 | нет | Лимит длительности теста до принудительного завершения |
ab-testing.min_sample_size | int | 100 | нет | Минимум конверсий на вариант до расчёта значимости (защита от ранней остановки на шуме) |
ab-testing.max_concurrent_tests | int | 10 | нет | Лимит одновременно активных тестов на сайт |
ab-testing.kill_switch | bool | false | да | Kill-switch: аварийная остановка всех активных тестов (все посетители видят контроль) без выключения модуля |
Лимиты и квоты: max_concurrent_tests защищает от неконтролируемого роста числа активных сегментов кеша (каждый тест — новое измерение ключа); достижение лимита отклоняет создание нового теста 422 с понятной ошибкой, не превышает лимит молча.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/ab-testing/tests | admin (ab-testing.view) | Список тестов со статусами и статистикой |
| POST | /api/v1/admin/ab-testing/tests | admin (ab-testing.manage) | Создание теста с вариантами и сплитом |
| POST | /api/v1/admin/ab-testing/tests/{id}/finish | admin (ab-testing.manage) | Принудительное завершение теста |
Компоненты
Filament: конструктор теста (варианты, сплит, цели), дашборд значимости по тесту. Команды: cms:ab-testing:evaluate --json (расчёт значимости и автозавершение).
Демо-контент: AbTestingDemoSeeder создаёт один активный демо-тест на демо-странице с двумя вариантами (контроль + вариант B) и синтетическими закреплениями/конверсиями — дашборд значимости в галерее /_gallery показывает график без реального трафика.
Фронтенд-бюджет: выбор варианта — серверный (middleware), клиентского JS для переключения контента не требуется — 0 влияния на фронтенд-бюджет; сам вариант рендерится как обычная ревизия темой без дополнительных ассетов.
Эксплуатация (ранбук): метрики ab_testing_assignments_total{test_id}, ab_testing_conversions_total{test_id,variant_id}, ab_testing_evaluate_duration_seconds; алерт — тест превысил max_duration_days без автозавершения (джоба evaluate не отрабатывает).
| Симптом | Что проверить / команда |
|---|---|
| Посетители видят «прыгающий» контент между визитами | проверить TTL cookie-бакета, уникальность (test_id, visitor_token) |
| Тест не завершается автоматически | cms:ab-testing:evaluate --json, проверить min_sample_size/significance_threshold |
| Кеш смешивает варианты | убедиться, что variant_id в ключе page-cache, см. «Производительность и кеш» |
| Слишком много активных тестов замедляет кеш | сверить max_concurrent_tests, оценить число сегментов кеша |
Бэкап/рестор: все четыре таблицы — в бэкапе целиком (включая журналы конверсий — статистика теста должна переживать рестор без потерь); cms:ab-testing:evaluate безопасно перезапускается после рестора для пересчёта незавершённых тестов.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
AbTestVariantAssigned | посетителю закреплён вариант | test_id, variant_id, visitor_token |
AbTestConversionRecorded | зафиксирована конверсия по цели | test_id, variant_id, goal_code |
AbTestFinished | тест завершён (значимость/лимит) | test_id, winning_variant_id, reason |
Слушает: события целей cms/analytics для учёта конверсий по вариантам (requires, без дублирования собственного реестра целей).
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/analytics (AnalyticsGoalTriggered) | событие (1, requires) | analytics → ab-testing | конверсия посетителя засчитывается варианту, за которым он закреплён |
| Ревизии контента ядра | сервис-вызов (4, requires) | ab-testing → ядро | вариант ссылается на конкретную ревизию, рендерится как обычная опубликованная страница |
| page-cache ядра | измерение ключа кеша (внутренний контракт) | ab-testing → ядро | variant_id сегментирует кеш аналогично city_id/locale |
очередь ab-testing | очередь (5) | ab-testing → воркер | evaluate — расчёт значимости и автозавершение |
Подписчики AbTestFinished | событие (1) | ab-testing → любой | например уведомление ответственного менеджера о завершении теста |
Фоновая работа
Очередь ab-testing: evaluate — плановый расчёт статистической значимости и автозавершение теста через ScheduleRegistrar. Выбор варианта — синхронный на middleware/edge, до формирования кеша.
Производительность и кеш
Ожидаемые объёмы: единицы–десятки одновременно активных тестов (max_concurrent_tests), тысячи-десятки тысяч cms_ab_assignments на популярный тест. Горячий путь — разрешение варианта на входе запроса (middleware, до кеша): чтение cookie-бакета — 0 запросов к БД при наличии cookie; при первом визите — один insert в cms_ab_assignments с уникальным индексом (test_id, visitor_token) (upsert-безопасно при гонке).
Собственный тег ab-testing:{test_id} для сброса кеша сегментированных вариантов при завершении/остановке теста. Ключ page-cache ядра обязан включать идентификатор варианта теста — иначе кеш смешает контрольную и тестовую версии между посетителями; это контрактный тест, не рекомендация. При нескольких одновременных тестах на разных сущностях ключ кеша включает variant_id каждого применимого к странице теста — max_concurrent_tests ограничивает комбинаторный рост числа сегментов.
Безопасность
Выбор варианта происходит на сервере (middleware) — клиент не может подменить вариант напрямую; создание/завершение теста — только под ab-testing.manage через FormRequest. visitor_token не связан с персональными данными.
Права: ab-testing.view, ab-testing.manage.
Матрица ролей:
| Действие | Администратор | Менеджер | Редактор | Studio |
|---|---|---|---|---|
Просмотр тестов и статистики (view) | ✅ | ✅ | ✅ | ✅ |
Создание теста, привязка вариантов к ревизиям (manage) | ✅ | ✅ | — | ✅ |
| Принудительное завершение теста | ✅ | ✅ | — | ✅ |
ab-testing.kill_switch (аварийная остановка всех тестов) | ✅ | — | — | ✅ |
UX-требования
Админ: пустое состояние списка тестов — «Активных тестов нет» со ссылкой «Создать тест»; массовое завершение нескольких тестов сразу; человеческая ошибка при попытке создать вариант со ссылкой на неопубликованную/отсутствующую ревизию («Ревизия ещё не опубликована — тест не может стартовать»); подтверждение перед принудительным завершением теста, не достигшего значимости («Тест завершится без статистически значимого результата — продолжить?»).
Посетитель: вариант не «прыгает» между визитами (стабильность cookie-бакета); переключение вариантов незаметно для скорости отклика (0 доп. задержки — решение принимается до кеша, не отдельным запросом); при отклонённых cookie (приватный режим) посетитель детерминированно получает контрольный вариант, не ошибку.
Крайние случаи и типовые баги
- Липкость варианта (cookie) — истечение/удаление cookie посетителем → новое закрепление по
(test_id, visitor_token), еслиvisitor_tokenвосстановим (например из UTM/сессии), иначе — новый бакет-хэш; тест допускает единичные «пересдвиги» варианта при потере cookie, это не считается критичным дефектом. - Page-cache и сегментация вариантов — без сегментации посетитель варианта A увидит закешированный вариант B. Механизм зафиксирован ревизией ядра 14.07.2026 (п. 7): тест отдельных блоков — блок объявляет себя
personalized(каркас страницы в общем кеше, вариант — фрагментом/островом); полностраничный тест (layout, лендинг целиком) —variant_idкак измерение ключа page-cache тестируемой страницы через тот же механизм сегментации ядра, самодельные ключи запрещены. Контрактный тест на утечку кеша между вариантами обязателен, как дляcms/multicity. - Стат-значимость и ранняя остановка — автозавершение при первом же достижении
significance_thresholdбез учёта размера выборки даёт ложные срабатывания (проблема «peeking») →min_sample_size— обязательный гейт перед расчётом значимости,evaluateне завершает тест раньше накопления минимальной выборки на каждый вариант. - Тест на странице со scheduled-publishing — плановая публикация новой ревизии базовой страницы во время активного теста → публикация контрольной ревизии не должна тихо ломать привязку варианта-контроля к устаревшей ревизии; тест либо продолжает использовать зафиксированную на старте ревизию (детерминированность эксперимента), либо явно требует ручного решения редактора — конфликт логируется, не применяется автоматически без уведомления.
- Гонка первого визита — два параллельных запроса одного нового посетителя (например preload + основной документ) пытаются создать
cms_ab_assignmentsодновременно → уникальный индекс(test_id, visitor_token)+ upsert разрешают гонку без дублей, оба запроса получают один и тот жеvariant_id. - Выключение модуля посреди активного теста —
kill_switchили полное отключение модуля → все посетители мгновенно видят контрольный вариант, накопленная статистика не удаляется, при повторном включении тест можно возобновить (не обязан начинаться заново). - Отсутствие
requires-модуляcms/analytics(жёсткая зависимость) → модуль не может стартовать новые тесты без реестра целей — self-test приenableзаваливается с понятной ошибкой «требуется cms/analytics»; уже идущие тесты (еслиcms/analyticsвыключили посреди) не могут фиксировать новые конверсии — деградация до сбора толькоAbTestVariantAssignedбезConversionRecorded. - Пустой/огромный тест — тест без конверсий за весь
max_duration_days→ принудительно завершается по лимиту длительности сreason = duration_limit, не висит бесконечно; тест с огромным трафиком (миллионы assignments) — таблицаcms_ab_assignmentsпроектируется под партиционирование поtest_id/времени при росте. - Измерение city/locale — тест на региональной странице должен работать независимо от
city_id/localeкак дополнительных измерений: ключ кеша включает иvariant_id, иcity_id/localeодновременно (композитное измерение, не взаимоисключающее) — контрактный тест проверяет оба режима (с мультигородом и без). - Противоречивые настройки:
split_percentвариантов в сумме не равен 100% → отклоняется на создании (422 «сумма сплитов должна быть 100%»), не тихая нормализация значений. - Конкурентное редактирование теста двумя админами (например один меняет сплит, другой завершает тест) →
lock_versionнаcms_ab_tests, конфликт — 409 с человеческим сообщением, не «последний победил». - Suggests отсутствует и жёсткая зависимость одновременно — у модуля нет
suggests, толькоrequires: cms/analytics; это осознанно (без реестра целей тест бессмысленен) — задокументировано, чтобы не путать с типовым паттерном мягкой зависимости остальных модулей блока «Маркетинг».
Донорский код
Донор: — (новая разработка)
Тесты и приёмка
- [ ] Контрактный тест: один и тот же посетитель получает один и тот же вариант повторно
- [ ] Ключ page-cache включает вариант — посетители разных вариантов не видят чужой кеш
- [ ] Выбор варианта происходит до формирования кеша (middleware/edge), не после
- [ ] Гонка первого визита не создаёт дублей
cms_ab_assignments(уникальный индекс + upsert) - [ ]
min_sample_sizeне даёт завершить тест раньше накопления минимальной выборки - [ ] Автозавершение срабатывает при достижении
significance_thresholdилиmax_duration_days - [ ]
kill_switchмгновенно переключает всех посетителей на контроль без выключения модуля - [ ] Сумма
split_percentвариантов, не равная 100%, отклоняется при создании теста - [ ] При выключении модуля все посетители видят контрольный вариант без ошибок
- [ ] Композитная сегментация кеша (
variant_id+city_id/locale) работает без утечек - [ ] Конкурентная правка одного теста двумя админами отдаёт 409, не «последний победил»
- [ ] Права
ab-testing.view/ab-testing.manageразграничивают просмотр и управление тестами - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут API; тестовая БД только
ab-testing_test,migrate:freshзапрещён