Skip to content

feat(spike): optimization surface for experiences integration [NT-3613]#362

Draft
Johannes Maximilian Toball (jmtoball) wants to merge 7 commits into
mainfrom
docs/NT-3613-optimization-experiences-spike-design
Draft

feat(spike): optimization surface for experiences integration [NT-3613]#362
Johannes Maximilian Toball (jmtoball) wants to merge 7 commits into
mainfrom
docs/NT-3613-optimization-experiences-spike-design

Conversation

@jmtoball

@jmtoball Johannes Maximilian Toball (jmtoball) commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

A spike for new APIs the experiences SDK needs to plug into Optimization for personalization + analytics. Not intended to merge as-is — the goal is to prove the integration works with minimal cost on both sides.

Companion PR: contentful/experiences#26.

Motivation

Can we surface Personalization + Analytics through @contentful/experiences-react with a small, additive change on both sides? This PR is the Optimization half of that answer — every addition reuses an existing SDK construct so the integration surface stays thin.

What's new

Area Addition Reuses
Schemas NodeViewEvent, subset SourceMap schemas Existing InsightsEvent union + Zod build
Core SDK trackNodeView (stateless), EventBuilder.buildNodeView Existing sendInsightsEvent + consent gate + queue
Core SDK getPersonalizationRequest() / ingestPersonalizationResponse() on CoreStateful Existing ExperienceQueue, applyOptimizationDataToSignals, request-state signal
Web SDK NodeInteractionRuntime, resolveNodeViewPayload, resolveNodeViewArgs Generalized from EntryInteractionRuntime; keeps existing IntersectionObserver + delegated listeners
React web SDK ./experiences-adapter subpath: getExperiencesAdapter(optimization) NodeInteractionRuntime + resolveNodeViewPayload unchanged
Consent trackNodeView → 'exo_node_view' + getPersonalizationRequest entries Existing ConsentPolicy.hasEventConsent

Introduces the schemas the spike consumes at runtime:

- experience/sourceMap: Zod-mini subset of assemblies'
  DeliveryViewSourceMapSchema, covering the fields resolveNodeViewPayload
  reads. Top-level shape mirrors the wire authority so .parse tolerates
  future-added fields.
- insights/event/NodeViewEvent: exo_node_view event carrying resolved
  personalization metadata, narrowed to Experience | Fragment entity
  kinds per the spike design. Added to the InsightsEvent discriminated
  union.

Also adds an implementation plan alongside the design spec that tracks
milestones, decisions, and a session log so work can be resumed across
sessions.
Port NodeViewBuilderArgs, NodeViewTrackingArgs, and EventBuilder.buildNodeView
from EXA-1489, narrowed to the shipped assemblies contract: no entryIds,
entityKindId, layers, or parameters; variantId (not variant); variantIndex
present; wire type 'exo_node_view'.

Add CoreStatefulEventEmitter.trackNodeView routing through sendInsightsEvent
with anonymousId derived from profileSignal when omitted, and parity
CoreStatelessRequest.trackNodeView gated on a request-bound profile.

Consent mapping (trackNodeView -> 'exo_node_view') lives in ConsentPolicy
alongside the other track* entries; the current tree uses hasEventConsent
rather than EXA-1489's static CONSENT_EVENT_TYPE_MAP.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Expose getPersonalizationRequest() and ingestPersonalizationResponse() on
CoreStateful (inherited by ContentfulOptimization). getPersonalizationRequest
returns { profileId?, events } — the anonymous profile id currently held plus
any Experience events drained from the offline queue — for use as the
extensions.personalization body on a delivery-client
view.getExperienceWithOverrides call. ingestPersonalizationResponse writes the
XDA response back through applyOptimizationDataToSignals, the same terminal
write path used inside sendExperienceEventWithResult.

Factor the consent gate step out of sendExperienceEventWithResult into
guardExperienceEventConsent; the new drainQueuedExperienceEvents helper reuses
it so blocked events remain queued (spec §8). ExperienceQueue gains a plain
drainQueuedEvents() that empties the queue and resets flush retry state.

Register 'getPersonalizationRequest' in ConsentPolicy.hasEventConsent: passes
when consent is granted or the allow-list overlaps with UNLOCKING_EVENT_TYPES.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Port resolveNodeViewPayload(nodeId, sourceMap) from EXA-1489 into
packages/web/web-sdk/src/entry-tracking/, narrowed to the shipped assemblies
contract: KNOWN_ENTITY_KINDS = { Experience, Fragment }; ResolvedNodeMetadata
drops entryIds and entityKindId (both absent in the current per-node fields);
function returns null (not undefined) on non-attributable nodes to match the
adapter contract in the spec.

Types imported from @contentful/optimization-core/api-schemas (the SDK-owned
SourceMap from Milestone 1), keeping the optimization SDK independently
versionable from @contentful/view-delivery-contract.

Add colocated unit tests covering every fixture from spec section 7:
personalization on Experience, personalization on Fragment, non-attributable
node inside an InlineFragment, missing nodes[id], layers-scope traversal edge
cases. Each fixture is round-tripped through SourceMap.parse to catch schema
drift.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ing [NT-3613]

Port and generalize the EXA-1489 NodeViewRuntime into a full-parity
NodeInteractionRuntime keyed on data-ctfl-node-id (spec §5.1d). Handles
views + clicks + hovers behind a single OptimizationTrackingApi
(enable/disable/enableElement/disableElement/clearElement), matching the
imperative surface EntryInteractionRuntime exposes today. The entry runtime
is kept in place; the two runtimes coexist and share no state.

Add three lean detectors implementing a shared NodeInteractionDetector
contract (observe/unobserve/disconnect):

- createNodeViewDetector wraps ElementViewObserver and delegates payload
  composition to resolveNodeViewArgs.
- createNodeClickDetector delegates click on document, walks up to the
  observed [data-ctfl-node-id] ancestor, and requires a clickable path
  (a[href]/button/role=button/onclick handler) between event target and
  node. Maps ResolvedNodeMetadata to trackClick args as componentId=entityId,
  experienceId=parentExperienceId ?? optimizationId, variantIndex=variantIndex.
- createNodeHoverDetector wraps ElementHoverObserver with the same mapping.

resolveNodeViewArgs.resolveNodeDataset is the single point that reads the
seven data-ctfl-* attributes (nodeId, entityId, entityKind, optimizationId,
variant, variantIndex, parentExperienceId) and narrows entityKind to
Experience | Fragment.

Adds NODE_ID_ATTRIBUTE and NODE_SELECTOR constants beside the existing
ENTRY_ID_ATTRIBUTE / ENTRY_SELECTOR pair.

Tests (jsdom): 9 in NodeInteractionRuntime.test.ts covering seeding,
DOM insert/remove fan-out, global disable, per-element enable/disable,
consent-blocked-then-released, reset, and destroy; 4 in
createNodeViewDetector.test.ts covering the shape contract and missing /
unknown-kind dataset guards. web-sdk suite: 289/289 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships @contentful/optimization-react-web/experiences-adapter — the sole seam
that @contentful/experiences-react reaches into. useNodeBinding memoizes
resolveNodeViewPayload and returns a ref-callback that stamps the seven
data-ctfl-* attributes the NodeInteractionRuntime reads back; attachInteractionRuntime
resolves a per-optimization runtime through a module-scope WeakMap and returns
a cleanup that disables only the interactions it enabled.

Surfaces NodeInteractionRuntime, resolveNodeViewPayload, ResolvedNodeMetadata,
and the NodeInteraction* types from @contentful/optimization-web's main entry
so the adapter imports through public paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Milestone 12 of the optimization spike (NT-3613). Closes the two remaining
Milestone-12 gaps left over from Milestone 2 (buildNodeView and
trackNodeView shipped without dedicated unit tests). Every other §7 test
target already landed colocated with the milestone that introduced it — the
plan documents the full coverage map.

- `packages/universal/core-sdk/src/events/EventBuilder.test.ts`: new
  `describe('EventBuilder.buildNodeView')` block (4 tests) — builds an
  `exo_node_view` with all node-view fields; includes `parentExperienceId`
  when supplied; accepts `entityKind = Fragment`; Zod rejects unknown
  `entityKind` values.
- `packages/universal/core-sdk/src/CoreStateful.trackNodeView.test.ts`
  (new, 6 tests) — emits an `exo_node_view` Insights event carrying the
  payload; defaults `anonymousId` to the active profile id; prefers an
  explicit `anonymousId`; reports a consent-blocked event when consent is
  missing; unblocks when `allowedEventTypes` opts into `'exo_node_view'`;
  passes `parentExperienceId` through.

Verified: `pnpm --filter @contentful/optimization-core test:unit` → 296/296
pass (+10 new); typecheck clean; `pnpm lint` clean; Prettier clean on the
two touched files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
*
* @public
*/
export const NodeViewEvent = z.extend(UniversalEventProperties, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't a NodeViewEvent what was a ComponentViewEvent conceptually? I understand, that there's additional metadata but it feels a bit to Contentful specific for me instead of an open platform concept.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's an extension of the ViewEvent concept to cover ExO-backed nodes (its' not a clean extension as some fields are renamed here to avoid clashes, e.g. (9t-)experienceId->optimizationId.
It could theoretically be made more Contentful-agnostic (even though it's of course still Contentful Optimization-specific via experienceId and variantIndex), but I'm not sure what that would buy us. It could make expressing the relationships more awkward if we're not talking about the ExO-entities directly and there is some abstraction over e.g. the concrete entities already to keep it extensible that could in principle be remapped outside Contentful. But maybe I'm not following where you're coming from with this 🤷‍♂️

@BraunreutherA BAlex (BraunreutherA) Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So far we have tried to create not too many events and rather stay agnostic and use the more generic fields of the events.
I just want to make sure that we're not having a long list of different event types in the future which are all pretty much the same.

The Event types have to get implemented on the backend then aswell.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants