Interaction tracking in Web SDKs
Table of contents
- Overview
- Tracking boundary
- Layer responsibilities
- Event paths
- Consent and profile gates
- Tracked entry metadata
- Runtime control and precedence
- DOM discovery
- View tracking mechanics
- Click tracking mechanics
- Hover tracking mechanics
- React Web mechanics
- Delivery and flushing
- Debugging model
- Design boundaries
- Reference
Overview
This page helps you understand how browser interaction tracking works in @contentful/optimization-web and the React layer provided by @contentful/optimization-react-web.
It explains how entry views, clicks, hovers, Custom Flag views, page events, and custom events move from browser behavior to Core event delivery.
For setup steps, see Integrate the Optimization Web SDK in a web app and Integrate the Optimization React Web SDK in a React app. For server-owned rendering, see Interaction tracking in Node and stateless environments.
Tracking boundary
Interaction tracking has two separate jobs:
- Detection - Observing browser state or application calls and deciding that an interaction happened.
- Delivery - Building a valid Optimization event, applying consent and profile gates, and sending the event to the correct API.
The Web SDK owns browser detection for Contentful entry views, clicks, and hovers. Core owns event construction, consent checks, queues, profile state, and API delivery. React Web does not implement a separate tracking engine. It renders Web SDK tracking metadata and exposes the underlying Web SDK tracking API through React providers and hooks.
This split keeps the browser-specific code small. A view observer can focus on IntersectionObserver, timers, and DOM lifecycle. Core can focus on whether a trackView() call is allowed, which event type it becomes, and which queue receives it.
Layer responsibilities
| Layer | Responsibility in interaction tracking |
|---|---|
@contentful/optimization-core |
Builds page, identify, track, entry view, entry click, entry hover, and Custom Flag view events. Applies consent gates. Queues Experience API and Insights API work. |
@contentful/optimization-web |
Initializes Core for a browser runtime. Persists consent, profile data, selected optimizations, and anonymous IDs. Discovers tracked DOM elements and observes interactions. |
@contentful/optimization-react-web |
Creates and tears down the Web SDK instance, resolves entries in React, emits data-ctfl-* attributes, exposes the SDK instance, and emits router-driven page() calls. |
The application still owns Contentful fetching, rendering policy, consent UX, identity policy, route ownership, and any business event taxonomy passed to track().
Event paths
Tracking events use two API paths:
| Path | Methods | Main effect |
|---|---|---|
| Experience API | page(), identify(), screen(), track(), sticky trackView() |
Evaluates or updates a profile and returns profile, selectedOptimizations, and changes. |
| Insights API | non-sticky trackView(), trackClick(), trackHover(), trackFlagView() |
Sends Analytics events for entry interactions and Custom Flag observations. |
The Web SDK normally starts a browser journey with page() or identify(). Those calls use the Experience API and populate the state that later Insights events need for attribution. If an Insights event is emitted before the stateful SDK has a profile, Core logs a warning and drops that Insights event because there is no profile to attach to the batch.
The entry interaction methods map to these wire event types:
| SDK method | Wire type | Common source |
|---|---|---|
trackView() |
component |
IntersectionObserver entry view detection, or direct application call. |
trackClick() |
component_click |
Document click listener, or direct application call. |
trackHover() |
component_hover |
Pointer or mouse hover listener, or direct application call. |
trackFlagView() |
component with componentType: 'Variable' |
getFlag() and states.flag(name) reads from stateful Core. |
Sticky entry views touch both paths. When trackView({ sticky: true, ... }) is called, Core sends the view through Experience first, then sends an Insights view event. Non-sticky views only use Insights.
Consent and profile gates
The Web SDK defaults allowedEventTypes to ['identify', 'page']. Until consent is accepted, Core blocks non-allowed event types such as track, component, component_click, and component_hover.
Consent affects two parts of automatic entry tracking:
- Observer startup - Auto-enabled entry interaction detectors start only when consent is
true. Callingconsent(false)stops those auto-enabled detectors. - Event delivery - Every event call still passes through Core's consent guard. Manual
tracking.enableElement(...)can force an element detector to run, but emitted events are still blocked unless consent orallowedEventTypespermits the event type.
This means a typical browser integration needs both:
- A profile-producing event such as
page()oridentify(). - Consent or an explicit event allow-list for the interaction event types the application wants to send.
Blocked events are observable through onEventBlocked and states.blockedEventStream. Events that are blocked by consent are not replayed later when consent changes.
Tracked entry metadata
Automatic tracking starts from DOM metadata. The Web SDK looks for HTML or SVG elements with a non-empty data-ctfl-entry-id attribute.
| Attribute | Used for |
|---|---|
data-ctfl-entry-id |
Required. Becomes componentId in entry interaction events. |
data-ctfl-optimization-id |
Optional. Becomes experienceId. |
data-ctfl-sticky |
Optional. true sends the first successful view for the element through the sticky Experience path. |
data-ctfl-variant-index |
Optional. Non-negative integer used as variantIndex; invalid or unsafe values are ignored. |
data-ctfl-track-views |
Optional. true or false override for view observation when the view detector is running. |
data-ctfl-track-clicks |
Optional. true or false override for click observation when the click detector is running. |
data-ctfl-track-hovers |
Optional. true or false override for hover observation when the hover detector is running. |
data-ctfl-view-duration-update-interval-ms |
Optional. Per-element interval for periodic view-duration updates. |
data-ctfl-hover-duration-update-interval-ms |
Optional. Per-element interval for periodic hover-duration updates. |
data-ctfl-clickable |
Optional. true marks a non-semantic element as part of a clickable path for click tracking. |
The tracking payload uses the resolved entry ID, not the baseline entry ID. When an application
needs the baseline ID for rerendering, store it separately, for example in data-ctfl-baseline-id.
The Web SDK does not use data-ctfl-baseline-id in event payloads.
Manual element observation can provide the same metadata without DOM attributes:
optimization.tracking.enableElement('views', element, {
data: {
entryId: resolvedEntry.sys.id,
optimizationId: selectedOptimization?.experienceId,
sticky: selectedOptimization?.sticky,
variantIndex: selectedOptimization?.variantIndex,
},
})
When manual data is valid, it takes precedence over data-ctfl-* values on the element. If manual data is missing or invalid, the detector falls back to the element attributes.
Runtime control and precedence
Automatic tracking is opt-in. If autoTrackEntryInteraction is omitted, all automatic interaction types default to false:
const optimization = new ContentfulOptimization({
clientId: 'your-client-id',
autoTrackEntryInteraction: {
views: true,
clicks: true,
hovers: false,
},
})
The same runtime can be controlled after initialization:
| API | Effect |
|---|---|
tracking.enable(interaction, options?) |
Enables automatic observation for 'views', 'clicks', or 'hovers'. |
tracking.disable(interaction) |
Disables automatic observation for that interaction and stops it when no manual overrides need it. |
tracking.enableElement(interaction, element, options?) |
Force-enables one element, even when global automatic tracking for that interaction is off. |
tracking.disableElement(interaction, element) |
Force-disables one element. |
tracking.clearElement(interaction, element) |
Removes the manual override so the element falls back to attribute overrides or automatic state. |
For each element, the runtime resolves tracking in this order:
- Manual API override from
enableElement(...)ordisableElement(...). - Per-element
data-ctfl-track-*override. - Global automatic tracking state and the element registry.
reset() stops interaction trackers and clears manual element overrides. destroy() also disconnects DOM observers and browser lifecycle listeners.
DOM discovery
The Web SDK has one shared entry element registry. When an interaction detector starts, the registry does the following:
- Seeds the initial set from
document.querySelectorAll('[data-ctfl-entry-id]'). - Subscribes to a
MutationObserverthat watches child additions and removals in the document subtree.
When a tracked element is added, the registry notifies the running view, click, and hover detectors. When a tracked element is removed, the registry notifies those detectors so they can unobserve the element or stop treating it as tracked.
The registry observes element existence, not arbitrary attribute changes. Entry payload attributes such as data-ctfl-entry-id are read when an event fires, so updated IDs can affect later payloads.
Per-element tracking overrides such as data-ctfl-track-views and interval attributes are resolved when the element is added to the detector. If an existing mounted element needs dynamic override changes, use the tracking.*Element(...) API or remount the tracked element.
View tracking mechanics
View tracking uses IntersectionObserver and dwell-time timers.
Default view settings:
| Setting | Default |
|---|---|
| Required visible dwell time | 1000 ms |
| Periodic duration update interval | 5000 ms |
| Minimum visible ratio | 0.1 |
IntersectionObserver root |
null |
IntersectionObserver root margin |
0px |
| Disconnected-element sweep interval | 30000 ms |
A view cycle works like this:
- The element crosses the configured visibility threshold.
- The observer starts a fresh cycle, assigns a
viewId, resets accumulated duration, and starts the dwell timer. - After the dwell time passes, the observer calls
trackView()withviewIdandviewDurationMs. - While the element remains visible, the observer sends periodic duration updates using the same
viewId. - When the element leaves view after at least one view event fired, the observer sends a final duration update.
- If the element leaves view before dwell time completes, the cycle resets without sending an event.
The observer pauses dwell accumulation when the page is hidden and resumes when the page becomes visible again. It coalesces in-flight callbacks so a slow or failing event send does not create duplicate concurrent sends for the same element. If visibility ends while an event send is in flight, the final duration update is sent after that in-flight attempt settles.
Sticky view handling is per DOM element. If the payload has sticky: true, the first view attempt for that element sends sticky: true to Core. After Core returns optimization data for that sticky view, later view events for the same element omit sticky. If the sticky attempt does not return optimization data, the detector retries sticky on the next visibility cycle for that element. Separately rendered elements with the same entry ID are treated as separate sticky targets.
Click tracking mechanics
Click tracking uses one document-level capture listener. It does not call preventDefault() or stopPropagation().
When a click occurs, the detector resolves two facts:
- Which tracked entry element owns the click.
- Whether the event path is semantically clickable.
The click detector treats these paths as clickable:
a[href]button- Non-hidden
input selecttextareasummary[role="button"][role="link"][onclick]- Elements with an
onclickproperty handler [data-ctfl-clickable="true"]
The tracked entry can be the clicked element, an ancestor of the clicked element, or a descendant of a clickable ancestor. The detector also handles click events whose original target is a text node by walking to the parent element.
If a tracked entry and a clickable path are found, the detector resolves entry metadata and calls trackClick(). Click events do not include a click duration or click ID. The payload carries the entry and optimization metadata.
Hover tracking mechanics
Hover tracking uses element-level listeners. When PointerEvent is available, the observer listens for pointerenter, pointerleave, and pointercancel, and ignores touch pointer events. When pointer events are unavailable, it falls back to mouseenter and mouseleave.
Default hover settings:
| Setting | Default |
|---|---|
| Required hover dwell time | 1000 ms |
| Periodic duration update interval | 5000 ms |
| Disconnected-element sweep interval | 30000 ms |
A hover cycle mirrors the view cycle:
- The pointer enters a tracked element.
- The observer starts a fresh cycle, assigns a
hoverId, resets accumulated duration, and starts the dwell timer. - After dwell time passes, the observer calls
trackHover()withhoverIdandhoverDurationMs. - While the pointer remains hovered, the observer sends periodic duration updates using the same
hoverId. - When the pointer leaves after at least one hover event fired, the observer sends a final duration update.
- If hover ends before dwell time completes, the cycle resets without sending an event.
Like view tracking, hover tracking pauses while the page is hidden, coalesces in-flight callbacks, and sweeps disconnected element state.
React Web mechanics
React Web wraps the Web SDK. It does not replace the Web SDK tracking runtime.
OptimizationRoot creates one ContentfulOptimization instance from its props and destroys that instance on unmount. trackEntryInteraction maps to the lower-level Web SDK's autoTrackEntryInteraction option. allowedEventTypes, defaults, queuePolicy, and other Web SDK configuration values pass through to the underlying instance.
OptimizedEntry does three tracking-related things:
- Resolves the baseline entry with
useOptimizedEntry(). - Renders a wrapper element with
display: contents. - Adds tracking attributes for the resolved entry when resolved content is ready.
The wrapper receives:
<div
data-ctfl-entry-id="resolved-entry-id"
data-ctfl-optimization-id="experience-id"
data-ctfl-sticky="true"
data-ctfl-variant-index="1"
data-ctfl-duplication-scope="session"
></div>
data-ctfl-duplication-scope is emitted by React Web for optimization metadata, but the Web SDK entry interaction payload does not use it.
During loading, OptimizedEntry does not emit resolved entry tracking attributes. Loading UI is therefore not tracked as the resolved Contentful entry. If children is a direct ReactNode instead of a render prop, the wrapper still receives tracking attributes, but the child content does not change based on the resolved entry.
useOptimizedEntry() only resolves data. It does not add DOM attributes or register an element. If a component uses useOptimizedEntry() directly, it must either render the data-ctfl-* attributes itself or use sdk.tracking.enableElement(...).
React Web router adapters emit page() calls when supported routers change route. They are page event helpers, not entry interaction detectors. Entry views, clicks, and hovers still come from the Web SDK runtime.
Delivery and flushing
Insights events are queued by current profile ID and sent in batches. The queue flushes periodically, flushes when the queued event count reaches the batch threshold, and can be flushed explicitly with optimization.flush().
Experience events are sent immediately when the browser is online. When the browser is offline, Experience events are queued up to the configured offline maximum and replayed when the online signal becomes true.
The Web SDK wires browser lifecycle events into this queue model:
onlineandofflineupdate Core's online signal. Going online forces a flush.visibilitychange,pagehide, andbeforeunloadcallflush()once per hide cycle.- Insights delivery uses the configured beacon handler, which defaults to
navigator.sendBeacon().
Browser persistence is best-effort. Consent, profile, selected optimizations, Custom Flag changes, and the anonymous ID are read from localStorage at startup when available. The anonymous ID is also persisted in the ctfl-opt-aid cookie and migrated from the legacy anonymous ID cookie when present.
Debugging model
When an expected interaction does not appear, check the gates in this order:
- Profile - Call
page()oridentify()before relying on Insights events. Insights delivery needs a current profile in state. - Consent - Confirm
states.consent.current === trueor configureallowedEventTypesfor the event types that must emit before consent. - Detector startup - Confirm React Web
trackEntryInteraction, lower-level Web SDKautoTrackEntryInteractionortracking.enable(...)is enabled for the relevant interaction. - Element metadata - Confirm the tracked element is an HTML or SVG element with non-empty
data-ctfl-entry-id, or thatenableElement(...)supplies validdata.entryId. - View threshold - Confirm the element stays above
minVisibleRatiofordwellTimeMs. - Clickability - Confirm clicks happen on a semantic clickable path or an element marked with
data-ctfl-clickable="true". - Hover source - Confirm the event is not a touch pointer event and that the pointer remains over the element long enough to satisfy dwell time.
- Manual override precedence - Confirm
disableElement(...)ordata-ctfl-track-*="false"is not suppressing the element.
For local diagnostics, subscribe to states.eventStream and states.blockedEventStream, and use onEventBlocked for consent-gating visibility. In React Web, use onStatesReady on OptimizationRoot when those subscribers should be attached as soon as SDK state exists, and before provider children can emit router page() events or entry interactions.
Design boundaries
The Web SDKs do not own every part of tracking:
- They do not fetch Contentful entries.
- They do not decide whether a user has granted consent.
- They do not infer a browser view from server rendering alone.
- They do not make non-clickable markup clickable.
- They do not replay events blocked before consent.
- They do not guarantee persistence in browsers that deny storage access.
Keep these boundaries explicit when integrating or changing tracking behavior. Detection belongs to the browser runtime, event semantics belong to Core, and application-specific policy stays in the application.
Reference
- Core state management - Core state, consent, queues, observables, and event streams.
- Entry personalization and variant resolution - How resolved entries and selected optimization metadata are produced before tracking metadata is rendered.
- Interaction tracking in Node and stateless environments - How browser tracking fits when server runtimes own personalization.
- Optimization Web SDK README - Package-level orientation and Web SDK setup options.
- Optimization React Web SDK README - React provider, hook, router, and entry-rendering orientation.
- Integrate the Optimization Web SDK in a web app - Step-by-step browser integration flow.
- Integrate the Optimization React Web SDK in a React app - Step-by-step React integration flow.