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

Писать просмотры я решил из браузера, а не серверным middleware, и главным доводом было вот это: боты JS не исполняют, значит краулеры, сканеры и монитор аптайма в статистику не попадут сами. Через три недели в таблице лежало 151 просмотр, из них 89 - с поднятым флагом бота. Довод оказался неверным. Трекинг из браузера при этом остался, и ниже - почему, плюс пять остальных ловушек, на которые я наступил по дороге.

Устройство: браузер присылает адрес, остальное сервер берёт сам

В общий шаблон подключён небольшой скрипт. Он делает один POST при показе страницы и второй, маленький, при уходе с неё:

fetch('/analytics/pageview', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        url: window.location.pathname,
        referrer: document.referrer || '',
        lang: navigator.language || '',
        client_id: clientId(),
    }),
    credentials: 'same-origin',
})
    .then(function (r) { return r.json(); })
    .then(function (d) { pvId = d && d.id ? d.id : null; })
    .catch(function () {});

Сервер отвечает идентификатором созданной записи - он понадобится, чтобы дописать в неё время на странице. Строка ложится в таблицу page_views: адрес, язык, реферер и его тип, IP, страна, устройство, браузер, ОС, utm-метки, идентификатор сессии и браузера.

Правило, которое я держал с самого начала: из тела запроса беру только то, что знает один браузер - путь, реферер, язык, utm-метки, идентификатор из localStorage. IP, User-Agent, страну и сессию сервер берёт у себя. Тело подделывается тривиально, соединение - нет. Идентификатор браузера при этом ещё и проверяется на формат:

private function clientId(Request $request): ?string
{
    $cid = (string) $request->input('client_id', '');

    return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $cid)
        ? strtolower($cid)
        : null;
}

Без проверки в колонке рано или поздно окажется чужая строка на 500 символов. Сам идентификатор живёт в localStorage, а не в сессии: сессия истекает за пару часов, и вернувшийся через день посетитель считался бы новым. В приватном режиме localStorage бросает исключение - там просто остаёмся без сквозного идентификатора, это нормальный режим работы, а не ошибка.

Ловушка 1: боты исполняют JS

Это главное, что я узнал. Разбор 89 ботских записей по разобранному User-Agent:

Chrome / Android / mobile / US   29
Chrome / -       / desktop / US  22
Chrome / -       / desktop / RU  20
Chrome / Linux   / desktop / CA   5
Chrome / Windows / desktop / US   4
Chrome / macOS   / desktop / CA   4
Chrome / Android / mobile / CA    3

Ни одного curl, ни одного python-requests. Всё - браузеры на движке Chrome, треть из них мобильные, и больше половины с американских адресов. То есть JS исполняли именно поисковые краулеры: Google рендерит страницы в Chrome уже лет десять, Яндекс тоже, и превью-краулеры мессенджеров туда же. Заходили они, кстати, осмысленно: 67 раз на главную и 9 раз на последнюю статью - ровно ту, что была свежей.

Вывод простой и неприятный: браузерный трекинг отсекает скрипты, но не краулеры. Фильтр по User-Agent нужен ровно так же, как в серверном варианте. У меня он уже был написан для статистики резюме, и я переиспользовал тот же список маркеров - иначе на сайте было бы два разных представления о том, кто тут бот.

Отдельно - моя же ошибка в схеме. В page_views нет колонки с сырым User-Agent: только флаг и разобранные поля. Колонку bot_name я в миграции завёл, а писать в неё забыл. В итоге я знаю, что запись ботская, но не знаю, чей это бот, и восстанавливаю это по стране и разбору. Если делать заново - сохранял бы сырой User-Agent хотя бы для тех записей, где флаг поднят: места это почти не занимает, а разбор всегда можно переделать только на будущих данных.

Ловушка 2: Chrome открывает страницы заранее

Браузер предугадывает переходы и подгружает страницу до клика. JS в предзагруженной странице исполняется, а человек её не видел - и счётчик получает просмотр, которого не было. Лечится штатным API:

if (document.prerendering) {
    document.addEventListener('prerenderingchange', registerView, { once: true });
} else if (document.visibilityState === 'hidden') {
    document.addEventListener('visibilitychange', function onVisible() {
        if (document.visibilityState !== 'hidden') {
            document.removeEventListener('visibilitychange', onVisible);
            registerView();
        }
    });
} else {
    registerView();
}

Две ветки, потому что случаи разные: document.prerendering - это именно предрендер, а скрытая вкладка бывает и от фонового открытия по средней кнопке мыши. Просмотр регистрируем только в момент реального показа.

И тут же вторая деталь: отсчёт времени на странице должен начинаться не при загрузке скрипта, а при показе. Поэтому start = Date.now() стоит внутри registerView. Иначе страница, предзагруженная за пять минут до клика, отдаст пять минут «чтения».

Ловушка 3: один показ, две записи

Повторный вызов на той же странице случается и без вашего участия: возврат по кнопке «назад» поднимает страницу из bfcache, а в некоторых сценариях скрипт срабатывает дважды за один показ. Дубли ловятся коротким окном:

$duplicate = PageView::where('session_id', $sessionId)
    ->where('url', $url)
    ->where('created_at', '>=', now()->subSeconds(5))
    ->value('id');

if ($duplicate) {
    return response()->json(['id' => $duplicate]);
}

Важна не только проверка, но и ответ: возвращаем прежний идентификатор, а не null. Иначе пинг с временем на странице уйдёт в пустоту и запись останется без времени.

Ловушка 4: эндпоинт открыт, и это не мелочь

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

private function fromOwnSite(Request $request, string $host): bool
{
    $source = $request->headers->get('Origin') ?: $request->headers->get('Referer');

    if (! $source) {
        return false;
    }

    $sourceHost = strtolower((string) parse_url($source, PHP_URL_HOST));

    return $sourceHost !== ''
        && preg_replace('/^www\./', '', $sourceHost) === preg_replace('/^www\./', '', strtolower($host));
}

Плюс throttle:120,1 на маршруте. Скажу честно: от целенаправленной накрутки это не защищает - заголовки подделываются в одну строку. Это фильтр от шума: случайных сканеров, чужих ботов, дёргающих найденный в исходниках адрес, и собственных ошибок вроде запроса из консоли. Для статистики личного сайта достаточно; для счётчика, по которому платят деньги, нужна подпись запроса.

Ловушка 5: время на странице теряется у каждого третьего

Время отправляется при уходе со страницы, и отправлять его надо не обычным запросом, а маячком - обычный браузер успеет отменить:

var fd = new FormData();
fd.append('pv_id', pvId);
fd.append('time', elapsed);
navigator.sendBeacon('/analytics/ping', fd);

Слушать надо visibilitychange и pagehide, а не unload: unload в Safari не срабатывает и вдобавок мешает bfcache. Но даже с маячком доходит не всё. В моих данных время на странице заполнено у 35 записей из 62 - то есть у 44% человеческих просмотров его нет вообще. Закрыли ноутбук крышкой, убили вкладку, потеряли сеть.

Отсюда два следствия для отчёта. Среднее считать только по заполненным значениям (whereNotNull('time_on_page')), иначе пропуски утянут его вниз. И отказ определять по числу страниц в визите, а не по времени: визит из одной страницы - отказ, а «ноль секунд» чаще всего означает не мгновенный уход, а не доехавший маячок. Среднее по тому, что доехало, у меня 22 секунды.

Ловушка 6: собственные служебные адреса

В топе страниц появились /analytics/pageview и /analytics/ping. Человек их не открывал никогда - это счётчик записал сам себя. Правится одной проверкой:

if (str_starts_with($url, '/analytics/')) {
    return response()->json(['id' => null]);
}

Урок не про трекинг, а про любые самописные метрики: в первый же день откройте отчёт и поищите в нём собственные служебные адреса. Они там будут.

Агрегаты: кеш на десять минут и версия структуры в ключе

Сводка считает около двадцати разрезов, и половина из них - COUNT(DISTINCT session_id). На каждый заход в админку это дорого, поэтому результат кешируется целиком на десять минут, с кнопкой принудительного пересчёта.

А вот грабли, которые ждут любой кешированный отчёт. Я добавил в сводку новые поля - тепловую карту по часам и точки входа. Код готов, вьюха готова, а страница падает с Undefined variable $heatmap: отчёт уходит во вьюху массивом и разворачивается в переменные, а из кеша приходит структура, собранная старой версией метода, и новых ключей в ней нет. Живёт это ровно до истечения TTL, и именно поэтому выглядит как привидение. Лечение - версия структуры прямо в ключе:

/** Меняется при добавлении полей в data(): старый кеш тогда просто не читается */
private const STRUCTURE_VERSION = 'v2';

public function cached(?Carbon $from, ?Carbon $to, bool $fresh = false): array
{
    $key = 'traffic_report_'.self::STRUCTURE_VERSION.'_'
        .md5(($from?->toDateString() ?? 'all').'|'.($to?->toDateString() ?? 'all'));
    ...
}

Меняешь набор полей - меняешь константу, и старые записи просто перестают читаться. Это надёжнее, чем помнить про cache:clear в деплое.

Две другие вещи в отчёте я тоже сделал не сразу. Первая: в кеше лежат обычные массивы, а не Eloquent-коллекции. Строки агрегатов - это не записи таблицы, модель им не нужна, а в кеше она лишний вес и лишняя привязка к классу, который ещё будет меняться. Вторая: отказы и точки входа считает база, а не PHP:

$singlePage = DB::query()->fromSub(
    $human()->select('session_id')->groupBy('session_id')->havingRaw('COUNT(*) = 1'),
    'single'
)->count();

Соблазн выгрузить сессии в коллекцию и посчитать в PHP велик, но на периоде в год это десятки тысяч значений в памяти и в IN (...). Подзапрос делает то же самое, не вынося данные из базы.

Что вышло

За три недели: 62 человеческих просмотра, 49 визитов, 38 уникальных браузеров. Источники - 47 переходов напрямую, 7 внутренних, 6 с внешних сайтов и 2 из поиска. Масштаб, на котором о выводах говорить смешно, - и ровно поэтому хорошо видно устройство: на таких числах каждая ложная запись заметна поимённо, а на сайте с миллионом просмотров и prerender, и собственные служебные адреса растворились бы в шуме.

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