When I added Search Analytics to ICanUp, the simplest approach seemed to be counting every request to /search.
But one real search can easily generate several HTTP requests. A user enters a query and presses Search. Then they switch from Posts to Tips, scroll for more results, open another page, or trigger an Inertia request that asks only for results.
If every GET becomes a new search event, analytics starts measuring frontend implementation details instead of user search intent.
One /search URL Can Have Several Different Causes
At the HTTP level these requests can look very similar, but they mean different things for analytics.
Event | Count as a new search |
|---|---|
The user enters a query and presses Search | Yes |
The user switches from Posts to News or Tips | No |
Infinite scroll loads more results | No |
Pagination requests another page | No |
Inertia requests only the results prop | No |
The user opens an existing /search?q=... URL directly | No |
A technical reload or prefetch occurs | No |
That became the important distinction for me between a search request and a search event.
The Frontend Explicitly Marks a Real Search Submit
I did not try to infer user intent from the URL alone.
SearchService has a separate submit() method. Only that path adds the X-Icanup-Search-Submit: 1 header.
The normal openIndex() path does not add the marker, while loadMore() explicitly asks Inertia for only the results prop.
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, {If the product knows where meaningful user intent occurs, it is better to mark that event explicitly than to reconstruct intent from every HTTP request.
The Backend Does Not Trust One Header Alone
The marker alone is still not enough to count the request.
SearchIndexAction also validates the HTTP method, Inertia context, prefetch state, the page parameter, and the partial-request headers.
This matters because frontend state can evolve and a single header can accidentally propagate into a later technical 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 Runs Only After the Search Has Been Resolved
I also wanted to avoid executing the search again only so Analytics could learn how many results existed.
SearchService first creates the SearchPage. Only for a real submit does the tracker receive the query, locale, and totalAcrossProviders().
Search remains responsible for search work, while Analytics receives the already resolved information needed for measurement.
$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 Means Zero Across All Search Providers
Search in ICanUp spans multiple content types: Posts, Categories, News, and Tips.
Zero results in the active tab does not necessarily mean the user found nothing. Posts may have zero matches while News or Tips still contain useful results.
Search Analytics therefore uses totalAcrossProviders(). zero_result_count increases only when the whole search has no result, not merely when one tab is empty.
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 Changes and Pagination Do Not Increase the Counter
I did not leave this behavior only as conditions inside the action. It is also part of the regression tests.
A direct URL, type switch, empty navigation, and page 2 can all work as valid Search requests while analytics_search_daily_aggregates remains unchanged.
$this->get( route( 'search.index', [ 'q' => 'direct link', 'type' => 'posts', ], ))->assertOk(); $this->get( route( 'search.index',An Inertia Partial Request Is Not a New Search Either
Search infinite scrolling uses a partial reload where the frontend asks only for results.
Without an explicit rule, that GET could look like another search request and inflate the statistics every time the user simply continues scrolling.
$this ->withHeaders( explicitSearchHeaders([ Header::PARTIAL_COMPONENT => 'Public/Search/Index', Header::PARTIAL_ONLY => 'results', ]) ) ->get( route( 'search.index', [ 'q' => 'partial results',Search Text Needs Its Own Privacy Boundary
A search query is different from an ordinary technical metric. A person can accidentally paste an email address, token, password assignment, API key, or another secret into Search.
The query therefore passes through separate normalization and privacy validation before persistence.
Unit tests cover whitespace normalization, Unicode compatibility characters, case normalization, empty and over-limit values, plus email addresses, JWT-like values, OpenAI-like, GitHub, GitLab and Slack tokens, Bearer tokens, password/API key assignments, and high-entropy values.
If a query is unsuitable for Analytics, Search itself still works. Only the analytics persistence is skipped.
$this ->withHeaders( explicitSearchHeaders() ) ->get( route( 'search.index', [ 'q' => 'person@example.com', 'type' => 'posts', ], ) )Search Analytics Is Separate From Ordinary Page Views
I do not store a search as another ordinary page-view metric.
Search has its own daily aggregate built around the normalized query, query hash, locale, searches_count, and zero_result_count.
This makes it possible to answer "what are people searching for?" and "what are they failing to find?" without mixing those concepts into general Content Analytics.
A regression test also verifies that a Search event does not create rows in the general analytics_daily_aggregates or analytics_hourly_aggregates tables.
Analytics Failure Must Not Break Search
Search is the user-facing capability. Analytics is secondary.
If the analytics repository becomes temporarily unavailable, a working search should not turn into a 500 only because statistics could not be persisted.
This boundary is also covered by a test: the repository throws an exception while the public Search response must still remain successful.
$repository = Mockery::mock( SearchAnalyticsRepositoryInterface::class); $repository ->shouldReceive('increment') ->once() ->andThrow( new RuntimeException( 'Search Analytics storage unavailable.' ) ); app()->instance( SearchAnalyticsRepositoryInterface::class, $repository,Analytics Should Not Repeat Expensive Search Work
Zero-result analytics needs the total number of matches across every provider.
A naive implementation could search Posts, Categories, News, and Tips again only for Analytics.
The test verifies that the active provider performs search() once and its paginator total is reused instead of issuing another count(). Other providers are counted only where the global total requires it.
$posts = new SearchProviderFake( SearchTypeEnum::POSTS, 4,); $categories = new SearchProviderFake( SearchTypeEnum::CATEGORIES, 3,); $news = new SearchProviderFake( SearchTypeEnum::NEWS, 2,); $tips = new SearchProviderFake(What Finally Counts as One Search
After this case, the contract became much clearer to me.
- The user explicitly submits a non-empty search query.
- The frontend marks the submit itself rather than every Search navigation.
- The backend verifies that the request is not prefetch, pagination, a partial request, or infinite scrolling.
- Search runs normally and produces its results.
- Analytics receives the normalized query, locale, and global result count.
- A sensitive or unsuitable analytics query is not persisted.
- If Analytics persistence fails, Search continues to work.
At that point searches_count represents actual search actions instead of the number of technical GET requests that happened to pass through one endpoint.
For product analytics, defining the event correctly matters more than finding a convenient controller location where a counter can be incremented.
Conclusion
Search Analytics in ICanUp did not start with a dashboard or an SQL aggregate. The first problem was defining what a search actually is.
One GET /search can represent a submit, direct link, tab change, pagination, or partial update. The endpoint itself is therefore not a sufficient analytics event.
Once the submit became explicit, the rest of the architecture became simpler: a dedicated tracker, normalized queries, zero-result counting, privacy filtering, separate aggregate storage, and fail-safe behavior.
The final "number of searches" now represents what a person expects that metric to mean, not how many HTTP requests the frontend happened to generate.



