Dilshat Rakhimov
Июль 2026 · 14 мин чтения

Вопрос, ответ на который должен занимать десять секунд, а в двух крупных банках Центральной Азии не находился вообще: сколько человек купили полис в прошлом месяце, в разбивке по продуктам?

Данные были на месте. В Amplitude приходило заметно больше 200 разных типов событий под 18 с лишним префиксами в названиях. Каждая продуктовая команда выкатывала свои события в своём ритме и в своём стиле, и никто никогда не владел картиной целиком. Поэтому ogpo_payment_success, halyk_casco_ad_payment_success, kasko_payment_fail и просто payment сосуществовали, означали слегка разное и считались в разных местах. Спрашиваешь покупки за месяц - получаешь спор, а не число.

Так выглядит таксономия событий через три года жизни без владельца. Ниже - как она схлопнулась до 35 событий: в чём собственно правило схлопывания, почему денежное событие пришлось увести из браузера и какой шаг миграции все знают и никто не делает.

Event taxonomy cover

Во что реально обходятся 200 событий

Хочется списать это на неаккуратность. Не выйдет. Ломаются четыре вещи, и все дорого.

Нельзя сегментировать по продукту. Когда ogpo_payment_success и kasko_payment_success - разные типы событий, «конверсия по продуктам» перестаёт быть разбивкой и становится ручным графиком на каждый продукт, который переделывают при каждом запуске. Тринадцать продуктов - тринадцать графиков, и ни одного способа сравнить их в одном экране.

Заканчивается квота на properties. Amplitude тарифицирует и ограничивает по количеству уникальных свойств. Вложенные структуры вида data.event_info.user.* и data.event_info.utm.* разгоняют этот счётчик быстро, а у потолка лечение одно - экстренно удалять свойства, на которых висит чей-то дашборд.

Атрибуция расслаивается. Тридцать с лишним вариаций одного по смыслу события - это тридцать с лишним мест, где определение воронки может быть тихо неверным. Никто не замечает, потому что каждый график по отдельности выглядит правдоподобно.

И вместе со всем этим едет PII. data.event_info.user.iin - национальный идентификатор - присутствовал в каждом событии, а рядом, в user properties, лежали телефон и имя. Это отдельная проблема со своим решением, но упомянуть её здесь стоит, потому что разросшаяся таксономия - это ровно тот механизм, которым она заводится: никто не ревьюит событие, у которого нет владельца.

Правило, которое схлопывает 200 в 35

Вся пересборка держится на одном решении: product_type становится обязательным параметром каждого событий воронки, а продукт перестаёт жить в названии события.

Двести разрозненных событий схлопываются в выровненный набор, платёжное событие вынесено на отдельный серверный слой

Вот и весь фокус. ogpo_payment_success, kasko_payment_fail и ещё двадцать восемь становятся payment_completed с product_type и is_success. Тринадцать продуктов - ОСАГО-аналог, каско, путешествия, имущество, здоровье, кредитные - теперь живут на одной схеме. Добавить четырнадцатый продукт означает добавить строку в каталог, а не выкатить новое событие и обновить каждый дашборд, который должен был его учесть.

Воронка покупки - тринадцать универсальных событий:

#СобытиеКогда срабатываетКлючевые параметры
1screen_viewedлюбой экран или лендингscreen_name, product_type, step_number, step_name
2form_startedпервое взаимодействие с формойproduct_type, entry_point, utm_*, gclid, fbclid
3form_field_completedтолько поле с высоким drop-offproduct_type, field_name, is_valid, step_number
4option_selectedвыбран тариф / покрытие / опцияproduct_type, option_category, option_value
5person_addedдобавлен водитель, путешественник, выгодоприобретательproduct_type, person_role, person_count
6price_calculatedрасчёт вернулся (середина воронки)product_type, premium (float), sum_insured, is_success
7checkout_startedнажата кнопка «Оплатить»product_type, premium, payment_method, has_add_ons
8pay_widget_shownотрисован виджет шлюзаproduct_type, premium
9otp_requestedOTP отправленproduct_type, otp_type
10otp_completedOTP подтверждёнproduct_type, is_success, is_resend
11payment_completedтолько server-side - см. нижеproduct_type, is_success, premium, payment_id, $revenue
12validation_errorне прошла валидация формыproduct_type, error_type, field_name, step_number
13promocode_appliedвведён промокодproduct_type, promocode, is_valid, discount_amount

Обратите внимание, чего здесь нет. Нет page_view на каждый продукт. Нет платёжного события на каждый продукт. Нет раздельных событий успеха и неудачи - is_success это булево поле, потому что событие успеха и событие неудачи, которые никогда не могут сработать вместе, - это одно событие с флагом, а их разделение удваивает таксономию просто так.

Оставшиеся 22 - честные исключения: 9 продуктовых событий для потоков, которые правда существуют только в одном продукте (загрузка и валидация фотографий в каско, тумблер кибер-риска, добавление страховки жилья в чекауте путешествия), 8 на поток урегулирования убытков, 4 на договоры и поддержку и 1 на технические ошибки.

Сам product_type - закрытый каталог, на момент работы 23 значения: базовые продукты, их рекламные варианты и два потока урегулирования. Закрытый - ключевое слово: значение вне каталога это баг, и схема вам об этом скажет.

Схлопните и события ошибок - они самые громкие в комнате

Самая объёмная находка оказалась не в воронке покупки. Это ошибки, которые приходили тремя отдельными типами:

СобытиеОбъём за 30 дней
api_error4,5 млн
front_error170 тыс.
fatal_error28 тыс.

Почти 4,7 млн событий ошибок в месяц, разложенных на три типа по признаку «откуда пришла ошибка» - а это свойство, не идентичность. Все три становятся одним technical_error с error_severity (fatal / error / warning) и error_source (api / frontend / network), которые делают ту работу, что названия событий делали плохо.

Тот же паттерн встречается в более уродливом виде. Поток загрузки фотографий в каско выкатил больше 80 разных типов событий - по одному на слот файла, на сторону машины, на фотографию повреждения. Порядка 10 тыс. событий в месяц, размазанных по 80 типам, что то же самое, что сказать: непригодно к использованию и навсегда мешается всем в списке событий. Все 80 становятся photo_uploaded с параметром photo_type.

Если забирать отсюда одну рабочую привычку, то эту: если в названии события есть значение, это значение хочет стать параметром. Продукт в названии, источник ошибки в названии, слот фотографии в названии, состояние фильтра в названии - каждое из этого было схлопыванием, ждущим своего часа, и вместе они составляли большую часть тех самых 200.

Расплющите свойства

Вместе со схлопыванием событий выпрямилась и структура свойств. Вложенные объекты вида data.event_info.utm.utm_source стали корневым utm_source.

Это менее косметическая правка, чем кажется. Вложенные свойства неудобно строить в графиках, они раздувают счётчик уникальных свойств, по которому меряется квота, и - главное - они прячут. data.event_info.user.iin пролежал незамеченным долго ровно потому, что никто, листая свойства события, не разворачивал так глубоко.

Теперь всё живёт в корне event_properties, с объявленными и проверяемыми типами: product_type строка и обязательное, premium float, is_success boolean, step_number integer.

Этот float - не вопрос оформления. На старой схеме сумма приходила строкой внутри JSON-блоба, а значит sums() по выручке в Amplitude возвращал ноль. Не «неверно» - ноль. Каждый график выручки в воркспейсе тихо показывал ничто, и делал это столько, сколько кто-либо мог вспомнить. Типизированная схема - это то, что не даёт такому классу багов быть невидимым.

Денежное событие обязано уехать из браузера

payment_completed - единственное событие таксономии, которое фронтенду отправлять не разрешено.

Логика та же, что для любой конверсии с участием платёжного шлюза. Браузер знает, что пользователь нажал оплатить и его отправило на редирект. Он не знает, ушли ли деньги. Между этими двумя фактами лежат 3-D Secure, редирект шлюза, callback и пользователь, который может закрыть вкладку в любой точке этой последовательности. Браузерное событие покупки измеряет намерение и отчитывается о нём как о выручке.

Поэтому работа фронтенда заканчивается на pay_widget_shown. Бэкенд отправляет payment_completed из callback'а шлюза, с revenue-свойствами в том же вызове:

def on_payment_callback(gateway_event):
user_uuid = get_user_uuid(gateway_event.user_id) # UUID, никогда не национальный ID
product = resolve_product_type(gateway_event)
success = gateway_event.status == 'success'
requests.post(
"https://api2.amplitude.com/2/httpapi",
json={
"api_key": AMPLITUDE_API_KEY,
"events": [{
"user_id": str(user_uuid),
"event_type": "payment_completed",
"event_properties": {
"product_type": product,
"is_success": success,
"premium": float(gateway_event.amount), # float, не строка
"payment_method": gateway_event.method,
"payment_id": gateway_event.id if success else None,
"error_description": gateway_event.error if not success else None,
},
"insert_id": f"pmt_{gateway_event.invoice_number}",
"revenue": float(gateway_event.amount) if success else None,
"$productId": product,
"$quantity": 1,
"$revenueType": resolve_revenue_type(gateway_event), # new | renewal | instalment
}]
}
)

Три детали здесь отрабатывают своё место.

insert_id, привязанный к номеру счёта. Amplitude отбрасывает повторный insert_id внутри скользящего окна, поэтому повторный callback или дважды сработавший воркер не стоят ничего. Без него каждый retry шлюза - фантомная продажа.

Revenue в том же вызове, а не отдельным. revenue, $productId, $revenueType едут на событии, а не уходят вторым вызовом Revenue API. Один сетевой хоп, одна точка отказа, и ARPPU сегментируется по product_type бесплатно.

user_id - это UUID. Не национальный идентификатор и не легаси-числовой ключ из корневой системы. Соответствие UUID реальному человеку живёт в CRM, за ролевым доступом, - это отдельная большая тема и причина, по которой работа над таксономией и работа над PII должны были случиться в одном квартале.

Удаление старых событий - вторая половина этой задачи. Общий payment на 154 тыс./мес., revenue_amount на 94 тыс./мес., которого никто не планировал и никто не мог объяснить, и три десятка пар *_payment_success / *_payment_fail уходят.

Шаг миграции, который все пропускают

Вот часть, которую вырезают, когда спринт поджимает, и ровно она определяет, сработает ли всё остальное.

Две недели логируйте обе схемы параллельно, прежде чем что-либо удалять. Старые события и новые, рядом, на одном трафике. Потом сверьте. Если payment_completed не сходится с суммой тридцати событий, которые он заменил, - маппинг неверный, и вы узнаете об этом, пока старые данные ещё существуют, а не через месяц, когда кто-то откроет график год-к-году и увидит обрыв.

Дальше, когда удалите, продолжайте смотреть:

Соберите дашборд deprecated_events с алертом. Он считает события, которых быть уже не должно. Цель - ноль, алерт - если что-то из них превышает ~100 в день через неделю после перехода. Потому что они вернутся. Чей-то ещё релиз всё ещё пушит ogpo_payment_success, или закешированный бандл неделями отдаёт старый трекер, или в проде живёт версия приложения, о которой вы забыли. Без этого алерта вы не обнаружите проблему - у вас просто будет медленная течь событий в схему, которую вы считаете чистой.

Включите блокировку незапланированных событий. Amplitude Data умеет помечать - и отклонять - события вне запланированной схемы. Отметьте 35 как planned, объявите обязательные свойства и их типы, поставьте алерт. Именно это не даёт таксономии тихо стать двумястами событиями снова за следующие три года, а это и есть настоящий сценарий отказа. Каждое из исходных 200 добавил кто-то разумный, выкатывая что-то разумное, во вторник.

И заблокируйте PII-поля на уровне схемы. Правило, отклоняющее свойства по маске /iin|phone|passport/, - это пятиминутная настройка, которая переживёт любое код-ревью, любой онбординг и любого благонамеренного фронтендера, добавляющего поле «временно, для отладки».

К чему мы это держали

Это критерии приёмки, под которые работа сдавалась, - планка, а не таблица результатов:

  • 35 запланированных событий в Amplitude Data, с объявленными типами на каждом обязательном свойстве.
  • sums() по выручке возвращает ненулевое число. Звучит тривиально; на деле это была самая цитируемая проверка того, что миграция состоялась, - потому что там годами был ноль.
  • Платёжных событий на покупателя < 1,05 - не больше 5% дублей, переживших дедупликацию.
  • Расхождение серверного события с логами шлюза в пределах 2%. Старая фрагментированная схема давала от -63% до +169% против того же источника истины - это и есть число, которым обосновывалась вся работа.
  • Ноль PII-полей в event properties и user properties.
  • Ноль незапланированных событий с новыми полями PII-формы за скользящую неделю.

Что из этого следует

Таксономия на 200 событий никогда не результат одного плохого решения. Это результат того, что никто не владел картиной целиком, пока тринадцать продуктовых команд делали каждая что-то локально разумное, - и стоит она вам возможности отвечать на базовые вопросы о собственной воронке.

Правило схлопывания достаточно простое, чтобы применить его на маркерной доске: если значение сидит в названии события, ему место в параметре. Продукт, источник ошибки, слот фотографии, состояние фильтра, успех и неудача - вытащите всё это, и большая часть двухсотенного бардака окажется примерно тридцатью пятью событиями, надевшими много костюмов.

Остальное держат две вещи. Денежное событие уезжает на сервер, потому что браузер может сказать вам, что человек нажал оплатить, и не может сказать, что платёж прошёл. И схема принуждается - запланированные события, типизированные свойства, заблокированные PII-поля, алерт на всё неожиданное, - потому что таксономия без принуждения это просто документ, описывающий, как выглядели ваши события в день, когда вы их записали.

Но сильнее всего я бы спорил за самый скучный пункт: две недели параллельного логирования перед удалением и алерт на deprecated-события месяц после. Это самый дешёвый шаг в проекте и единственный, который говорит вам правду о том, сработало ли всё остальное.