Search Analytics & Click Tracking
Search is the one part of a product that tells you, in the user’s own words, exactly what they wanted — and most products throw that away. This area is about capturing it properly: an event model that supports the questions you will actually ask, the handful of metrics that reveal search health, and the privacy discipline that keeps query logs from becoming a liability. It sits within search frontend and UX patterns and feeds directly into relevance evaluation, which consumes these events as training and judgment data.
One framing to carry through everything below: search analytics is not web analytics applied to a search page. The unit of analysis is the search, not the pageview, and the questions are about whether a specific intent was satisfied rather than about traffic. Tooling built for the second rarely answers the first, which is why a purpose-built event model is worth the modest effort even where a general analytics product is already in place.
Prerequisites
- A place to send events with a schema you control — a warehouse table, a stream, or an analytics product you can query freely.
- Agreement on what counts as search success for your product, which is rarely just a click.
- A privacy position on query text, decided before the first event ships rather than after a review.
- The ability to add a request identifier to the search response and carry it through the UI.
Concept Deep-Dive: the event model that answers later questions
Almost every difficult analytics question comes down to whether three things can be joined: what was searched, what was shown, and what happened next. Log those as separate events sharing a request identifier and nearly every question becomes a query; log a single denormalised “search” event and most of them become impossible.
The search event carries the query, the filters applied, the result count, the latency, and the configuration identifiers — which ranking version, which experiment arm. It is emitted server-side, because the client does not know the configuration and can be blocked.
The impression events carry which documents were shown and at what positions. They are emitted client-side on render, ideally when a result actually enters the viewport rather than when it is included in the response, because a result nobody scrolled to was not really shown.
The interaction events carry clicks, and — where the product has them — the downstream actions that indicate the search actually worked: add to cart, subscribe, download, open. Interactions are where success is defined, and the distinction between a click and a downstream action is the difference between “looked promising” and “was correct”.
The joining key ties all three together. Without it, a click cannot be attributed to the result set that produced it, and every metric downstream is an approximation.
Step-by-Step Implementation
1. Emit the search event server-side with a generated identifier
# search_handler.py — the identifier is created here and returned to the client
def handle_search(request):
request_id = str(uuid.uuid4())
t0 = time.monotonic()
results = engine.search(request.query, filters=request.filters, size=20)
analytics.emit("search.executed", {
"request_id": request_id,
"query_hash": sha256(normalise(request.query)),
"query_length": len(request.query.split()),
"filter_count": len(request.filters),
"result_count": results["total"],
"latency_ms": round((time.monotonic() - t0) * 1000, 1),
"ranking_version": CONFIG_VERSION,
"experiment_arm": request.arm,
})
return {"request_id": request_id, "results": results["hits"]}
Verify: every search response contains a request identifier, and the emitted event count matches the request count over a window.
2. Emit impressions on viewport entry, not on render
// impressions.js — a result is "shown" when the user could actually see it
const seen = new Set();
const observer = new IntersectionObserver((entries) => {
const batch = [];
for (const e of entries) {
if (!e.isIntersecting) continue;
const { docId, position } = e.target.dataset;
if (seen.has(docId)) continue;
seen.add(docId);
batch.push({ doc_id: docId, position: Number(position) });
}
if (batch.length) analytics.emit("search.impression", { request_id, shown: batch });
}, { threshold: 0.5 });
Verify: on a page showing ten results where the user scrolls to five, exactly five impressions should be recorded — not ten.
3. Record clicks with enough context to be useful later
function onResultClick(docId, position) {
analytics.emit("search.click", {
request_id, doc_id: docId, position,
ms_since_results: Date.now() - resultsRenderedAt,
});
}
Verify: every click event joins to an impression event for the same document and request. Orphan clicks mean the impression logging is missing cases.
4. Close the loop with a downstream success event
// The action that means search worked, whatever it is for your product.
analytics.emit("search.success", { request_id, doc_id, kind: "add_to_cart" });
Verify: the success rate per search should be stable day to day. A step change without a deploy usually means an instrumentation break rather than a behaviour change.
5. Build the four metrics that matter
-- One query, four numbers, grouped by day.
SELECT date_trunc('day', s.ts) AS day,
count(*) AS searches,
avg(CASE WHEN s.result_count = 0 THEN 1 ELSE 0 END) AS zero_result_rate,
avg(CASE WHEN c.request_id IS NOT NULL THEN 1 ELSE 0 END) AS click_through_rate,
avg(CASE WHEN x.request_id IS NOT NULL THEN 1 ELSE 0 END) AS success_rate
FROM search_executed s
LEFT JOIN LATERAL (SELECT 1 FROM search_click WHERE request_id = s.request_id LIMIT 1) c ON true
LEFT JOIN LATERAL (SELECT 1 FROM search_success WHERE request_id = s.request_id LIMIT 1) x ON true
GROUP BY 1 ORDER BY 1;
Verify: the four numbers should move together in sensible ways. Click-through rising while success falls is a real and important signal — results look more appealing and are less correct.
Privacy is a design constraint, not a review step
Search queries are among the most sensitive data a product collects. Users type names, addresses, symptoms, order numbers and things they would not say aloud, and they do so without any sense that it is being recorded. Treating query logs like ordinary telemetry is how a search analytics pipeline becomes a compliance problem.
The default position that works for almost every product is to hash the query for grouping and never store the raw text in the analytics path. Hashing preserves everything the common analyses need — repeat rates, zero-result grouping, click-through by query, trending terms via a separate curated list — and removes the ability to read what any individual searched for.
Where raw text is genuinely required, and it sometimes is for zero-result triage, keep it in a separate store with a short retention period, explicit access control, and no join to user identity. The difference between “we keep hashed queries indefinitely and raw queries for fourteen days behind an access request” and “queries are in the warehouse” is the difference between a defensible design and an incident waiting for a subject access request.
Two further habits cost nothing. Strip anything that looks like an identifier — email addresses, long digit sequences, card-shaped numbers — before the event leaves the server, because users paste them into search boxes constantly. And exclude query text from traces entirely, since traces are retained longer and shared far more widely than logs.
Configuration Reference
| Name | Default | Type | Effect |
|---|---|---|---|
request_id |
none | uuid | Join key across all three event types. Without it, no attribution is possible and most analysis is unavailable. |
query_hash |
none | string | Hashed query for grouping repeated searches without retaining the text. The default position for any product handling personal data. |
impression_threshold |
0.5 |
float | Viewport fraction at which a result counts as seen. Lower over-reports; higher under-reports on small screens. |
dwell_threshold_s |
20 |
integer | Seconds after a click before it counts as engaged rather than a bounce. Domain-specific and worth calibrating. |
sample_rate |
1.0 |
float | Fraction of searches instrumented. Full sampling is usually affordable and avoids the analysis errors sampling introduces. |
retention_days |
none | integer | How long raw events are kept. Aggregates can live indefinitely; raw query-linked events usually should not. |
The reports that earn their keep
Four recurring reports cover most of what a search team needs, and building them once removes the ad-hoc analysis that otherwise consumes an engineer every week.
Top zero-result queries, grouped and counted, is the work queue described in reducing zero-result searches. It is the highest-value report and the easiest to build.
Top queries by volume with their click-through and success rates identifies the head queries where a small relevance improvement affects many users. A high-volume query with unusually low success is the best possible target for manual attention.
Queries whose results changed most after a deploy turns a ranking change from an abstraction into a list a merchandiser can review. Comparing the top ten before and after for the thousand most common queries takes minutes to compute and catches regressions no aggregate would show.
Filter and facet usage answers a question that comes up in every redesign: which refinements do users actually apply? The answer is almost always that two or three carry nearly all the usage, which justifies removing the rest and speeds up every query.
None of these requires anything beyond the three event types already described, and each one replaces a recurring manual investigation with a link.
A fifth report is worth adding once the first four are in place: searches that produced results but no click, grouped by query. These are the quiet failures — search returned something, the user looked, and nothing was worth opening. They are harder to act on than zero-result queries because the cause is relevance rather than matching, but they are where the remaining improvement lives once the obvious failures are gone.
Failure Modes & Debugging
Click-through rate rises while conversion falls
Symptom: a ranking change improves click-through by several points and revenue per search drops.
Root cause: the change promoted results that attract clicks without satisfying intent — cheaper items, more striking images, misleading titles.
Remediation: this is exactly why the success event exists. Gate ranking changes on the downstream metric rather than on clicks, and treat a divergence between the two as the signal it is.
Impressions far exceed what users could have seen
Symptom: every search records twenty impressions regardless of scroll behaviour.
Root cause: impressions are emitted when results are rendered rather than when they enter the viewport, so results below the fold count as shown.
Remediation: move to an intersection observer. The distinction matters enormously for click-through calculations and for the position-bias correction used when deriving judgments.
Zero-result rate looks implausibly low
Symptom: the metric sits near zero on a corpus where users certainly search for things that do not exist.
Root cause: usually a fallback that quietly broadens the query when nothing matches, so the recorded result count is never zero.
Remediation: record both the original result count and whether a fallback fired. The fallback is good product behaviour and terrible instrumentation if it hides the signal.
Events stop arriving from one platform
Symptom: overall volume looks normal; a specific platform’s share drops to zero after a release.
Root cause: client-side instrumentation broken by a refactor, ad blocking, or a consent change — and the aggregate hides it because other platforms compensate.
Remediation: alert on per-platform event volume rather than the total, and reconcile client-side impression counts against server-side search counts as a continuous check.
Who reads these numbers, and when
Search analytics fails most often for organisational rather than technical reasons: the events exist, the dashboard exists, and nobody looks at it. Three habits prevent that.
Attach a small number of metrics to an existing ritual rather than creating a new one. Zero-result rate and success rate in a weekly product review get read; a dedicated search dashboard does not. The bar for a metric earning a slot in someone else’s meeting is high, which is a useful filter.
Give the zero-result queue an owner and a cadence. It generates concrete, finishable work — add a synonym, fix an analyzer, tell merchandising about missing demand — and a queue with an owner gets worked while a report with an audience does not.
Publish the ranking diff after every relevance deploy, unprompted. It is the artefact that makes non-engineers able to participate in relevance work, and their participation is what keeps the judgment set connected to what the business actually wants.
The common thread is that these numbers are only valuable when they change someone’s behaviour. A metric nobody acts on is instrumentation cost with no return, and it is worth deleting rather than maintaining.
Where a team already runs a general product-analytics tool, the pragmatic answer is usually to send search events to both: the general tool for the people who live in it, and a queryable table for the analyses that need joins the tool cannot express. Duplication is cheap at these volumes and avoids an argument about tooling that has nothing to do with search.
Performance & Scale Notes
- Event volume is roughly one search event, one impression batch and 0.3 click events per search. At 100,000 searches a day that is comfortably within any warehouse and does not warrant sampling.
- Batch impressions rather than emitting per result. Twenty individual events per search multiplies volume twentyfold for no analytical gain.
- Client-side events are lossy — ad blockers, closed tabs, network failures — typically 5–15% below server-side counts. Reconcile rather than assuming parity, and never compute a rate with a client-side numerator and a client-side denominator from different collectors.
- Query hashing costs nothing and removes an entire category of privacy risk. Keep a short-retention raw sample only if a specific analysis needs it, and put it behind explicit access control.
- Aggregate early for dashboards. Daily rollups by query class answer nearly every recurring question and let raw events expire on a short schedule.
The metric that repays instrumentation fastest is the zero-result rate, because it needs no labels, moves when almost anything breaks, and points directly at queries worth fixing. A team with nothing else should start there.
Starting small
The full event model is worth building eventually and is not where to begin. A single server-side event carrying the query hash, the result count and the latency — nothing else — already supports the zero-result queue, which is the highest-value output of this entire area. It takes an hour and needs no client work at all.
Impressions and clicks come next, and they are worth adding together because neither is much use alone: clicks without impressions cannot be normalised by position, and impressions without clicks measure nothing. The downstream success event comes last, when someone asks a question that clicks cannot answer — which they will, usually within a month of the first CTR dashboard appearing.
Building in that order means every stage delivers something usable, and it avoids the common outcome where a comprehensive instrumentation project ships six months later with nobody left who remembers what it was for.
The events described here are also the input to every other measurement discipline on this site: they become the judgment labels, the experiment outcomes and the health alerts. Instrumenting them well once is what makes all of that possible later.
Related
- Search frontend and UX patterns — the interfaces these events instrument.
- Instrumenting click-through rate for search — the metric in production detail, including position normalisation.
- Reducing zero-result searches — turning the highest-value metric into a work queue.
- Building a judgment list from click logs — the downstream consumer that turns these events into relevance labels.
- Observability and SRE for search — the operational metrics that sit alongside these product ones.