Skip to content

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_exposure when a rendered recommendation actually becomes visible, and
  • a recommendation_click when 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.

AttributeRequiredMaps to
data-arc-compass-item-idYesEnvelope item_id — the recommended item this card points at.
data-arc-compass-attribution-idYesattributionId — the per-item join token from the Recommender. Also the discovery key: elements are found by this attribute’s presence.
data-arc-compass-exposure-idYesexposureId — shared by every card in one rendered set.
data-arc-compass-surface-idNosurfaceId.
data-arc-compass-positionNoposition (integer).
data-arc-compass-issued-atNoissuedAt (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-id are tracked automatically — do not also pass them to trackExposure (or observeRecommendations), or every exposure and click is counted twice.
  • Scope or disable the scan with the attributionRoot init option (default document.body; a container element or CSS selector narrows it, false turns 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:

  • container is the click-delegation root, not the visibility root. Exposure is always measured against the viewport; container only 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.

FieldTypeRequiredDescription
exposureIdstringYesIdentifier of the rendered set (1–256 chars). Every per-item exposure from one set MUST share the same exposureId.
attributionIdstringYesOpaque per-item attribution identifier from the Recommender (1–256 chars).
surfaceIdstringNoCustomer-visible surface where the set was rendered (1–256 chars).
positionnumberNoZero-based position of the item within the set; integer in [0, 10000].
issuedAtDate | stringNoWhen the Recommender issued the context. Pass the ISO-8601 string it emits, or a Date.
sourcestringNoAttribution 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 position is clamped into [0, 10000];
  • an empty or over-long id, a source that does not match the pattern, or an otherwise invalid value is dropped (with a console.warn under debug) 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_exposure fires 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 IntersectionObserver is unavailable, exposures are a no-op; delegated click capture still works.

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.

See also