@mparticle/web-sdk 3.21.0 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,8 @@ export declare class BatchUploader {
35
35
  private exitHandler;
36
36
  private visibilityChangeHandler;
37
37
  private destroyed;
38
+ private batchOrder;
39
+ private nextBatchOrder;
38
40
  /**
39
41
  * Creates an instance of a BatchUploader
40
42
  * @param {IMParticleWebSDKInstance} mpInstance - the mParticle SDK instance
@@ -1,6 +1,6 @@
1
1
  export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet' | 'link';
2
2
  export type LinkType = 'same_page' | 'outbound' | 'download';
3
- export type ButtonLabelSource = 'labelledby' | 'aria' | 'text' | 'alt' | 'title' | 'value' | 'none';
3
+ export type ButtonLabelSource = 'labelledby' | 'aria' | 'text' | 'label' | 'alt' | 'title' | 'value' | 'none';
4
4
  export interface IButtonClick {
5
5
  label?: string;
6
6
  labelSource: ButtonLabelSource;
@@ -16,5 +16,6 @@ export interface IButtonClick {
16
16
  linkType?: LinkType;
17
17
  linkDomain?: string;
18
18
  fileExtension?: string;
19
+ checked?: boolean;
19
20
  }
20
21
  export declare function describeButtonClick(control: Element): IButtonClick | undefined;
@@ -15,6 +15,9 @@ export declare class ButtonTracker {
15
15
  private pagePath;
16
16
  private pageEvents;
17
17
  private pending;
18
+ private uploadTimerId;
19
+ private uploadHeld;
20
+ private leaving;
18
21
  constructor(mpInstance: IMParticleWebSDKInstance);
19
22
  get isActive(): boolean;
20
23
  init(options: IButtonTrackerOptions): void;
@@ -28,6 +31,9 @@ export declare class ButtonTracker {
28
31
  private readonly onEvent;
29
32
  private observe;
30
33
  private logClick;
34
+ private queuedForUpload;
35
+ private uploadNow;
36
+ private cancelUpload;
31
37
  private emit;
32
38
  private eventsEnabled;
33
39
  private isKitSuppressed;
@@ -1,3 +1,9 @@
1
1
  export type CheckoutIntentKind = 'commit' | 'wallet' | 'checkout_step' | 'frame';
2
+ export type CheckoutIntentSource = 'vendor' | 'label' | 'brand' | 'frame' | 'submit';
3
+ export interface ICheckoutIntent {
4
+ kind: Exclude<CheckoutIntentKind, 'frame'>;
5
+ source: CheckoutIntentSource;
6
+ }
7
+ export declare function classifyIntent(control: Element, pathname: string): ICheckoutIntent | undefined;
2
8
  export declare function classifyIntentControl(control: Element, pathname: string): Exclude<CheckoutIntentKind, 'frame'> | undefined;
3
- export declare function classifyIntentFrame(target: EventTarget | null, win: Window): 'frame' | undefined;
9
+ export declare function classifyIntentFrame(target: EventTarget | null, win: Window): 'wallet' | 'frame' | undefined;
@@ -1,6 +1,9 @@
1
1
  export declare const CONTROL_SELECTOR = "button, input[type=\"submit\"], input[type=\"button\"], input[type=\"image\"], [role=\"button\"]";
2
2
  export declare function hasComposedAncestor(element: Element, selector: string): boolean;
3
3
  export declare function isRoktElement(element: Element): boolean;
4
+ export declare function vendorButtonKind(control: Element): 'wallet' | 'checkout_step' | 'none' | undefined;
4
5
  export declare function isWalletElement(element: Element): boolean;
6
+ export declare function isQuietIntentControl(control: Element, isVendorWallet: boolean): boolean;
5
7
  export declare function findControl(path: Array<EventTarget | null>): Element | undefined;
6
8
  export declare function isUnsafeTextSource(element: Element): boolean;
9
+ export declare function isUnsafeLabelSource(label: Element): boolean;
@@ -0,0 +1,10 @@
1
+ import { valueof } from './utils';
2
+ export declare const UrlMode: {
3
+ readonly Path: "path";
4
+ readonly Full: "full";
5
+ };
6
+ export type UrlMode = valueof<typeof UrlMode>;
7
+ export declare const EXCLUDED_URL_QUERY_PARAMS: string[];
8
+ export declare const EXCLUDED_URL_FRAGMENT_PARAMS: string[];
9
+ export declare const cleanUrl: (href: string, mode: UrlMode) => string;
10
+ export declare const cleanPageHref: () => string;
@@ -3,6 +3,8 @@ import { BaseEvent } from './sdkRuntimeModels';
3
3
  import { Dictionary } from './utils';
4
4
  export declare const WIN_APV_KEY = "__mpApv__";
5
5
  export declare const ALLOWED_QUERY_PARAMS: string[];
6
+ export declare const ALLOWED_QUERY_PARAM_PREFIXES: string[];
7
+ export declare const isAllowedQueryParam: (name: string) => boolean;
6
8
  interface IPageSnapshot {
7
9
  path: string;
8
10
  params: Dictionary<string>;
@@ -5,10 +5,11 @@ import { Dictionary, AttributeValue } from "./utils";
5
5
  import { SDKIdentityApi } from "./identity.interfaces";
6
6
  import { SDKLoggerApi } from "./sdkRuntimeModels";
7
7
  import { IStore, LocalSessionAttributes } from "./store";
8
+ import { MPID } from "@mparticle/web-sdk";
8
9
  import { IErrorReportingService, ILoggingService } from "./reporting/types";
9
10
  import { IRoktLauncherOptions } from "./roktLauncherOptions";
10
11
  import IntegrationCapture from "./integrationCapture";
11
- import type { CheckoutIntentKind } from "./buttonTracking/checkoutIntent";
12
+ import type { CheckoutIntentKind, CheckoutIntentSource } from "./buttonTracking/checkoutIntent";
12
13
  export type RoktAttributeValueArray = Array<string | number | boolean>;
13
14
  export type RoktAttributeValueType = string | number | boolean | undefined | null;
14
15
  export type RoktAttributeValue = RoktAttributeValueType | RoktAttributeValueArray;
@@ -46,6 +47,8 @@ export interface RoktKitFilterSettings {
46
47
  export interface ICheckoutIntentSignal {
47
48
  kind: CheckoutIntentKind;
48
49
  t: number;
50
+ source?: CheckoutIntentSource;
51
+ pathname?: string;
49
52
  }
50
53
  export interface IRoktKitSettings {
51
54
  accountId?: string;
@@ -80,6 +83,7 @@ export default class RoktManager {
80
83
  kit: IRoktKit;
81
84
  filters: RoktKitFilterSettings;
82
85
  private currentUser;
86
+ private createFilteredUser?;
83
87
  private messageQueue;
84
88
  private sandbox;
85
89
  private placementAttributesMapping;
@@ -114,15 +118,17 @@ export default class RoktManager {
114
118
  * @param {Function} captureTiming - Function to capture performance timing marks
115
119
  * @param {IErrorReportingService} errorReporter - Dispatcher for error/warning reporting
116
120
  * @param {ILoggingService} loggingService - Dispatcher for informational logging
121
+ * @param {Function} createFilteredUser - Builds a user as the Rokt connection's filters and the data plan allow
117
122
  *
118
123
  * @throws Logs error to console if placementAttributesMapping parsing fails
119
124
  */
120
- init(roktConfig: IKitConfigs, filteredUser: IMParticleUser, identityService: SDKIdentityApi, store: IStore, logger?: SDKLoggerApi, options?: IRoktOptions, captureTiming?: (metricsName: string) => void, errorReporter?: IErrorReportingService, loggingService?: ILoggingService, integrationCapture?: IntegrationCapture): void;
125
+ init(roktConfig: IKitConfigs, filteredUser: IMParticleUser, identityService: SDKIdentityApi, store: IStore, logger?: SDKLoggerApi, options?: IRoktOptions, captureTiming?: (metricsName: string) => void, errorReporter?: IErrorReportingService, loggingService?: ILoggingService, integrationCapture?: IntegrationCapture, createFilteredUser?: (mpid: MPID) => IMParticleUser): void;
121
126
  get isInitialized(): boolean;
122
127
  attachKit(kit: IRoktKit): void;
123
128
  private watchRouteChanges;
124
129
  private notifyRouteChange;
125
130
  isCheckoutIntentSuppressed(): boolean;
131
+ isCheckoutIntentLive(): boolean;
126
132
  notifyCheckoutIntent(signal: ICheckoutIntentSignal, pathname?: string): void;
127
133
  discardCheckoutIntents(): void;
128
134
  private flushCheckoutIntents;
@@ -143,6 +149,7 @@ export default class RoktManager {
143
149
  * });
144
150
  */
145
151
  selectPlacements(options: IRoktSelectPlacementsOptions): Promise<IRoktSelection>;
152
+ private logFilteredIdentity;
146
153
  /**
147
154
  * Hashes attributes and returns both original and hashed versions
148
155
  * with Rokt-compatible key names (like emailsha256, mobilesha256)
@@ -19,6 +19,7 @@ import { IErrorLogMessage, IMParticleWebSDKInstance, IntegrationDelays } from '.
19
19
  import Constants from './constants';
20
20
  import RoktManager, { IRoktLauncherOptions } from './roktManager';
21
21
  import { IConsoleLogger } from './logger';
22
+ import { UrlMode } from './pageUrl';
22
23
  import { ErrorCodes, IErrorReportingService, ILoggingService } from './reporting/types';
23
24
  export type SDKEventCustomFlags = Dictionary<any>;
24
25
  export interface SDKEvent {
@@ -194,6 +195,7 @@ export interface IMParticleInstanceManager extends MParticleWebSDK {
194
195
  _instances: Dictionary<IMParticleWebSDKInstance>;
195
196
  _isTestEnv?: boolean;
196
197
  _forceNoLocalStorage?: boolean;
198
+ _cleanUrl(href: string, mode: UrlMode): string;
197
199
  config: SDKInitConfig;
198
200
  isIOS?: boolean;
199
201
  MPSideloadedKit: typeof MPSideloadedKit;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mparticle/web-sdk",
3
- "version": "3.21.0",
3
+ "version": "3.23.0",
4
4
  "description": "mParticle core SDK for web applications",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -3,7 +3,8 @@ import Constants from './constants';
3
3
  import { SDKEvent, SDKEventCustomFlags } from './sdkRuntimeModels';
4
4
  import { convertEvents } from './sdkToEventsApiConverter';
5
5
  import { MessageType, EventType } from './types';
6
- import { getRampNumber, getHref, isEmpty, obfuscateDevData } from './utils';
6
+ import { getRampNumber, isEmpty, obfuscateDevData } from './utils';
7
+ import { cleanPageHref } from './pageUrl';
7
8
  import { SessionStorageVault, LocalStorageVault, StorageResult } from './vault';
8
9
  import {
9
10
  AsyncUploader,
@@ -52,6 +53,11 @@ export class BatchUploader {
52
53
  private exitHandler: (() => void) | null = null;
53
54
  private visibilityChangeHandler: (() => void) | null = null;
54
55
  private destroyed = false;
56
+ // The order this page's uploads first picked each batch up in, by request
57
+ // id. Batches enter uploads in creation order, so this keeps that order for
58
+ // batches that share a millisecond.
59
+ private batchOrder = new Map<string, number>();
60
+ private nextBatchOrder = 0;
55
61
 
56
62
  /**
57
63
  * Creates an instance of a BatchUploader
@@ -199,7 +205,7 @@ export class BatchUploader {
199
205
  Debug: SDKConfig.isDevelopmentMode,
200
206
  ActiveTimeOnSite: _timeOnSiteTimer?.getTimeInForeground() || 0,
201
207
  TotalTimeOnSite: _Store.getTotalTimeOnSite?.() || 0,
202
- PageUrl: getHref() || null,
208
+ PageUrl: cleanPageHref() || null,
203
209
  IsBackgroundAST: true
204
210
  } as SDKEvent;
205
211
 
@@ -444,12 +450,27 @@ export class BatchUploader {
444
450
  // Clear out pending batches to avoid any potential duplication
445
451
  const batchesToUpload = this.batchesQueuedForProcessing;
446
452
  this.batchesQueuedForProcessing = [];
453
+ batchesToUpload.forEach(batch => {
454
+ if (!this.batchOrder.has(batch.source_request_id)) {
455
+ this.batchOrder.set(batch.source_request_id, this.nextBatchOrder++);
456
+ }
457
+ });
447
458
 
448
459
  const batchesThatDidNotUpload = await this.uploadBatches(
449
460
  batchesToUpload,
450
461
  useBeacon
451
462
  );
452
463
 
464
+ // Only a batch that will be retried needs its place kept.
465
+ const retriedIds = (batchesThatDidNotUpload || []).map(
466
+ batch => batch.source_request_id
467
+ );
468
+ batchesToUpload.forEach(batch => {
469
+ if (retriedIds.indexOf(batch.source_request_id) === -1) {
470
+ this.batchOrder.delete(batch.source_request_id);
471
+ }
472
+ });
473
+
453
474
  // Batches that do not successfully upload are added back to the process queue
454
475
  // in the order they were created so that we can attempt re-transmission in
455
476
  // the same sequence. This is to prevent any potential data corruption.
@@ -475,7 +496,19 @@ export class BatchUploader {
475
496
  }
476
497
 
477
498
  private storeBatchesQueuedForProcessing(): void {
478
- const batches = this.batchesQueuedForProcessing;
499
+ // Another upload may have stored its failed batches while this one was
500
+ // in flight. Merge them in creation order, whichever upload finished
501
+ // first, so they are not overwritten and quota trimming drops the oldest.
502
+ const orderOf = (batch: Batch): number =>
503
+ this.batchOrder.get(batch.source_request_id) ?? -1;
504
+ const batches = [
505
+ ...this.batchesQueuedForProcessing,
506
+ ...(this.batchVault.retrieve() ?? []),
507
+ ].sort(
508
+ (a, b) =>
509
+ orderOf(a) - orderOf(b) ||
510
+ (a.timestamp_unixtime_ms ?? 0) - (b.timestamp_unixtime_ms ?? 0)
511
+ );
479
512
 
480
513
  // Nothing to persist: reset offline storage to an empty state.
481
514
  if (isEmpty(batches)) {
@@ -42,6 +42,9 @@ export const buildButtonClickEvent = (
42
42
  data[key] = optional[key];
43
43
  }
44
44
  }
45
+ if (click.checked !== undefined) {
46
+ data.checked = click.checked;
47
+ }
45
48
  return {
46
49
  messageType: MessageType.PageEvent,
47
50
  name: BUTTON_CLICK_EVENT_NAME,
@@ -1,6 +1,7 @@
1
1
  import { isFunction } from '../utils';
2
2
  import {
3
3
  hasComposedAncestor,
4
+ isUnsafeLabelSource,
4
5
  isUnsafeTextSource,
5
6
  isWalletElement,
6
7
  } from './controls';
@@ -19,6 +20,7 @@ export type ButtonLabelSource =
19
20
  | 'labelledby'
20
21
  | 'aria'
21
22
  | 'text'
23
+ | 'label'
22
24
  | 'alt'
23
25
  | 'title'
24
26
  | 'value'
@@ -39,6 +41,7 @@ export interface IButtonClick {
39
41
  linkType?: LinkType;
40
42
  linkDomain?: string;
41
43
  fileExtension?: string;
44
+ checked?: boolean;
42
45
  }
43
46
 
44
47
  function elementKind(control: Element): ButtonElementKind {
@@ -63,9 +66,10 @@ function nonBlank(value: string | null): string | undefined {
63
66
  }
64
67
 
65
68
  // The button's name as a screen reader would read it, simplified: the first of
66
- // aria-labelledby, aria-label, own text (or an inner image's alt), title, and for
67
- // inputs their value. The text sources are skipped for controls that hold form
68
- // fields or more than four children, so a typed value is never read.
69
+ // aria-labelledby, aria-label, own text (or an inner image's alt), title, for
70
+ // inputs their value, and for checkboxes and radios their label's text. The text
71
+ // sources are skipped for controls that hold form fields or more than four
72
+ // children, so a typed value is never read.
69
73
  function accessibleName(
70
74
  control: Element
71
75
  ): { raw?: string; source: ButtonLabelSource } {
@@ -108,6 +112,16 @@ function accessibleName(
108
112
  // What the browser shows on an input without a value.
109
113
  return { raw: type, source: 'value' };
110
114
  }
115
+ } else if (type === 'checkbox' || type === 'radio') {
116
+ // The label the browser ties to the box, never the box's value.
117
+ const label = control.labels ? control.labels[0] : undefined;
118
+ const text =
119
+ label && !isUnsafeLabelSource(label) && label.textContent
120
+ ? label.textContent.trim()
121
+ : '';
122
+ if (text) {
123
+ return { raw: text, source: 'label' };
124
+ }
111
125
  }
112
126
  } else if (readable) {
113
127
  const text = textOf(control);
@@ -318,5 +332,12 @@ export function describeButtonClick(
318
332
  linkType: link ? link.type : undefined,
319
333
  linkDomain: link ? link.domain : undefined,
320
334
  fileExtension: link ? link.fileExtension : undefined,
335
+ // The box as the click left it: the browser toggles it before the click
336
+ // reaches listeners, and puts it back if the click is cancelled.
337
+ checked:
338
+ control instanceof HTMLInputElement &&
339
+ (control.type === 'checkbox' || control.type === 'radio')
340
+ ? control.checked
341
+ : undefined,
321
342
  };
322
343
  }
@@ -4,7 +4,8 @@ import { buildButtonClickEvent } from './buttonClickEvent';
4
4
  import { describeButtonClick, IButtonClick } from './buttonDescription';
5
5
  import {
6
6
  CheckoutIntentKind,
7
- classifyIntentControl,
7
+ CheckoutIntentSource,
8
+ classifyIntent,
8
9
  classifyIntentFrame,
9
10
  } from './checkoutIntent';
10
11
  import { findControl } from './controls';
@@ -19,6 +20,9 @@ const PATH_LIMIT = 12;
19
20
  // A ceiling, not a dedupe: a held Enter key or browser automation fires a
20
21
  // trusted click per repeat, and each one is forwarded to every kit.
21
22
  const PAGE_EVENT_LIMIT = 100;
23
+ // A click uploads at once. Taps that follow it this quickly are held and share
24
+ // one upload once they stop.
25
+ const UPLOAD_DEBOUNCE_MS = 100;
22
26
 
23
27
  interface IBtnState {
24
28
  tracker?: ButtonTracker;
@@ -77,7 +81,7 @@ const CAPTURE_PASSIVE: AddEventListenerOptions = {
77
81
  const PASSIVE: AddEventListenerOptions = { passive: true };
78
82
 
79
83
  // Logs every button click as an mParticle event, and hands checkout intent to the
80
- // Rokt kit as a frozen {kind, t}. Submits and wallet frame focus feed the kit only.
84
+ // Rokt kit as a frozen {kind, t, source, pathname}. Submits and wallet frame focus feed the kit only.
81
85
  export class ButtonTracker {
82
86
  private readonly mpInstance: IMParticleWebSDKInstance;
83
87
  private active = false;
@@ -90,6 +94,10 @@ export class ButtonTracker {
90
94
  private pageEvents = 0;
91
95
  // Clicks read at dispatch and waiting for the next paint.
92
96
  private pending: Array<() => void> = [];
97
+ private uploadTimerId: ReturnType<typeof setTimeout> | null = null;
98
+ private uploadHeld = false;
99
+ // Set while the page is left, when the SDK's exit upload sends the queue.
100
+ private leaving = false;
93
101
 
94
102
  constructor(mpInstance: IMParticleWebSDKInstance) {
95
103
  this.mpInstance = mpInstance;
@@ -181,6 +189,7 @@ export class ButtonTracker {
181
189
  this.lastSent = {};
182
190
  this.pagePath = null;
183
191
  this.pageEvents = 0;
192
+ this.cancelUpload();
184
193
  }
185
194
 
186
195
  private enqueue(delivery: () => void): void {
@@ -206,7 +215,14 @@ export class ButtonTracker {
206
215
  event.type === 'pagehide' ||
207
216
  document.visibilityState === 'hidden'
208
217
  ) {
209
- this.drain();
218
+ this.leaving = true;
219
+ try {
220
+ this.drain();
221
+ } finally {
222
+ this.leaving = false;
223
+ }
224
+ // The SDK's exit upload sends the queue, held taps included.
225
+ this.cancelUpload();
210
226
  }
211
227
  };
212
228
 
@@ -244,7 +260,9 @@ export class ButtonTracker {
244
260
  // button that disables or relabels itself is seen as it was clicked.
245
261
  // Logging and the kit hand-off wait for the next paint.
246
262
  let click: IButtonClick | undefined;
263
+ let box: HTMLInputElement | undefined;
247
264
  let kind: CheckoutIntentKind | undefined;
265
+ let source: CheckoutIntentSource | undefined;
248
266
  let unconfirmedCommit = false;
249
267
  if (layer === 'click') {
250
268
  // The composed path is only populated during dispatch.
@@ -255,10 +273,15 @@ export class ButtonTracker {
255
273
  );
256
274
  if (control) {
257
275
  click = logsEvents ? describeButtonClick(control) : undefined;
276
+ if (click && click.checked !== undefined) {
277
+ box = control as HTMLInputElement;
278
+ }
258
279
  // Checkout intent is only worked out when the kit can use it.
259
- kind = signalsKit
260
- ? classifyIntentControl(control, pathname)
280
+ const intent = signalsKit
281
+ ? classifyIntent(control, pathname)
261
282
  : undefined;
283
+ kind = intent ? intent.kind : undefined;
284
+ source = intent ? intent.source : undefined;
262
285
  }
263
286
  } else if (layer === 'submit') {
264
287
  const submitter = (event as Event & { submitter?: Element | null })
@@ -268,8 +291,11 @@ export class ButtonTracker {
268
291
  // commit click, which is known once that click is delivered.
269
292
  if (!submitter) {
270
293
  kind = 'commit';
294
+ source = 'submit';
271
295
  } else if (control) {
272
- kind = classifyIntentControl(control, pathname);
296
+ const intent = classifyIntent(control, pathname);
297
+ kind = intent ? intent.kind : undefined;
298
+ source = intent ? intent.source : undefined;
273
299
  }
274
300
  unconfirmedCommit = !submitter;
275
301
  }
@@ -284,6 +310,7 @@ export class ButtonTracker {
284
310
  // Read after the blur settles: some browsers have not moved
285
311
  // focus into the frame yet when the window blurs.
286
312
  kind = classifyIntentFrame(document.activeElement, window);
313
+ source = 'frame';
287
314
  } else if (
288
315
  unconfirmedCommit &&
289
316
  t - this.lastCommitClick > SUBMIT_AFTER_COMMIT_CLICK_MS
@@ -291,10 +318,21 @@ export class ButtonTracker {
291
318
  kind = undefined;
292
319
  }
293
320
  if (click && logsEvents) {
321
+ // The box as this click left it. The browser toggles it
322
+ // before dispatch and puts it back once a cancelled click
323
+ // is over, so only a cancelled click changes the reading
324
+ // taken at dispatch: a checkbox to the other state, a
325
+ // radio to however it was restored.
326
+ if (box && event.defaultPrevented) {
327
+ click.checked =
328
+ box.type === 'checkbox'
329
+ ? !click.checked
330
+ : box.checked;
331
+ }
294
332
  this.logClick(click, pathname);
295
333
  }
296
334
  if (kind) {
297
- this.emit(kind, layer, t, pathname);
335
+ this.emit(kind, source, layer, t, pathname);
298
336
  }
299
337
  } catch (e) {
300
338
  this.log('[error] delivery threw: ' + e);
@@ -317,13 +355,59 @@ export class ButtonTracker {
317
355
  this.pageEvents++;
318
356
  // Restarts an expired session, as public logEvent() and page views do.
319
357
  this.mpInstance._SessionManager.resetSessionTimer();
358
+ const queued = this.queuedForUpload();
320
359
  this.mpInstance._Events.logEvent(
321
360
  buildButtonClickEvent(click, pathname)
322
361
  );
362
+ // Only a click waiting for the batch interval is sent early: a held
363
+ // one goes out when released, and with batching off it already has.
364
+ // A checkbox or radio waits for the batch, since choosing an option
365
+ // doesn't leave the page the way a confirm click can.
366
+ if (
367
+ !this.leaving &&
368
+ click.checked === undefined &&
369
+ this.queuedForUpload() > queued
370
+ ) {
371
+ this.uploadNow();
372
+ }
373
+ }
374
+
375
+ // Events waiting in the upload queue for the next batch interval.
376
+ private queuedForUpload(): number {
377
+ const uploader = this.mpInstance._APIClient.uploader;
378
+ return uploader ? uploader.eventsQueuedForProcessing.length : 0;
379
+ }
380
+
381
+ // Uploads a click without waiting for the batch interval. A tap inside the
382
+ // wait after the last one is held, and each restarts the wait, so a burst
383
+ // after the first click goes out in one request once the taps stop.
384
+ private uploadNow(): void {
385
+ if (this.uploadTimerId === null) {
386
+ this.mpInstance.upload();
387
+ } else {
388
+ clearTimeout(this.uploadTimerId);
389
+ this.uploadHeld = true;
390
+ }
391
+ this.uploadTimerId = setTimeout(() => {
392
+ this.uploadTimerId = null;
393
+ if (this.uploadHeld) {
394
+ this.uploadHeld = false;
395
+ this.mpInstance.upload();
396
+ }
397
+ }, UPLOAD_DEBOUNCE_MS);
398
+ }
399
+
400
+ private cancelUpload(): void {
401
+ if (this.uploadTimerId !== null) {
402
+ clearTimeout(this.uploadTimerId);
403
+ this.uploadTimerId = null;
404
+ }
405
+ this.uploadHeld = false;
323
406
  }
324
407
 
325
408
  private emit(
326
409
  kind: CheckoutIntentKind,
410
+ source: CheckoutIntentSource | undefined,
327
411
  layer: string,
328
412
  t: number,
329
413
  pathname: string
@@ -342,7 +426,7 @@ export class ButtonTracker {
342
426
  this.lastSent[kind] = { t, pathname };
343
427
 
344
428
  this.mpInstance._RoktManager.notifyCheckoutIntent(
345
- Object.freeze({ kind, t }),
429
+ Object.freeze({ kind, t, source, pathname }),
346
430
  pathname
347
431
  );
348
432
  }