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

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

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

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

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

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

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;
}

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