Skip to content

feat(rokt): fire early selectPlacements preselect call on configured pageviews - #1398

Open
alexs-mparticle wants to merge 12 commits into
v3-developmentfrom
rokt-preselection
Open

feat(rokt): fire early selectPlacements preselect call on configured pageviews#1398
alexs-mparticle wants to merge 12 commits into
v3-developmentfrom
rokt-preselection

Conversation

@alexs-mparticle

@alexs-mparticle alexs-mparticle commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an early, speculative selectPlacements call ("preselect") that fires on a configured pageview before the customer reaches the page a placement is actually needed on, so Rokt's backend can warm its fingerprint-matching cache ahead of time.

  • Config (kits/rokt/src/preselectionConfig.ts): a hardcoded list (to move to a server-delivered kit setting later) of entries keyed by accountId + trigger pathname, each naming the targetPageIdentifier to pre-select for and the attributeKeys required to fire. pathname (where the call fires) and targetPageIdentifier (the page being pre-selected for) are deliberately separate fields — conflating them was the root cause of a prior production incident where a speculative call resolved against the wrong page.
  • Trigger (kits/rokt/src/preselection.ts, wired into Rokt-Kit.ts#process): on every pageview, maybeFirePreselect looks up a matching config entry, then requires a valid user identifier, and all of the entry's attributeKeys present on the pageview event or in userAttributes. Any missing key logs a PRESELECT_MISSED diagnostic and the call does not fire — partial attribute sets are never sent.
  • Dedup: a successful fire is cached in localStorage (activePreselectStorage.ts) for 60s keyed by account + pathname; a repeat pageview with unchanged attributes within that window is skipped (PRESELECT_SKIPPED) rather than re-fired.
  • Queueing: if the Rokt launcher isn't attached yet, the dispatch is queued (PRESELECT_QUEUED) instead of dropped, and flushed once the launcher attaches. initRoktLauncher calls attachKit before flushing the queue, not after, so a same-page live selectPlacements call (e.g. a partner's own placement request) always gets its call started first and can't be pre-empted by our own warm-up request.
  • Wire contract: the dispatch sets preselect: true, identifier: <targetPageIdentifier>, and omitUrl: true as top-level selectPlacements options (not inside attributes) — this is what the launcher reads to emit the rokt-is-pre-selection header that keeps speculative traffic out of real placement counts. cacheMatchKeys is passed as the entry's attributeKeys array on both the preselect call and the later live call from the same config entry, so the live call can match against the warmed cache.
  • Isolation from RoktManager: the preselect path calls selectPlacements directly — no passback ID capture, no identify-on-mismatch, and no customer-facing SelectPlacements analytics event is logged for it, so it's invisible to the rest of the SDK and to the customer's own event stream.

PRESELECTION_CONFIG currently has one entry (a pilot account, triggering on /checkout, pre-selecting a prod.rokt.conf placement) to validate the mechanism end-to-end; more entries get added as additional partners onboard.

Test plan

  • npx vitest run — full suite passing, including dedicated preselect coverage in kits/rokt/test/src/tests.spec.ts (queueing/flush, identity gating, missing-attribute handling, dedup TTL, diagnostic codes, cacheMatchKeys shape on both preselect and live calls)
  • npm run build — builds cleanly

@alexs-mparticle
alexs-mparticle changed the base branch from main to v3-development September 3, 2026 15:55
Comment thread kits/rokt/src/preselectionConfig.ts Outdated
@alexs-mparticle
alexs-mparticle marked this pull request as ready for review September 3, 2026 20:14
@alexs-mparticle
alexs-mparticle requested a review from a team as a code owner September 3, 2026 20:14
@cursor

cursor Bot commented Sep 3, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes when and how selectPlacements runs and which attributes/keys are sent, with localStorage dedupe and hardcoded partner config—incorrect config or launcher contract could affect placement timing or caching behavior.

Overview
Adds Rokt placement preselection: on matching pageviews, the kit can fire an early selectPlacements with preselect: true (when the launcher sets enablePreselection) to warm Rokt’s backend path before the normal placement call.

Matching is driven by a new hardcoded PRESELECTION_CONFIG (account ID + pathname → target page identifier + attribute keys). Attributes are taken from the pageview event first, then user attributes; dispatch requires a valid user identity and skips duplicate fires within a 60s localStorage window when attributes are unchanged. Attempts before the kit/launcher is ready are queued and flushed after attach.

selectPlacements now optionally sends cacheMatchKeys (from the same config, keyed by identifier) when preselection is enabled, and does not emit the customer-facing selectPlacements mParticle event for preselect dispatches. New placement diagnostics cover preselect fired/missed/queued/skipped outcomes. djb2 and LS_NAMESPACE_KEY are centralized in shared modules for reuse.

Reviewed by Cursor Bugbot for commit ce2ca98. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread kits/rokt/src/Rokt-Kit.ts Outdated
Comment thread kits/rokt/src/Rokt-Kit.ts
Comment thread kits/rokt/src/preselectionConfig.ts

@crisryantan crisryantan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed against our follow-ups, The sticky-attribute fix you found is the right catch and was the thing I would most have expected to bite.

Three below. The first two mean that as written a fired preselect would pre-select the page it fired from and go out untagged, which together is the February shape.

🤖 Generated with Claude Code

Comment thread kits/rokt/src/Rokt-Kit.ts Outdated
Comment thread kits/rokt/src/Rokt-Kit.ts Outdated
Comment thread kits/rokt/src/preselectionConfig.ts
Comment thread kits/rokt/src/preselectionConfig.ts Outdated
Comment thread kits/rokt/src/Rokt-Kit.ts Outdated
Comment thread kits/rokt/src/Rokt-Kit.ts Outdated
Comment thread kits/rokt/src/Rokt-Kit.ts Outdated
Comment thread kits/rokt/src/preselectionConfig.ts Outdated
Comment thread kits/rokt/src/Rokt-Kit.ts Outdated
Comment thread kits/rokt/src/Rokt-Kit.ts Outdated
Comment thread kits/rokt/src/preselectionConfig.ts
…pageviews

Adds a static, hardcoded config (kits/rokt/src/preselectionConfig.ts) keyed
by Rokt account ID + pathname, naming which attributes to collect and the
target page identifier being warmed. When a matching pageview fires and the
current user has at least one valid identifier, Rokt-Kit dispatches an
early selectPlacements call carrying the preselect/identifier/omitUrl
options, independent of RoktManager, to warm Rokt's backend preselect/
fingerprint-matching path. The call is tagged via the preselect option
(not folded into attributes) so it flows through as speculative traffic
rather than a real, billable placement request.

Calls made before the launcher is ready are queued (capturing the pathname
and pageview event at enqueue time) and re-evaluated once the launcher
attaches, so identity/attribute state that wasn't available yet at first
pageview is retried rather than dropped, and a queued call still resolves
against the page it was fired from even if the user has since navigated
away. Firing requires every configured attribute to be present; missing
attributes are each logged individually for diagnosability. Hit/miss/queued/
fired diagnostics are logged throughout, and preselect dispatches are
excluded from the customer-facing selectPlacements event log.

A dedicated #preselect test suite covers attribute precedence, gating,
queueing/flush re-evaluation, diagnostics, and the preselect contract.
Comment thread kits/rokt/src/Rokt-Kit.ts
Comment thread kits/rokt/src/Rokt-Kit.ts
Comment thread kits/rokt/src/Rokt-Kit.ts Outdated
Comment thread kits/rokt/src/Rokt-Kit.ts Outdated
buildPreselectDiagnosticLogEntry took a fired/missed boolean, so the
not-ready enqueue path logged PRESELECT_FIRED even though nothing was
dispatched yet. A later flush-time miss then double-reported the same
attempt as both fired and missed. Adds a third queued outcome with its
own PRESELECT_QUEUED code.
Persists a short-lived (1 minute) "active preselection" record keyed by
account + pathname after a preselect fires, storing the attributes that
were sent. A repeat firing for the same page is skipped while that window
is still active and the attributes are unchanged, and fires again once the
window expires or the collected attributes differ. Guards against redundant
preselect calls on rapid re-renders or repeat navigation to the same page.

Also extracts all preselect dispatch/queueing logic (previously several
private RoktKit methods) into a standalone kits/rokt/src/preselection.ts
module operating on an explicit state object and a small host interface,
mirroring the existing launcherAttachState.ts pattern. Rokt-Kit.ts now just
builds the host and state, and calls into the module.
The Rokt launcher now sets enablePreselection on the object it returns from createLauncher/createLocalLauncher, reflecting its own PRESELECT_SAMPLING_PERCENTAGE rollout gate (ROKT/sdk-web#1798). Treat a missing or non-true value the same as false so the kit never attempts a preselect the launcher isn't ready to serve.
pageViewStorage.ts and activePreselectStorage.ts each redefined the same 'mp-rokt-kit' localStorage namespace key independently; export it once from storage.ts, which both already depend on.
A disabled/missing enablePreselection flag is an expected, routine outcome, not something worth a diagnostic log entry.
Comment thread kits/rokt/src/Rokt-Kit.ts Outdated
A real (non-preselect) selectPlacements call always supersedes an in-flight one, but a preselect call never does. Firing preselect before attachKit let a live partner call (e.g. Cinemark's Pay+ selection on checkout) cancel our own warm-up prefetch for a different page. Firing it after attachKit puts the real call safely in flight first, since nothing later cancels it, while our own late-starting preselect isn't at risk either.

Addresses PR #1398 review thread on Rokt-Kit.ts:1156.
The kit never named which attribute keys the WSDK should match a preselect cache entry on. Pass the same attributeKeys already required to fire preselect, so cache matching is scoped explicitly rather than relying on the WSDK's default behavior.
Rather than naming all attribute keys in cacheMatchKeys, compute one hash over the collected key/value pairs and point cacheMatchKeys at a single synthetic attribute carrying it. Any change to a tracked attribute value changes the hash, so the WSDK treats it as a new selection rather than serving a stale cache hit.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7823837. Configure here.

Comment thread kits/rokt/src/preselection.ts Outdated
Comment thread kits/rokt/src/preselection.ts Outdated
Move cacheMatchKeys off the preselect-only path and into _dispatchPlacements, the single choke point both preselect and live selectPlacements calls share. It is now a top-level string (never inside attributes, so it can't leak into persisted userAttributes) recomputed fresh for every dispatch by looking up the matching preselection config via identifier and hashing its attributeKeys, gated on isPreselectionEnabled() to skip the work entirely when preselection isn't active. Addresses both Cursor Bugbot findings on the prior commit: the hash no longer leaks into later placements, and the live call now recomputes and sends it so WSDK can check it against the cached preselect.

Also consolidates the djb2 hash and buildCacheMatchHash helper into utils.ts (previously duplicated between Rokt-Kit.ts and a since-removed cacheMatchHash.ts).
Comment thread kits/rokt/src/Rokt-Kit.ts Outdated
The hash removed the one signal that would let a mismatch be debugged
(which attribute actually changed), and required this kit's
serialization to stay bit-for-bit consistent with a separate WSDK
repo it can't verify against. WSDK's cacheMatchKeys is being changed
in parallel (ROKT/sdk-web#1798) to only accept an array, for the same
reasons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGJZDPXqhcudxi94RDKfGu
@sonarqubecloud

sonarqubecloud Bot commented Sep 8, 2026

Copy link
Copy Markdown

Comment thread kits/rokt/src/Rokt-Kit.ts
}

private buildCacheMatchKeys(identifier: unknown, attributes: Record<string, unknown>): string | undefined {
private buildCacheMatchKeys(identifier: unknown): string[] | undefined {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: string

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.

5 participants