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

Страницы:  1

Ответить
 

Professor Seleznov


Без сторонних библиотек, одной колонкой в БД, для соло-разработчика которому надо узнать что у него работает.
Я делаю голосовой 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
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();
Дальше при регистрации добавляем поле source в DTO:
const { accessToken } = await registerApi({
name,
email,
password,
agreementsVersion: AGREEMENTS_VERSION,
source: getStoredSource() ?? undefined,
});
Часть 2. Backend: колонка и валидация
Миграция тривиальная:
public async up(qr: QueryRunner): Promise {
await qr.query(`
ALTER TABLE
ADD COLUMN IF NOT EXISTS acquisition_source VARCHAR(128) NULL;
`);
}
public async down(qr: QueryRunner): Promise {
await qr.query(`
ALTER 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
  • localStorage недоступен (Safari private mode, старые WebView) Падать нельзя. Все обращения к localStorage в try/catch, возвращаем null. Источник просто не запишется — это не критично.
  • Возвращающийся пользователь с другим UTM
    First-touch — игнорируем. Если человек пришёл по utm_source=habr 2 недели
    назад, потом по utm_source=vc сейчас — для меня он остаётся habr. Это
    сознательный выбор: я хочу знать «кто познакомил пользователя с продуктом», а не «через что он зашёл сегодня». Last-touch уже есть в Yandex.Metrika.
  • Capacitor (нативное Android)
    Здесь отдельная подножка: window.location.search пустой, потому что бандл
    загружается с localhost (assets из APK). UTM параметры ловить через URL не получится. Решение — другой канал атрибуции: Capacitor приходит из RuStore, и каждая установка через RuStore просто помечается как app:rustore на бэке (определяем через user-agent или X-Auth-Mode: bearer заголовок, который и так шлётся на нативе для bearer-аутентификации).
  • GDPR / 152-ФЗ
    UTM — не персональные данные. start_param — тоже. Это маркер канала, а не идентификатор человека. В оферте/политике конфиденциальности про это даже писать не нужно (но я написал — лишним не будет).
  • Что если злоумышленник запихает 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
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, костылите как я, или вообще не считаете и
ориентируетесь на ощущения?-Источник
 
Loading...
Error