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тестуваннярозробка
🦎

Потренуйся до справжньої співбесіди

Кем ставить справжні питання і чесно оцінює кожну відповідь.

Пройти інтервʼю безкоштовно →