Skip to content

feat(rokt): fire a logged-in shopper's express tap before checkout - #1639

Open
crisryantan wants to merge 34 commits into
v3-developmentfrom
feat/checkout-intent-logged-in-express
Open

crisryantan wants to merge 34 commits into
v3-developmentfrom
feat/checkout-intent-logged-in-express

Conversation

@crisryantan

@crisryantan crisryantan commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Builds on #1635, now in v3-development. Hold until the release that carries #1635 is out, so the confirm-click fire and this off-entry fire can be measured separately. Today a commit or wallet tap off every configured entry route is only logged, which misses shoppers who pay with an express wallet or buy-now button on a product or cart page.

This change fires a pre-selection for those taps when mParticle sees the shopper as logged in. Everyone else keeps today's observe-only behaviour.

  • Logged in means mParticle's own isLoggedIn() on the current user, counted only when that user is the one the kit's call goes out for. The kit's filtered user does not expose login state. It is mParticle's persisted identity state, not the site's sign-in: in a workspace where email is a login identity, a returning guest whose email was once identified also counts, and logged_in will count more than site sign-ins. The gates and payload are the same as an on-entry call.
  • Which page. Only an account with one entry fires. With several entries, each names its own target page and nothing ties the tap to one of them, so those taps are observed instead of guessed.
  • Gates. Checkout intent must be live, and the tap needs an identity, the entry's required attributes, an email on the outgoing call and no pre-selection of the same attributes for that target page in the last 30 minutes, the same check the confirm-click guard uses. Changed attributes fire again. A replayed tap does not fire, the page form is never read off the entry route, and steps and frames never fire.
  • At dispatch. The call can wait on identity search or launcher setup, so it goes out only if the same shopper is still logged in. Otherwise it is dropped and rolled back, as is a failed call.

Telemetry

  • The call's rokt.preselecttrigger is intent_commit_off_entry or intent_wallet_off_entry with the entry's path, distinct from the confirm-click fire and still under the intent_ prefix the dispatch-time email check reads.
  • Fired lines carry intent_route=before_entry or left and logged_in=true.
  • Off-entry intent_observed lines carry logged_in, and a logged-in tap that did not fire adds cause: multiple_entries, path_changed, no_valid_identity, active_preselection, missing_attribute or no_strict_email.
  • A call stopped at dispatch logs missed with reason logged_in_dropped.

Testing

Kit Vitest passes, 1,314 tests on current v3-development, with new specs for each gate, several entries, logout or a user switch during both waits, rollback, and a changed-attribute tap firing again while an identical one is skipped.

🤖 Generated with Claude Code

crisryantan and others added 28 commits October 16, 2026 17:05
With event batching on, an auto button click waited in the upload queue
until the next batch interval. A logged click now schedules an upload
300 ms later, and each further click restarts the wait, so a double or
multi-tap goes out as one request carrying every click. 300 ms is about
the gap between the taps of a double tap, and a single click still
leaves well within a second.

The early upload goes through the SDK's upload(). It only runs when the
click is waiting in the upload queue: a click held back until there is
an MPID goes out when it is released, and with batching off every event
already uploads as it is logged. When the page is hidden or left, the
SDK's exit upload already sends the queue, so the pending upload is
dropped rather than sent twice. Teardown, reset and opt-out drop it too.

Clicks are logged exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shortens the upload wait from 300 ms to 100 ms so a single click reaches
the server sooner. Each click still restarts the wait, so rapid taps
share one request.

The burst test now spaces taps 60 ms apart to stay inside the window,
and a new case shows two clicks more than 100 ms apart upload separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… intent is active

Removes the per-entry intentTrigger key. While checkout intent is active
for the workspace (auto button tracking on, nothing suppressing it, and
intent privacy allowed), every pre-selection entry's page view or
pathname trigger waits for the shopper's commit or wallet click and fires
then. There is no delay fallback, so a shopper who never clicks gets no
speculative call, and the call that does go out carries the checkout
details the shopper filled in. When checkout intent is not active, an
entry keeps its dispatchDelayMs exactly as before. Configured trigger
events still dispatch at once.

A preselectionConfig setting that still carries intentTrigger, with any
value, parses and the key is ignored like any other unknown key.

Along with it:
- Checkout intent is recorded for every target page of the account, and
  the intent arrival line is logged while checkout intent applies.
- A fired page view or configured event always guards a later click
  that would resend it with fewer attributes, including one fired before
  checkout intent turned on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checkout intent matched a control's whole label, so a confirm button
whose label carried more than the action fell through. Labels are now
read without bracketed asides such as "(2 items)" and without the
separators, arrows and symbols left at either end once the price is
removed, so "Place order - $43.95", "Place order (2 items)" and
"Pay now ($43.95)" read as a commit. Steps and wallet labels get the
same treatment. Matching stays anchored to the whole remaining label,
so "Place order history" is still not a commit, and an excluded word
anywhere in the original label still vetoes the control.

A control holding a form field or more than four children now gives
the classifier its aria-label, as it already does for button
descriptions. Its text is still never read.

Focus moving into a hosted wallet button frame now signals a wallet
click, which the Rokt kit acts on like a wallet button. That covers
the wallet hosts' own frames and the quick-pay button frames a card
processor serves beside its card fields, matched by stable frame path
segments. Card entry fields and pay-later messages still signal only a
frame, which the kit observes without firing. A frame marked
data-mp-no-track, or inside a marked element, signals nothing.

In the Rokt kit, a checkout intent signal that arrives after consent
removes the kit from the active forwarders is no longer recorded or
logged, and the intent arrival line is no longer sent. No call went
out in that state before either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
prepareAndUpload takes events and batches out of the queue before the
request goes out, so a fetch that a navigation cancels loses them: the
exit beacon then has nothing left to send. That window is now common,
since auto button clicks upload 100 ms after the last tap and a confirm
click is usually followed by a navigation.

Send each fetch upload with keepalive when its body is within 60,000
bytes, under the browser's 64 KB limit for a page's in-flight keepalive
bodies. Larger batches send as before. A keepalive request the browser
refuses throws, and the existing catch requeues the batch for the next
upload. The beacon path is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A click that navigates can leave the page before a delayed upload starts.
The first click now uploads as soon as it is logged. Taps that follow
within 100 ms are held and go out together once they stop. A click
delivered while the page is being left still goes with the exit upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checkout intent now ignores pickers and toggles (radio, tab, option and
checkbox roles, aria-checked, aria-selected, a button inside a radio group
or tab list, aria-expanded or aria-pressed) and anything inside pay-later
messaging. Those clicks are still logged as auto button clicks.

Vendor wallet buttons are read from their own markup, on the control or
the shadow host that owns it: Apple's element and class, Google's button
and card button, PayPal's web components, Shopify's buttons, Amazon Pay
and the Afterpay entry point. A vendor button counts before any label
rule; Apple's set-up and top-up buttons give no intent and PayPal's card
button is a checkout step. A merchant wrapper reusing a vendor class does
not turn its children into wallet buttons.

findControl rules out Rokt elements and data-mp-no-track with one composed
walk from the target instead of one per path entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checkout intent is classified in a fixed order: quiet pickers and
messaging, a vendor's own wallet button, a confirm label on any source,
then the look-alike exclusions, then wallet phrases and steps.

- Labels are normalised: '&' and '+' read 'and', separators become
  spaces, currency codes are dropped, and common wallet spellings are
  aliased. A label over eight words neither matches nor vetoes.
- Confirm labels accept leading 'agree and' or 'review and', trailing
  'now', 'securely', 'with <tender>', totals and item counts, and
  'transaction'. 'Place another order' and gift-card payments stay out.
- Wallet phrases such as 'Pay with <wallet>', 'Buy with <wallet>' and
  'Continue to <wallet>' count on button-like controls, as does a
  wallet's bare name. Account sign-in labels do not.
- Steps add 'Let's check out', 'Continue to review', 'Review order' and
  'Pay with card'.
- On a plain link only placing, submitting or completing an order or
  purchase commits in English.
- English exclusions match whole words; the other languages keep their
  existing substring lists, and their commit and step lists are unchanged.

classifyIntent returns the kind and the rule that recognised it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… buttons

Focus into a hosted frame now reads:
- wallet for PayPal only on its /smart/buttons and /smart/menu frames;
  its other frames, such as card fields and pay-later messages, are frames;
- frame for card-entry and fingerprint frames on adyen.com, braintreegateway.com,
  cybersource.com, squarecdn.com and checkout.com, including their
  subdomains, so every regional Adyen host is covered.

Hosts are matched exactly or as a dotted subdomain, over HTTPS only. A
frame never fires pre-selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…intent live

RoktManager.isCheckoutIntentLive() is true when checkout intent is not
suppressed and the browser exposes composedPath. It reads only config, so
tracker setup and teardown during init never change it. The kit now waits
for the confirm click only when that method exists and returns true; a
core released without it keeps the entry's dispatchDelayMs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A button anywhere inside a radio group, tab list or list box, not only
as a direct child, is a picker and gives no intent. 'Buy now' and
'Purchase' count as a confirm click only on a button-like control on a
checkout route, never on a plain link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s email on a confirm click

- Once this tab fired pre-selection for a target page, another fire for it
  is skipped (skipped/active_preselection) until the shopper arrives or 30
  minutes pass. That covers a later step of the same checkout, a repeated
  confirm click and a retry after a declined card. A page view on the same
  page with changed attributes still re-fires outside the confirm click.
- A fire on the confirm click needs an email the call will carry: an email
  or emailsha256 attribute, the filtered email identity, the identity
  mapped to the hashed email, or the entry's form email. Without one it
  waits, logs missed/no_strict_email once per page, and fires when an email
  arrives while the shopper is still there. Delay fires are unchanged.
- When the shopper moves between two pages of one entry, such as from
  payment to review under a '/checkout/*' entry, the wait for the confirm
  click moves with them without a left_trigger_path or second
  awaiting_intent line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on their own budget

- A commit or wallet tap off every entry, such as an express wallet on a
  cart page, is now logged as skipped/intent_observed instead of dropped
  silently. Steps and frames are still observed on entries only.
- Observed lines carry intent_route (entry, left, before_entry),
  intent_source when the core sends it, has_email and has_identity.
  hold_running and on_trigger_path are dropped. 'left' means this tab saw
  an entry's trigger within 30 minutes. Each route and kind pair is
  observed once per page visit, still at most four per document.
- Fired lines for a confirm click carry intent_route and intent_source.
- Fired and arrived lines go through their own transport and rate limiter,
  so a run of held, missed or observed lines can't suppress them.

Only enums and booleans are added; no label, path, class or value is
logged or stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One entry's pathname moves from its payment page to a trailing wildcard,
so the shopper's confirm click counts on any step of that checkout, the
review page included. A single '*' matches exactly one segment, so the
cart and the confirmation page stay outside it, and the kit still sends
one pre-selection per checkout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one-fire guard on failure

- The email check reads what selectPlacements would send: the kit's cached
  user attributes merged in and the user attribute filters applied. A
  cached email outside the entry's keys counts; an email the filters strip
  does not.
- Setting an email-class user attribute flushes waiting fires.
- A fire whose selectPlacements call throws, rejects or is dropped no
  longer counts as the checkout's pre-selection, so it does not hold off
  the next fire.
- The first fire after the 30-minute window starts a fresh window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…signal

The tracker now hands the kit {kind, t, source, pathname}. source names
the rule that recognised the intent: vendor markup, a label phrase, a
wallet's bare name, a hosted frame, or a form submitted right after a
confirm click. pathname is the path the shopper acted on.

The kit uses a carried path in place of its own route history, which
starts when the kit attaches, so a confirm click buffered before then
still fires on its entry. A path that is not a plain path falls back to
the history. Older kits read only kind and t, and nothing new is logged,
stored or sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… final payload

- Each fire builds one rollback. A call that throws, rejects or is
  dropped restores the active record it replaced and the fire time it
  built on, so an identical retry goes out at once and an earlier
  successful fire in the window is kept.
- The email check reads the payload in the order selectPlacements merges
  it: identities, then filtered and overridden attributes, then session
  attributes. An empty cached email that overwrites an identity email no
  longer passes.
- A confirm-click call that reaches dispatch without an email, for
  example after the only email was removed while it waited, is stopped
  and logs missed/no_strict_email with cause lost_before_dispatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e one-line email miss

Adds specs for a rollback called twice, restoring an unexpired active record, a no-op restore with no
fire recorded, a flush after a move between pages of one entry, the missing-email line logged once
per page, and the identity fallback when the host cannot build the outgoing attributes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssification

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* cov-detection:
  test: cover an unparseable src and a missing getRootNode in click classification
…tive record

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* cov-live-signal:
  test: ignore a submit whose submitter is not a checkout control
  test: cover an unparseable src and a missing getRootNode in click classification
- A commit or wallet tap off every entry route, such as an express
  wallet or buy-now button on a product or cart page, now fires a
  pre-selection when the shopper is logged in. Login is mParticle's own
  isLoggedIn() on the current user, counted only when that user is the
  one the kit's call goes out for. A guest's tap is still only observed.
- The fire is for the account's one entry. An account with several
  entries names several target pages and nothing ties the tap to one of
  them, so its taps are observed instead.
- The usual gates apply: checkout intent live, an identity, the entry's
  required attributes, an email on the outgoing call and one
  pre-selection per checkout. A tap replayed after the shopper moved on
  does not fire, and the page form is never read off the entry route.
- The fired line carries intent_route=before_entry or left and
  logged_in=true. Off-entry intent_observed lines carry logged_in, and a
  logged-in tap that did not fire says why in cause: multiple_entries,
  path_changed, no_valid_identity, active_preselection,
  missing_attribute or no_strict_email.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ill logged in

- The call can wait on identity search or launcher setup. An off-entry
  fire now carries the user it was made for, and the kit sends it only
  if that user is still the kit's user, still the current user and
  still logged in.
- A call that fails the check is dropped, logs
  missed/logged_in_dropped and rolls back its records, so a later tap
  can fire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ser change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…into feat/checkout-intent-logged-in-express

* origin/feat/checkout-intent-fire-gate:
  test: ignore a submit whose submitter is not a checkout control
  test(rokt): check the stored namespace when a rollback removes the active record
  test: cover an unparseable src and a missing getRootNode in click classification
  test(rokt): cover the fire rollback, the quiet move on a flush and the one-line email miss
@codecov

codecov Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.50000% with 1 line in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (v3-development@8259cc4). Learn more about missing BASE report.

Files with missing lines Patch % Lines
kits/rokt/src/Rokt-Kit.ts 90.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@                Coverage Diff                @@
##             v3-development    #1639   +/-   ##
=================================================
  Coverage                  ?   58.49%           
=================================================
  Files                     ?       75           
  Lines                     ?     9093           
  Branches                  ?     2456           
=================================================
  Hits                      ?     5319           
  Misses                    ?     3494           
  Partials                  ?      280           
Flag Coverage Δ
core 49.22% <ø> (?)
rokt-kit 91.94% <97.50%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Base automatically changed from feat/checkout-intent-fire-gate to feat/checkout-intent-live-signal October 18, 2026 13:40
Base automatically changed from feat/checkout-intent-live-signal to feat/checkout-intent-generic-detection October 18, 2026 14:59
Base automatically changed from feat/checkout-intent-generic-detection to feat/auto-button-click-upload-now October 18, 2026 15:51
Base automatically changed from feat/auto-button-click-upload-now to v3-development October 18, 2026 22:32
crisryantan and others added 4 commits October 18, 2026 18:38
The stack below this branch landed on v3-development as one squashed
commit, so the branch now sits on the new base plus its own change: a
logged-in shopper's commit or wallet tap off every entry fires for the
account's one entry.

Storage files and specs this branch never changed take the base as is.
In preselection.ts the off-entry fire keeps going through fireDispatch,
which now carries the base's digest guard and linked rollback. Its
specs expect the digest on the fire mark and on each rollback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The off-entry tap checked for an earlier fire before it collected
attributes, so any fire in the last 30 minutes held it off, even when
the shopper's attributes or email had changed since. It now collects
first and passes the same digest fireDispatch uses, so an identical tap
is still skipped and a changed one goes out, as on the entry route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@crisryantan
crisryantan marked this pull request as ready for review October 19, 2026 03:04
@crisryantan
crisryantan requested a review from a team as a code owner October 19, 2026 03:04

@alexs-mparticle alexs-mparticle left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks, this is careful work: the tap-time and dispatch-time user binding, the shared once-per-checkout guard and the rollback paths all hold up, and the tests cover the negative cases well. Kit tests pass at the head (1,277) and on current v3-development (1,294). A few things before merge:

  1. Merge order with #1644. That PR renames the host hook isIntentPrivacyAllowed to isPreselectionPrivacyAllowed. The two merge cleanly as text, but the new "honors the privacy gate" case here still sets the old name, so on the combined tree the tap fires and that test fails (1 of 1,296). Renaming the mock fixes it. Since v3-development has no required checks, whichever lands second should rerun CI after the rename.
  2. Release timing. #1635 is merged but not released yet, and nothing here holds this change back from the same release. Could we hold this until the release with #1635 is out, and say so in the description? Shipped together, the two effects are hard to separate: the off-entry call carries the same rokt.preselecttrigger value as a confirm-click call (intent_wallet|<entry path>), and the arrival line only says fired. If it's cheap, a distinct trigger value for the off-entry fire would let us measure it on its own.
  3. What "logged in" means. isLoggedIn() is mParticle's identity state, not the site's sign-in. It is persisted across page loads, and in workspaces where email is a login ID it can turn true for a guest once any identify carries their email (including the background identify selectPlacements makes when it's passed an email), and stay true on later visits. Privacy-wise that's fine (same gates and payload as the on-entry call), but "guests stay observe-only" isn't quite true there, and logged_in will over-count site logins. Worth a line in the description and the comment, and a check that product is OK with it.

Smaller, inline below.

Comment thread kits/rokt/test/src/preselection.spec.ts Outdated
Comment thread kits/rokt/src/preselection.ts Outdated
Comment thread kits/rokt/src/preselection.ts Outdated
Comment thread kits/rokt/src/preselection.ts
Comment thread kits/rokt/test/src/preselection.spec.ts
crisryantan and others added 2 commits October 19, 2026 16:03
…ut-intent-logged-in-express

* origin/v3-development:
  fix(rokt): skip every pre-selection call under do-not-share-or-sell or GPC (#1644)
  feat(rokt): say why a tab reached the target page without seeing the trigger (#1630)
  fix: send event batch uploads without keepalive (#1642)
  feat(apv): allow s, oq and colour/size query params on auto page views (#1643)
… to the target page

An off-entry tap's call now carries intent_commit_off_entry or
intent_wallet_off_entry, keeping the intent_ prefix the dispatch-time
email check reads, so it can be measured apart from the confirm click.
Its active record is scoped to the entry's target page, as a recovered
fire is, rather than to the tapped page. The logged-in comment says it is
mParticle's persisted identity state, and the privacy-gate case uses the
renamed isPreselectionPrivacyAllowed. A tap while checkout intent is not
live sends nothing and logs nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@crisryantan

Copy link
Copy Markdown
Contributor Author

Thanks Alex. Merged v3-development with the rename, split out the off-entry trigger, and the description now says to hold this until the release with #1635 is out. On logged in, it is spelled out in the comment and the description; I will check with product that counting a returning guest is fine before we merge.

This branch has not been deployed

No deployments
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