Аналитика онбординга payments-linking: что собираем и как не ошибиться в выводах
Документ для продуктовых/маркетинговых решений по конверсии в платящих. Онбординг = путь от установки до первой реальной пользы (первая автоматическая привязка). Всё остальное (клики, открытия, «настроил» по авто-правилам) — только намерение, и по нему решения принимать нельзя.
1. Единица анализа и ключи
- Единица анализа — инсталляция. Ключ соединения шагов —
account_id(UUID аккаунта МойСклад): он есть во всех маркерах. account_uidне годится как ключ: вLINKэтоorgбезuser@, в GUI-маркерах — полныйuser@org@domain. Форматы разные, сшивка по нему врёт.pl_data_id— внутренний id состояния приложения: меняется при переустановке, использовать только внутри одного приложения, не для кросс-апп аналитики.- Время — UTC, окна фиксированные (7 / 30 / 90 дней), когорта = неделя установки.
2. Что уже эмитится (реальные маркеры, а не пожелания)
| Маркер | Где | Что значит | Как читать |
|---|---|---|---|
pl_setup_done |
установка приложения | созданы 8 правил по умолчанию (source=auto, rules_created) |
НЕ считать настройкой пользователем |
pl_view |
рендер страницы | открыл настройки (S2) | верх воронки после установки |
pl_config |
update_pl_data |
переключил алгоритм (turned_on=true/false) |
включение/выключение алгоритма |
pl_config_saved |
все записи в БД из UI | журнал изменений: action=switch:<тип> / settings:<поле> / rule_created / rule_updated / rule_deleted / ap_rule_created |
аудит и атрибуция изменений |
pl_rule |
create_rule |
создал правило вручную (rule_type, payment_type) |
S4 |
pl_rule_enabled |
update_rule / update_rules_switch |
включил правило (switch=true) — только при фактическом переключении |
S5 |
pl_onboarding_done |
set_onboarding_status(done) |
принял подсказку/включил алгоритм (algorithm) |
прогресс онбординга |
pl_onboarding_dismissed |
set_onboarding_status(dismissed) |
скрыл подсказку, не включив алгоритм (algorithm, для подсказки очерёдности — usage_order) |
трение: «не понял / не нужно» |
pl_ob_click |
карточка алгоритма | клик «Включить» / «Настроить» (algorithm) |
намерение, не результат |
pl_ob_dismiss |
dismiss_algorithm (кнопка «Скрыть») |
нажал «Скрыть» у карточки алгоритма (algorithm) |
намерение; факт закрытия подсказки — pl_onboarding_dismissed |
pl_onboarding_reopened |
reopen_onboarding («Показать снова») |
сбросил статус подсказки алгоритма (algorithm) |
возврат: подсказку вернули — значит она нужна, а не «пройдена» |
pl_ob_example_found / pl_ob_example_empty / pl_ob_example_failed |
пример правила в карточке алгоритма | пример для алгоритма нашёлся / нет данных для примера / ошибка загрузки (algorithm) |
качество подсказки: empty и failed = пользователю нечего показать |
pl_first_link_redis_failed |
_first_link (Redis недоступен) |
первую связку определить не удалось, связывание не блокировано (fail-open) | смещение S6→S7, а не деградация продукта: по маркеру ищется окно сбоя |
LINK |
связывание платежа | попытка связки: first_link, payments_name, documents_name, task_id |
S6 (first_link=true) / S7 (повтор) |
pl_no_link |
поиск не нашёл пару | search_type, reason, candidates, href |
почему пользы не случилось |
pl_link_journal (таблица) |
запись связки | подтверждённый факт: платёж ↔ документ, algorithm, rule_id, task_id |
верификация «LINK = реальная запись» |
| ошибки | — | pl_config_no_pl_data, pl_onboarding_done_no_pl_data, pl_rules_load_failed, pl_rule_delete_failed, pl_ob_enable_failed, pl_ob_scene_missing, pl_ob_static_mount_failed, pl_link_journal_failed, pl_link_journal_pool_timeout |
сигналы поломок, отдельный дашборд |
Две тонкости, которые нельзя терять:
LINKэмитится до исполнения Celery-задачи — это «попытка связки». Факт подтверждает запись вpl_link_journal(или документ в МС). Для честных цифр считать связки по журналу, аLINK— как прокси с пометкой.- Авто-правила при установке (
pl_setup_done) и ручные правила (pl_rule) — разные маркеры. Если их сложить, «настроили всё» покажут 100% установок.
3. Воронка
S1 установка ──► S2 pl_view ──► S3 pl_config(turned_on) ──► S4 pl_rule
──► S5 pl_rule_enabled ──► S6 LINK(first_link=true) ──► S7 удержание (≥2 связки)
──► S8 платящий (снапшот статуса инсталляции, см. §6)
Правила чтения:
- Считаем уникальные
account_idвнутри одной когорты, а не число событий. - Все шаги — в одном окне наблюдения (иначе «S3 больше S2» — артефакт окон).
- Знаменатель — все установки, существовавшие на начало окна, включая отключённые: иначе выживший bias завышает конверсию.
4. Какие решения закрывает каждая метрика
- Time-to-value — медиана часов от
pl_viewдо первогоLINK. Если она большая, проблема не в рекламе, а в пороге входа. - Где обрыв — шаг с максимальной потерей (обычно S2→S3: открыли и не включили; и S3→S5: включили алгоритм, но правило не включили).
- Какие алгоритмы реально работают — включения по
rule_type× наличие последующегоLINK: «по номерам» может включаться чаще, а пользу давать «по маске». - Качество, а не только факт — доля
pl_no_linkот числа попыток, топreason, доля связок сcandidates>1(неоднозначность). Иначе «конверсия растёт» при том, что связки ошибочны. - Трение интерфейса —
pl_onboarding_dismissedбезpl_onboarding_done(скрыл, не включил),usage_order(скрыл подсказку очерёдности),pl_ob_clickбез последующего включения. - Настройки и их эффект — какой режим очерёдности выбирают
(
settings:name_usage_type) и как он влияет на долю успешных связок (внутриаккаунтное сравнение «до/после переключения» — самый сильный доступный дизайн при N≈48). - Откат — правило выключили/удалили в первые 7 дней
(
switch:<тип> enabled=false,rule_deleted): установили и бросили.
5. Guardrails: как не сделать ошибок в выводах
- Намерение ≠ результат.
pl_ob_click,pl_rule, «страница открыта» — это намерение. Ценность —LINK+ подтверждение в журнале. - Авто-настройка ≠ настройка. 8 правил создаются при установке
(
pl_setup_done source=auto) — из «пользователь настроил» исключать. - Малые числа. При N≈48 на шаг ≤5 аккаунтов выводы не делать: давать абсолютные числа и доверительный интервал Уилсона, не сравнивать проценты без CI.
- Когорты по неделе установки + метка периода релиза. Смена UI между периодами ломает «до/после»: сравнивать только внутри одинакового интерфейса.
- Тестовые аккаунты — по allowlist
account_id, dev-приложения отделять поapp_uid(dev-UUID ≠ prod-UUID изconstants.py). - Отключённые инсталляции (показан notice «инсталляция отключена») держать в знаменателе, но помечать: они объясняют часть оттока.
- Не строить вывод по 1–2 дням и по дням с разовыми всплесками: минимум две полные недели на точку.
- Платящий ≠ активный. Событий об оплате у нас нет (биллинг в МС) — без §6 «конверсия в платящих» не измеряется вообще, только «дошёл до пользы».
- S6 при сбое Redis — СМЕЩЕНИЕ, а не «нижняя оценка».
first_linkсчитается через Redis SETNX и при недоступности Redis отдаётfalse(fail-open, чтобы не блокировать связывание). Пока Redis лежит, ВСЕ реально первые связки уезжают в S7: смещение одностороннее и привязано к окну сбоя, поэтому падение S6 (и всплеск S7) в таком окне НЕЛЬЗЯ читать как продуктовую деградацию. Окно сбоя Redis исключать из когорты, а не «списывать» его в S7 (см.onboarding-funnel.sql, блок «СЕМАНТИКА S6/S7»).
6. Чего не хватает (следующий шаг, приоритет по важности)
- Ежедневный снапшот инсталляции (Celery beat, одна таблица):
account_id,app_uid,install_date,is_active, версия приложения, состояние 4 переключателей, число правил, число связок за 7/30 дней, статус подписки (если доступен через вендор-API). Это даёт когорты, retention и настоящую конверсию в платящих; Logfire-события такой картины не дают (там только действия). - Источник установки (маркетплейс/прямой): UTM внутри МС нет — тянуть всё, что отдаёт инсталляция, в снапшот; иначе канал нельзя оценить.
- Дашборд: воронка по когортам + time-to-value + топ
reasonизpl_no_link - доля ошибочных связок.
- Сверка
LINK↔pl_link_journalраз в сутки: расхождение = сломанные привязки, которые воронка показывает как успех.
7. Запросы (Logfire SQL, таблица records)
-- 1. Воронка по когортам (неделя установки): уникальные аккаунты на шаг
WITH base AS (
SELECT account_id, min(timestamp) AS installed_at
FROM records
WHERE span_name = 'pl_view' AND app_uid = '<PROD_APP_UID>'
GROUP BY account_id
), events AS (
SELECT account_id, span_name, attributes, timestamp
FROM records
WHERE app_uid = '<PROD_APP_UID>'
AND span_name IN ('pl_view','pl_config','pl_rule','pl_rule_enabled','LINK')
)
SELECT
toStartOfWeek(b.installed_at, 1) AS cohort_week,
count(DISTINCT b.account_id) AS s1_installs,
count(DISTINCT if(e.span_name = 'pl_config'
AND e.attributes['turned_on'] = true, e.account_id, NULL)) AS s3_configured,
count(DISTINCT if(e.span_name = 'pl_rule', e.account_id, NULL)) AS s4_rule_created,
count(DISTINCT if(e.span_name = 'pl_rule_enabled'
AND e.attributes['switch'] = true, e.account_id, NULL)) AS s5_rule_enabled,
count(DISTINCT if(e.span_name = 'LINK'
AND e.attributes['first_link'] = true, e.account_id, NULL)) AS s6_first_link
FROM base b
LEFT JOIN events e USING (account_id)
GROUP BY cohort_week
ORDER BY cohort_week;
-- 2. Time-to-value: медиана часов от открытия настроек до первой связки
SELECT
account_id,
dateDiff('hour',
min(if(span_name = 'pl_view', timestamp, NULL)),
min(if(span_name = 'LINK' AND attributes['first_link'] = true, timestamp, NULL))
) AS hours_to_first_link
FROM records
WHERE app_uid = '<PROD_APP_UID>'
AND span_name IN ('pl_view','LINK')
GROUP BY account_id
HAVING hours_to_first_link IS NOT NULL
ORDER BY hours_to_first_link;
-- 3. Почему связок нет: топ причин (качество, а не только конверсия)
SELECT attributes['reason'] AS reason, attributes['search_type'] AS search_type,
count() AS attempts, count(DISTINCT account_id) AS accounts
FROM records
WHERE app_uid = '<PROD_APP_UID>' AND span_name = 'pl_no_link'
GROUP BY reason, search_type
ORDER BY attempts DESC;
-- 4. Попытки связки vs подтверждённые записи (журнал) — сверка за сутки
SELECT
(SELECT count() FROM records
WHERE span_name = 'LINK' AND app_uid = '<PROD_APP_UID>'
AND timestamp >= now() - INTERVAL 1 DAY) AS link_attempts,
(SELECT count() FROM pl_link_journal
WHERE create_time >= now() - INTERVAL 1 DAY) AS link_rows;
-- 5. Трение онбординга: скрыли подсказку, не включив алгоритм
SELECT attributes['algorithm'] AS algorithm,
count(DISTINCT account_id) AS dismissed_accounts
FROM records
WHERE app_uid = '<PROD_APP_UID>' AND span_name = 'pl_onboarding_dismissed'
GROUP BY algorithm ORDER BY dismissed_accounts DESC;
-- 6. Откат конфигурации в первые 7 дней (установили и бросили)
SELECT attributes['action'] AS action, count() AS n,
count(DISTINCT account_id) AS accounts
FROM records
WHERE app_uid = '<PROD_APP_UID>' AND span_name = 'pl_config_saved'
AND (attributes['action'] LIKE 'switch:%' AND attributes['enabled'] = false
OR attributes['action'] = 'rule_deleted')
GROUP BY action ORDER BY n DESC;
<PROD_APP_UID> — прод-UUID приложения из constants.py (dev-UUID из apps.env
даёт другую воронку и мешает когортам). Для CI-оценки доли шага использовать
интервал Уилсона, а не «процент ± ничего».
8. Как пользоваться (ритуал)
- Раз в две недели: воронка по когортам (§7.1) → найти шаг с максимальной потерей.
- Сверить
LINK↔ журнал (§7.4): если расхождение > 5% — сначала чинить связки, потом смотреть конверсию. - Посмотреть
pl_no_link(§7.3) — самая частая причина почти всегда объясняет обрыв воронки лучше, чем «плохая реклама». - Только после этого — решения по упаковке/рекламе, и обязательно на когорте того же интерфейса, что и правка.
9. Vendor-события: выручка и отток (таблица vendor_event)
Событий об оплате в Logfire нет — они приходят в vendor-эндпоинты МойСклад и
складываются в таблицу vendor_event (VendorEventService): одно событие =
одна строка с полным телом запроса в JSONB + извлечённые поля подписки
(tariff_id/name, trial, expiry_moment, not_for_resale, partner),
event_type (Install/Resume/TariffChanged/Autoprolongation/Uninstall/Suspend/
status_check) и status.
- Дедуп ретраев. МойСклад повторяет запрос с тем же заголовком
X_Lognex_RequestId→request_idуникален, повтор не задваивает событие (гонка ловится поIntegrityError). - Ретеншена нет намеренно. Строка — это одно тело запроса (сотни байт), нужна вся история: она даёт когорты, отток, ARPU и сроки продлений.
- Фильтруйте
status_codeпри подсчёте «сколько событий случилось». Спаны Logfire пишутся вvendor_eventНЕЗАВИСИМО от кода ответа: строка сstatus_code >= 400— это пришедший, но не удавшийся вызов. Для воронки установок это верно (404 видно и отличимо), для «событий жизненного цикла» (Install/Uninstall/TariffChanged) — нет: беритеstatus_code < 400, иначе счётчики оттока/продлений завышаются неуспешными вызовами. status_check(GET) — самый объёмный тип событий: МойСклад дёргает статус инсталляции регулярно, и каждая проверка пишет строку. Это осознанно (по ним видно живость инсталляции), но при анализе объёма их стоит фильтровать (event_type = 'status_check') или агрегировать по дням — в «событиях жизненного цикла» (Install/Uninstall/TariffChanged) их быть не должно, иначе они раздувают счётчики оттока/продлений. У GET-запросов тела нет: у событий из бэкфилла тип подставлен вbody.causeи помеченbody.synthetic_cause = true— при разборе тел запросов такие строки надо исключать (body->>'synthetic_cause' IS NULL).- Что чем закрывается:
Install/Uninstall/Suspend/Resume→ отток и реактивации;TariffChanged/Autoprolongation+expiry_moment→ выручка и продления;trial/not_for_resale/partner→ сегментация (триалы, партнёрские аккаунты исключать из «платящих», иначе конверсия завышается).
Бэкфилл (история до деплоя)
МойСклад НЕ отдаёт историю событий vendor API, поэтому таблица наполняется с момента деплоя, а прошлое восстанавливается из того, что реально есть:
| Источник | Что даёт | Период |
|---|---|---|
Logfire (fetch_vendor_spans) |
настоящие события vendor API: тело запроса + X_Lognex_RequestId |
период хранения логов (≈1 месяц) |
install |
базовая отметка InstallBackfill на инсталляцию (source=install_table) |
вся жизнь приложения (6 лет) |
# в контейнере (нужен LOGFIRE_TOKEN в env)
docker exec backend0 python -m src.tools.backfill_vendor_events --source all
docker exec backend0 python -m src.tools.backfill_vendor_events --source logfire --since 2025-01-01 --dry-run
Пагинация Logfire — по 100 строк за запрос, потолок задаётся --max-pages
(по умолчанию 50 = 5000 спанов за прогон). Если выборка упёрлась в потолок,
в статистике будет "truncated": true — это неполный бэкфилл, и читать его
как «всё, что было» нельзя: увеличьте --max-pages или сузьте --since.
Идемпотентно: дедуп по request_id (install:<id> для базлайна), повторный
запуск только увеличивает счётчик duplicates. Базлайн помечен source=install_table
в body — его нельзя принять за реальное событие vendor API и он не портит
метрики продлений (в нём нет тарифа).
Синтетический cause. У GET-спанов Logfire тела запроса нет, поэтому
event_type подставляется из метода, а сама подстановка помечается в теле
body.synthetic_cause = true. Это НЕ тело vendor API: при анализе тел запросов
(разбор subscription/access, причин смены тарифа) исключайте такие строки
условием body->>'synthetic_cause' IS NULL, иначе синтетический status_check
попадёт в выборку как настоящий запрос.
Что с этим мерить
- Доля инсталляций, дошедших до платного продления (не триала) по когортам.
- Отток:
Uninstall/Suspendпо времени жизни, а не по календарю. - Реактивации:
ResumeпослеSuspend— цена «возврата» против цены удержания. - Триалы: конверсия
trial=true→ первое продление (Autoprolongation). - Связка с онбордингом: дошёл ли аккаунт до
LINKдо первого продления — это и есть проверка гипотезы «активация в первую неделю определяет оплату».