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

Я делаю голосовой 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, костылите как я, или вообще не считаете и
ориентируетесь на ощущения?

Комментарии (0)