Dilshat Rakhimov
Июль 2026 · 11 мин чтения
Когда ваш веб-сервис открывается не в Safari или Chrome, а внутри WebView крупного SuperApp (Halyk-класса), привычная модель атрибуции разваливается сразу с двух сторон. Со стороны продуктовой аналитики - непонятно, откуда пришла сессия: из меню приложения, из баннера-витрины или всё-таки по платному диплинку. Со стороны перформанса - браузерный пиксель Meta/Google внутри WebView часто просто выключен kill-switch'ем, а значит денежное событие некому донести в рекламный кабинет.
Эта статья - про то, как захватить атрибуцию внутри WebView так, чтобы (1) продуктовая аналитика (Amplitude/Mixpanel) знала слой и источник каждой сессии, (2) платный источник не смешивался с внутренним трафиком SuperApp, и (3) ROAS считался server-side, устойчиво к отключённому пикселю. Это дополнение к разбору диплинков и WebView-атрибуции: там - про сохранность UTM/MMP на переходе, здесь - про то, как эти сигналы попадают в продуктовую аналитику и в деньги.

Почему WebView ломает атрибуцию с двух сторон
WebView - это не «немного другой браузер». Это отдельный слой внутри нативного приложения со своим управлением cookie, своей навигацией и своими ограничениями. Для нашей задачи важны два следствия.
Первое: сессия внутри WebView по умолчанию «безымянная». Пользователь мог попасть в сервис тремя разными путями - через меню SuperApp, через баннер-витрину, через платный диплинк из рекламы, - и во всех трёх случаях WebView открывает одну и ту же страницу. Без явного сигнала продуктовая аналитика не отличит платную сессию от навигации внутри приложения.
Второе: браузерный рекламный стек в WebView часто заблокирован. На практике встречается guard вида is_webview → выключить весь Meta-пиксель и часть Google, вплоть до того, что typeof fbq === "undefined" - базовый пиксель даже не грузится. А при этом именно на WebView приходится основная масса платежей (порядка 85%). Значит, полагаться на браузерный пиксель для денежного события нельзя в принципе - атрибуция обязана ехать server-side.
Механизм: launch-URL + native getAttribution(), это расширение, а не переезд
Ключевая идея - не выбрасывать пользователя во внешний браузер и не переклеивать пиксель, а дать нативному слою при открытии WebView дописать параметры в launch-URL.
Всегда, для любой сессии, натив добавляет слой и точку входа: layer, entry_point, launch_id. Дополнительно - только для ad-origin запусков (когда WebView открыт по рекламному диплинку) - натив дописывает атрибуцию из нативного MMP (например, Adjust или AppsFlyer) через getAttribution():
Эти параметры читает существующий механизм захвата на инициализации (условный «Click ID Locker» на GTM Init) и пишет их в user-свойства Amplitude рядом с layer. adj_is_deferred отдельно отмечает deferred-диплинк (случай «сначала установка, потом открытие»), чтобы не путать его с прямым запуском.
Важно, что это именно расширение текущей схемы, а не миграция на новый стек: продуктовые события остаются теми же, к ним просто добавляются свойства слоя и источника.
Ловушка router.replace(): читать параметры до того, как их сотрут
SPA-роутер на первой же навигации переписывает URL и стрипает query-string (а на oauth-хопе может снести и служебный url=). Если тег читает launch-параметры лениво, к моменту чтения их уже нет - роутер их стёр. Поэтому launch-URL нужно читать на самой ранней инициализации, до router.replace(), и сразу зафиксировать значения (залочить). Это тот же принцип, что и захват click id first-touch в обычном вебе: поймать и запереть до того, как что-либо перепишет URL. Пропустить этот момент - значит потерять всю атрибуцию диплинка ещё до того, как её кто-то прочитал.
Критический гейт: adj_* только на ad-origin
Самая дорогая ошибка в этой схеме - дописывать adj_* на всех запусках подряд. Нельзя. adj_* должны появляться только на рекламных диплинках в сервис. Для внутренних переходов (меню SuperApp, баннер-витрина) их добавлять запрещено - иначе getAttribution() вернёт stale-значение прошлой атрибуции, и вы получите уже известную проблему superapp-mixing.
Superapp-mixing выглядит так: внутрь платного канала попадают уже авторизованные пользователи SuperApp, которые пришли из меню, а не из рекламы. У них конверсия под 80% (они уже клиенты), они «разбавляют» платный сегмент фантомно высоким CR, и алгоритм оптимизируется на людей, которых реклама не приводила. Отчётность врёт, ставки едут, бюджет утекает в никуда.
Гейт по ad-origin решает это на корню: платными признаются только сессии, реально открытые по рекламному диплинку. На стороне разметки это согласуется с плановым utm_source=superapp&utm_medium=webview и blocking-триггером, который глушит платные теги на внутренних переходах.
Два слоя, которые нельзя смешивать
Разделение слоёв - смысловой центр всей схемы.
Продуктовый слой: launch-URL + Amplitude. Отвечает на вопрос «какой слой и источник у сессии, как она движется по воронке». Здесь живут layer, entry_point, adj_* как user-свойства. Real-time, для продуктовой аналитики.
Финансовый слой (ROAS): отдельный и server-side. adj_* пробрасываются не только в Amplitude, но и в тело продуктового API-вызова, который и так уже несёт идентификаторы (например, запрос расчёта/котировки, в теле которого есть utm, телефон, id клиента). На бэкенде это джойнится с payment-webhook в отдельной таблице атрибуции, и оттуда идёт fan-out в Meta CAPI / Google Enhanced Conversions / TikTok Events API. Дедуп - по transaction_id.
Почему деньги обязаны ехать server-side, уже сказано выше: браузерный Meta-стек в WebView убит kill-switch'ем, а платежи в основном происходят именно в WebView. Server-side слой устойчив к этому по построению - ему не нужен ни fbq, ни cookie, ни живой пиксель. Продуктовый слой (Amplitude) и финансовый слой (CAPI/EC) питаются от одного захвата adj_*, но живут в разных трубах и не зависят друг от друга.
Критерии успеха
Чтобы схему можно было принять как рабочую, а не «вроде поехало», её стоит закрывать измеримыми критериями:
layer/entry_pointзаполнены у >= 99% in-app сессий (значит захват на Init работает почти всегда);adj_*= 0 на внутренних переходах (значит гейт по ad-origin держит и superapp-mixing не течёт);- сходимость платных app-сессий с консолью MMP <= 15% расхождения (значит платный сегмент считается честно);
adj_*доходит до calculate-join у >= 90% ad-origin заявок (значит финансовый слой получает источник);attribution_channelзаполнен на 100% (каждая заявка размечена каналом).
Итог
Атрибуция внутри WebView SuperApp собирается не пикселем, а нативным launch-URL: слой и точку входа - всегда, рекламную атрибуцию из getAttribution() - только на ad-origin диплинках, читая всё это на самой ранней инициализации, до того как роутер сотрёт query. Продуктовая аналитика получает layer и источник как user-свойства; деньги едут отдельным server-side слоем, устойчивым к убитому браузерному пикселю; а гейт по ad-origin не даёт внутреннему трафику SuperApp с его 80%-й конверсией отравить платный канал. Три вещи, которые чаще всего забывают: читать launch-параметры до router.replace(), держать adj_* строго на ad-origin, и не пытаться считать WebView-выручку браузерным пикселем, которого в WebView нет.
Частые вопросы
Почему WebView ломает атрибуцию с двух сторон?
На входе платный трафик приходит по ad-origin диплинкам, и launch URL — единственное место, где вообще есть источник. На выходе хост-SuperApp может выключить ваш браузерный пиксель, и этот рубильник не ваш.
В чем ловушка `router.replace()`?
Роутер SPA переписывает URL и срезает query-строку раньше, чем ваш код успевает ее прочитать. Ошибки при этом нет — параметров просто не оказывается на месте. Читайте и сохраняйте их до того, как отработает роутер.
Зачем ограничивать `adj_*` только ad-origin трафиком?
Потому что разметка органических сессий рекламными параметрами отравляет платный канал. Клиентский слой продуктовой аналитики и серверный слой ROAS имеют разные идентификаторы и разные режимы отказа — смешивать их нельзя.
