Сквозная аналитика без Roistat: считаем ROI по каналам на чистом SQL
Сквозная аналитика на SQL: расходы, визиты, лиды, сделки → ROI по каналам. Готовые запросы PostgreSQL + ClickHouse. Без Roistat.
Ты построил атрибуцию и знаешь, какой канал привёл клиента. Отлично. Но CMO приходит с вопросом, от которого нельзя отмахнуться: «Мы потратили 2 миллиона на рекламу. Сколько вернулось?»
Атрибуция без расходов — это полкартины. Ты знаешь, что Яндекс.Директ привёл 40% клиентов, но не знаешь, обошлось это в 200 тысяч или в 2 миллиона. А без выручки по сделкам — даже CPA не посчитаешь.
Сквозная аналитика — это когда расходы, клики, лиды, сделки и выручка живут в одной таблице и ты видишь ROI по каждому каналу и кампании. В этой статье собираем её с нуля на SQL, без Roistat.
Зачем платить 30k/мес, если можно SELECT
В одной компании 8 месяцев платили за Roistat 40k/мес. Когда я спросил, какие отчёты реально смотрят — оказалось, один: «ROI по каналам за месяц». Один SELECT. Финдиректор, когда узнал цену этого SELECT’а, сделал лицо, которое я запомнил надолго.
Roistat делает 4 вещи:
- Собирает расходы из рекламных кабинетов (API Директа, Google Ads, VK)
- Склеивает визиты с лидами и сделками (через client_id, roistat_visit)
- Атрибутирует конверсии каналам
- Строит отчёт ROI/ROAS по каналам и кампаниям
Всё это можно повторить на SQL. Разница — в скорости старта и стоимости поддержки:
| Критерий | Roistat / Calltouch | Своя SQL-реализация |
|---|---|---|
| Стоимость | от 30-50k ₽/мес | VPS 1-3k ₽/мес |
| Время до первого отчёта | 1-3 дня | 2-4 недели |
| Кастомизация | В рамках интерфейса | Полная — ваш SQL, ваши правила |
| Владение данными | Облако вендора | Ваша БД |
| Вендорная зависимость | Высокая (vendor lock-in) | Нет |
| Кто поддерживает | Техподдержка вендора | Ваш аналитик |
| Когда выбирать | Нет аналитика, нужно быстро | Есть аналитик с SQL, нужна гибкость |
Архитектура сквозной аналитики: 5 слоёв от клика до рубля
Сквозная аналитика — это не один волшебный запрос. Это пайплайн из 5 слоёв данных, каждый из которых отвечает за свой кусок реальности.
💰 Слой 1 — Рекламные расходы
🌐 Слой 2 — Визиты + UTM
📋 Слой 3 — Лиды / заявки
🤝 Слой 4 — Сделки + выручка
📊 Слой 5 — ROI-отчёт по каналам
Слой 1 — Рекламные расходы
Откуда: API Яндекс.Директ, VK Ads API, Google Ads API (или ручной CSV-экспорт). Зачем: без расходов ROI не посчитаешь — это числитель формулы.
Слой 2 — Визиты и UTM
Откуда: Яндекс.Метрика (Logs API) или GA4 (BigQuery export). Зачем: UTM-метки связывают клик по рекламе с визитом на сайте. Подробнее о выборе между Метрикой и GA4. Если у вас ecommerce — данные о покупках уже можно собирать в Метрике.
Слой 3 — Лиды
Откуда: CRM (amoCRM, Битрикс24, RetailCRM, или свой). Зачем: лиды — это мост между анонимным визитом и живым клиентом. Тут происходит главная склейка: client_id → лид.
Слой 4 — Сделки и выручка
Откуда: тот же CRM. Зачем: сделки — это деньги. Статус (won/lost), сумма, дата закрытия. Без этого слоя у вас CPA, но не ROI.
Слой 5 — Склейка
Тут всё сходится: расходы + визиты + лиды + сделки = ROI по каналам. Один JOIN, который связывает клик в рекламном кабинете с рублём на счёте.
Схема данных: 4 SQL-таблицы для сквозной аналитики
Никакой магии. Плоские таблицы, понятные поля. Адаптируй под свою CRM — структура остаётся.
-- Расходы: ежедневные, по кампаниямCREATE TABLE ad_costs ( dt TEXT, -- дата (YYYY-MM-DD) source TEXT, -- yandex_direct, vk_ads, google_ads medium TEXT, -- cpc, cpm, social_paid campaign TEXT, -- название кампании channel TEXT, -- нормализованный канал (paid_search, paid_social, paid_display) cost REAL, -- расход в рублях (НДС включён, валюта сконвертирована) impressions INT, clicks INT);
-- Визиты: каждый визит с UTMCREATE TABLE visits ( visit_id INT, client_id TEXT, -- _ym_uid из Метрики или GA client_id visit_dt TEXT, utm_source TEXT, utm_medium TEXT, utm_campaign TEXT, channel TEXT -- нормализованный канал);
-- Лиды: заявки из CRMCREATE TABLE leads ( lead_id INT, client_id TEXT, -- прокинутый из визита через hidden field created_dt TEXT, lead_type TEXT, -- form, callback, chat phone TEXT, email TEXT);
-- Сделки: закрытые и в работеCREATE TABLE deals ( deal_id INT, lead_id INT, -- связь с лидом status TEXT, -- won, lost, in_progress revenue REAL, -- сумма сделки closed_dt TEXT, product TEXT);Попробуй: данные для сквозной аналитики
Обрати внимание на данные:
- uid_001: пришёл из Яндекс.Директ, вернулся из органики, потом Direct → лид → сделка 180k
- uid_003: пришёл из Google Ads, вернулся из органики → лид → сделка 210k
- uid_004: два визита из VK Ads → лид → сделка проиграна. VK потратил деньги, но не заработал
- uid_006: лид без визита (позвонил по телефону с рекламы) — нет client_id в visits
Шаг 1 — Собираем расходы из рекламных кабинетов
Это самый нудный, но обязательный шаг. Без расходов ROI не существует — это просто буква R.
-
Выгрузи данные из кабинетов. Яндекс.Директ — API Reports (отчёт CAMPAIGN_PERFORMANCE_REPORT), VK Ads — API статистики, Google Ads — API или BigQuery Data Transfer. Если API пугает — начни с CSV-экспорта, это нормально.
-
Нормализуй валюту и НДС. Google Ads отдаёт USD (или валюту аккаунта). Яндекс — рубли с НДС. VK — рубли без НДС. Конвертируй всё в рубли с НДС при загрузке. Справочник курсов ЦБ обновляй ежедневно.
-
Замэппи кампании на каналы. Через справочник UTM → канал нормализуй source + medium в бизнес-каналы: paid_search, paid_social, paid_display. Одна кампания = один канал.
-
Загрузи в таблицу ad_costs. Ежедневно или еженедельно. Данные из API приходят с лагом 1-2 дня — учитывай при построении отчётов.
Агрегируем расходы по каналам для дальнейшего JOIN’а с выручкой:
-- Агрегация расходов по каналам за периодSELECT channel, SUM(cost) AS total_cost, SUM(clicks) AS total_clicks, SUM(impressions) AS total_impressions, ROUND(SUM(cost) / NULLIF(SUM(clicks), 0), 2) AS avg_cpcFROM ad_costsWHERE dt BETWEEN '2026-02-01' AND '2026-02-28'GROUP BY channelORDER BY total_cost DESC;Google Ads отдаёт расходы в долларах. Яндекс — в рублях с НДС. VK — в рублях без НДС. Если у вас все три — добро пожаловать в валютный ад. Решение: конвертация и НДС при загрузке, в ad_costs всё хранится в рублях с НДС.
Шаг 2 — Связываем визиты с лидами
Это самый болезненный шаг. Здесь ломается 80% самописных систем. Визит — анонимный (client_id из cookie). Лид — живой человек (email, телефон). Как их связать?
Техника hidden field
Самый надёжный способ — прокинуть client_id из веб-аналитики в CRM через скрытое поле формы:
- JavaScript на сайте достаёт
_ym_uidиз cookie Метрики (или_gaдля GA4) - Записывает значение в hidden input формы заявки
- Форма отправляется в CRM — лид уже содержит
client_id - В SQL:
JOIN visits ON visits.client_id = leads.client_id
Fallback: матчинг по email и телефону
Не у всех лидов есть client_id (звонки, оффлайн, кривые формы). Fallback — матчинг по email или телефону из посещённых визитов. Работает хуже, но лучше, чем ничего.
На одном проекте CRM-менеджеры вручную вбивали email. Через месяц я обнаружил: 12% email’ов содержат опечатки, 3% — вымышленные (test@test.com, asdf@asdf.ru), а один креативный менеджер записал «клиент отказался» прямо в поле email. Матчинг сломался так красиво, что отладка заняла два дня.
Реальный match rate: 70-85%. Не 100%. Никогда. Звонки без client_id, заблокированные cookie, мобайл → десктоп — всё это рвёт связку. 85% — отличный результат. 70% — рабочий. Ниже 60% — пересмотрите сбор данных.
-- Связываем визиты с лидами:-- 1) по client_id (точный матч)-- 2) fallback по email (если client_id нет)WITH attributed_visits AS ( SELECT v.client_id, v.visit_dt, v.channel, v.utm_campaign, ROW_NUMBER() OVER ( PARTITION BY v.client_id ORDER BY CASE WHEN v.channel IN ('paid_search','paid_social','paid_display') THEN 1 WHEN v.channel IN ('organic_search','smm_organic') THEN 2 ELSE 3 END, v.visit_dt DESC ) AS rn FROM visits v WHERE v.client_id IS NOT NULL)SELECT l.lead_id, l.client_id, l.created_dt, av.channel AS attributed_channel, av.utm_campaign AS attributed_campaignFROM leads lLEFT JOIN attributed_visits av ON av.client_id = l.client_id AND av.rn = 1;Обрати внимание: внутри ROW_NUMBER — LPC-атрибуция с приоритетами: платные каналы побеждают бесплатные, даже если бесплатный визит был позже. Это тот же CASE WHEN channel IN (...) THEN 1/2/3, что мы разбирали в статье про атрибуцию, только встроенный в JOIN.
Шаг 3 — Прокидываем атрибуцию до сделки
Теперь у каждого лида есть атрибутированный канал. Осталось протянуть эту цепочку до сделки с выручкой:
-- Полная цепочка: визит → лид → сделкаWITH attributed_leads AS ( SELECT l.lead_id, l.client_id, COALESCE( -- Приоритет 1: LPC по client_id (SELECT v.channel FROM visits v WHERE v.client_id = l.client_id ORDER BY CASE WHEN v.channel IN ('paid_search','paid_social','paid_display') THEN 1 WHEN v.channel IN ('organic_search','smm_organic') THEN 2 ELSE 3 END, v.visit_dt DESC LIMIT 1), -- Приоритет 2: нет визитов → direct 'direct' ) AS channel FROM leads l)SELECT al.channel, COUNT(DISTINCT d.deal_id) AS deals, COUNT(DISTINCT CASE WHEN d.status = 'won' THEN d.deal_id END) AS won_deals, COALESCE(SUM(CASE WHEN d.status = 'won' THEN d.revenue END), 0) AS revenueFROM attributed_leads alLEFT JOIN deals d ON d.lead_id = al.lead_idGROUP BY al.channelORDER BY revenue DESC;Попробуй: атрибутируй выручку по каналам
Результат:
| channel | deals | won | revenue |
|---|---|---|---|
| paid_search | 4 | 4 | 550 000 ₽ |
| paid_social | 1 | 0 | 0 ₽ |
| direct | 1 | 0 | 0 ₽ |
Paid_search забрал 4 выигранные сделки (uid_001, uid_002, uid_003, uid_005 — LPC атрибутировал их к платному поиску, потому что у каждого был визит из Директа или Google Ads). Paid_social (VK Ads) — одна проигранная сделка, 0 выручки. Direct — лид без визитов (uid_006).
Шаг 4 — Считаем ROI и ROAS по каналам
Вот он — финальный запрос. Ради этого всё затевалось.
Формулы:
- ROI = (Revenue − Cost) / Cost × 100% — сколько процентов заработали/потеряли на каждый вложенный рубль
- ROAS = Revenue / Cost — сколько рублей выручки на рубль расходов
- CPA = Cost / Won deals — стоимость привлечения клиента
- CPL = Cost / Leads — стоимость лида
-- Финальный отчёт: ROI по каналамWITH attributed_leads AS ( SELECT l.lead_id, l.client_id, COALESCE( (SELECT v.channel FROM visits v WHERE v.client_id = l.client_id ORDER BY CASE WHEN v.channel IN ('paid_search','paid_social','paid_display') THEN 1 WHEN v.channel IN ('organic_search','smm_organic') THEN 2 ELSE 3 END, v.visit_dt DESC LIMIT 1), 'direct' ) AS channel FROM leads l),channel_revenue AS ( SELECT al.channel, COUNT(DISTINCT al.lead_id) AS leads, COUNT(DISTINCT CASE WHEN d.status = 'won' THEN d.deal_id END) AS won_deals, COALESCE(SUM(CASE WHEN d.status = 'won' THEN d.revenue END), 0) AS revenue FROM attributed_leads al LEFT JOIN deals d ON d.lead_id = al.lead_id GROUP BY al.channel),channel_costs AS ( SELECT channel, SUM(cost) AS total_cost, SUM(clicks) AS total_clicks FROM ad_costs GROUP BY channel)SELECT COALESCE(cr.channel, cc.channel) AS channel, COALESCE(cc.total_cost, 0) AS cost, cc.total_clicks AS clicks, cr.leads, cr.won_deals, cr.revenue, ROUND(COALESCE(cc.total_cost, 0) / NULLIF(cr.leads, 0), 0) AS cpl, ROUND(COALESCE(cc.total_cost, 0) / NULLIF(cr.won_deals, 0), 0) AS cpa, ROUND(cr.revenue::NUMERIC / NULLIF(cc.total_cost, 0), 2) AS roas, ROUND((cr.revenue - COALESCE(cc.total_cost, 0)) / NULLIF(cc.total_cost, 0) * 100, 1) AS roi_pctFROM channel_revenue crFULL OUTER JOIN channel_costs cc ON cc.channel = cr.channelORDER BY cr.revenue DESC NULLS LAST;Полный расчёт: от расходов до ROI
Итоговый результат — ради него всё затевалось:
| channel | cost | clicks | leads | won | revenue | CPL | CPA | ROAS | ROI % |
|---|---|---|---|---|---|---|---|---|---|
| paid_search | 145 000 | 2 650 | 4 | 4 | 550 000 | 36 250 | 36 250 | 3.79 | +279.3% |
| paid_social | 115 000 | 1 000 | 1 | 0 | 0 | 115 000 | — | 0.00 | −100% |
| paid_display | 12 000 | 250 | 0 | 0 | 0 | — | — | 0.00 | −100% |
| direct | 0 | — | 1 | 0 | 0 | 0 | — | — | — |
Что мы видим:
- Paid search (Яндекс.Директ + Google Ads) — ROAS 3.79, ROI +279%. Каждый вложенный рубль вернул почти 4. Работает и работает отлично.
- Paid social (VK Ads) — потратили 115k, получили 0 выручки. ROI −100%. Единственная сделка — проиграна.
- Paid display — 12k расходов, 0 лидов. Возможно, работает на awareness (First Click это покажет), но по LPC — дыра в бюджете.
ROI без атрибуции — число, которое врёт в удобную сторону. uid_002 пришёл из VK Ads, но позже вернулся через Яндекс.Директ. Last Click засчитал бы конверсию в Direct (последний визит). LPC — в paid_search (последний платный). А без атрибуции вы бы спорили, чей это клиент, до вечера.
Атрибуция отвечает «кто привёл?», воронки — «где теряем?», а сквозная аналитика — «сколько это стоит?». Три инструмента, которые работают вместе. Для сравнения периодов используйте оконные функции — LAG и SUM OVER покажут динамику ROI по месяцам.
Дашборд для CMO: куда вывести ROI-отчёт
SQL-таблица — это хорошо для аналитика. Для CMO нужен дашборд. Три варианта:
- Yandex DataLens — бесплатен, подключается к PostgreSQL и ClickHouse напрямую. Для российского стека — оптимальный выбор. Подробнее — в гайде по DataLens.
- Metabase — open source, self-hosted. Красивее DataLens, сложнее в настройке.
- Google Looker Studio — бесплатен, но нужен BigQuery или Sheets как источник.
Грабли и пограничные случаи
Можно написать идеальный SQL, но реальность найдёт, как его сломать. Вот грабли, на которые я наступал — и которые наступят на вас:
| Проблема | Последствие | Что делать |
|---|---|---|
| Рефанды | Канал «заработал» 100k, маркетинг отчитался. Через неделю — возврат. KPI выполнен, а денег нет | Колонка refunded_amount в deals, пересчёт revenue |
| Задержка CRM | Сделка закрывается через 3 недели после лида. Отчёт за февраль не видит мартовских закрытий | Скользящее окно: отчёт за период = лиды периода + сделки, закрытые в течение N дней после |
| Офлайн-конверсии | Клиент позвонил / пришёл в офис — нет digital-следа | Коллтрекинг для звонков, CRM-матчинг по телефону. Тема для отдельной статьи |
| Multi-touch + split расходов | 1 визит = 1 кампания, но расход — на уровне дня по всей кампании | Распределять расход пропорционально кликам, не лидам |
| Валюта | Google Ads — USD, Яндекс — RUB с НДС, VK — без НДС | Конвертация при загрузке. Справочник курсов ЦБ |
| Дубли лидов | Один клиент оставил 3 заявки = 3 лида. Канал получает 3× атрибуцию | Дедупликация: first lead по client_id или phone |
| UTM-бардак | Каналы определяются неверно → мусор в ROI | Почистить UTM-разметку до старта |
Roistat vs самописная: когда строить, когда покупать
Я не скажу, что самописная аналитика «лучше Roistat». Она гибче, дешевле и прозрачнее. Но требует аналитика, который понимает SQL и не уволится через три месяца. Если такого нет — Roistat честнее, чем самописный Excel-монстр, который никто не может поддерживать.
| Ситуация | Решение | Почему |
|---|---|---|
| Нет аналитика с SQL | Roistat | Самописное без поддержки сгниёт за квартал |
| Нужен отчёт через неделю | Roistat | Своё — 2-4 недели минимум |
| Бюджет на рекламу < 200k/мес | Roistat или ничего | ROI самописной не окупится |
| Есть аналитик + кастомные каналы | Своя SQL | Roistat не покроет вашу логику |
| Длинный цикл (B2B, fintech) | Своя SQL | Нужны кастомные окна и приоритеты |
| Данные в нескольких CRM | Своя SQL | Roistat не склеит 3 источника |
| Нужен полный контроль над данными | Своя SQL | Ваши данные — в вашей БД |
Ты собрал сквозную аналитику на SQL. Расходы, визиты, лиды, сделки — всё в одном месте. ROI по каналам считается, VK Ads разоблачён, Яндекс.Директ оправдан.
Следующий шаг — автоматизировать обновление: cron-скрипт для загрузки расходов, инкрементальная загрузка визитов, webhook из CRM. Но это уже инженерная задача, а не аналитическая. Подпишись на Telegram-канал, чтобы не пропустить.
FAQ
Сколько стоит собрать сквозную аналитику самому vs Roistat?
Roistat — от 30k ₽/мес + настройка (разово 30-100k). Самописная: VPS 1-3k ₽/мес + время аналитика (2-4 недели на запуск, потом 2-4 часа/неделю на поддержку). Break-even через 2-3 месяца. Но если аналитик уходит — вы остаётесь с SQL, который некому поддерживать. Учитывайте этот риск.
Какие данные нужны для минимально рабочей сквозной аналитики?
Минимум: расходы по кампаниям (CSV-экспорт из кабинетов) + визиты с UTM (Метрика Logs API) + сделки из CRM с суммой и статусом. Даже без полноценной CRM-интеграции: расходы + достижение целей в Метрике уже дают CPA по каналам. Не идеально, но лучше, чем ничего.
Как часто нужно обновлять данные?
Ежедневно — стандарт. Расходы из API приходят с лагом 1-2 дня. Статусы сделок в CRM меняются (in_progress → won/lost) — обновляйте. Хороший паттерн: ежедневный инкрементальный апдейт + еженедельный full refresh. Real-time — overkill для 99% бизнесов.
Что делать, если CRM не отдаёт данные по API?
CSV-экспорт раз в неделю → скрипт загрузки в PostgreSQL. Большинство CRM (Битрикс24, amoCRM) имеют API, но если ваша — не из их числа, еженедельный CSV вполне рабочий вариант. Не позволяйте идеальному быть врагом работающего.
Подходит ли этот подход для мобильных приложений?
Веб-часть (визиты → лиды → сделки) работает. Мобильная атрибуция — другой мир: там AppsFlyer, Adjust, SKAdNetwork, и данные приходят в совершенно ином формате. Объединение web + app требует единого user_id и отдельного пайплайна. Совет: стабилизируйте веб-часть, потом добавляйте мобайл.
Нравится контент? Подписывайся на Telegram
SQL-задачки, кейсы из практики и закулисье аналитики.
Похожие статьи
Модели атрибуции в SQL: от Last Click до Time Decay на реальных данных
6 моделей атрибуции в SQL: Last Click, LPC с 3-уровневым приоритетом, линейная, U-shape, Time Decay. Готовые запросы PostgreSQL + ClickHouse.
UTM-разметка: почему у тебя бардак и как навести порядок раз и навсегда
Реальные проблемы UTM: legacy, редиректы, мэтчинг фронта и бэка, нормализация, мониторинг каналов. SQL-запросы для ClickHouse и PostgreSQL.
Анализ воронки на чистом SQL: от визита до покупки
Строим полный анализ воронки конверсий в ClickHouse: расчёт CR между шагами, поиск узких мест, когортные воронки.