К содержанию
ActionPulse

Доставка, повторы и дубли

Что означает ответ 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') очищает буфер: события, не успевшие уйти, не отправятся никогда. Это осознанное поведение — см. согласие на сбор.

Что проверить

  1. Код читает rejected, а не только код ответа.
  2. event_id генерируется один раз при создании факта и переиспользуется при повторах.
  3. Повторяются только ошибки транспорта, 429 rate_limited, 500 и 503.
  4. Retry-After используется вместо фиксированной паузы.
  5. time отправляется в UTC и в формате RFC 3339, часы на серверах синхронизированы.
  6. Браузер и бэкенд не используют общий event_id для одного факта.

Если событие приняли, но его не видно, идите в диагностику отправки — там инструменты, а не симптомы.