Если у вас настроен A/B-тест, и он вроде как работает, и заказчик доволен, и метрики капают — это ещё ни о чём не говорит, и я это понял на одном проекте, где настроил сплит пейволла на четыре стратегии показа платной расшифровки, всё выглядело живым, а когда полез в данные, оказалось что 90 сессий из 90 видели ровно один и тот же вариант. То есть A/B был мёртв с самого начала, просто красиво притворялся живым. Дальше история про то, как это вскрылось, почему так вышло и как теперь проверять что сплит реально варьируется, а не показывает всем одну и ту же кнопку.
Что вообще делали
Есть приложение, где результат частично платный, и нам надо было понять как лучше дожимать человека до оплаты. Не в лоб «плати», а через разные стратегии показа пейволла. Я через Claude Code собрал четыре варианта: контрольный (полный блок и предложение купить), вариант с тизером саммари, вариант где прячется только часть с расхождениями, и вариант где показывается одно саммари без деталей. Логика была sticky на сессию, то есть один человек в рамках своей сессии всегда видит один и тот же вариант, чтобы он не дёргался от того что пейволл у него меняется при каждом обновлении. Звучит разумно, и на бумаге всё правильно: распределяем по вариантам, фиксируем выбор за сессией, собираем конверсию. Классический сплит, ничего экзотического.
Параллельно там же крутился второй A/B, на цену, но привязанный к меткам ссылок и со sticky-фиксацией на тридцать дней. По нему, кстати, у нас был спор «а зачем вообще A/B цены привязывать к источнику перехода, это же другая логика», но это отдельная тема, к нашему багу она прямого отношения не имеет.
Заказчик усомнился — и правильно сделал
Дальше случилось то, ради чего я вообще пишу этот пост. Заказчик написал мне примерно так: «Меня вот только беспокоит реально ли а/б тесты (оба два) работают. И пэйволл и цены. реально ли срабатывают. Проверять руками лень». И вот это «проверять руками лень» — золотая фраза, потому что руками такое и не проверишь нормально. Можно зайти десять раз, обновить страницу, увидеть один вариант, потом другой, и решить «ну вроде переключается, работает». А реальную картину видно только в данных, по всем сессиям сразу.
Был ещё симптом, на который сначала никто не обратил внимания: «я попробовал включить Paywall - нифига не блюрит и все такое». Тогда это списали на мелочь в отображении, а по факту это был первый звоночек что с выбором варианта что-то не так. Когда вам показывают не тот вариант, который вы ждёте, дело не всегда в вёрстке.
Так что вместо того чтобы кликать руками, я просто пошёл в базу и посчитал распределение по вариантам за всё время. И вот тут стало неуютно: 90 из 90, все до единой сессии, и все на одном варианте, том самом контрольном. Ни одной сессии с тизером, ни одной с пряткой расхождений, ни одной с чистым саммари. Сплит, который я считал рабочим, не варьировался вообще, сто процентов трафика шло в одну ветку, а остальные три варианта существовали только в коде и в моей голове.
Почему так вышло — баг тонкий, но логичный
Когда начал копать, причина оказалась из разряда «вроде всё правильно, а на деле всё наоборот». Функция, которая определяет стратегию для сессии, искала уже существующий выбор по этой сессии, логика sticky как раз про это: нашёл прошлый выбор — отдал тот же. Но искала она в той же таблице, куда только что записалась текущая запись с дефолтным значением, тем самым контрольным. То есть запрос находил не прошлый осознанный выбор варианта, а вот эту свежевставленную текущую запись со своим же дефолтом. И так каждый раз. Система спрашивала «а что я выбирала для этой сессии раньше?» и получала ответ «ты только что выбрала контрольный», потому что контрольный и стоял дефолтом до того как распределение вообще успевало отработать.
Получается замкнутый круг: дефолт записался, запрос его нашёл, зафиксировал как «выбор сессии», отдал наружу. И так для всех 90 сессий подряд, потому что баг детерминированный, он у всех срабатывал одинаково. Никакой случайности, никакого распределения, просто система каждый раз честно возвращала свой же дефолт и думала что это и есть результат сплита.
Фикс по сути в двух местах. Первое — при поиске прошлого выбора надо исключать текущую запись, чтобы запрос не натыкался на самого себя. Второе — правильный порядок сортировки, чтобы брать действительно последний осмысленный выбор, а не что попало. И сверху регрессионный тест, который ловит именно эту ситуацию: новая сессия не должна видеть свой же только что вставленный дефолт как готовый выбор. Без теста этот баг тихо вернулся бы при первом же рефакторинге, а так он теперь зафиксирован.
Второй сюрприз от ревьюера
И пока я разбирался с основным багом, ревьюер кода (я гоняю ревью через ai-агентов перед каждым коммитом) выцепил вторую проблему, которую я бы сам прозевал. При повторной постановке задачи в очередь старая запись перезаписывалась, и это могло снести уже зафиксированный выбор варианта. То есть даже почини я первый баг, второй продолжил бы потихоньку портить данные. Защита простая: перезаписывать только когда это действительно нужно, а в обычном случае существующую запись не трогать. Вот за это я и люблю прогонять код через отдельного ai-агента — человек, который сам же писал логику, такие штуки видит плохо, глаз замылен, а свежий ревьюер цепляет.
Что я из этого вынес
Главный вывод даже не про конкретный баг, а про то что A/B-тест очень легко собрать так, чтобы он выглядел работающим и при этом не работал. У вас есть код распределения, есть варианты, есть запись в базу, всё компилируется, ничего не падает, а сплита нет, потому что одна тонкая ошибка в логике sticky заворачивает весь трафик в дефолт. И самое коварное что это не видно глазами. Это перекликается с историей про зелёные тесты и пустой экран, там тоже всё формально проходило, а по факту фича была отключена. «Работает» и «делает то что должно» — это два разных утверждения, и второе проверяется только данными.
Поэтому если вы катите сплит-тестирование, конверсию или любой пейволл, первым делом не на кнопки смотрите, а считайте распределение по вариантам за всё время. Один запрос на группировку по варианту, и сразу видно: если у вас 90 из 90 в одной ветке, значит никакого A/B нет, есть дорогая иллюзия аналитики. Заказчик который сказал «проверять руками лень» был прав вдвойне: руками это и не проверяется, а лень — единственная честная реакция на бессмысленное занятие.
И да, я тут код не писал руками, я оркестрировал через Claude Code, и сам сплит, и фикс, и регресс-тест собирал он под моим контролем. Это к тому, что вайбкодинг и разработка с ии не отменяют проверки данными, а делают её ещё важнее: вы быстро получаете много готового кода, который выглядит правильно — а потом оказывается, что зелёные тесты не равны работающему продукту, и единственная страховка от такого вот тихого 90 из 90 — лезть в реальные цифры и не верить тому что просто компилируется. Вот и делайте выводы.