Пустое поле — это не измерение
Я выкатил атрибуцию, написал на неё тесты, и через десять дней все строки были пустые. Я прочитал это как «рано судить». А означало это, что код не выполнился ни разу — и второй баг в нём был хуже, потому что он выдавал ответ вместо пустоты.
Восьмого августа я добавил в продукт атрибуцию: пять колонок в профиле, куда записывается, откуда пришёл каждый новый пользователь. Источник, канал, кампания, реферер, страна. Пишется при регистрации, только первое касание, потом не перезаписывается.
На неё были тесты. Она прошла ревью. Она уехала в прод.
Через десять дней я впервые выполнил очевидный запрос:
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;
| provider | users | after_it_shipped | with_country |
|---|---|---|---|
| 9 | 6 | 0 | |
| 3 | 0 | 0 |
Шесть человек прошли через код, когда он уже существовал, и не оставили ничего. Это не проблема размера выборки. Это код, который не выполняется.
Где он не выполнялся
Атрибуция писалась в 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 укладывался в историю, в которую я мог поверить. Пустота, по-своему, тоже. А проверку в итоге спровоцировала мелкая нестыковка — часть этих пользователей новее самой фичи. Обычно так и бывает.
Ничего экзотического здесь нет. Это обычный способ, которым ломается измерение: тихо и в сторону благополучия.