AACsearch
SDKsRecetario

Click analytics

Track which results users actually click and which queries lead to conversions — feeds the AACsearch relevance tuner and your own funnel dashboards.

Search relevance is only as good as the signal you feed back into it. AACsearch accepts events on POST /api/events/track for click-through rate, conversions, and zero-results queries. This recipe shows how to wire them in without slowing down the click.

What you track

EventFired whenWhy
search_queryA search returns resultsQuery volume, top queries
zero_resultsSearch returns found: 0Find queries that need synonyms or content
result_clickUser clicks a resultCTR per query, per position
conversionUser completes a goal (add to cart, purchase)Tie revenue back to the originating query
filter_usedUser toggles a facetMost-used filters; UI prioritization

The relevance tuner uses result_click and conversion to demote results that are clicked but never converted, and to promote results that convert above their CTR baseline.

Send an event

async function trackEvent(event: string, properties: Record<string, unknown>) {
	await fetch("/api/events/track", {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: JSON.stringify({
			event,
			properties: {
				...properties,
				timestamp: new Date().toISOString(),
				sessionId: getOrCreateSessionId(),
			},
		}),
		// keepalive lets the request survive page navigation
		keepalive: true,
	});
}

The endpoint accepts the request and returns 202 immediately — events are queued, not synchronously processed.

Click tracking with queryId

Every search response includes a queryId you should attach to subsequent clicks so AACsearch can join the click back to the originating search.

const result = await client.search({ q: "shoes", queryBy: "title" });
// result.queryId: "qry_abc123..."

// Render results
result.hits.forEach((hit, position) => {
	renderCard({
		product: hit.document,
		onClick: () => {
			trackEvent("result_click", {
				queryId: result.queryId,
				query: "shoes",
				documentId: hit.document.id,
				position, // 0-indexed
				indexSlug: "products",
			});
		},
	});
});

The position field is critical — without it, you cannot compute click position bias correction.

Use navigator.sendBeacon for SPA navigation

If the click navigates away (the most common case), the standard fetch may be cancelled by the browser before it sends. Three reliable options:

function trackClickThenNavigate(url: string, payload: object) {
	if ("sendBeacon" in navigator) {
		navigator.sendBeacon("/api/events/track", JSON.stringify(payload));
	} else {
		// Fallback: fire-and-forget fetch with keepalive
		fetch("/api/events/track", {
			method: "POST",
			body: JSON.stringify(payload),
			keepalive: true,
		});
	}
	window.location.href = url;
}

sendBeacon is fire-and-forget and survives unload. Use it for click-tracking; use fetch with keepalive for events that have a body too large for sendBeacon (~64 KB limit).

Conversion tracking

A conversion is fired by the page that completes the user's goal — checkout success, add-to-cart, signup, etc. Pass the originating queryId through navigation:

// On the search results page
<Link href={`/products/${id}?qid=${result.queryId}`}>...</Link>;

// On the product page
const queryId = useSearchParams().get("qid");

function onAddToCart() {
	trackEvent("conversion", {
		queryId,
		documentId: id,
		conversionType: "add_to_cart",
		value: product.price,
		currency: "USD",
	});
}

For multi-step funnels (search → product → cart → checkout), persist the queryId in sessionStorage so it survives all the hops.

Server-side rendering

If your search page is server-rendered, you cannot fire result_click from the server (you do not know which result the user will click). Instead:

  1. Render data-qid={result.queryId} and data-pos={i} on each result link.
  2. Wire a click handler in a small client-side script that reads those attributes and fires the event.
<a href={`/products/${id}`} data-qid={result.queryId} data-pos={i} className="result-link">
	{title}
</a>;

// One global listener, mounted once
document.addEventListener("click", (e) => {
	const target = (e.target as HTMLElement).closest("a.result-link");
	if (!target) return;
	const qid = target.getAttribute("data-qid");
	const pos = Number(target.getAttribute("data-pos"));
	const docId = target.getAttribute("href")?.split("/").pop();
	navigator.sendBeacon(
		"/api/events/track",
		JSON.stringify({
			event: "result_click",
			properties: { queryId: qid, position: pos, documentId: docId },
		}),
	);
});

Privacy

Events accept arbitrary properties. Do not put PII (email, phone, full names) in there — they are stored for relevance analysis, not for user profiling. Specifically:

  • userId: "user_abc" (opaque internal ID)
  • country: "DE"
  • email: "..."
  • address: "..."

If your downstream analytics needs PII, store it elsewhere and join on userId.

Event ingestion is rate-limited per org at 1000 events/sec. For very high volumes, batch with the events:batch endpoint (max 1000 events per call).

Inspecting the data

Open Search → Analytics → "Top queries" and "Top zero-result queries". The relevance tuner picks up signals after ~24 hours of data; for fresh dashboards check Search → Analytics → "Recent events" (5-minute lag).

On this page