feat(spike): optimization surface for experiences integration [NT-3613]#362
Conversation
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, { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 🤷♂️
There was a problem hiding this comment.
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.
A spike for new APIs the
experiencesSDK 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-reactwith 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
NodeViewEvent, subsetSourceMapschemasInsightsEventunion + Zod buildtrackNodeView(stateless),EventBuilder.buildNodeViewsendInsightsEvent+ consent gate + queuegetPersonalizationRequest()/ingestPersonalizationResponse()onCoreStatefulExperienceQueue,applyOptimizationDataToSignals, request-state signalNodeInteractionRuntime,resolveNodeViewPayload,resolveNodeViewArgsEntryInteractionRuntime; keeps existingIntersectionObserver+ delegated listeners./experiences-adaptersubpath:getExperiencesAdapter(optimization)NodeInteractionRuntime+resolveNodeViewPayloadunchangedtrackNodeView → 'exo_node_view'+getPersonalizationRequestentriesConsentPolicy.hasEventConsent