Browser tracker
@releval/tracker is a small JavaScript library that reports the interactions that make search relevance measurable - searches, result impressions, result clicks, and the conversions that follow - in the canonical joinable shape (query_id + object_id + ordinal).
It exists so you don't have to hand-roll DOM listeners, batching, retries, and reliable delivery on page unload.
Use it for the client-side events in the event flow. Server-side events (for example a checkout completed on your backend) are posted directly to the Track Events API and don't need the library.
The tracker sends to User Behavior Insights, which is available only when ClickHouse is configured.
Before you start
You need a registered Site.
The Site issues the public site_id the tracker stamps on every event, and it declares the origins
allowed to submit events.
Register your site's origin (for example https://shop.example.com) as an allowed origin, or the
server will drop the browser's events.
That allow list is separate from the deployment's CORS settings.
Event submission carries its own policy, so it does not need an entry in Cors__AllowedOrigins; the
Site's allowed origins are what decide whether an event is kept.
Install
With a bundler (React, Vue, Angular, and so on):
npm install @releval/tracker
Without a bundler, load the IIFE build with a <script> tag; it exposes a global Releval.
Self-host the script file alongside your other static assets.
Download releval-tracker.global.js from the
latest release, or copy it out of
node_modules/@releval/tracker/dist/ if the package is already installed.
Configure
import { Tracker } from '@releval/tracker';
const tracker = new Tracker({
application: 'primary-search', // a label for this source of queries
siteId: 'YOUR_SITE_ID', // the public id from your registered Site
endpointHost: '${RELEVAL_HOST}',
});
tracker.start();
siteId is required whenever endpointHost is set.
Omit endpointHost during development and events go to the console instead - nothing leaves the page.
Starting the tracker sends nothing on its own; the calls and collectors below are what produce data.
The query_id is the join key
Everything the tracker sends is only as useful as its join back to the query that produced it, and
query_id is that join.
A click on its own tells you very little; a click you can line up against the query that ran, the
results it returned, and the position it was clicked at is what makes relevance measurable.
A query_id comes from your search backend, not the browser.
The backend is the only party that sees the full picture of a search - the query it ran and the
candidate results it returned - so it registers the query with the
Track Queries API and returns the query_id to the
browser alongside the results.
The backend chooses where that id comes from. Send a query_id on the request and Releval records
the query under it, which is convenient when you already have a request or trace id you want the
search data to line up with. Omit it and Releval generates one and returns it in the response.
Either way, the value the browser receives is the one to pass to every tracker call below.
That leaves two small values to carry with every search, one in each direction.
First up, browser to backend: send tracker.clientId with the search request, and have your
backend pass it to Track Queries as client_id.
It's the only browser-side key on the query record; without it a query can never be joined back to
the sessions and events that browser produced, and that includes abandoned queries - the ones with
no clicks at all, which are often the most interesting.
Next up, backend to browser: return the issued query_id with the results, and pass it to
every tracker call below.
Get those two hops right and everything else in this guide falls out naturally; miss one and the events still arrive, but they can't be joined to anything.
Report searches, impressions and clicks
// `query` and `queryId` came from your search API response.
// A query with no clicks still leaves a row, so abandonment
// and reformulation are analysable.
tracker.trackSearch({ query, queryId });
// When results scroll into view:
tracker.trackResultImpression({
queryId,
query,
items: results.map((r, i) => ({
objectId: r.product_id,
ordinal: i + 1,
objectIdField: 'product_id',
})),
});
// When the user clicks a result:
tracker.trackResultClick({
queryId,
query,
objectId: product.product_id,
ordinal: rank,
objectIdField: 'product_id',
});
If the query_id is missing, the tracker warns once in the console: the events are still delivered,
but they cannot be joined back to a query and are therefore of little use for relevance analysis.
ordinal is the absolute, 1-based rank across pagination: (page - 1) * pageSize + positionOnPage.
It must equal index + 1 of the object in the query_response_hit_ids your backend sent to Track
Queries for that query_id, and an ordinal that is not a positive integer is dropped with a warning
rather than sent - a fabricated rank is worse than none.
The shape of the events the tracker sends is the Event type in the
Track Events API reference.
Server-rendered sites
Multi-page sites don't need per-interaction code.
Put data-query-id (and optionally data-query) on the results container, data-object-id and
data-ordinal on each result, and let the declarative collectors emit the canonical events:
tracker.trackResultClicks({
selector: '[data-query-id] [data-object-id]',
ignore: '[data-add-to-cart]', // nested buttons are not result clicks
});
tracker.trackResultImpressions({
selector: '[data-query-id] [data-object-id]',
});
// Conversion buttons carrying data-action-name route through
// trackResultEvent and resolve the originating query automatically.
tracker.trackResultClicks({ selector: '[data-add-to-cart]' });
// Report the search this page was rendered for.
const grid = document.querySelector('[data-query-id]');
if (grid) {
tracker.trackSearch({ queryId: grid.dataset.queryId, query: grid.dataset.query });
}
tracker.start();
Impressions fire once per result per query as they enter the viewport; clicks are delegated, so results added later - pagination, infinite scroll - are covered automatically. A click that cannot be resolved into a joinable event is skipped with a console warning rather than sent broken. Every full page load builds a fresh tracker and re-runs this setup; the client id, session and click attribution persist in browser storage, so nothing is lost between pages.
Custom event attributes
Anything extra you know about an interaction can ride along on the event, persisted under its
event_attributes.
With the direct API - and the React impression hook - add keys to the object you already pass:
tracker.trackResultClick({ objectId, ordinal, queryId, badge: 'sale' });
tracker.trackSearch({ query, queryId, filters: { brand: 'acme' } });
useResultImpression({ objectId, ordinal, badge: 'sale' });
In markup, data-event-* attributes on the result element do the same for its clicks and
impressions:
<a data-object-id="SKU-1" data-ordinal="1" data-event-badge="sale"
data-event-filters='{"brand":"acme"}' href="/p/SKU-1">...</a>
data-event-badge="sale" lands as badge: "sale"; hyphens camelCase (data-event-promo-code ->
promoCode), underscores survive (data-event-sale_price -> sale_price).
A value that looks like a JSON object or array is parsed as one -
data-event-filters='{"brand":"acme"}' lands structured - and a malformed one is kept as a string
with a one-time console warning.
Any other value stays a string; use the direct API when a scalar needs to be a number or a boolean.
Only the data-event-* family is read, so unrelated data- attributes (test ids, framework
state) never end up in your analytics.
Values must be JSON-serialisable, and keys the tracker itself owns (object, position,
event_id, tracker, page, browser) are overwritten by it.
For attributes that belong on every event - an A/B variant, a store, a locale - use an enricher
(tracker.addEnricher) instead.
Conversions follow the product
trackResultClick records which query produced each clicked result.
A conversion later - another route in a SPA, or pages later on a classic site - can then omit
queryId and ordinal entirely:
// On the product or cart page:
tracker.trackResultEvent({ actionName: 'add_to_cart', objectId: sku });
The tracker resolves the recorded attribution for that objectId and sends a fully joinable row.
Attribution is scoped to the session (30-minute inactivity window by default); a conversion in a
later session is deliberately sent unattributed rather than joined to a stale query.
React
The @releval/tracker/react subpath ships React bindings: a provider that owns the tracker's
lifecycle, a context that declares which search produced the results beneath it, and a
StrictMode-safe impression hook.
React is an optional peer dependency (17 or later), pulled in only when you import the subpath, and
the entry carries the 'use client' directive so it works under the Next.js App Router.
import {
SearchResults,
TrackerProvider,
useResultImpression,
useSearchResults,
useTracker,
} from '@releval/tracker/react';
Provide the tracker
Wrap your app in TrackerProvider and read the instance anywhere below it with useTracker():
import { TrackerProvider } from '@releval/tracker/react';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<TrackerProvider
options={{
application: 'my-app',
siteId: 'YOUR_SITE_ID',
endpointHost: 'https://releval.example.com',
}}
>
<App />
</TrackerProvider>
</React.StrictMode>,
);
The provider constructs the tracker once, calls start() on mount and stop() on unmount, so
components never import a module singleton and the tracker is torn down cleanly.
options is read once; later prop changes are ignored, so update identity with
setUserId rather than by changing options.userId.
useTracker() throws when called outside a provider.
As everywhere else, omit endpointHost during development and events go to the console.
One-time wiring with onInit
Custom enrichers and sinks are one-time wiring, and React StrictMode mounts twice in development.
onInit runs exactly once, before the first start(), guarded against the double mount:
<TrackerProvider
options={{ application: 'my-app', siteId: 'YOUR_SITE_ID', endpointHost: '...' }}
onInit={(tracker) => {
tracker.addEnricher({
enrich(event) {
event.event_attributes.experiment = { variant: 'b' }; // your A/B split key
},
});
}}
>
Both addEnricher and addSink return disposers, but inside onInit you rarely need them - the
wiring lives as long as the tracker.
Bringing your own instance
Pass tracker instead of options when something outside React owns the instance, and
autoStart={false} when that owner also controls start()/stop(); the provider then only
exposes the instance via context.
Exactly one of options and tracker is required.
// tracking.ts - created and started outside React, so plain
// JavaScript on the page can use the same tracker.
import { Tracker } from '@releval/tracker';
export const tracker = new Tracker({
application: 'my-app',
siteId: 'YOUR_SITE_ID',
endpointHost: 'https://releval.example.com',
});
tracker.start();
// A React island inside that page: the page owns the lifecycle,
// the provider only makes the instance available to the hooks.
import { TrackerProvider } from '@releval/tracker/react';
import { tracker } from './tracking';
<TrackerProvider tracker={tracker} autoStart={false}>
<SearchIsland />
</TrackerProvider>;
Declare the search context
SearchResults is the SPA twin of data-query-id on a server-rendered results container: it
declares which search produced the results rendered beneath it, so result components never thread
queryId down as a prop.
function ResultsPage({ response, query }) {
return (
<SearchResults queryId={response.query_id} query={query}>
{response.results.map((r, i) => (
<ResultCard key={r.sku} result={r} ordinal={i + 1} />
))}
</SearchResults>
);
}
useSearchResults() returns { queryId, query? } inside the context and null outside it - a
card rendered on a home page or in a related-products strip has no search to join to, and your
components use that null to send nothing rather than something unjoinable.
Report the search itself where the response arrives, so an abandoned query still leaves a row:
const onResults = (response) => {
tracker.trackSearch({ query, queryId: response.query_id });
setResponse(response);
};
Impressions
useResultImpression returns a callback ref that reports ONE canonical impression the first time
the element becomes visible, attributed to the surrounding SearchResults:
function ResultCard({ result, ordinal }) {
const ref = useResultImpression({
objectId: result.sku,
ordinal,
objectIdField: 'sku',
});
return <article ref={ref}>{result.name}</article>;
}
Its semantics are chosen so the impression count (the CTR denominator) stays honest:
- Once per query, not once per component. A new
queryIdre-arms the hook, so a card that stays mounted across consecutive searches is counted for each query. - Remounts cannot double-count. The tracker also dedupes impressions per
(queryId, objectId)for its lifetime, so virtualized lists and route re-entry cannot re-fire a pair the page already reported. - No context, no event. Outside a
SearchResults(or whereIntersectionObserveris unavailable) the hook no-ops; pass{ disabled: true }as the second argument to switch it off conditionally.
ordinal follows the same rules as the direct API above: the absolute, 1-based rank across
pagination.
Clicks
Clicks need no hook: call trackResultClick from your own handler, inside the search context.
It emits the canonical click and records click-time attribution for the object:
function ResultCard({ result, ordinal }) {
const tracker = useTracker();
const search = useSearchResults();
const ref = useResultImpression({ objectId: result.sku, ordinal });
return (
<article
ref={ref}
onClick={() => {
if (search) {
tracker.trackResultClick({
objectId: result.sku,
ordinal,
queryId: search.queryId,
query: search.query,
});
}
navigate(`/product/${result.sku}`);
}}
>
{result.name}
</article>
);
}
Conversions on other routes
Conversions work exactly as described in
Conversions follow the product: omit queryId and ordinal,
and the recorded click resolves them - from any route, and across full reloads.
A conversion fired inside the results grid should instead pass what the card already knows, so
it is fully attributed even without a prior result click:
tracker.trackResultEvent({
actionName: 'add_to_cart',
objectId: result.sku,
queryId: search?.queryId,
query: search?.query,
ordinal: search ? ordinal : undefined,
});
Deep links and hard loads
React runs child effects before the provider's own effect, so a hard load of a deep route (a
product page dispatching a view in a mount effect) dispatches before start().
The tracker buffers pre-start dispatches (bounded at 100) and replays them at start(), stamped
with the timestamp and user identity current when they happened - nothing is lost and nothing is
misdated.
Identity on login and logout
const tracker = useTracker();
tracker.setUserId(user.id); // on login
tracker.setUserId(undefined); // on logout
Applies to events dispatched after the call and does not rotate the session.
The user id must be an opaque, pseudonymous identifier - never an email address or name.
Next.js App Router
The react entry ships the 'use client' directive, so it can be imported from a server component
tree; the tracker itself is a browser library, so the provider and hooks live in client
components:
// app/providers.tsx
'use client';
import { TrackerProvider } from '@releval/tracker/react';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<TrackerProvider
options={{
application: 'my-app',
siteId: 'YOUR_SITE_ID',
endpointHost: 'https://releval.example.com',
}}
>
{children}
</TrackerProvider>
);
}
Render <Providers> in your root layout and keep components that call useTracker() client
components.
TypeScript
The subpath re-exports everything a React consumer needs, so you never import the root entry:
the Tracker class, TrackerOptions, ResultRef, the TrackSearchOptions /
TrackResultClickOptions / TrackResultImpressionOptions / TrackResultEventOptions call
shapes, the Enricher, Sink and Logger extension-point interfaces, and the Event,
EventAttributes, EventObject and EventPosition event types.
Delivery and diagnostics
The default sink batches events, retries transient failures with backoff, delivers via the Beacon API on page unload, and persists still-failing batches for the next load.
Ingestion is silent by design: the server answers 202 to everything, and events with an unknown
site_id or from a disallowed origin are dropped without a client-visible error.
A logged delivery therefore proves transport, not acceptance - the activity column on the
Sites page is how you confirm events actually land.
Warnings and errors (a missing siteId, a rejected batch, storage failures) go to the console by
default; pass debug: true to also log every dispatched event and every delivery outcome - the
one-line install check.