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