Compass Web SDK: Recommendation Attribution
Recommendation attribution closes the Confirmed-CTR loop: it links each recommendation click back to the exposure that produced it, so the analytics pipeline can measure click-through against what the reader actually saw. Confirmed CTR needs two signals from the client for a rendered recommendation set:
- a
recommendation_exposurewhen a rendered recommendation actually becomes visible, and - a
recommendation_clickwhen the reader clicks one,
each carrying the attribution identifiers the Recommender issued when it served the set. You rarely emit these by hand: once each rendered recommendation is registered — by stamping DOM attributes or by an imperative call — the SDK times the exposure and captures the click for you, reusing the same consent gate, identity, batching, and retry behavior as every other event.
Host-rendered sets: the data-arc-compass-* binding
When your site renders recommendations itself, stamp one set of
data-arc-compass-* attributes on each rendered recommendation element and the
SDK discovers them automatically — an initial scan at init() (deferred to
DOMContentLoaded when needed) plus a MutationObserver that picks up elements
inserted later by lazy load, infinite scroll, or an SPA re-render. Discovered
elements feed the same detector as every other path, so exposure timing, click
delegation, consent, and batching are identical. This is the recommended
host-rendered path: it is declarative and framework-agnostic, and there is no
handle to manage.
| Attribute | Required | Maps to |
|---|---|---|
data-arc-compass-item-id | Yes | Envelope item_id — the recommended item this card points at. |
data-arc-compass-attribution-id | Yes | attributionId — the per-item join token from the Recommender. Also the discovery key: elements are found by this attribute’s presence. |
data-arc-compass-exposure-id | Yes | exposureId — shared by every card in one rendered set. |
data-arc-compass-surface-id | No | surfaceId. |
data-arc-compass-position | No | position (integer). |
data-arc-compass-issued-at | No | issuedAt (ISO-8601 string). |
<div data-arc-compass-item-id="REC-A" data-arc-compass-attribution-id="attr-a" data-arc-compass-exposure-id="set-1" data-arc-compass-surface-id="rail" data-arc-compass-position="0"> …card markup…</div>The recommended-item id comes from the recommendation, not the page’s
arc-compass:item-id meta tag (which identifies the article the reader is on):
each card points at a different item. An element missing any of the three
required attributes is skipped — the first violation logs a console.warn
naming the missing attribute(s); later ones log only under debug: true.
Four rules keep the data correct:
- Stamp attributes before the element is inserted. The binding reads them when the element is discovered and does not watch for later attribute changes.
- Recreate nodes when the set is replaced. Registration is once per DOM element: a framework re-render that reuses the same nodes with new attribute values (React reconciliation, for example) keeps the original attribution and will not fire a new exposure. Key cards by recommendation so a new set produces new elements.
- Pick one path per set. Elements carrying
data-arc-compass-attribution-idare tracked automatically — do not also pass them totrackExposure(orobserveRecommendations), or every exposure and click is counted twice. - Scope or disable the scan with the
attributionRootinit option (defaultdocument.body; a container element or CSS selector narrows it,falseturns it off). See Configuration reference.
trackExposure — the imperative escape hatch
Hosts that already hold an element reference can register a single element instead of stamping attributes:
ArcCompass.trackExposure(element, { itemId: "REC-A", // required attributionId: "attr-a", // required exposureId: "set-1", // required surfaceId: "rail", position: 0,});Despite the name, this arms both signals for the element: a
recommendation_exposure when it becomes viewport-qualified and a
recommendation_click when it (or a descendant) is clicked — the same semantics
as the declarative binding, including the once-per-element rule. The element must
live under attributionRoot for clicks to resolve. Calls made through the
pre-load snippet stub are queued and replayed once the bundle loads, and
registering the same element twice is a no-op.
observeRecommendations — batch registration for custom SPAs
Fully client-rendered surfaces (React / Vue) that already hold element references and the attribution data in component state can register a whole set in one call and receive a handle to tear it down:
const handle = ArcCompass.observeRecommendations({ container, // the element wrapping the rendered set; also the click-delegation root items: [ { element: elA, attribution: { itemId: "REC-A", attributionId: "attr-a", exposureId: "set-1", surfaceId: "rail", position: 0 } }, { element: elB, attribution: { itemId: "REC-B", attributionId: "attr-b", exposureId: "set-1", surfaceId: "rail", position: 1 } }, ],});
// When the set is replaced (re-render, SPA route change, teardown):handle.disconnect();Prefer the data-arc-compass-* binding or trackExposure unless you specifically
need what this adds: batch registration, and click delegation scoped to the set’s
own container rather than the global attributionRoot. Two things to know:
containeris the click-delegation root, not the visibility root. Exposure is always measured against the viewport;containeronly scopes which clicks resolve to an observed element.- You own the lifecycle. Always
disconnect()a replaced set, or the stale detector keeps firing exposures. (The declarative binding avoids this: teardown is implicit when you recreate nodes.) Live detectors are also torn down when the client is disposed.
Registration is once per element, and the call is safe before the bundle finishes loading — the snippet stub queues it and returns a deferred handle that wires to the real detector on replay.
Attribution fields
Every path above carries an attribution context. For the data-arc-compass-*
binding, each field maps from the attribute in the table above; for
trackExposure and observeRecommendations, you pass it as the attribution
object; and for a manual track("recommendation_exposure" | "recommendation_click", …)
call it is the attributionContext prop. The Recommender issues these identifiers
when it serves a set — forward them verbatim.
| Field | Type | Required | Description |
|---|---|---|---|
exposureId | string | Yes | Identifier of the rendered set (1–256 chars). Every per-item exposure from one set MUST share the same exposureId. |
attributionId | string | Yes | Opaque per-item attribution identifier from the Recommender (1–256 chars). |
surfaceId | string | No | Customer-visible surface where the set was rendered (1–256 chars). |
position | number | No | Zero-based position of the item within the set; integer in [0, 10000]. |
issuedAt | Date | string | No | When the Recommender issued the context. Pass the ISO-8601 string it emits, or a Date. |
source | string | No | Attribution origin tag matching ^[a-z0-9_-]{1,64}$. Omit to default to fy. |
exposureId and attributionId are the load-bearing keys of the Confirmed-CTR
join: without attributionId a click can never match its exposure, and without
exposureId the exposure can confirm nothing. The imperative paths also require
itemId (the recommended item, which the binding reads from
data-arc-compass-item-id). The SDK refuses to emit attribution events that lack
any of these three.
Value validation
The SDK validates each field against the collector’s constraint before
sending, so one bad value never 422s the whole batch it rides in:
- an out-of-range
positionis clamped into[0, 10000]; - an empty or over-long id, a
sourcethat does not match the pattern, or an otherwise invalid value is dropped (with aconsole.warnunderdebug) rather than sent.
Forward the Recommender’s values verbatim and they pass through untouched.
Exposure and click timing
However a recommendation element is registered, the detector applies the same rules:
- Viewport-qualified exposure. A
recommendation_exposurefires when an element is ≥50% visible for ≥1000ms — not when it renders. An item below the fold is not an exposure until scrolled into view, and each element exposes once (a scroll out and back in does not re-fire). The dwell only advances while the page is visible, so a background tab produces no exposures until the reader brings it to the front. - Delegated click. A single listener resolves any click to the nearest
registered element and fires
recommendation_click. - Fallback. Where
IntersectionObserveris unavailable, exposures are a no-op; delegated click capture still works.
Consent
Attribution events are ordinary events: they emit through track() and respect
the same single consent gate as all collection. Nothing is sent or persisted
before setConsent(true), and the resolved identity is applied at flush. The
consent mechanics are not repeated here — see Consent & Identity.