Браузер и бэкенд вместе
Как разделить события между браузером и бэкендом, не получить дубли одного факта, склеить одного человека между двумя контурами и не отправлять оплату из браузера.
На этой странице
Как только у продукта появляется оплата, события начинают приходить из двух контуров: браузер знает, что человек нажал, а бэкенд знает, что на самом деле произошло. Эта страница — про то, как поделить между ними ответственность так, чтобы отчёты не считали один факт дважды и не считали ложные факты вообще.
Права здесь определяются типом ключа, а не тем, что указал вызывающий: браузерный токен публичен и лежит в разметке страницы, серверный ключ секретен. См. типы ключей приёма.
Кто что отправляет
| Что | Откуда | Почему так |
|---|---|---|
| Просмотры, клики, прокрутка, формы | браузер, автоматически | это и есть поведение в интерфейсе |
| Шаги пути в интерфейсе, начало оформления | браузер | бэкенд об этих шагах не знает |
| Регистрация подтверждена | сервер | факт создаётся вашей базой |
| Оплата, заказ, счёт | сервер | подтверждение приходит от платёжного провайдера |
| Возврат, отмена, списание | сервер | часто происходит вообще без участия человека |
| Выдача и отзыв доступа, смена тарифа | сервер | решение принимает ваш бэкенд |
Часть имён приём принимает только с серверным ключом:
registered, subscription_started, trial_started, trial_activated,
trial_converted, trial_expired, payment_succeeded, payment_attached,
order_created, first_order_created, order_paid, invoice_paid, refund.
Имена checkout_started и signup_completed в этот список намеренно не входят:
это сигналы интерфейса, а не подтверждённые факты, и их отправляют из браузера.
Обратное правило тоже действует: серверный ключ не может отправлять имена из
пространства pp.* — их формирует браузерный сбор.
Один факт — один источник
Простое правило: у каждого факта ровно один отправитель. Если канонический
order_paid создаёт бэкенд, браузер такого события не отправляет вообще.
Причина техническая. Защита от дублей действует в пределах одного источника:
одинаковый event_id, отправленный браузерным токеном и серверным ключом, даёт
два разных события. Это сделано специально — иначе публичный браузерный токен
мог бы занять event_id заранее и подавить настоящее серверное событие.
Если факт всё-таки виден с двух сторон
Отчёты и список событий читают данные так, что на один логический факт
приходится одна запись. Логический факт — это имя события плюс свойство
business_event_id (UUID). Когда двух записей с одинаковым логическим фактом
несколько, побеждает более достоверный источник: серверная запись выигрывает у
браузерной.
Поэтому, если один и тот же факт по каким-то причинам приходит из обоих контуров:
- имя события должно быть одинаковым — иначе это два разных логических факта;
- свойство
business_event_idдолжно быть одинаковым и быть корректным UUID; некорректное значение просто игнорируется, и склейки не произойдёт; - транспортный
event_idу каждой стороны свой — общий не даст ничего и создаст тот самый риск подавления.
Рекомендуемая схема
Штатный способ выглядит иначе и надёжнее: стороны отправляют события с разными
именами и связывают их общим business_event_id.
{
"event": "checkout_started",
"event_id": "3f1b8c22-6e7a-4a5f-9b0d-1c2d3e4f5a6b",
"anon_id": "…",
"session_id": "…",
"props": {
"business_event_id": "9c4e77d0-2f31-4a8c-b6d5-0e1f2a3b4c5d",
"correlation_id": "b1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f"
}
}
Бэкенд после подтверждения транзакции отправляет order_paid со своим
event_id, тем же business_event_id, тем же user_id и тем же session_id.
Два имени — два разных факта, которые не мешают друг другу в воронке, но которые
можно сопоставить по свойству. correlation_id — просто ваше свойство для сверки
с собственными логами: ActionPulse не придаёт ему особого смысла.
Как склеить одного человека
Между контурами человека связывает user_id — ваш собственный идентификатор из
вашей базы. Это не удобство, а обязательное условие: если браузер шлёт один
user_id, а бэкенд другой, в отчётах будет два разных актора.
Что передавать с сервера:
| Поле | Когда | Зачем |
|---|---|---|
user_id |
всегда, когда человек известен | единственная связь между контурами |
anon_id |
если бэкенд его знает | позволяет связать анонимную историю |
session_id |
если фронтенд передал его вам | сшивает серверный факт с сессией в браузере |
Значение сессии берётся в браузере вызовом pp.getSessionId() и передаётся на
бэкенд вместе с бизнес-данными. До получения согласия на сбор этот вызов
возвращает undefined — сессии просто ещё нет.
Связывание анонимной истории
Пока человек не вошёл, у него только anon_id. Связать анонимную историю с
user_id можно двумя способами:
- Из браузера — вызовом
identify(). Он отправляет запрос на/v1/identify, сохраняет связь и попутно создаёт событиеidentify. - С сервера — запросом на
/v1/aliasс телом{anon_id, user_id}. Он создаёт только связь и никакого события не порождает.
После создания связи события, у которых есть только anon_id, начинают
приходить уже с подставленным user_id. Уже принятая история переписывается
отдельной фоновой задачей, а не в момент ответа 202: считайте,
что склейка прошлого применяется с задержкой, и не проверяйте её сразу же в
отчёте. Подробно — идентификация пользователей.
События, о которых браузер не знает
Часть фактов вообще не имеет браузерного контекста: продление подписки по расписанию, возврат, оформленный поддержкой, заказ по телефону, уведомление от платёжного провайдера, отключение за неоплату. Их отправляет бэкенд, и это нормальная ситуация, а не пробел в разметке.
Что учесть:
- Актор. Достаточно
user_id. Придумыватьanon_idиsession_idдля такого события не нужно:session_idнеобязателен. - Время. Поле
timeдолжно попадать в окно ±48часов от текущего момента. Время вне окна не отклоняется — событию проставляется время приёма, то есть факт «переедет» на сегодня. - Старее окна приёма. Загрузить историю за прошлые месяцы обычным приёмом нельзя. Для этого есть загрузка данных.
- Заголовки о происхождении.
X-PP-SDK-Name,X-PP-Sourceи подобные — диагностика, а не права. Источник события определяется типом ключа. Попытка объявить себя внутренним продюсером приводит к отклонению всех элементов батча.
Типичная ошибка: оплата из браузера
Соблазн понятный: страница «Спасибо за покупку» уже открыта, вызвать оттуда
track('order_paid') — одна строка. Так делать нельзя, и вот что произойдёт на
практике.
- Приём ответит
202— как и на любой корректно разобранный запрос. - Внутри ответа элемент будет в массиве
rejectedс кодомcredential_event_forbidden. - Если код не проверяет
rejected, во фронтенде всё выглядит успешным, а в отчётах оплат нет. Расхождение обнаружится через недели, когда воронка покажет нулевую конверсию последнего шага.
Даже если бы имя не было закрыто, страница благодарности не годится в источник факта оплаты: её перезагружают, открывают из истории браузера, присылают ссылкой. Событие оплаты должно рождаться там, где оплата подтверждена, — в обработчике уведомления от платёжного провайдера или после успешной транзакции в вашей базе.
Правильное разделение того же сценария:
- браузер отправляет
checkout_startedпри начале оформления; - бэкенд отправляет
order_paidпосле подтверждения, сrevenueв рублях и общимbusiness_event_id; - воронка строится по шагам
checkout_started→order_paid.
Проверка
- В коде фронтенда нет ни одного имени из списка серверных событий и нет строки
pp_sk_. - Ответы приёма проверяются на непустой
rejected— и в браузере, и на бэкенде. - Один и тот же человек в отчётах — один актор: найдите себя в списке событий по
user_idи убедитесь, что действия из браузера и с бэкенда лежат под одним актором. - Одна оплата — одно событие: проведите тестовую оплату и убедитесь, что
order_paidв списке событий ровно один. - Повторная отправка того же серверного факта не увеличивает счётчик.
Если что-то из этого не сходится, начните с проверки интеграции — там видно, какой источник прислал событие и что именно не совпало с договорённостью. Разбор частых симптомов — в статье решение проблем.