Аналитика без cookie: дневной HMAC вместо идентификатора посетителя

от автора

Внешний счётчик ставится за пять минут, и обычно этого достаточно. Но у модели «сторонний скрипт с чужого домена» есть технический потолок, и на магазине я в него упёрся: запрос к третьему домену режут блокировщики и антитрекинг браузеров, на объёме включается сэмплирование, cookie тянет за собой баннер согласия (а все, кто его отклонил, выпадают из статистики целиком), и главное – поведение ваших покупателей лежит в чужой системе, рядом с которой нет ваших заказов.

Ниже – как устроен свой cookieless-трекинг в двух проектах на Next.js: интернет-магазин (Next.js 15.5, TypeScript, Prisma 6, PostgreSQL) и маркетплейс услуг (тот же Next.js, но pg без ORM). Код в статье – из работающих проектов, с теми решениями, которые оказались неочевидными, и с теми, которые пришлось переделать.

Сразу оговорка: это не «Метрика – зло». Для контентного сайта внешний счётчик закрывает вопрос целиком. Разговор начинается там, где аналитика – часть продукта.

Идентификатор, которого нет

Главный вопрос своей аналитики – не «куда писать события», а «как отличить одного посетителя от другого, ничего не записав в браузер и не сохранив персональных данных».

Ответ, который используют privacy-first счётчики и который в итоге стоит у меня: не хранить идентификатор вообще, а каждый раз вычислять его заново – как HMAC от того, что и так пришло в HTTP-запросе, с календарным днём внутри подписываемой строки.

import { createHmac } from "node:crypto";const HASH_KEY = process.env.AUTH_SECRET ?? "fallback-key";const MSK_OFFSET_MS = 3 * 60 * 60 * 1000;/** `yyyy-mm-dd` календарного дня по MSK для момента времени. */function mskDateStamp(now: Date): string {  return new Date(now.getTime() + MSK_OFFSET_MS).toISOString().slice(0, 10);}/** Анонимный, ротирующийся по дням visitor-хэш (32 hex-символа). */export function dailyVisitorHash(ip: string, userAgent: string, now: Date): string {  return createHmac("sha256", HASH_KEY)    .update(`${mskDateStamp(now)}|${ip}|${userAgent}`)    .digest("hex")    .slice(0, 32);}

Что здесь важно, кроме самого хеша:

День берётся по своему часовому поясу, а не по UTC. Иначе «уникальные за сегодня» перещёлкиваются в 3 часа ночи по Москве, и вечерний трафик разрезается на две части. Пять строк кода, которые иначе тихо портят все дневные срезы.

День внутри подписываемой строки, а не в ключе. Практическое следствие: сшить посетителя между днями нельзя by design, «уникальные» считаются в пределах суток. Честная плата за это – ниже, в ограничениях.

Обрезка до 32 символов. 128 бит на анонимный дневной счётчик достаточно, а колонка и индекс по ней вдвое меньше.

IP и User-Agent в базу не попадают. Они живут в памяти процесса ровно на время вычисления хеша.

Что писать в базу

Схема события – ровно то, из чего строятся отчёты, и ничего «на всякий случай»:

model PageView {  id           String   @id @default(cuid())  path         String  visitorHash  String  referrerHost String?  source       String   @default("direct")  campaign     String?  device       String   @default("desktop")  country      String?  createdAt    DateTime @default(now())  @@index([createdAt])  @@index([path, createdAt])  @@index([visitorHash, createdAt])  @@index([source, createdAt])  @@index([campaign, createdAt])}

От referrer-а остаётся только хост: в query-строке приезжает мусор и иногда чужие персональные данные, а для отчёта «откуда пришли» хост исчерпывающ. Индексы все составные с createdAt, потому что любой запрос в аналитике – это «что-то за период», и одиночный индекс по path в таком плане бесполезен.

Дальше – метки кампаний. Поле campaign заполняется из utm_campaign, то есть из адресной строки, то есть кем угодно. Без фильтра туда можно насыпать произвольных строк и раздуть кардинальность колонки, по которой стоит индекс:

export function normaliseCampaign(raw: string | null | undefined): string | null {  if (!raw) return null;  const cleaned = raw.trim().toLowerCase().slice(0, 64);  return /^[a-z0-9._-]+$/.test(cleaned) ? cleaned : null;}

Почему beacon, а не middleware

Интуитивно правильный ход в Next.js – считать визиты в middleware: серверно, вырезать нечем. На App Router это работает плохо. Через middleware проходят не только переходы человека: там же RSC-запросы, префетч ссылок при наведении, повторные запросы на ревалидацию. Считая всё это, вы получаете красивый растущий график, который не имеет отношения к тому, что кто-то смотрел страницу.

Поэтому событие шлёт клиент – одним sendBeacon на смену маршрута, а сервер по этому событию считает идентификатор. Разделение выходит такое: что произошло, знает клиент, кто это сделал – решает только сервер.

"use client";export function PageviewTracker() {  const pathname = usePathname();  const lastSent = useRef<string | null>(null);  const isFirstView = useRef(true);  useEffect(() => {    if (!pathname || ADMIN_PATH.test(pathname) || doNotTrackEnabled()) return;    if (lastSent.current === pathname) return;    // На первой странице визита referrer настоящий, дальше SPA-навигация    // его не меняет — подставляем предыдущий свой путь сами.    const referrer = isFirstView.current      ? document.referrer      : `${window.location.origin}${lastSent.current ?? ""}`;    // Метку берём только на первой странице визита: она отвечает на вопрос    // «откуда пришли», а не «где ходили». Иначе один переход из письма,    // после которого человек полистал каталог, засчитался бы как десяток.    const campaign = isFirstView.current      ? (new URLSearchParams(window.location.search).get("utm_campaign") ?? undefined)      : undefined;    lastSent.current = pathname;    isFirstView.current = false;    const body = JSON.stringify({ path: pathname, referrer, campaign });    try {      if (navigator.sendBeacon) {        navigator.sendBeacon("/api/track", new Blob([body], { type: "application/json" }));      } else {        void fetch("/api/track", { method: "POST", body, keepalive: true,          headers: { "content-type": "application/json" } });      }    } catch {      // Capture is best-effort — never disrupt navigation.    }  }, [pathname]);  return null;}

Два момента, которые всплыли на практике. Referrer в SPA живёт только до первой клиентской навигации – дальше document.referrer остаётся тем же самым, и без ручной подстановки предыдущего пути внутренние переходы выглядят как повторные входы из поиска. И sendBeacon вместо fetch – не из-за производительности, а потому что он доживает до конца выгрузки страницы: событие с последней страницы перед уходом иначе теряется.

Тут же честная граница метода. Это клиентский JS, и «нечего блокировать» – преувеличение: с выключенным JS визит не посчитается. Что реально меняется – запрос идёт на свой домен, /api/track, и в фильтрах блокировщиков его нет, потому что фильтры составлены по третьим доменам. Мимо проходит не «слепая зона неизвестного размера», а понятная и небольшая доля.

Приёмник: всё, что может пойти не так, идёт в 204

export async function POST(request: Request) {  // Respect Do-Not-Track at the edge — skip entirely, store nothing.  if (request.headers.get("dnt") === "1") return NO_CONTENT;  const limited = await withRateLimit(request, "track", { limit: 120, windowMs: 60_000 });  if (limited) return limited;  const parsed = schema.safeParse(await request.json().catch(() => null));  if (!parsed.success) return NO_CONTENT;  const path = normalisePath(parsed.data.path);  if (!isTrackablePath(path)) return NO_CONTENT;  // ...  await recordPageView({ /* ip, userAgent, ownHost, country из заголовков */ });  return NO_CONTENT;}

Правила приёмника простые и стоят того, чтобы их назвать:

  • DNT: 1 – выходим сразу, до всего остального. Проверка есть и на клиенте, но полагаться на клиент в таком вопросе неправильно.

  • Публичный beacon без авторизации – это открытая ручка на запись в вашу БД, поэтому rate-limit по IP обязателен.

  • Ответ всегда 204, ошибки БД глушатся. Аналитика не имеет права ронять страницу или мешать навигации.

  • Админка и /api/* в подсчёт не идут – иначе собственная работа в админке становится вашим самым популярным разделом.

Классификация – три regex-а по User-Agent и хосту: бот / устройство / источник. Ботов режем до вставки (иначе краулеры выглядят как трафик, и в этом году их особенно много), устройство и источник пишем в строку. Один неочевидный приоритет в классификации источника:

// Метка важнее referrer: почтовые клиенты его либо режут (тогда переход// выглядел бы «прямым»), либо подставляют свой веб-интерфейс (тогда// письмо засчиталось бы в «поиск» — mail.ru совпадает с шаблоном// поисковиков). Метку ставим мы сами, и она однозначна.if (campaign) return "email";if (!referrerHost) return "direct";

Определение источника по referrer-у ломается именно на почте, и это не редкий случай, а весь email-трафик целиком.

Тот же механизм в маркетплейсе: воронка для партнёра

Во втором проекте – маркетплейс услуг – тот же дневной HMAC (портирован файлом, только на pg вместо Prisma) обслуживает другую задачу: партнёр видит в своём кабинете воронку из четырёх шагов – показы карточек в листингах → просмотры → уникальные визиты → заявки, с конверсией между шагами.

Показ фиксируется через IntersectionObserver при видимости не менее половины карточки, с дедупом пары (компания, карточка) на сессию вкладки, чтобы прокрутка туда-сюда не накручивала счётчик. Транспорт тот же sendBeacon.

И здесь – самая полезная ошибка из всей этой истории. В первой версии visitorId присылал клиент. Так проще: у клиента уже есть sessionStorage, генерируй туда UUID и слай. Проблема в том, что цифры из этой воронки партнёр видит – а значит, у стороны, которая может подделать поле, появляется мотив его подделать: накрутить себе показы и просмотры ничего не стоит. Клиентский visitorId из API убрали, теперь он не принимается совсем, visitor_id считается на сервере тем же дневным HMAC, а у ботов остаётся NULL, чтобы не попадать в уникальных.

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

Где на самом деле появляется преимущество

Ожидаемый ответ – «сджойнить pageview с заказами». На практике самое ценное строится вообще без журнала визитов. Товарная аналитика в магазине собрана из данных, которые лежат в БД и так: серверная копия корзины, заказы, запросы «сообщить о поступлении».

  • Воронка корзина → заказ – сколько корзин с товаром создано и сколько из них превратилось в заказ, за день/неделю/месяц. Это состояние в базе, а не цепочка событий, поэтому цифра не зависит от того, доехал ли beacon.

  • Брошенные корзины в рублях – не «сколько корзин брошено», а сколько денег в них стоит. Порог простой: корзина активна и час без движения (тот же порог, что у первого письма о брошенной корзине – одно определение на аналитику и на рассылку, иначе цифры в отчёте и в письмах разъезжаются).

  • Бестселлеры и продажи по категориям – в единицах и в выручке, по оплаченным заказам.

  • Неудовлетворённый спрос – запросы «сообщить о поступлении» по товарам, которых нет в наличии. Прямой список того, что стоит привезти.

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

Ни один из этих вопросов внешний счётчик закрыть не может: он знает про страницы и клики, но не знает ваш каталог, ваши статусы заказов и ваши деньги. Журнал визитов при этом нужен – он отвечает на «откуда пришли и что смотрели», – но продуктовые ответы живут в предметных таблицах. Если строите своё, начинать стоит с серверной корзины, а не с журнала событий.

Про 152-ФЗ, без юридического оптимизма

Что снимается технически: в браузер ничего не записывается, значит нет cookie – значит нет и предмета для баннера согласия под аналитику. В базе нет ни IP, ни User-Agent, ни идентификатора, живущего дольше суток.

Что остаётся и о чём стоит говорить прямо: IP до момента хеширования всё равно проходит через ваш сервер, а вопрос «является ли необратимый хеш от IP персональными данными» – предмет обсуждения, а не решённый факт. Мы убираем сам источник проблемы – хранение, – а не выдаём себе индульгенцию. Формулировки политики – всё равно к юристу.

Отдельно: sessionStorage для дедупа показов в маркетплейсе – не cookie и не идентификатор посетителя, но это клиентское хранилище, и в строгих трактовках это тоже тема для разговора. Если хотите совсем чисто – дедуп переносится на сервер по (visitor_hash, card, день) ценой лишних запросов.

Честные ограничения

  • Точность идентификации грубее cookie. За одним NAT сидят десятки людей с похожими UA, у одного человека несколько устройств. Для трендов, источников и воронок этого достаточно, для точного cross-device – нет.

  • Ключ стабильный, ротируется только дневная метка. Значит человек с доступом к секрету и к списку IP+UA технически может пересчитать хеши за прошлые дни. Полностью это закрывается случайной солью, которая генерируется на сутки и потом выбрасывается; я сознательно выбрал стабильный секрет – при выбрасываемой соли любой перезапуск процесса или второй инстанс дают разные хеши в пределах одного дня, и «уникальные» ломаются. Кому важна именно эта угроза – соль надо хранить в общем месте (Redis, таблица) и удалять на следующий день.

  • Боты отсекаются по User-Agent. Это эвристика: честные краулеры представляются, нечестные – нет.

  • Никакой экосистемы. Вебвизора, карт кликов и готовых отчётов нет – есть только то, что вы построили. Ретаргетинг и look-alike у рекламных площадок завязаны на их же счётчики: нужны аудитории – счётчик всё равно ставится, параллельно своему.

  • Это код, который надо поддерживать. Своя аналитика – часть продукта со своими багами, миграциями и ростом таблицы событий, а не «вставил скрипт и забыл».

Кому не нужно

Если сайт – блог, визитка или лендинг без транзакций, а вопрос ограничивается «сколько зашло и откуда» – ставьте внешний счётчик и не усложняйте. Своё оправдано там, где данные лежат рядом с деньгами: магазин, маркетплейс, платформа – где нужны воронки в рублях, где цифры показывают третьей стороне, и где не хочется отдавать поведение клиентов наружу.

Итог

Весь механизм – это дневной HMAC вместо идентификатора, одна таблица событий с составными индексами, beacon на смену маршрута и приёмник, который на любую проблему отвечает 204. Пара сотен строк, ноль зависимостей, ничего в браузере. Основную ценность при этом дают не события, а то, что аналитика стоит на тех же таблицах, что заказы и корзины: вопросы вида «сколько денег зависло в брошенных корзинах» решаются запросом, а не интеграцией.

ссылка на оригинал статьи https://habr.com/ru/articles/1073296/