0% прочитано

Laravel Search Analytics: чому кожен GET /search не є новим пошуком

У ICanUp один пошуковий запит може породити кілька GET /search: submit, перемикання вкладки, pagination, infinite scroll або Inertia partial reload. Розбираю, як рахувати тільки реальний пошук, окремо бачити zero-result queries і не зберігати чутливі запити.

25 вересня 2026 р. 10 хв читанняЛаравель

Коли я додала Search Analytics у ICanUp, найпростішим рішенням здавалося рахувати кожен запит до /search.

Але один реальний пошук легко створює кілька HTTP requests. Користувач вводить запит і натискає Search. Потім перемикає вкладку з Posts на Tips. Доскролює результати. Відкриває другу сторінку. Inertia запитує тільки results. Браузер або frontend може повторити технічний request.

Якщо кожен такий GET вважати новим пошуком, аналітика починає вимірювати не поведінку користувача, а особливості реалізації інтерфейсу.

Головне правило цієї реалізації: Search Analytics рахує не URL request, а явну дію пошуку.

Один /search має кілька різних причин

На рівні HTTP ці запити можуть виглядати дуже схоже, але для аналітики означають різні речі.

Подія

Чи рахувати як новий пошук

Користувач ввів запит і натиснув Search

Так

Користувач перемкнув вкладку Posts на News або Tips

Ні

Infinite scroll завантажив наступні результати

Ні

Відкрилася наступна сторінка pagination

Ні

Inertia зробила partial request тільки за results

Ні

Користувач відкрив готовий /search?q=... URL напряму

Ні

Технічний reload або prefetch

Ні

Для мене це стало ключовою різницею між search request і search event.

Frontend явно позначає справжній submit

Я не намагалася вгадувати намір користувача лише з URL.

У SearchService є окремий submit(). Саме він додає заголовок X-Icanup-Search-Submit: 1.

Звичайний openIndex() цього маркера не додає, а loadMore() взагалі просить Inertia повернути тільки results.

JavaScript - Explicit submit is separate from navigation and load more
export default {    submit(filters = {}, options = {}) {        return this.openIndex(filters, {            ...options,            headers: {                ...(options.headers ?? {}),                "X-Icanup-Search-Submit": "1",            },        });    },     openIndex(filters = {}, options = {}) {        return router.get(            route("search.index"),            filters,            {

Якщо продукт знає, де відбулася значуща дія користувача, краще позначити її явно, ніж намагатися відновити намір із кожного HTTP request.

Backend не довіряє одному header

Сам маркер ще не означає, що request точно потрібно рахувати.

SearchIndexAction додатково перевіряє HTTP method, Inertia request, prefetch, наявність page і всі partial request headers.

Це важливо, тому що frontend state може змінюватися, а один заголовок легко випадково протягнути у наступний технічний request.

PHP - Server-side explicit submit guard
private function isExplicitSubmit(    Request $request,): bool {    if (        ! $request->isMethod('GET')        || $request->prefetch()        || (string) $request->header(            'X-Icanup-Search-Submit'        ) !== '1'        || ! $request->headers->has(            Header::INERTIA        )        || $request->query->has('page')    ) {        return false;    }
Навіть request із правильним X-Icanup-Search-Submit не рахується, якщо це pagination, Inertia partial request, prefetch або infinite-scroll merge.

Тільки після цього запускається Search Analytics

Ще одна деталь, яку я хотіла зберегти: аналітика не повинна окремо повторювати пошук тільки заради того, щоб дізнатися кількість результатів.

Спочатку SearchService створює SearchPage. Потім, тільки для справжнього submit, tracker отримує сам query, locale і totalAcrossProviders().

Тобто Search залишається власником пошукової роботи, а Analytics отримує вже готовий результат для вимірювання.

PHP - Track the explicit search using the existing SearchPage result
$searchPage = $this->searchService->page(    $type,    $query,); if (    $query !== ''    && $this->isExplicitSubmit($request)) {    $this->searchAnalyticsTracker->track(        query: $query,        locale: $this->application->getLocale(),        totalResults:            $searchPage->totalAcrossProviders(),    );}

Zero-result означає нуль у всіх типах контенту

Пошук у ICanUp має кілька типів контенту: Posts, Categories, News і Tips.

Тому нуль результатів у поточній вкладці ще не означає, що користувач нічого не знайшов взагалі. У Posts може бути 0, але в News або Tips можуть бути збіги.

Для Search Analytics я використовую totalAcrossProviders(). zero_result_count збільшується лише тоді, коли весь пошук не дав результату, а не коли порожня одна вкладка.

PHP - A real submit records one search and one zero-result search
function explicitSearchHeaders(    array $extra = [],): array {    return [        Header::INERTIA => 'true',        Header::VERSION => Inertia::getVersion(),        'X-Icanup-Search-Submit' => '1',        ...$extra,    ];} $this    ->withHeaders(        explicitSearchHeaders()    )    ->get(

Direct GET, tab switch і pagination не збільшують counter

Цю поведінку я зафіксувала не лише умовами в action, а й regression tests.

Direct URL, перемикання типу, порожня навігація та page 2 можуть успішно працювати як Search requests, але таблиця analytics_search_daily_aggregates після них залишається порожньою.

PHP - Navigation and pagination are not new searches
$this->get(    route(        'search.index',        [            'q' =>                'direct link',             'type' =>                'posts',        ],    ))->assertOk(); $this->get(    route(        'search.index',

Partial Inertia request теж не є новим пошуком

Infinite scroll у пошуку використовує partial reload: frontend просить тільки results.

Без окремого правила такий GET міг би виглядати як черговий search request і штучно збільшувати статистику щоразу, коли користувач просто догортає список.

PHP - Partial results request does not create Search Analytics
$this    ->withHeaders(        explicitSearchHeaders([            Header::PARTIAL_COMPONENT =>                'Public/Search/Index',             Header::PARTIAL_ONLY =>                'results',        ])    )    ->get(        route(            'search.index',            [                'q' =>                    'partial results',

Пошуковий текст потребує окремої privacy-межі

Search query відрізняється від звичайної технічної метрики. Людина може випадково вставити email, token, password assignment, API key або інший секрет.

Тому перед persistence запит проходить окрему нормалізацію і privacy validation.

Unit tests перевіряють нормалізацію whitespace, Unicode compatibility characters і case, а також відмову від empty та over-limit значень. Окремими datasets перевіряються email, JWT-like values, OpenAI-like, GitHub, GitLab і Slack tokens, Bearer token, password/API key assignments та high-entropy values.

Важлива поведінка: якщо query не підходить для Analytics, сам пошук не блокується. Не зберігається лише аналітичний запис.

PHP - Sensitive query works but is not persisted
$this    ->withHeaders(        explicitSearchHeaders()    )    ->get(        route(            'search.index',            [                'q' =>                    'person@example.com',                 'type' =>                    'posts',            ],        )    )
Search Analytics не повинна перетворювати пошукове поле на журнал секретів. Те, що користувач відправив рядок у Search, ще не означає, що його безпечно зберігати для статистики.

Search Analytics живе окремо від звичайних page views

Я не записую пошук як ще один звичайний page view metric.

Для нього є окремий daily aggregate із normalized query, query hash, locale, searches_count і zero_result_count.

Це дозволяє відповідати на питання "що люди шукають?" і "що вони не знаходять?" без змішування цих даних із загальною Content Analytics.

Regression test окремо перевіряє, що Search event не створює записи в загальних analytics_daily_aggregates та analytics_hourly_aggregates.

Search - основна функція для користувача. Analytics - допоміжна.

Якщо analytics repository тимчасово недоступний, я не хочу перетворювати робочий пошук на 500 тільки тому, що статистику не вдалося записати.

Ця межа теж зафіксована тестом: repository кидає exception, але public Search response все одно має залишитися успішним.

PHP - Search remains available when Analytics storage fails
$repository = Mockery::mock(    SearchAnalyticsRepositoryInterface::class); $repository    ->shouldReceive('increment')    ->once()    ->andThrow(        new RuntimeException(            'Search Analytics storage unavailable.'        )    ); app()->instance(    SearchAnalyticsRepositoryInterface::class,    $repository,

Аналітика не повинна повторювати дорогу пошукову роботу

Для zero-result analytics потрібна загальна кількість результатів у всіх providers.

Наївна реалізація могла б після основного пошуку ще раз пройти Posts, Categories, News і Tips тільки заради Analytics.

У тесті active provider виконує search() один раз, а його total повторно використовується замість додаткового count(). Інші providers рахуються тільки там, де це справді потрібно для загального total.

PHP - Reuse provider work while calculating the global result total
$posts = new SearchProviderFake(    SearchTypeEnum::POSTS,    4,); $categories = new SearchProviderFake(    SearchTypeEnum::CATEGORIES,    3,); $news = new SearchProviderFake(    SearchTypeEnum::NEWS,    2,); $tips = new SearchProviderFake(

Що в підсумку вважається одним пошуком

Після цього кейсу контракт став для мене значно чіткішим.

  1. Користувач явно відправляє непорожній search query.
  2. Frontend позначає саме submit, а не будь-яку навігацію по Search.
  3. Backend перевіряє, що це не prefetch, pagination, partial request або infinite scroll.
  4. Search виконується звичайним шляхом і формує результати.
  5. Analytics отримує normalized query, locale і загальний result count.
  6. Sensitive або неприйнятний для analytics query не зберігається.
  7. Якщо запис Analytics не вдався, Search продовжує працювати.

Так searches_count починає означати реальні пошукові дії, а не кількість технічних GET requests, які випадково пройшли через один endpoint.

Для продуктової аналітики важливіше правильно визначити подію, ніж просто мати зручне місце в controller, де можна збільшити counter.

Висновок

Search Analytics у ICanUp почалася не з dashboard і не з SQL aggregate. Спочатку потрібно було визначити, що саме вважати пошуком.

Один GET /search може означати submit, direct link, tab switch, pagination або partial update. Тому endpoint сам по собі не є достатньою аналітичною подією.

Після того як submit став явним, решта архітектури стала простішою: окремий tracker, normalized queries, zero-result counter, privacy filtering, власне aggregate storage і fail-safe поведінка.

У результаті цифра "кількість пошуків" означає те, що від неї очікує людина, а не те, скільки HTTP requests створив frontend.