Коли я додала Search Analytics у ICanUp, найпростішим рішенням здавалося рахувати кожен запит до /search.
Але один реальний пошук легко створює кілька HTTP requests. Користувач вводить запит і натискає Search. Потім перемикає вкладку з Posts на Tips. Доскролює результати. Відкриває другу сторінку. Inertia запитує тільки results. Браузер або frontend може повторити технічний request.
Якщо кожен такий GET вважати новим пошуком, аналітика починає вимірювати не поведінку користувача, а особливості реалізації інтерфейсу.
Один /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.
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.
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; }Тільки після цього запускається Search Analytics
Ще одна деталь, яку я хотіла зберегти: аналітика не повинна окремо повторювати пошук тільки заради того, щоб дізнатися кількість результатів.
Спочатку SearchService створює SearchPage. Потім, тільки для справжнього submit, tracker отримує сам query, locale і totalAcrossProviders().
Тобто Search залишається власником пошукової роботи, а Analytics отримує вже готовий результат для вимірювання.
$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 збільшується лише тоді, коли весь пошук не дав результату, а не коли порожня одна вкладка.
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 після них залишається порожньою.
$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 і штучно збільшувати статистику щоразу, коли користувач просто догортає список.
$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, сам пошук не блокується. Не зберігається лише аналітичний запис.
$this ->withHeaders( explicitSearchHeaders() ) ->get( route( 'search.index', [ 'q' => 'person@example.com', 'type' => 'posts', ], ) )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.
Analytics не повинна ламати сам Search
Search - основна функція для користувача. Analytics - допоміжна.
Якщо analytics repository тимчасово недоступний, я не хочу перетворювати робочий пошук на 500 тільки тому, що статистику не вдалося записати.
Ця межа теж зафіксована тестом: repository кидає exception, але public Search response все одно має залишитися успішним.
$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.
$posts = new SearchProviderFake( SearchTypeEnum::POSTS, 4,); $categories = new SearchProviderFake( SearchTypeEnum::CATEGORIES, 3,); $news = new SearchProviderFake( SearchTypeEnum::NEWS, 2,); $tips = new SearchProviderFake(Що в підсумку вважається одним пошуком
Після цього кейсу контракт став для мене значно чіткішим.
- Користувач явно відправляє непорожній search query.
- Frontend позначає саме submit, а не будь-яку навігацію по Search.
- Backend перевіряє, що це не prefetch, pagination, partial request або infinite scroll.
- Search виконується звичайним шляхом і формує результати.
- Analytics отримує normalized query, locale і загальний result count.
- Sensitive або неприйнятний для analytics query не зберігається.
- Якщо запис 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.



