Skip to main content

A browser tracker for Releval

· 12 min read
Russ Cam
Founder

When I wrote about Releval 1.0, I made the point that every query your users run is effectively another test case. You didn't choose it. It's running in production right now, and unless you're collecting what happened afterwards, you may never know how it went.

@releval/tracker 1.0 is out.

@releval/trackerCapture searches, impressions, clicks and conversions in the browser.1.0.0Apache-2.08.4 kB gzippednpm i @releval/tracker

It's a browser library for collecting that second half of the story: the results users actually saw, the ones they clicked, and the conversions that followed.

Every event is sent using the open User Behavior Insights event format and carries the id of the query that produced it. A purchase three pages later can still point back to the search that started it.

The package is Apache-2.0 licensed, around 8.4 kB gzipped, and has a single runtime dependency.

There are ESM, CommonJS and script-tag builds, full TypeScript definitions, and a React adapter under @releval/tracker/react.

The interesting bit isn't really collecting a click, though, plenty of libraries can do that. It's joining that click back to the search.

Four events, not analytics

@releval/tracker isn't intended to be a general-purpose browser analytics library. For search, there are four things we're interested in collecting:

  • a search, when someone runs a query;
  • an impression, when a result is actually seen;
  • a click, when someone chooses one; and
  • whatever conversion follows, which might be a view, an add to cart, a purchase, or something else relevant to your domain.

Events use the open UBI event format. A click looks like this on the wire:

{
"action_name": "click",
"timestamp": "2026-09-07T10:14:22.113Z",
"application": "relevaltech-html",
"site_id": "01K4S...",
"query_id": "01K4S...",
"session_id": "01K4S...",
"client_id": "01K4S...",
"event_attributes": {
"position": { "ordinal": 3 },
"object": { "object_id": "NT-001", "object_id_field": "product_id" }
}
}

You tell the tracker which interactions you're interested in and how to find the relevant data on the page. It takes care of turning those interactions into events and sending them to Releval. With the collection part covered, we need to be able to stitch this all together.

It all hangs on query_id

A click on its own tells you somebody clicked something. That's useful, but not particularly useful for understanding search. A click carrying the query_id of the search that produced it, the object that was clicked, and the ordinal where it appeared is something you can actually compute with.

Now you can ask things like:

  • Which queries get results but no clicks?
  • How does click-through rate change by rank?
  • Which searches eventually lead to a purchase?
  • Which queries are people repeatedly reformulating?

The common thread is that you need to know which search produced the interaction. That is what query_id is for. The catch is that the browser can't mint a query_id on its own.

Your application is already sitting between the user and the search engine, so it's also the one place that knows both sides of the search: what was asked and what came back.

It registers that pair with Releval and passes the ids down with the results it was returning anyway. The query_id can come from your system or Releval:

  • If you already have a request id, trace id or some other identifier worth correlating against, supply it and Releval will use it.
  • If you don't, leave it out and Releval generates one.

Either way, the important thing is that the same value makes it back to the page and onto everything that follows.

Who sends what

Once the page has the query_id, the rest looks like this:

There are a couple of details to focus in on.

Impressions and clicks come from the browser because the browser is the thing that knows whether a result was actually visible and what the user clicked. Trying to infer an impression from "the server returned this result" is not the same thing. Result 37 may have been returned. That doesn't mean the user saw it.

The checkout at the bottom is different. By that point, the server knows what was actually bought, so the conversion can be sent from the backend system. Both events from the frontend and backend land against the same query_id.

That's the whole model in a nutshell: collect events where the truth about those events lives, and carry enough context through to join them afterwards.

Get the query_id from your backend to your page and most of this becomes fairly mechanical. Miss it and you have a pile of interactions you can count but can't attribute.

Four steps and one check

The @releval/tracker repository ships two example storefronts. The walkthrough below takes the plain HTML one through the full flow against a real Releval deployment, from registering a Site through to querying the events that arrived:

Doing the same thing on your own site is four steps. And one check that is very easy to forget.

1. Register a Site

First, register a Site in Releval under Insights and give it the origins your search pages are served from:

https://shop.example.com
https://staging.shop.example.com

That gives you a site_id. The site_id is public and is intended to live in the page. It isn't a secret. The origin allow list mitigates some unrelated website from pointing its tracker at your Releval deployment and spamming it with events. If an event comes from an origin that isn't on the list, it is dropped.

2. Start the tracker

Install the tracker and point it at Releval:

import { Tracker } from '@releval/tracker';

export const tracker = new Tracker({
application: 'web-search',
endpointHost: '${RELEVAL_HOST}',
siteId: 'YOUR_SITE_ID',
});

tracker.start();

application is worth thinking about rather than typing web and forgetting about it. It's what lets you distinguish your website search from your mobile app search, or one experiment from another, when you come back to analyse the events later.

3. Register the query

Wherever the search happens on your backend, register the query with Releval and return the query_id with the search results. This looks something like:

const results = await mySearchEngine.search(userQuery);

const { query_id } = await releval.trackQuery({
application: 'web-search',
user_query: userQuery,
client_id: clientIdFromTheBrowser, // tracker.clientId, sent with the search request
// query_id: requestId, // optional: your own id, or Releval generates one
});

return { results, query_id };

One easy thing to leave out here is tracker.clientId. The browser creates it, so it needs to go up with the search request and then into trackQuery. Without it, the query and the later events can still be joined through query_id, but Releval can't tell that separate searches came from the same browser.

4. Tell the tracker what a result is

For a server-rendered page, this can be little more than some data attributes:

<ul data-query-id="01K4S..." data-query="wireless headphones">
<li data-object-id="SKU-1" data-ordinal="1">...</li>
<li data-object-id="SKU-2" data-ordinal="2">...</li>
</ul>

Then wire up clicks and impressions:

tracker.trackResultClicks({
selector: '[data-query-id] [data-object-id]',
ignore: '[data-add-to-cart]',
});

tracker.trackResultImpressions({
selector: '[data-query-id] [data-object-id]',
});

That's the whole result-tracking setup for a simple server-rendered page. The collectors watch clicks and viewport impressions and turn them into UBI events using the attributes already on the result.

The ignore is worth calling out. Say your result card contains an add-to-cart button. Without that exclusion, clicking the button may count as both an add-to-cart and a result click. Congratulations, your CTR just improved!

For React applications, the @releval/tracker/react package takes a slightly more React-shaped approach with a SearchResults context and an impression hook.

5. Check that anything arrived

This one is worth making a step in its own right because the ingest path is deliberately quiet. track-event responds with 202 whether the event is accepted or not.

An unknown site_id, an origin that isn't allow-listed and a malformed event can therefore all look like success from the browser. The reason is logged server-side instead. That behaviour is deliberate because the analytics endpoint shouldn't become a side channel for probing the deployment, and collecting an event shouldn't interfere with the application somebody is actually trying to use.

It does mean that staring at a green request in DevTools doesn't tell you very much. The Site activity column in Releval tells you whether events are actually arriving.

Then you can ask questions

Once there is some data, the Insights query workspace lets you query the underlying UBI events directly. For example, click-through rate by rank:

SELECT toInt32(event_attributes.position.ordinal) AS rank,
countIf(action_name = 'impression') AS impressions,
countIf(action_name = 'click') AS clicks,
round(clicks / impressions, 3) AS ctr
FROM ubi_events
WHERE rank > 0
GROUP BY rank
ORDER BY rank;

Run this after opening the example shop and clicking around for a minute, and you'll get a few rows of numbers that mean almost nothing. Which is the correct result. The figures in the walkthrough come from a hundred or so sessions of the same fairly artificial clicking about. That's enough for the shape to become visible: around 47% of shown results at rank one were clicked, 9% at rank two, 1% at rank three, and nothing below that.

I'm not suggesting those numbers say anything general about search behaviour. They say something about our small demo dataset 😄 What they do demonstrate is why ordinal is attached to every impression and click; You can't interpret clicks without thinking about position. A result at rank one and exactly the same result at rank ten do not have the same opportunity to be clicked. Which leads to the other important boundary in this release.

Collection, not judgment

This is the collection half. Today, you can use it to find out:

  • what people actually search for
  • which queries return results that nobody touches
  • where users reformulate
  • which searches eventually lead to conversions; and
  • which queries probably belong in your evaluation sets.

That last one is useful before doing anything clever with click models. The queries your users actually care about are rarely the twenty queries somebody happened to write down when search was first built. If a query is common, commercially important, or repeatedly going badly in production, it probably deserves to be represented in your relevance evaluation.

Today, copying those queries out of the Insights workspace and into a query set is manual. What Releval does not do yet is turn those interactions into relevance judgments automatically. That's intentional because a click is evidence of relevance, but that isn't the same thing as a relevance judgment. Position bias is the obvious example here; Results at the top get clicked more because they're at the top. You can't divide clicks by impressions, call the result a relevance grade, and pretend you've removed that effect. There is a reason click models have generated quite a lot of information retrieval research. That's also where I think this gets more interesting. A later version of Releval may use click models over the collected UBI data to derive implicit judgments that can feed back into evaluations. The important part is doing that in a way that accounts for the biases in the interaction data rather than treating every click as ground truth.

The tracker is the first piece necessary in this journey: collect the raw behaviour, preserve enough context to model it properly, and make the data available for analysis.

Where to get it

With a bundler:

npm install @releval/tracker

If you don't use a bundler, grab releval-tracker.global.js from the latest release and serve it with your own static assets. It exposes a global Releval.

There's also a React adapter under @releval/tracker/react, with the SearchResults context and impression hook, so you don't need to write another IntersectionObserver wrapper yourself.

The browser tracker guide has the full reference, and there are two example applications in the repository if you want to see the integration end to end.

Happy tracking! 🎉