TL;DR
Сквозная аналитика для B2C - это связать «клик в рекламе → заявку → оплату → выручку → ROMI». Звучит просто, ломается на стыках.
Ядро MVP - три источника (реклама + веб-аналитика + биллинг), Google Sheets как витрина и пара экранов в BI - реально собирается за ~две недели. Claude Code тут дает ×5 к скорости: разведка незнакомых API, ETL, парсинг, тесты, бойлерплейт.
Атрибуция, метрики и грабли конкретных API - это то, где LLM даже вредит. Его скорость работы усложняет задачу анализа и осмысления.
Технически интересного по дороге набралось много: Google Sheets в роли БД, фаззи-резолвинг namespace, идемпотентный upsert, классификация ошибок для ретраев, fail-closed гейт свежести, read-only как защита от собственного кода и от LLM-агента.
Минимальная сквозная: три источника, которые замыкают «клик → деньги»
Прежде чем разбирать, что в этом сюжете интересного, покажу скучную часть - то, что собирается за две недели и работает. Чтобы получить именно сквозную воронку, а не «дашборд по расходам», нужно минимум три источника, и они должны смыкаться в деньги:
Рекламный кабинет (у меня - Яндекс.Директ): расход, клики, показы, CTR, CPC по кампаниям. Это «сколько потратили».
Веб-аналитика (Метрика): источники трафика и динамика. С важной оговоркой - только как leading-сигнал тренда, не для абсолютов (про это ниже).
Биллинг (своя админка и БД): оплаты с UTM-привязкой и выручка. Это «сколько вернулось».
Связка «реклама ↔ биллинг» по UTM - и есть та самая сквозная нить. Остальные источники (CRM, Telegram Ads, VK Ads, вебинарная платформа) - это уже наращивание охвата, их добавляешь по одному позже. Но три перечисленных дают замкнутый контур «потратили → вернулось» - минимально достаточный для ROMI.
Поток данных в MVP простой:
[Директ API] ─┐
[Метрика API] ─┼──> Python ETL ──> Google Sheets (витрина) ──> BI-дашборд
[Биллинг БД] ─┘ (pandas) один лист = одна сущность
Никаких Kafka, Airflow и DWH. На старте они только тормозят. Оркестратор - cron, хранилище - Google Sheets, «процессор» - Python с pandas. Это и есть «на коленке», и для MVP этого достаточно.
С чего все началось: где трех источников недостаточно
Звучит так, будто описанная выше схема и закрывает задачу. Если бы это было так, статья закончилась бы абзацем выше, а у меня ушли бы те самые две недели вместо уже месяцев. Картина, знакомая любому, кто заходил в маркетинг B2C: данные есть, но они в пяти разных кабинетах, и ни один не отвечает на главный вопрос - окупается ли реклама.
Рекламный кабинет знает расход, клики, показы - но «конверсии» там это его внутренние цели (часто - регистрации на вебинар), а не реальные заявки и тем более не оплаты.
Веб-аналитика знает визиты и источники - но с погрешностью и без денег.
Биллинг знает оплаты и выручку - но не знает, из какой рекламы пришел человек.
CRM знает сделки - но матчится с остальным по принципу «как повезет».
«Сквозная аналитика» - это попытка склеить все это в одну воронку клик → заявка → оплата → выручка и посчитать поверх нее ROMI. Маркетологу нужен один экран; мне как разработчику нужно, чтобы цифры на этом экране не были искаженными.
Дальше я покажу вам две вещи:
MVP-ядро - что собирается за пару недель (этот раздел короткий, потому что тут как раз все просто).
Грабли - почему «дособрать до честной сквозной» заняло потом еще месяцы (этот раздел длинный, потому что тут все интересное).
Google Sheets как база данных (да, серьезно)
Самое спорное решение. Идея: каждый «билдер» (отдельный Python-модуль) собирает один Google-лист. build_channel_spend.py → лист «Расход по каналам», build_leads.py → лист «Лиды», и т.д. Лист - это, по сути, материализованная вьюха. Джойнов в Sheets нет - все джойны делаются в pandas в памяти, а в лист пишется уже готовая tidy-таблица.
Почему Sheets, а не сразу Postgres:
Ноль инфраструктуры. Сервис-аккаунт,
gspread, и через 10 минут ты пишешь данные.Бесплатный UI «из коробки». Маркетолог видит, правит, комментирует прямо в таблице. Для BI поверх - тоже готовый источник.
Прозрачность. Любой косяк виден глазами, без
SELECT.
Где больно:
Лимиты API. Google Sheets отдает
429/503на пиках. Лечится ретраями (см. ниже) и батч-операциями (batchGet/одна запись на лист вместо построчной).Нет транзакций и типов. Числа читаются строками («
1 234,56»), даты - как попало. Каждый загрузчик обязан сам приводить типы.Нет джойнов и индексов. Все в pandas. На сотнях тысяч строк это ок, на десятках миллионов - уже нет.
Ключевой паттерн, который спасает от дублей при инкрементальном сборе, - идемпотентный upsert по стабильному ключу. В Sheets его пишешь руками:
def upsert_rows(existing: list[dict], fresh: list[dict], key: str) -> list[dict]:
"""Слить свежие строки в существующие по стабильному ключу (а не по позиции).
Свежая строка с тем же key перезаписывает старую; остальные сохраняются."""
by_key = {r[key]: r for r in existing}
for r in fresh:
by_key[r[key]] = r # перезапись или добавление
return list(by_key.values())
Сказка заканчивается, когда выберешь «ключ» неудачно. У меня было такое: ключом для рекламного объявления я взял слаг + заголовок + дата. Оказалось, у части креативов совпадали заголовок и минута создания - и upsert схлопывал разные объявления в одно. Лечится переходом на стабильный id из самой системы. Вывод из этого опыта вечен: ключ должен быть идентификатором, а не «вроде бы уникальной» комбинацией полей.
Когда Sheets перестает тянуть (у меня это случилось на этапе BI-дашборда с прямым коннектором), витрину переносишь в Postgres - но к этому моменту схема уже отлажена на Sheets, и перенос механический.
Атрибуция - это не «прикрутить UTM»
Вот здесь LLM ускоряет написание кода, но архитектуру атрибуции придумываешь сам. Это самая недооцененная часть сквозной аналитики.
Namespace-ад
Выяснилось, что «продукт» в моей системе живет в трех несводимых пространствах:
Слаг лендинга- кусок URL посадочной (/lessons/python-basic/);utm_campaign- метка в ссылке объявления (python,py-2026,cplus);Слагв биллинге - идентификатор продукта в оплатах.
Эти три не равны друг другу 1:1. Маркетолог в utm пишет как привык; лендинг живет своей жизнью; биллинг - третьей. Чтобы посчитать «расход на продукт X», нужно сшить пространства. Решение - приоритетный резолвер: официальная карта utm → продукт → fallback на слаг лендинга → fallback на имя кампании:
def resolve_product(utm_campaign, landing_slug, campaign_name, utm_map, name_index):
# 1) официальная карта маркетолога (приоритет)
if (slug := utm_map.get(utm_campaign.lower())):
return slug, "utm"
# 2) slug посадочной (часто = course_slug)
if landing_slug:
return landing_slug, "slug"
# 3) фаззи-матч по имени кампании (для архивных РК без меток)
if (slug := fuzzy_match(campaign_name, name_index)):
return slug, "name"
return None, "unmatched"
Третий шаг - фаззи-матч по имени - спасает там, где меток нет вообще. Например, в одном рекламном источнике до 70% кампаний оказались лид-формами без ссылки на сайт (заявка заполняется внутри площадки) - у них нет ни utm, ни landing-slug, только название кампании. Только по имени их и привяжешь к продукту.
Важная деталь нормализации имен: нельзя схлопывать различающие символы. Я споткнулся на том, что универсальная нормализация убивала ++ и #, и C++ склеивался с C#. Пришлось делать отдельную нормализацию, которая их сохраняет:
def norm_product(s: str) -> str:
# сохраняем ++ и #, иначе C++ == C# (реальный баг на 200+ объявлениях)
s = s.lower().replace("++", "plusplus").replace("#", "sharp")
return re.sub(r"[^0-9a-zа-яе]+", "", s)
Дедуп лида
Что вообще считать «лидом»? Один человек оставил заявку, заказал обратный звонок и записался на вебинар - это один лид или три? Договорились: человек × продукт = один лид, категория по приоритету. Без дедупа все воронки завышены кратно. Дедуп - обычный groupby, но определение сущности - продуктовое решение, которое ни одна LLM за тебя не примет.
First-touch / last-touch и почему ROMI считается когортно
Дальше - атрибуция касаний. Лид мог прийти по рекламе, а оплатить через месяц по прямому заходу. Если считать «выручка этого месяца / расход этого месяца», получишь мусор: лаг оплаты в B2C - 1-3 месяца. Поэтому ROMI считается по когортам: берем пользователей, пришедших из рекламы в месяце T, и смотрим их оплаты в окне T…T+3, без двойного счета.
Тут я поймал себя на ошибке: первая версия форвард-когорты давала артефактный «+200%» ROMI, потому что при стабильном расходе выручка одного месяца попадала в несколько перекрывающихся окон и считалась многократно. Переписал на помесячный срез + годовой итог. Мораль: в когортной атрибуции двойной счет - это дефолтное состояние, пока ты явно от него не защитился.
Грабли внешних API (где LLM точно не спас)
Все примеры - из подключения реальных рекламных API, и ни один из этих нюансов не написан в их документации внятно. Ну либо я их не нашел. И Claude тоже.
Грабля №1: summary-эндпоинт, который игнорирует даты
Подключаю статистику одного рекламного API. Есть два эндпоинта: summary (итоги) и day (по дням). Беру summary, передаю date_from/date_to - получаю числа. Беру другой период - получаю… те же числа до рубля.
summary молча игнорирует диапазон дат и отдает lifetime-итог за все время. Если бы я не сверил два непересекающихся окна вручную, насчитал бы расход «за месяц», равный расходу за три года. Проверка, которая это вскрыла, заняла пять строк:
old = api.stats(level="plans", date_from="2024-06-01", date_to="2025-01-01", kind="summary")
new = api.stats(level="plans", date_from="2025-05-01", date_to="2026-06-14", kind="summary")
assert sum_spent(old) != sum_spent(new), "summary игнорирует даты - это lifetime!"
# assert падает. Вывод: для оконного среза только day.
Claude Code сгенерировал мне клиент к этому API за минуты. Но «эй, а ты заметил, что summary не фильтрует по датам?» он не сказал. Это ловится только эмпирикой и паранойей. Последнее для работы с LLM – must.
Грабля №2: два разных лимита у дневной статистики и кламп из текста ошибки
Ловлю две разные ошибки, которые маскируют друг друга:
дневная статистика доступна только за последние ~14 месяцев (плавающий floor);
интервал одного запроса ограничен ~732 днями.
Хитрость: когда я пробовал floor на полном диапазоне, API возвращал ошибку про интервал (732 дня), а не про floor, и я чуть не сделал неверный вывод. Решение - динамически вытаскивать допустимую дату из текста ошибки и клампить:
FLOOR_RE = re.compile(r"minimal allowed date is '(\d{4})-(\d{1,2})-(\d{1,2})'")
def clamp_from(api, date_from, date_to, probe_id):
# пробуем 1-дневное окно (не упремся в лимит интервала), ловим floor
try:
api.stats(ids=[probe_id], date_from=date_from, date_to=date_from, kind="day")
eff = date_from
except ApiError as e:
if (m := FLOOR_RE.search(str(e))):
eff = f"{int(m[1]):04d}-{int(m[2]):02d}-{int(m[3]):02d}"
else:
raise
# и отдельно не даем интервалу превысить ~731 день
span_floor = (date.fromisoformat(date_to) - timedelta(days=731)).isoformat()
return max(eff, span_floor)
Парсить дату из текста ошибки - некрасиво, но floor плавающий (зависит от «сегодня»), а хардкодить его - значит сломаться через месяц. Лучше некрасиво и правильно.
Грабля №3: предел атрибуции - лид-формы
Уже упоминал, но повторю как принцип: у части площадок (лид-формы соцсетей) заявка живет внутри площадки, ссылки на сайт нет, UTM до биллинга не доезжает. Это значит: расход и охват по такому каналу ты видишь, а вот связать конкретное объявление с оплатой - нельзя в принципе, не из-за кривого кода, а из-за модели данных. Вывод в дашборде: для таких каналов показываем расход и охват, а не покурсовый ROMI, и прямо пишем об этом баннером. Лучше явный пробел, чем фейковая точность.
Грабля №4: API не отдает то, что показывает в кабинете
Еще пример из основного рекламного API: у адаптивных (responsive) объявлений в кабинете UTM-ссылка есть, а через API поле с этой ссылкой не отдается. То есть глазами видно, программно - нет. Лечится тем же fallback на имя кампании. Мораль: то, что есть в UI, не равно тому, что есть в API. Всегда проверяй на реальных данных, а не по докам.
Read-only как мантра (особенно когда с LLM)
Отдельный архитектурный принцип, который стал еще важнее в эпоху LLM-агентов: код никогда не пишет в рекламные кабинеты. Только GET и отчеты.
Никаких POST/PUT/DELETE ставок, бюджетов, статусов кампаний. Любая «правка» - это текстовая рекомендация маркетологу, который меняет руками. Причина простая: цена ошибки. Баг в чтении - кривой график. Баг в записи - слитый рекламный бюджет или остановленные кампании.
С LLM-агентом это перестает быть гигиеной и становится защитой контура. Когда часть кода генерит модель, ты не можешь на 100% гарантировать, что в какой-то ветке не прилетит мутирующий запрос. Поэтому ограничение - на уровне архитектуры и доступов: токены и обвязка позволяют только чтение. Агент физически не может навредить кабинету, даже если очень захочет.
Второй слой защиты от «агент насорил» - тесты чистых функций как контракт. Я жестко делю код на:
чистые функции (агрегация, парсинг, резолв, нормализация) - без сети и I/O, детерминированные;
I/O-слой (API, Sheets, БД) - тонкий, инъектируемый.
Вся бизнес-логика - в чистых функциях, и они покрыты тестами под завязку (у меня их больше тысячи). Это дает сгенерированному коду верифицируемость: LLM пишет функцию, тест ее ловит. Пример - то самое разделение, которое делает namespace-резолвер тестируемым без единого сетевого вызова:
# чистая функция - тестируется без сети
def aggregate_by_product(rows, utm_map, name_index):
acc = {}
for r in rows:
slug, src = resolve_course(r["utm"], r["slug"], r["name"], utm_map, name_index)
bucket = acc.setdefault(slug, {"spent": 0.0, "clicks": 0})
bucket["spent"] += to_float(r["spent"]) # API отдает строкой
bucket["clicks"] += int(r["clicks"])
return acc
# тест - детерминированный, без единого запроса
def test_cpp_csharp_not_merged():
rows = [{"utm": "cplus", "slug": "", "name": "C++ разработчик", "spent": "100", "clicks": "2"}]
out = aggregate_by_product(rows, utm_map={}, name_index=NAME_INDEX)
assert "cpp" in out and "csharp" not in out
Сетевой слой при этом инъектируется (session или fake-клиент), так что и ретраи, и пагинацию я тестирую без реальных запросов. Чем больше кода пишет LLM, тем важнее, чтобы за ним стоял детерминированный тест. Это не «good practice ради галочки», это способ доверять машинно-сгенерированному коду.
Как пайплайн не падает по ночам
Несколько приемов, которые превратили набор скриптов в систему, которая будит меня только когда реально сломалось. Шутка. Никто меня не будит.
Fail-closed гейт свежести. Перед сборкой дневных отчетов - проверка, что входные данные биллинга реально обновились сегодня (по updated_at, а не по «файл существует»). Если данные устарели - пайплайн останавливается с ошибкой и не обновляет витрину. Это сознательный отказ от graceful degradation: лучше показать вчерашние данные с пометкой, чем сегодняшние наполовину.
Классификация ошибок для ретраев. Не все подряд ретраить. 429, 5xx, сетевые таймауты - транзиентные, ретраим с экспоненциальным бэкоффом. 401/403/404 - фатальные, ретраить бессмысленно, надо чинить:
def is_transient(exc) -> bool:
if isinstance(exc, (ConnectionError, Timeout)):
return True
if isinstance(exc, HttpError) and exc.status in (429, 500, 502, 503, 504):
return True
return False # 401/403/404 и прочее - наружу, это не «повтори попозже»
@retry(retry=retry_if_exception(is_transient),
stop=stop_after_attempt(4), wait=wait_exponential(min=2, max=20))
def call(...): ...
Decoupling: внешний API не в критическом пути сборки. Грабля, на которой я обжегся: дашборд собирался одним скриптом, который последовательно строил три витрины, и одна из них дергала живой внешний API. Если этот API падал - не записывалась ни одна из трех витрин, дашборд устаревал целиком из-за одного источника. Переделал: «тяжелый» источник пишет свой результат в промежуточный лист-шину заранее (отдельным шагом), а сборка дашборда читает готовую шину и переживает ее отсутствие (graceful → канал просто пустой). Принцип: сбой некритичного источника не должен ронять весь дашборд.
Idempotent + lock-файлы. Каждый шаг идемпотентен (повторный запуск не плодит дубли), а на дневные и недельные задачи - lock-файл от двойного запуска. Плюс watchdog: если к такому-то времени нет флага успешного прогона - письмо мне. Сама система мониторит, что сама отработала.
Грабли по метрикам
Самое ценное, что дала сквозная аналитика, - это опыт интерпретации метрик и баги с цифрами. Три примера.
1. Конверсии рекламного кабинета ≠ лиды. В кабинете «конверсий» было в 5-10 раз больше, чем реальных заявок в биллинге. Потому что туда настроены цели «регистрация на вебинар» с нескольких счетчиков. Выдавать это за лиды - обманывать себя. Реальный CPL считается только по событиям биллинга, а кабинетные конверсии помечаются «(вебинары)» и не смешиваются.
2. ROMI по прямой атрибуции в окне 1-3 мес - отрицательный. И это не приговор рекламе. Прямая атрибуция (первое касание → оплата в окне) не ловит assist/halo-эффект, оплаты по другому каналу позже, прямые заходы вне меток. Три независимых метода (помесячный, lead-когорты, last-touch) дали отрицательный ROMI - а значит, истинный вклад канала виден только в multi-touch на длинном горизонте (6+ мес). Правильный вывод: «в окне 1-3 мес по прямому касанию не окупается» ≠ «реклама не работает». Разница между этими формулировками - это вся зрелость аналитики.
3. Валютный баг на ~миллиард. В выручке вдруг возник порядок, которого быть не может. Корень: у части рублевых оплат стояла метка валюты USD, и выгрузка послушно домножала их на курс - рублевая сумма превращалась в «долларовую» и раздувалась в ~70 раз. Лечится защитной функцией: если валюта не RUB, берем исходную сумму, а не конвертированную; плюс детектор аномалий как бэкстоп. Мораль: любое поле, пришедшее извне, считай враждебным, пока не доказано обратное - особенно если на нем строится выручка.
Эти три пункта - главная причина, почему я считаю, что сквозную аналитику вообще стоит строить, даже если «и так примерно понятно». Начинаешь разбираться в нюансах.
Дашборд: DataLens поверх Postgres-витрины
Финальный слой - единый экран для руководителя. С BI-инструментом определились сразу: Яндекс DataLens. Удобный, читается прямо из Postgres через прямой коннектор, не требует промежуточных выгрузок и устраивает по скорости публикации виджетов. Технически дашборд читает витрину напрямую из Postgres (отдельный шаг пайплайна переписывает несколько таблиц в БД), BI подхватывает свежие данные сам, без человека.
Пара нюансов, которые сэкономят ваше время:
Коннектор DataLens удваивает
%в именах колонок в генерируемом SQL - колонку«CR, %»он прочитать не может. Лечится переименованием%→pctна уровне витрины.Egress к части сервисов из не-РФ-инфры приходится гнать через SOCKS-прокси на РФ-релее - отдельная маленькая инженерная история про
autosshиproxiesвrequests, но это уже про инфраструктуру, не про аналитику.
Экранов на MVP достаточно двух: «обзор» (расход/выручка/ROMI/тренд) и «воронка по каналам». Остальное наращивается.
Что реально дал Claude Code (и где думал я)
Теперь обещанный в начале статьи баланс, ради которого статья и затевалась.
Где Claude Code дал ×5 к скорости:
Разведка незнакомых API. Накидать клиент, перебрать эндпоинты, понять формат ответа - то, на что руками уходит день, делается за час диалога.
ETL-бойлерплейт. Пагинация, ретраи, парсинг, приведение типов, запись в Sheets - скучный код, который LLM пишет ровно и быстро.
Тесты. Сгенерировать таблицу кейсов для чистой функции - почти бесплатно.
Документация и рефакторинг. Привести модуль к единому стилю, дописать docstrings.
Где LLM не помог и не мог и не надо вообще на него надеяться:
Заметить, что
summaryврет про даты. Это эмпирика и паранойя, а не генерация.Придумать модель атрибуции. Namespace, дедуп, когорты с лагом, защита от двойного счета - это архитектурные решения под конкретные данные.
Сказать «эти конверсии - фейк». Понимание, что кабинетные конверсии ≠ лиды, доменное, а не алгоритмическое.
Поймать валютный баг. Аномалию надо было заподозрить по «не может такого быть».
Если свести в одну фразу: LLM снимает с тебя «как написать», но не снимает «что правильно». В сквозной аналитике 80% сложности - во втором.
И про «две недели». За две недели собирается именно ядро: три источника → Sheets → связка до оплаты → пара экранов. Это работающий MVP, который уже отвечает на «окупается ли в первом приближении». Все, что описано в разделах про грабли, атрибуцию и метрики, - это следующие месяцы итераций. И вот тут LLM экономит уже не проценты, а просто делает реалистичным то, что команда разработки в принципе доводит до прода: держать в голове и Директ, и биллинг, и CRM, и инфру, и тесты. Только Claude помогает это делать без команды.
Итого: чек-лист, если хотите повторить
Начните с трех источников, замыкающих деньги (реклама + веб + биллинг). Не тащите все сразу.
Google Sheets - нормальная витрина для MVP. Один билдер = один лист, джойны в pandas, идемпотентный upsert по стабильному id.
Атрибуцию проектируйте руками. Резолвер с приоритетами (карта → slug → имя), дедуп по продуктовому определению лида, когортный ROMI с защитой от двойного счета.
Считайте внешние данные враждебными. Проверяйте API на реальных данных (даты, валюты, отсутствующие поля), а не по докам.
Read-only по умолчанию. Код только читает рекламные кабинеты. С LLM-агентом это уже не гигиена, а защита бюджета.
Чистые функции + тесты = контракт с LLM. Бизнес-логика без I/O, покрыта тестами; тогда сгенерированному коду можно доверять.
Fail-closed на свежести. Лучше громко не обновить, чем тихо показать неверное.
Точность важнее красоты. Если канал не атрибутируется - напишите это прямо. Отрицательный ROMI по прямой атрибуции - повод разобраться, а не спрятать.
Сквозную аналитику для B2C реально поднять малыми силами и быстро - LLM сделал это посильным для одного человека. Но «быстро» относится к коду и рутине; думать про то, что цифры значат на самом деле, по-прежнему приходится самому. И, прямо скажем, это хорошая новость.
Дмитрий Волошин, сооснователь и генеральный директор OTUS, основатель Клуба менторов, основатель Школы бизнеса Ninox. Заметки про управление, найм и фаундерский опыт: t.me/coffee_notes

























