惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Attack and Defense Labs
Attack and Defense Labs
aimingoo的专栏
aimingoo的专栏
月光博客
月光博客
D
Darknet – Hacking Tools, Hacker News & Cyber Security
K
Kaspersky official blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Stack Overflow Blog
Stack Overflow Blog
T
Threatpost
AWS News Blog
AWS News Blog
小众软件
小众软件
美团技术团队
L
Lohrmann on Cybersecurity
Hacker News: Ask HN
Hacker News: Ask HN
Security Latest
Security Latest
Jina AI
Jina AI
Hacker News - Newest:
Hacker News - Newest: "LLM"
宝玉的分享
宝玉的分享
A
Arctic Wolf
M
MIT News - Artificial intelligence
Scott Helme
Scott Helme
爱范儿
爱范儿
The Cloudflare Blog
博客园 - 聂微东
云风的 BLOG
云风的 BLOG
博客园 - 叶小钗
有赞技术团队
有赞技术团队
The GitHub Blog
The GitHub Blog
T
The Blog of Author Tim Ferriss
I
InfoQ
V
Visual Studio Blog
N
Netflix TechBlog - Medium
Latest news
Latest news
C
CERT Recently Published Vulnerability Notes
T
Tenable Blog
H
Heimdal Security Blog
www.infosecurity-magazine.com
www.infosecurity-magazine.com
Cisco Talos Blog
Cisco Talos Blog
L
LINUX DO - 最新话题
博客园 - 三生石上(FineUI控件)
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Application and Cybersecurity Blog
Application and Cybersecurity Blog
T
Threat Research - Cisco Blogs
S
Secure Thoughts
The Hacker News
The Hacker News
S
Schneier on Security
博客园 - 【当耐特】
T
The Exploit Database - CXSecurity.com
博客园_首页
N
News | PayPal Newsroom
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Откуда пришли пользователи: first-touch attribution для NestJS + React + Telegram Mini App в 100 строк кода
anatoliyshir · 2026-05-02 · via Все публикации подряд на Хабре

Уровень сложностиПростой

Время на прочтение7 мин

Охват и читатели0

Мнение

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

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