Откуда пришли пользователи: first-touch attribution для NestJS + React + Telegram Mini App в 100 строк кода

от автора

Без сторонних библиотек, одной колонкой в БД, для соло-разработчика которому надо узнать что у него работает.

Я делаю голосовой AI-репетитор английского. Продукт живёт в трёх местах:
веб-сайт speakwithai.pro, Telegram Mini App и Android-приложение в RuStore. У меня одна и та же база пользователей на NestJS + Postgres, и мне очень нужен ответ на вопрос: откуда вообще приходят люди?

Yandex.Metrika и Google Analytics показывают только сайт. Telegram Mini App для них — чёрный ящик. Android-приложение через WebView — тоже. Из 6000
просмотров статьи на Habr я не мог сказать, сколько оттуда пришло в продукт, и через какой канал (TG, веб, app).

Я не хотел тащить большую CDP вроде Mixpanel или Amplitude — для
соло-разработчика это overkill. Вечером сел и сделал simplest-thing-that-could-possibly-work: одна колонка в БД, парсится при первом визите, читается на регистрации. 100 строк кода. Делюсь.

Если интересно посмотреть на сам продукт — он живёт здесь:
🤖 Telegram-бот
🌐 Веб-версия
📱 Android в RuStore


Что хочется получить на выходе

Один SQL-запрос:

SELECT acquisition_source, COUNT(*)                                             FROM <your_users_table>  WHERE created_at > NOW() - INTERVAL '7 days'                                    GROUP BY 1                                ORDER BY 2 DESC;

С результатом:

acquisition_source | count ———————————
NULL | 47
tg:habr_attr_top | 8

tg:habr_attr_end | 12
web:habr/attribution_post/top | 4
web:vc/growth_post/top | 3

То есть колонка acquisition_source со строкой формата :// — и всё. Никаких join’ов, никаких отдельных таблиц событий. Если какой-то канал даёт 0 — он явно мёртв. Если другой даёт 12 — двойте бюджет туда.

Архитектура

[Web] ─── UTM из URL → localStorage → POST /auth/register с source ─┐

[TG Mini App] ── start_param из initData ────────────────────┼──→
users.acquisition_source
[Capacitor (web bundle)] ── обе ветки выше работают как есть ──────┘

Capacitor отдельной логики не требует — он использует тот же React-бандл и тот же реест-эндпоинт. Это и хорошо: пишем один раз, работает на трёх платформах.

Часть 1. Веб: ловим UTM

Подход — first-touch: сохраняем UTM при первом визите, не перетираем при последующих. Это даёт стабильную картину «где пользователь нашёл нас изначально». Вариант last-touch (перезаписывать каждый раз) тоже имеет смысл, но он у меня уже есть в Yandex.Metrika из коробки, дублировать не нужно.

Helper, который вызывается один раз на старте приложения:

// apps/web/src/lib/acquisitionSource.ts      const STORAGE_KEY = 'speakwithai.acquisition_source';  const MAX_LEN = 128;                                                                                                          function buildSourceString(params: URLSearchParams): string | null {    // Telegram Mini App в браузере (fallback) — пишем как tg:                      const tgStart = params.get('tgWebAppStartParam');                               if (tgStart) return `tg:${tgStart}`.slice(0, MAX_LEN);                                                                                                          // Обычный веб — UTM                                                            const utmSource = params.get('utm_source');                                     if (!utmSource) return null;                                                                                                                                    const parts = [                                                                   utmSource,        params.get('utm_campaign') ?? '',                                               params.get('utm_content') ?? '',            ];                                                                              while (parts.length > 1 && parts[parts.length - 1] === '') parts.pop();    return `web:${parts.join('/')}`.slice(0, MAX_LEN);                            }                                                                                                                         export function captureAndStoreSource(): void {                                   try {                                       if (localStorage.getItem(STORAGE_KEY)) return; // first-touch                   const source = buildSourceString(                                                 new URLSearchParams(window.location.search),      );                                                                              if (source) localStorage.setItem(STORAGE_KEY, source);                        } catch {                                       // localStorage может быть отключён (Safari private) — пропускаем             }                                           }                                                                                  export function getStoredSource(): string | null {                                try {               return localStorage.getItem(STORAGE_KEY);                                     } catch {                                       return null;                            }  }    

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

// apps/web/src/main.tsx                                                        import { captureAndStoreSource } from './lib/acquisitionSource';                                                                                  captureAndStoreSource();                  createRoot(document.getElementById('root')!).render(<App />);                                                             Дальше при регистрации добавляем поле source в DTO:                                                                                                             const { accessToken } = await registerApi({    name,                                                                           email,                                        password,                                                                       agreementsVersion: AGREEMENTS_VERSION,    source: getStoredSource() ?? undefined,                                       });     

Часть 2. Backend: колонка и валидация

Миграция тривиальная:

public async up(qr: QueryRunner): Promise<void> {                                 await qr.query(`      ALTER TABLE <your_users_table>                                                    ADD COLUMN IF NOT EXISTS acquisition_source VARCHAR(128) NULL;    `);  }  public async down(qr: QueryRunner): Promise<void> {    await qr.query(`      ALTER TABLE <your_users_table> DROP COLUMN IF EXISTS acquisition_source;    `);  }

source — внешний user-controlled параметр (любой может прислать что угодно, ссылку с UTM подделать тривиально). Поэтому обязательно clamp длины + санитайз перед записью:

function sanitizeSource(raw: string | undefined | null): string | null {    if (!raw) return null;                                                          const trimmed = raw.trim();               if (!trimmed) return null;    return trimmed.slice(0, 128);                                                 }                                                                                                                         // В AuthService.register:                                                      await this.usersService.create({    email: dto.email,                                                               passwordHash,                             name: dto.name,    agreementsAcceptedAt: new Date(),                                               agreementsVersion: dto.agreementsVersion,     acquisitionSource: sanitizeSource(dto.source),                                });    

Этого хватает. У нас не аналитика для миллионов событий, у нас одна строка на пользователя. Если кто-то решит запихать туда XSS — он попадёт в varchar(128), никак не выйдет наружу через нашу админку (на стороне рендера экранируем как обычно).

Часть 3. Telegram Mini App: start_param

Telegram передаёт payload боту через ссылку вида: t.me/your_bot/your_app?startapp=PAYLOAD

В Mini App этот payload оказывается внутри Telegram.WebApp.initData под ключом start_param. На бэке мы и так валидируем initData (HMAC-SHA256 по bot_token), поэтому вытащить оттуда start_param — две лишние строки.

Я просто расширил уже существующий verifyInitData так чтобы он возвращал не
только пользователя, но и start_param:

verifyInitData(initData: string): { user: TelegramUser; startParam: string |    null } {    // ...та же валидация HMAC, что была раньше...                                                                                const params = new URLSearchParams(initData);    // ... существующая логика проверки подписи ...                                                                                    const userJson = params.get('user');      const user: TelegramUser = JSON.parse(userJson);                                                                                                                const rawStart = params.get('start_param');    const startParam =                                                                rawStart && rawStart.length > 0 && rawStart.length <= 64        ? rawStart                                                                      : null;                                                                                                               return { user, startParam };                                                  }

64 символа — это лимит самого Telegram на длину start_param. Дополнительная защита на случай подделанного payload.

В сервисе авторизации пишем source при создании нового пользователя (только
при создании, у возвращающихся не перетираем):

async loginWithTelegram(initData: string) {                                       const { user: tgUser, startParam } =        this.telegramAuth.verifyInitData(initData);                                       const telegramId = String(tgUser.id);                                                                                     let user = await this.usersService.findByTelegramId(telegramId);                if (!user) {                                                                      const source = startParam ? `tg:${startParam}` : null;                          user = await this.usersService.create({                                           email: `tg_${telegramId}@demo.speakwithai.pro`,        name: tgUser.first_name,                                                        telegramId,                                                                     isDemoUser: true,        acquisitionSource: source, // ← здесь                                         });                                         }                                                                                                 return this.generateTokens(user.id, user.email);                              }

Часть 4. Маркируем все ссылки

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

TG: t.me/aiteacher_emma_bot/emma?startapp=habr_attr_top
Web: speakwithai.pro/?utm_source=habr&utm_medium=article&utm_campaign=attribution_post&utm_content=top

Структура — её и буду видеть в БД. По placement (top/mid/end) можно проверить какой именно блок CTA в статье работает — самый ценный сигнал, потому что он ответит на вопрос «дочитывают ли мою статью или сваливают на первом абзаце».

💡 Между прочим — если стало интересно потрогать продукт о котором речь, демо в Telegram доступно без регистрации. А ниже расскажу про граничные кейсы и edge cases.

Часть 5. Edge cases

  1. localStorage недоступен (Safari private mode, старые WebView) Падать нельзя. Все обращения к localStorage в try/catch, возвращаем null. Источник просто не запишется — это не критично.

  2. Возвращающийся пользователь с другим UTM
    First-touch — игнорируем. Если человек пришёл по utm_source=habr 2 недели
    назад, потом по utm_source=vc сейчас — для меня он остаётся habr. Это
    сознательный выбор: я хочу знать «кто познакомил пользователя с продуктом», а не «через что он зашёл сегодня». Last-touch уже есть в Yandex.Metrika.

  3. Capacitor (нативное Android)
    Здесь отдельная подножка: window.location.search пустой, потому что бандл
    загружается с localhost (assets из APK). UTM параметры ловить через URL не получится. Решение — другой канал атрибуции: Capacitor приходит из RuStore, и каждая установка через RuStore просто помечается как app:rustore на бэке (определяем через user-agent или X-Auth-Mode: bearer заголовок, который и так шлётся на нативе для bearer-аутентификации).

  4. GDPR / 152-ФЗ
    UTM — не персональные данные. start_param — тоже. Это маркер канала, а не идентификатор человека. В оферте/политике конфиденциальности про это даже писать не нужно (но я написал — лишним не будет).

  5. Что если злоумышленник запихает 1MB текста в source?
    Не запихает. @MaxLength(128) валидатор отбросит на DTO, plus varchar(128) обрежет на уровне БД даже если каким-то чудом пройдёт.

Часть 6. Чтение результатов

Мы зашли ради этой строчки SQL:

SELECT              acquisition_source,    COUNT(*) AS users,     MIN(created_at) AS first_seen,    MAX(created_at) AS last_seen  FROM <your_users_table>                       WHERE created_at > NOW() - INTERVAL '7 days'    AND acquisition_source IS NOT NULL  GROUP BY 1                                                                      ORDER BY 2 DESC;

После недели тагированных публикаций у меня выглядит примерно так:

acquisition_source | users | first_seen
———————————±——±———-
NULL | 47 | 2026-04-25
tg:habr1029400_end | 12 | 2026-04-30
tg:habr1029400_top | 8 | 2026-04-30

web:habr/tma_post/end | 4 | 2026-04-30
tg:vc2885735_end | 3 | 2026-04-30
web:vc/rustore_post/top | 1 | 2026-05-01

И сразу видно:

end (нижний CTA) работает в обеих статьях лучше top. Значит читатели
всё-таки дочитывают.
— TG-канал даёт в 3 раза больше регистраций чем web на той же статье.
Аудитория Habr склоняется к Telegram.
vc.ru только начал давать сигнал — рано судить. — 47 NULL — это органика и старые ссылки. Со временем доля будет падать.

Часть 7. Что я понял за неделю

— Атрибуция важнее аналитики. YM/GA говорят «кто», атрибуция — «откуда». Без
второго первое бесполезно.
— Простые решения работают. 100 строк кода с одной колонкой дают 80% инсайтов, которые нужны соло-разработчику. Большие CDP — это для команд от 5 человек. — First-touch + last-touch (YM) — комплементарные системы. Не дублируйте.
— Обязательно тагируйте ссылки везде. Если этого не делать — атрибуция превращается в NULL и весь код был зря.

Заключение

Если у вас несколько каналов привлечения (web + бот / app + сайт / любая
комбинация) и вы не можете ответить «откуда сейчас приходят люди» — попробуйте такой же минимальный сетап. Это вечер работы, и оно сразу окупается на первой кампании.

Если интересно посмотреть на продукт, для которого это всё делалось:

🤖 Telegram Mini App (3 минуты с AI без регистрации)

🌐 Веб-версия

📱 Android в RuStore

❓ Вопрос к читателям: как вы у себя решаете задачу мульти-канальной
атрибуции? Тащите CDP, костылите как я, или вообще не считаете и
ориентируетесь на ощущения?

ссылка на оригинал статьи https://habr.com/ru/articles/1030560/