Контекстная конверсия «Обсудить»: opaque-токен, серверный редирект и аналитика без трекеров
- аналитика
- hmac
- конверсия
- 152fz
- astro
- privacy-first
- server-side
Кнопка «Обсудить» по всему сайту вела на голый /contacts — и теряла две вещи сразу: контекст страницы (что именно читал посетитель перед кликом) и сам сигнал конверсии (сильное намерение не попадало во внутреннюю аналитику). Мы переписали путь так: каждая страница получает opaque HMAC-токен своей темы, клик идёт через серверный redirect-tracking /go/discuss (он пишет событие discuss_click с контекстом и хешированным IP), /contacts показывает баннер «Обсуждаем: <тема>», а отправка формы пишет lead_submit с тем же click_id. Плюс отдельный слой — поведенческая аналитика на логах Traefik через GoAccess, без единого трекер-скрипта. Ниже — инженерные решения и честные компромиссы.
Проблема: ссылка, которая ничего не помнит
Обычная ссылка <a href="/contacts">Обсудить</a> — это разрыв контекста. Посетитель пятнадцать минут читал кейс про интеграцию с 1С, нажал «Обсудить» и оказался на пустой форме, где надо заново формулировать, о чём вообще речь. Для нас это ещё и слепое пятно: мы не знаем, какая страница рождает обращение. Конверсия есть, а её источник — нет.
Первая идея на брейншторме была амбициознее, чем нужно: держать persistent WebSocket на каждой странице, чтобы «видеть» поведение в реальном времени. Мы её отклонили — об этом в конце. Сначала — что в итоге построили.
Слой 1: воронка «Обсудить» на opaque-токене
Полный путь — клик → discuss_click → баннер → форма → lead_submit, связанный одним идентификатором.
Opaque-токен как идентичность страницы
На сборке каждая страница получает opaque HMAC-SHA256 токен. Он кодирует то, что нам нужно знать про источник: slug страницы, аудиторию (dev/biz) и тип CTA (карточка/кнопка/ссылка). Подпись — по keyring с идентификатором ключа (kid), чтобы можно было ротировать секрет без инвалидации старых ссылок.
Ключевая деталь — у токена нет TTL. Это не сессионный токен и не CSRF-наунс; это идентичность страницы. Страница «кейс про 1С» остаётся той же и завтра, и через месяц. Срок жизни здесь был бы лишней сложностью и источником ложных «протухших» ссылок.
«Opaque» означает: посетитель видит в URL непрозрачную строку, а не ?from=blog&audience=dev. Это не про безопасность данных (там нет секретов) — это про целостность статистики. Открытый параметр легко подменить руками или случайно расшарить искажённым; подписанный токен либо валиден, либо отбрасывается. Расшифровать его можем только мы — этого достаточно для цели «сигнал понятен только нам».
Один модуль context-token.mjs используется и build-плагином, и рантаймом — кодирование и декодирование симметричны и живут в одном месте.
Серверный redirect-tracking вместо клиентского beacon
Клик «Обсудить» ведёт не на /contacts, а на серверный эндпоинт /go/discuss?c=<токен> (Astro SSR-роут). Там происходит:
- Декод и проверка подписи токена. Невалидный — отбрасываем.
- Генерация
click_id(uuid). - Серверное обогащение события: хеш IP, user-agent, referer, гео.
- Запись
discuss_clickв таблицу аналитики. - 302 на
/contacts?c=<токен>&k=<click_id>. - Отсечка ботов и prefetch — они не должны раздувать воронку.
Почему redirect-tracking, а не клиентский sendBeacon на клике? Потому что сервер — единственное место, где гарантированно есть IP, referer и гео для обогащения, и где запись не зависит от того, доехал ли клиентский JS. Beacon можно заблокировать ad-блокером или потерять при быстром уходе со страницы; серверный редирект — часть самого перехода, его не пропустить.
click_id, потому что anonymous_id на редиректе недоступен
Самое неочевидное решение. У нас, как у многих, есть anonymous_id в localStorage для сшивки визитов. Но на серверном редиректе localStorage недоступен — браузер его не передаёт, сервер физически не может его прочитать в момент /go/discuss.
Поэтому корреляция визита идёт по click_id, который сервер генерирует сам и кладёт в URL (без cookie). Этот же click_id приезжает на /contacts, а оттуда — в lead_submit при отправке формы. Так замыкается воронка клик → лид без зависимости от клиентского хранилища.
Важное разделение ролей: токен c — это идентичность страницы (общий для всех её посетителей), поэтому он даёт агрегатную page-level статистику («сколько обращений рождает эта страница»). А click_id — идентификатор конкретного перехода, он сшивает один клик с одним лидом. Не путать одно с другим: токен не идентифицирует человека, click_id не идентифицирует страницу.
Баннер и необязательное «дополнить»
Страница /contacts переведена в SSR (prerender = false), декодит токен и резолвит человекочитаемую тему по коллекциям контента (resolvePageTitle). Посетитель видит баннер «Обсуждаем: <тема>» — он уже не должен заново объяснять, откуда пришёл. Поле сообщения при наличии контекста становится необязательным (тема и так известна), но name, email и капча остаются обязательными — релаксация затрагивает только то, что безопасно ослабить.
Одна компонента вместо семи разрозненных CTA
Раньше «Обсудить» было реализовано в семи местах по-разному (Hero, главная, /biznesu, layout услуги, layout кейса, навбар, мобильное меню). Мы свели их в одну компоненту DiscussCTA с вариантами card / button / link. А markdown-контент (~37 файлов) переписывается на билде rehype-плагином: все ссылки на /contacts превращаются в /go/discuss?c=…. Если HMAC-ключей в окружении нет — плагин gracefully оставляет /contacts, и сайт продолжает работать.
Слой 2: поведенческая аналитика без трекер-JS
Второй слой — про «что вообще происходит на сайте», и здесь принципиально ноль клиентского кода.
Клиентские трекеры (Plausible, Umami и подобные) режутся ad-блокерами — данные неполные by design. Мы пошли иначе: GoAccess читает access-лог Traefik. Коллектор берёт JSON-лог обратного прокси, фильтрует по хосту webshturm.ru, а GoAccess раз в несколько минут регенерирует статический HTML-дашборд с флагом --anonymize-ip. Дашборд отдаётся за basic-auth.
Это не трогает статическую конфигурацию Traefik (access-лог и так пишется в stdout) и не добавляет на страницы ни байта JavaScript. Источник правды — серверные логи, которые ad-блокер не отрежет.
Яндекс.Метрику при этом оставили: поведенческие факторы Яндекса — сильный SEO-сигнал, а GoAccess её дополняет, а не заменяет.
Privacy-first и 152-ФЗ
Контур приватности встроен, а не прикручен:
- IP хешируется (SHA256 с солью, тем же паттерном, что и журнал согласий). Сырой IP нигде не хранится.
- Гео — производное: страна / город / регион в meta-поле события, без привязки к конкретному адресу.
- ПДн (имя, почта) — только на submit формы, где согласие уже берётся явным чекбоксом.
- GoAccess — с
--anonymize-ip. - robots:
Disallow /go/и/admin/.
Событие discuss_click — это first-party essential measurement собственного сайта, поэтому отдельный баннер согласия для него не нужен. Хранение событий ограничено по сроку retention-функцией (152-ФЗ, принцип минимизации).
Оспоренное решение: почему не persistent WebSocket
Исходная идея — держать постоянный WebSocket с каждой вкладки. Мы её разобрали и отклонили:
- TLS уже шифрует весь трафик. WSS — это тот же TLS поверх WebSocket; шифрование канала он не усиливает.
- Постоянное соединение даёт больше данных, а не меньше. Это противоречит и минимизации по 152-ФЗ, и здравому смыслу нагрузки.
- Индустрия шлёт события, а не держит каналы.
sendBeacon/ POST компактнее и надёжнее на уходе со страницы. - Цель была сформулирована неточно. «Понятно только нам» — это про opaque-токен (подпись), а не про транспорт. Подменили задачу транспорта задачей подписи — и она решилась проще.
Полезное напоминание: прежде чем выбирать технологию, стоит проверить, ту ли проблему она решает.
Graceful degradation как принцип
Вся фича спроектирована так, чтобы отсутствие части окружения не ломало сайт:
- Нет HMAC-ключей на билде → rehype оставляет
/contacts, ссылки работают. - Невалидный или отсутствующий токен на
/go/discuss→ редирект на/contactsбез баннера. - Нет гео-базы → событие пишется без гео-полей, не падает.
Аналитика — вторичный слой поверх основного пути. Она не должна быть единой точкой отказа для главного действия пользователя — нажать «Обсудить» и дойти до формы.
Итог
Контекстная конверсия — это не «ещё один трекер», а аккуратная инженерия вокруг одного намерения: связать страницу, клик и лид так, чтобы данные были полезны нам и безопасны для посетителя. Opaque-токен даёт целостность статистики, серверный редирект — надёжную запись с обогащением, click_id — сшивку воронки без cookie, а серверная аналитика на логах — картину поведения без слежки.
Если у вас на сайте есть кнопка, которая теряет контекст, — напишите нам, обсудим, как замкнуть её в измеримую воронку.