Доставка, повторы и дубли
Что означает ответ 202 и что происходит с событием после него, окно приёма по времени, защита от дублей по event_id, какие коды повторять и как ведёт себя браузер при обрыве страницы.
На этой странице
Приём событий устроен так, чтобы повтор запроса был безопасным, а потеря
события — заметной. Эта страница объясняет, что именно гарантирует ответ
202, по какому полю работает защита от дублей и какие коды имеет
смысл повторять.
Что означает 202
202 возвращается только после того, как все элементы батча
подтверждены долговременной очередью приёма. Если подтверждения нет в течение
пяти секунд, вы получите 500, и запрос нужно повторить целиком: ни одно
событие в этом случае не считается принятым.
{ "accepted": 2, "n": 3, "rejected": [{ "i": 1, "code": "invalid_event" }] }
| Поле | Смысл |
|---|---|
n |
сколько элементов вы прислали |
accepted |
сколько принято |
rejected |
отказы по элементам, i — индекс в вашем исходном батче |
rejected присутствует всегда и равен [], когда отказов нет. Индексы
отсортированы по возрастанию.
После 202 событие уже не пропадёт из-за перезапуска сервиса, но
в отчётах появится не мгновенно: накопленное пишется в хранилище пакетами, сброс
пакета — каждые 2 секунды. Нормальная задержка между ответом и появлением
события измеряется секундами.
Чего не видно в ответе
Ответ не рассказывает о нормализации. Событие могло быть принято, но выглядеть не так, как вы отправили.
| Что происходит с событием | Как проверить |
|---|---|
time вне окна ±48 ч заменяется временем приёма |
сравните event_time и server_time |
session_id обрезается до 256 символов |
значение в самом событии |
url и referrer обрезаются до 2048 символов |
значение в самом событии |
Свойства с чувствительными именами заменяются на [redacted] |
значение в самом событии |
| Значение с ключом, JWT или заголовком авторизации заменяется целиком | значение в самом событии |
Добавляются device_type, os, browser из User-Agent |
колонки события |
Добавляются utm_source, utm_medium, utm_campaign из url |
колонки события |
При наличии только anon_id подставляется связанный user_id |
колонка user_id |
Обрезка и замена — не отказ: событие принимается. Поэтому «свойство пустое или
[redacted]» диагностируется не по коду ответа, а по самому событию. Подробнее
про маскирование — в приватности.
Окно приёма по времени
Поле time принимается в пределах ±48 часов от момента
приёма. Поведение при выходе за окно намеренно прощающее, а не отклоняющее:
| Что вы прислали | Что запишется |
|---|---|
time отсутствует |
время приёма |
time не разбирается как RFC 3339 |
время приёма |
time старше 48 ч |
время приёма |
time в будущем более чем на 48 ч |
время приёма |
Ошибки в ответе при этом не будет. Практическое следствие: сбитые часы на сервере или попытка догрузить историю через приём событий не вызовут отказ — события просто окажутся «сейчас» и испортят распределение по времени.
Защита от дублей
Признак дубля — поле event_id. Это UUID, который выбираете вы. Отдельного
заголовка идемпотентности у приёма нет: event_id и есть ключ идемпотентности.
| Условие | Результат повтора |
|---|---|
Тот же event_id, тот же тип ключа, в пределах окна |
одно событие |
event_id не передан |
сервер сгенерирует свой — повтор создаст второе событие |
event_id изменился между попытками |
два разных события |
Тот же event_id, но другой тип ключа |
два разных события |
Область действия — проект и класс источника, который выводится из типа
ключа, а не из заголовков запроса. Одно и то же значение event_id,
отправленное браузерным токеном pp_bt_… и серверным ключом
pp_sk_…, даёт два независимых события.
Окно защиты по умолчанию совпадает с окном приёма по времени —
48 часов; в собственной установке оператор может его
изменить.
Стабильный event_id из бизнес-ключа
Генерируйте event_id один раз — в момент, когда факт произошёл в вашей
системе, — и переиспользуйте его во всех попытках отправки. Если хранить UUID
негде, соберите его детерминированно из неизменяемого бизнес-ключа:
import { createHash } from 'node:crypto';
// Детерминированный UUID из бизнес-ключа: одинаковый вход — одинаковый выход.
function stableEventID(businessKey) {
const raw = createHash('sha256').update(businessKey, 'utf8').digest().subarray(0, 16);
raw[6] = (raw[6] & 0x0f) | 0x80;
raw[8] = (raw[8] & 0x3f) | 0x80;
const hex = raw.toString('hex');
return [
hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16),
hex.slice(16, 20), hex.slice(20),
].join('-');
}
const eventId = stableEventID(`order_paid:${orderId}:v1`);
Суффикс версии (:v1) оставляет вам возможность осознанно переотправить факт
заново, если разметка события изменилась.
Как безопасно повторять
Повтор безопасен, когда тело запроса побайтово то же самое: те же
event_id, то же time. Не пересоздавайте время и идентификаторы перед
повторной попыткой.
| Код ответа | Повторять | Как |
|---|---|---|
| Ошибка транспорта, таймаут | да | экспоненциальная задержка |
429 rate_limited |
да | по заголовку Retry-After (секунды) |
500 |
да | экспоненциальная задержка |
503 |
да | экспоненциальная задержка |
409 privacy_job_active |
да, позже | только у связывания личности, в details приходит retryable |
401, 403 |
нет | ключ, права или origin |
402 |
нет | биллинг: нет действующей подписки |
413, 422 |
нет | тело запроса нужно исправить |
429 quota_exceeded |
нет | лимит тарифа исчерпан |
429 trial_quota_exceeded |
нет | лимит пробного периода исчерпан |
Разница внутри 429 существенная: у rate_limited есть Retry-After и повтор
поможет, у квотных кодов заголовка нет и повтор не поможет — нужно менять тариф
или ждать следующего периода. Различайте их по полю error.code, а не по коду
ответа.
Стоимость запроса для ограничителя считается как максимум из числа элементов и
размера тела в килобайтах. Батч из 500 элементов стоит дорого, поэтому
слепой повтор большого батча с коротким интервалом сам провоцирует 429.
Порядок событий и опоздания
Отчёты упорядочивают события по event_time — тому времени, которое вы
прислали, — а не по порядку получения. Из этого следует:
- Порядок доставки не имеет значения: можно отправлять пачками и параллельно.
- Опоздавшее событие внутри окна
48ч встанет на своё место в прошлом. Отчёт за уже прошедший период после этого изменится — это нормально и ожидаемо. - Опоздавшее событие вне окна встанет «сейчас» и исказит текущий период.
- Два события с одинаковым
event_timeне имеют определённого порядка между собой. Если порядок важен (шаги воронки), различайте их временем с точностью до миллисекунд.
Отдельно про server_time: его всегда ставит приём, и подменить его нельзя.
Разница server_time - event_time — готовый индикатор опозданий и сбитых часов.
Отправка из браузера и обрыв страницы
Браузерный трекер буферизует события и переживает перезагрузку страницы. Проверенные значения по умолчанию:
| Параметр | Значение |
|---|---|
| Отправка при накоплении | 10 событий |
| Отправка по таймеру | каждые 5 секунд |
Буфер в localStorage |
200 событий |
| Максимум событий в одном запросе | 500 |
| Задержка повтора | 1 секунда, удвоение до 30 секунд |
Буфер лежит в localStorage и восстанавливается при следующей инициализации,
поэтому обычная навигация и перезагрузка события не теряют. При достижении
предела буфера старые события вытесняются новыми.
При уходе со страницы (pagehide или скрытие вкладки) остаток отправляется
двумя разными способами: служебные события pp.* — запросом с keepalive
(до 20 событий и до 60 000 байт), остальные — через navigator.sendBeacon
(до 500 событий и до 60 000 байт). Всё, что не влезло в эти границы,
остаётся в буфере до следующей загрузки страницы.
Ещё одна особенность, о которой стоит знать: трекер повторяет только 429 и
5xx. Любой другой неуспешный ответ он считает доставленным и удаляет события
из очереди. Поэтому неверный токен в браузере приводит к тихой потере, а не к
накоплению очереди.
Отзыв согласия (setConsent('denied') или 'revoked') очищает буфер: события,
не успевшие уйти, не отправятся никогда. Это осознанное поведение — см.
согласие на сбор.
Что проверить
- Код читает
rejected, а не только код ответа. event_idгенерируется один раз при создании факта и переиспользуется при повторах.- Повторяются только ошибки транспорта,
429rate_limited,500и503. Retry-Afterиспользуется вместо фиксированной паузы.timeотправляется в UTC и в формате RFC 3339, часы на серверах синхронизированы.- Браузер и бэкенд не используют общий
event_idдля одного факта.
Если событие приняли, но его не видно, идите в диагностику отправки — там инструменты, а не симптомы.