Skip to content

Аналитика онбординга 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 сигналы поломок, отдельный дашборд

Две тонкости, которые нельзя терять:

  1. LINK эмитится до исполнения Celery-задачи — это «попытка связки». Факт подтверждает запись в pl_link_journal (или документ в МС). Для честных цифр считать связки по журналу, а LINK — как прокси с пометкой.
  2. Авто-правила при установке (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. Какие решения закрывает каждая метрика

  1. Time-to-value — медиана часов от pl_view до первого LINK. Если она большая, проблема не в рекламе, а в пороге входа.
  2. Где обрыв — шаг с максимальной потерей (обычно S2→S3: открыли и не включили; и S3→S5: включили алгоритм, но правило не включили).
  3. Какие алгоритмы реально работают — включения по rule_type × наличие последующего LINK: «по номерам» может включаться чаще, а пользу давать «по маске».
  4. Качество, а не только факт — доля pl_no_link от числа попыток, топ reason, доля связок с candidates>1 (неоднозначность). Иначе «конверсия растёт» при том, что связки ошибочны.
  5. Трение интерфейса — pl_onboarding_dismissed без pl_onboarding_done (скрыл, не включил), usage_order (скрыл подсказку очерёдности), pl_ob_click без последующего включения.
  6. Настройки и их эффект — какой режим очерёдности выбирают (settings:name_usage_type) и как он влияет на долю успешных связок (внутриаккаунтное сравнение «до/после переключения» — самый сильный доступный дизайн при N≈48).
  7. Откат — правило выключили/удалили в первые 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. Чего не хватает (следующий шаг, приоритет по важности)

  1. Ежедневный снапшот инсталляции (Celery beat, одна таблица): account_id, app_uid, install_date, is_active, версия приложения, состояние 4 переключателей, число правил, число связок за 7/30 дней, статус подписки (если доступен через вендор-API). Это даёт когорты, retention и настоящую конверсию в платящих; Logfire-события такой картины не дают (там только действия).
  2. Источник установки (маркетплейс/прямой): UTM внутри МС нет — тянуть всё, что отдаёт инсталляция, в снапшот; иначе канал нельзя оценить.
  3. Дашборд: воронка по когортам + time-to-value + топ reason из pl_no_link
  4. доля ошибочных связок.
  5. Сверка 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. Как пользоваться (ритуал)

  1. Раз в две недели: воронка по когортам (§7.1) → найти шаг с максимальной потерей.
  2. Сверить LINK ↔ журнал (§7.4): если расхождение > 5% — сначала чинить связки, потом смотреть конверсию.
  3. Посмотреть pl_no_link (§7.3) — самая частая причина почти всегда объясняет обрыв воронки лучше, чем «плохая реклама».
  4. Только после этого — решения по упаковке/рекламе, и обязательно на когорте того же интерфейса, что и правка.

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 до первого продления — это и есть проверка гипотезы «активация в первую неделю определяет оплату».