Compass Web SDK: Events & Reliability
This page covers what you send (the track() API and event shape) and how the
SDK gets it there reliably (batching, page-unload delivery, and retries).
Tracking events
ArcCompass.track(eventType, props?);eventType is one of:
| Event | Typical use |
|---|---|
page_view | A new page or route is shown. |
click | The user clicks a tracked element. |
video_start | Video playback begins. |
video_progress | Video has reached a checkpoint (eventValue = fraction watched, 0.0–1.0). |
video_complete | Video reaches 100% (or near-100%). |
search | The user runs a search query. |
share | The user shares an item. |
recommendation_exposure | A rendered recommendation became visible. Carries attributionContext. |
recommendation_click | The user clicked a rendered recommendation. Carries attributionContext. |
catch_up_shown | A Catch Up card segment became visible (one per material segment). Carries attributionContext. |
catch_up_click | The reader clicked through to a story in the Catch Up card. Carries attributionContext. |
catch_up_dismiss | The reader completes the takeover by reaching the “I’m caught up” end state. An early close (the ✕, Escape, or navigating away) does not fire it. Carries attributionContext. |
The recommendation_exposure and recommendation_click types carry an
attributionContext and are how host-rendered surfaces close the Confirmed-CTR
loop — you rarely emit them by hand. See Recommendation Attribution.
The catch_up_* types are emitted only by the Catch Up card Experience,
which fires them itself — you never call track() for them. They carry the card’s
exposureId and, on catch_up_shown / catch_up_click, the moved segment’s
attributionId, tagged source: "fy-catchup", so a click confirms against its
impression. Like every SDK event they are consent-gated: none fire until the
reader has granted consent.
These are the event types the browser SDK emits — a client-side subset of the
full Compass taxonomy. For the complete server-side catalog
(including article_save, deepest_scroll, and engaged_read, which are
ingested through the API rather than the browser SDK), see Supported Event Types.
props (all optional):
| Field | Type | Description |
|---|---|---|
itemId | string | Overrides the resolved item ID for this event only. |
siteId | string | Overrides the resolved site ID for this event only. |
eventValue | number | Numeric value (e.g. video watch fraction). |
sessionId | string | Overrides the SDK-managed session ID for this event. |
timestamp | Date | string | number | Overrides the event timestamp (default: capture time). Accepts a Date, a parseable ISO-8601 string, or epoch milliseconds; an unparseable value falls back to the capture time. |
attributionContext | AttributionContext | Attribution for recommendation_exposure / recommendation_click events. See Recommendation Attribution. |
Item ID resolution
The collector requires an item_id on every event. The SDK resolves it in this
order:
props.itemIdpassed totrack().<meta name="arc-compass:item-id" content="…">read from the page.
The resolved item_id must match the ID the content was ingested under. The
SDK forwards it verbatim and never derives it from the URL or invents a default.
If neither source resolves, the event is dropped with a console.warn.
For multi-page apps, set the meta tag once in HTML:
<meta name="arc-compass:item-id" content="ARTICLE-ABC123" />Site ID resolution
site_id attributes an event to the Site it originated on, for tenants that run
more than one Site. Unlike item_id, it is optional — single-site tenants
omit it entirely. The SDK resolves it in the same order as item_id:
props.siteIdpassed totrack().<meta name="arc-compass:site-id" content="…">read from the page.
Use the same Site identifier the content was ingested under (the site_id on the
content record) so events and items line up. When neither source resolves, the
event is still sent — just without a site_id; it does not affect the
collector’s derived event_id.
<meta name="arc-compass:site-id" content="the-daily" />Automatic page_view
By default the SDK fires a page_view for you, so you do not have to call
track() on every page. The SDK watches the arc-compass:item-id tag: a
page_view fires each time that tag reads back a value different from the one it
last reported, and the arc-compass:site-id tag is read at the same moment. That
covers the initial load and every route change in a single-page app.
When no item tag ever resolves for a page, no page_view fires (the SDK warns
once) and you should call track('page_view', { itemId }) yourself.
Turn it off with the autoPageView init option:
ArcCompass.init({ domain, apiKey, autoPageView: false });Single-page apps
Write the item-id tag whenever your route knows the ID: before or after
pushState, in a render effect, or once an awaited fetch resolves. There is no
ordering you have to honour. The SDK reads the tag when you write it, so it
cannot read it too early, however long your router, data loader, and render take.
// React Router + any head manager. No SDK call, and no ordering requirement.function Article({ id }) { const { data } = useArticle(id); // resolves after the route has rendered
return ( <> <Helmet> {data && <meta name="arc-compass:item-id" content={data.itemId} />} </Helmet> {/* … */} </> );}Two route changes produce no page_view:
- A change that lands on the same item. A query or hash change, or a
replaceStatethat only normalises the URL, is not a new view of new content. - A route with no item, such as a homepage or a section front. Clear the tag, or leave it out, on those routes. Returning to an article you viewed earlier fires again.
If you want a page_view per URL rather than per item, or you would rather
drive capture yourself, set autoPageView: false and track each route
explicitly:
router.afterEach((to) => { ArcCompass.track("page_view", { itemId: to.params.articleId });});search and other item-less events
search events do not naturally correspond to a content item. Because the
collector requires an item_id on every event, pass a synthetic ID per call:
ArcCompass.track("search", { itemId: "search:" + encodeURIComponent(query) });Envelope shape
The SDK builds and POSTs event envelopes shaped like this:
{ "user_id": "…", "item_id": "…", "event_type": "page_view", "timestamp": "2026-03-27T14:30:00.000Z", "session_id": "…", "site_id": "the-daily", "event_value": 0.75}event_value and site_id are omitted when not provided.
Reliability
Batching
Events are buffered in memory and sent in batches via POST /events/batch. A
flush is triggered by whichever fires first:
batch.maxEventsreached (default 20).batch.flushIntervalMselapsed since the last flush (default 2000 ms).- The page enters
visibilitychange→hidden(tab backgrounded, app switched, etc.). - The page emits
pagehide(hard nav, tab close). - The host calls
ArcCompass.flush().
The collector caps requests at 100 events; the batcher splits larger flushes into multiple requests automatically.
Page-unload delivery
Both visibilitychange and pagehide trigger a fetch call with
keepalive: true. The browser keeps the request alive past page teardown, so
events fired late in the page lifecycle still reach the collector.
Failure handling
A batch send that hits a transient failure is retried with bounded
exponential back-off; everything else is dropped immediately. The SDK never
throws — failures surface only when debug: true is set, or through the
onError callback for terminal batch drops (see Error visibility).
Retry policy
- Retried:
5xxresponses and network / CORS errors. - Not retried:
4xx(including429). A4xxwill not succeed on re-send, and rate-limit handling is the collector’s concern, not the client’s — so these are terminal and the batch is dropped. - Back-off: exponential with full jitter — each retry waits a random
interval in
[0, 1s × 2^attempt](nominal ~1s then ~2s). - Bounds: at most 2 retries (3 attempts total) and at most 30s of total wall-clock across attempts and waits, whichever comes first.
- When the budget is exhausted, the batch is dropped, the last error is logged
via
console.errorunderdebug, andonError(if set) is called.
Unload flushes get a minimal budget
A flush triggered by visibilitychange
→ hidden or pagehide runs a single keepalive attempt with no
retries. The page is being torn down, so blocking on back-off would risk
losing the request entirely; the one keepalive request already survives past
teardown.
No cross-page persistence
Events are not persisted across page loads. A
persistent retry queue (localStorage / IndexedDB) would raise consent and
correctness questions — replaying events captured under one consent state after
the state has changed — so it is deliberately out of scope.
De-duplication (why retries are safe)
Retries re-POST the byte-identical
request body: the batcher drains and builds each envelope once, then the
transport replays that exact payload on every attempt. The collector derives a
stable event_id from the event’s fields, so identical re-sends collapse to the
same event downstream — see the events endpoint
in the API Developer Guide. The SDK therefore does not mint its own event ID.
In-memory queue cap
The queue holds at most queue.maxBuffered events (default 500). On overflow,
the oldest event is dropped to make room and a console.warn fires so engineers
notice sustained collector outages during development.
Concurrency
Flushes are serialized — at most one HTTP request is in flight at a time. Subsequent flush triggers wait for the in-flight request to resolve before draining more events.