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