P
prepair.app
Пройти интервью бесплатно →
← Все статьи
24 августа 2026 г.·5 мин чтения

Пустое поле — это не измерение

Я выкатил атрибуцию, написал на неё тесты, и через десять дней все строки были пустые. Я прочитал это как «рано судить». А означало это, что код не выполнился ни разу — и второй баг в нём был хуже, потому что он выдавал ответ вместо пустоты.

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

На неё были тесты. Она прошла ревью. Она уехала в прод.

Через десять дней я впервые выполнил очевидный запрос:

select coalesce(signup_country, '?') as country,
       coalesce(signup_source, 'direct') as source,
       count(*) as signups
from profiles
group by 1, 2;

Одна строка. ?, direct, 12.

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

Ноль строк и «пока рано» выглядят одинаково

Неправильное значение заявляет о себе само. Если бы в колонке стояло US у человека из Киева, я бы полез разбираться через минуту.

Пустая колонка не говорит ничего, и голова подставляет самое доброжелательное объяснение из доступных: фича новая, трафика мало, дай время. Это объяснение доступно всегда, оно часто верно, и опровергнуть его нельзя, не пойдя проверять. Поэтому проверка и не происходит.

Проверить меня заставило то, что часть из этих двенадцати зарегистрировалась после релиза фичи. И я разделил вопрос:

select coalesce(u.raw_app_meta_data->>'provider', 'email')  as provider,
       count(*)                                             as users,
       count(*) filter (where p.created_at >= '2026-08-08') as after_it_shipped,
       count(*) filter (where p.signup_country is not null) as with_country
from auth.users u
join profiles p on p.id = u.id
group by 1;
providerusersafter_it_shippedwith_country
google960
email300

Шесть человек прошли через код, когда он уже существовал, и не оставили ничего. Это не проблема размера выборки. Это код, который не выполняется.

Где он не выполнялся

Атрибуция писалась в API-роуте регистрации. Регистрация по почте отправляет туда POST. OAuth — нет: он общается с провайдером напрямую и возвращается на страницу колбэка, которая обо всём этом не знает.

Три четверти моих регистраций — Google.

Комментарий, объясняющий это, уже лежал в файле колбэка. Написанный мной, неделями раньше:

OAuth sign-ups skip /api/auth/signup, so the Telegram alert is sent from here.

Я знал про развилку. Я обработал её для уведомления. Когда позже добавлял атрибуцию — добавил в роут, туда, где регистрация «очевидно» происходит, и о развилке больше не подумал, потому что ничто меня к этому не подтолкнуло. Тесты, которые я написал, звали роут. Разумеется звали: это был тот код, который я написал.

Набор тестов покрывает пути, о которых ты подумал. И память об архитектуре — тоже. Развилка была задокументирована в четырёх файлах отсюда, в комментарии, который я написал сам, и это не помогло.

Второй баг, который хуже

Чиня путь OAuth, я пошёл посмотреть, где вообще происходит захват в браузере. captureAttribution() — функция, сохраняющая реферер при заходе, — вызывалась ровно на двух страницах: на лендинге и на форме регистрации.

Люди приходят не туда.

Вся моя контентная стратегия ведёт поисковый трафик на страницы вопросов по ролям и на статьи блога. Человек, нашедший статью в Google, попадает туда, читает и кликает дальше. captureAttribution() не вызывался никогда, потому что эти страницы серверные и позвать его там негде. К моменту, когда человек доходил до формы регистрации, единственным доступным реферером был наш собственный домен — а функция внутренние рефереры отбрасывает, и правильно делает: они не источники.

Так посетитель записывался как direct.

Это хуже, чем пустота, и стоит быть точным насчёт почему. Пустота — это отсутствие данных, а отсутствие данных чаще всего просто игнорируют. direct — это ответ. Он говорит: человек набрал ваш домен руками, его никто не привёл. Накопите таких побольше — и отчёт скажет, что поиск не приводит никого. Это ровно наоборот, и это тот вывод, по которому начинают действовать. Цифру, которая отвечает на твой вопрос, не перепроверяют.

Оба бага давали тишину. Один давал ложь.

Как починено

Путь OAuth теперь отправляет данные в маленький роут из колбэка. Не дожидаясь ответа перед редиректом: знание о том, откуда человек пришёл, не стоит ничего рядом с тем, чтобы его впустить.

Условие живёт внутри записи, потому что колбэк срабатывает при каждом входе, а не только при первом:

await supabaseAdmin
  .from('profiles')
  .update(values)
  .eq('id', user.id)
  .is('signup_source', null)     // побеждает первое касание
  .is('signup_country', null)
  .select('id')

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

Захват переехал в корневой layout, чтобы срабатывать там, где человек приземлился, включая страницы, которые напишутся в следующем году.

И миграция переехала. Пять колонок лежали в docs/migrations/ — папке, которую никто не проигрывает. Применены руками в проде и отсутствуют на чистой базе, где вставка при регистрации упала бы сразу. У меня есть тест, который читает все таблицы, упомянутые в исходниках, и требует миграцию на каждую. Он проверяет таблицы. Про колонки он молчит.

Что я отсюда забираю

Отсутствие данных не доказывает ничего. Оно ровно одинаково согласуется с «нет трафика» и с «нет кода», а реакции на это нужны противоположные. Отличить можно только запросом, который в двух случаях выглядел бы по-разному, — здесь это разбивка по провайдеру и по дате, тридцать секунд работы и десять дней опоздания.

Если фича отчитывается о себе, читать её надо рано. Не тестом — глазами, по настоящей таблице, в день релиза, с вопросом «здесь вообще есть хоть что-нибудь». Я нашёл бы оба бага девятого августа.

Цифра, которая отвечает на твой вопрос, заслуживает большего подозрения, чем та, которая не отвечает. direct укладывался в историю, в которую я мог поверить. Пустота, по-своему, тоже. А проверку в итоге спровоцировала мелкая нестыковка — часть этих пользователей новее самой фичи. Обычно так и бывает.

Ничего экзотического здесь нет. Это обычный способ, которым ломается измерение: тихо и в сторону благополучия.

аналитикаoauthтестированиеразработка
🦎

Потренируйся до реального собеседования

Кем задаёт настоящие вопросы и честно оценивает каждый ответ.

Пройти интервью бесплатно →