Контекстная конверсия «Обсудить»: 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> — это разрыв контекста. Посетитель пятнадцать минут читал кейс про интеграцию с , нажал «Обсудить» и оказался на пустой форме, где надо заново формулировать, о чём вообще речь. Для нас это ещё и слепое пятно: мы не знаем, какая страница рождает обращение. Конверсия есть, а её источник — нет.

Первая идея на брейншторме была амбициознее, чем нужно: держать persistent WebSocket на каждой странице, чтобы «видеть» поведение в реальном времени. Мы её отклонили — об этом в конце. Сначала — что в итоге построили.

Слой 1: воронка «Обсудить» на opaque-токене

Полный путь — клик → discuss_click → баннер → форма → lead_submit, связанный одним идентификатором.

Opaque-токен как идентичность страницы

На сборке каждая страница получает opaque HMAC-SHA256 токен. Он кодирует то, что нам нужно знать про источник: slug страницы, аудиторию (dev/biz) и тип CTA (карточка/кнопка/ссылка). Подпись — по keyring с идентификатором ключа (kid), чтобы можно было ротировать секрет без инвалидации старых ссылок.

Ключевая деталь — у токена нет TTL. Это не сессионный токен и не CSRF-наунс; это идентичность страницы. Страница «кейс про » остаётся той же и завтра, и через месяц. Срок жизни здесь был бы лишней сложностью и источником ложных «протухших» ссылок.

«Opaque» означает: посетитель видит в URL непрозрачную строку, а не ?from=blog&audience=dev. Это не про безопасность данных (там нет секретов) — это про целостность статистики. Открытый параметр легко подменить руками или случайно расшарить искажённым; подписанный токен либо валиден, либо отбрасывается. Расшифровать его можем только мы — этого достаточно для цели «сигнал понятен только нам».

Один модуль context-token.mjs используется и build-плагином, и рантаймом — кодирование и декодирование симметричны и живут в одном месте.

Серверный redirect-tracking вместо клиентского beacon

Клик «Обсудить» ведёт не на /contacts, а на серверный эндпоинт /go/discuss?c=<токен> (Astro SSR-роут). Там происходит:

  1. Декод и проверка подписи токена. Невалидный — отбрасываем.
  2. Генерация click_id (uuid).
  3. Серверное обогащение события: хеш IP, user-agent, referer, гео.
  4. Запись discuss_click в таблицу аналитики.
  5. 302 на /contacts?c=<токен>&k=<click_id>.
  6. Отсечка ботов и 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, и сайт продолжает работать.

Поток контекстной конверсии: страница с opaque-токеном → клик → серверный редирект /go/discuss с записью discuss_click → страница контактов с баннером темы → отправка формы и событие lead_submit → воронка по click_id

Слой 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, а серверная аналитика на логах — картину поведения без слежки.

Если у вас на сайте есть кнопка, которая теряет контекст, — напишите нам, обсудим, как замкнуть её в измеримую воронку.

Часто задаваемые вопросы

Зачем opaque-токен страницы вместо обычного query-параметра ?from=blog?
Открытый параметр посетитель видит и может подменить, исказив статистику. Opaque HMAC-токен подписан серверным ключом: содержит slug, аудиторию и тип CTA, но читается только нами. Цель «понятно только нам» решает подпись, а не шифрование транспорта — TLS и так шифрует канал.
Почему корреляция идёт по click_id, а не по anonymous_id из localStorage?
На серверном редиректе localStorage недоступен — браузер не отдаёт его при переходе. Поэтому сервер сам генерирует click_id (uuid), кладёт его в URL и записывает с событием discuss_click. Тот же click_id уходит в lead_submit при отправке формы — так воронка клик→лид замыкается без cookie.
Это слежка за пользователем? Как это соотносится со 152-ФЗ?
Нет. Событие клика обогащается на сервере, но IP хешируется (SHA256 с солью), сырой IP нигде не хранится, гео — производное (страна/город). Персональные данные появляются только при отправке формы, где согласие уже берётся. Баннер согласия для first-party измерения не требуется.
Почему отказались от persistent WebSocket, который был в исходной идее?
WSS — это тот же TLS, шифрование канала он не усиливает. Постоянное соединение генерирует больше данных, а не меньше, и сложнее в эксплуатации. Индустрия шлёт компактные события (sendBeacon/POST). Задачу «сигнал понятен только нам» закрывает opaque-токен, а не выбор транспорта.
Что происходит, если HMAC-ключей нет в окружении сборки?
Graceful degradation: rehype-плагин и компонент оставляют обычную ссылку на /contacts, сайт работает как прежде, просто без записи контекста. Токены генерируются на билде, поэтому ключи нужны в CI; их отсутствие не ломает сборку.