@mparticle/web-sdk 3.20.0 → 3.22.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.
- package/dist/mparticle.common.js +91 -38
- package/dist/mparticle.esm.js +91 -38
- package/dist/mparticle.js +425 -79
- package/dist/types/src/batchUploader.d.ts +2 -0
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +7 -2
- package/dist/types/src/buttonTracking/buttonTracker.d.ts +6 -0
- package/dist/types/src/buttonTracking/checkoutIntent.d.ts +7 -1
- package/dist/types/src/buttonTracking/controls.d.ts +3 -0
- package/dist/types/src/roktManager.d.ts +4 -1
- package/package.json +1 -1
- package/src/batchUploader.ts +33 -1
- package/src/buttonTracking/buttonClickEvent.ts +6 -0
- package/src/buttonTracking/buttonDescription.ts +92 -8
- package/src/buttonTracking/buttonTracker.ts +92 -8
- package/src/buttonTracking/checkoutIntent.ts +199 -66
- package/src/buttonTracking/controls.ts +150 -32
- package/src/pageViewTracker.ts +7 -1
- package/src/roktManager.ts +16 -1
|
@@ -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,5 +1,6 @@
|
|
|
1
|
-
export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet';
|
|
2
|
-
export type
|
|
1
|
+
export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet' | 'link';
|
|
2
|
+
export type LinkType = 'same_page' | 'outbound' | 'download';
|
|
3
|
+
export type ButtonLabelSource = 'labelledby' | 'aria' | 'text' | 'label' | 'alt' | 'title' | 'value' | 'none';
|
|
3
4
|
export interface IButtonClick {
|
|
4
5
|
label?: string;
|
|
5
6
|
labelSource: ButtonLabelSource;
|
|
@@ -12,5 +13,9 @@ export interface IButtonClick {
|
|
|
12
13
|
form?: string;
|
|
13
14
|
element: ButtonElementKind;
|
|
14
15
|
type: string;
|
|
16
|
+
linkType?: LinkType;
|
|
17
|
+
linkDomain?: string;
|
|
18
|
+
fileExtension?: string;
|
|
19
|
+
checked?: boolean;
|
|
15
20
|
}
|
|
16
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;
|
|
@@ -8,7 +8,7 @@ import { IStore, LocalSessionAttributes } from "./store";
|
|
|
8
8
|
import { IErrorReportingService, ILoggingService } from "./reporting/types";
|
|
9
9
|
import { IRoktLauncherOptions } from "./roktLauncherOptions";
|
|
10
10
|
import IntegrationCapture from "./integrationCapture";
|
|
11
|
-
import type { CheckoutIntentKind } from "./buttonTracking/checkoutIntent";
|
|
11
|
+
import type { CheckoutIntentKind, CheckoutIntentSource } from "./buttonTracking/checkoutIntent";
|
|
12
12
|
export type RoktAttributeValueArray = Array<string | number | boolean>;
|
|
13
13
|
export type RoktAttributeValueType = string | number | boolean | undefined | null;
|
|
14
14
|
export type RoktAttributeValue = RoktAttributeValueType | RoktAttributeValueArray;
|
|
@@ -46,6 +46,8 @@ export interface RoktKitFilterSettings {
|
|
|
46
46
|
export interface ICheckoutIntentSignal {
|
|
47
47
|
kind: CheckoutIntentKind;
|
|
48
48
|
t: number;
|
|
49
|
+
source?: CheckoutIntentSource;
|
|
50
|
+
pathname?: string;
|
|
49
51
|
}
|
|
50
52
|
export interface IRoktKitSettings {
|
|
51
53
|
accountId?: string;
|
|
@@ -123,6 +125,7 @@ export default class RoktManager {
|
|
|
123
125
|
private watchRouteChanges;
|
|
124
126
|
private notifyRouteChange;
|
|
125
127
|
isCheckoutIntentSuppressed(): boolean;
|
|
128
|
+
isCheckoutIntentLive(): boolean;
|
|
126
129
|
notifyCheckoutIntent(signal: ICheckoutIntentSignal, pathname?: string): void;
|
|
127
130
|
discardCheckoutIntents(): void;
|
|
128
131
|
private flushCheckoutIntents;
|
package/package.json
CHANGED
package/src/batchUploader.ts
CHANGED
|
@@ -52,6 +52,11 @@ export class BatchUploader {
|
|
|
52
52
|
private exitHandler: (() => void) | null = null;
|
|
53
53
|
private visibilityChangeHandler: (() => void) | null = null;
|
|
54
54
|
private destroyed = false;
|
|
55
|
+
// The order this page's uploads first picked each batch up in, by request
|
|
56
|
+
// id. Batches enter uploads in creation order, so this keeps that order for
|
|
57
|
+
// batches that share a millisecond.
|
|
58
|
+
private batchOrder = new Map<string, number>();
|
|
59
|
+
private nextBatchOrder = 0;
|
|
55
60
|
|
|
56
61
|
/**
|
|
57
62
|
* Creates an instance of a BatchUploader
|
|
@@ -444,12 +449,27 @@ export class BatchUploader {
|
|
|
444
449
|
// Clear out pending batches to avoid any potential duplication
|
|
445
450
|
const batchesToUpload = this.batchesQueuedForProcessing;
|
|
446
451
|
this.batchesQueuedForProcessing = [];
|
|
452
|
+
batchesToUpload.forEach(batch => {
|
|
453
|
+
if (!this.batchOrder.has(batch.source_request_id)) {
|
|
454
|
+
this.batchOrder.set(batch.source_request_id, this.nextBatchOrder++);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
447
457
|
|
|
448
458
|
const batchesThatDidNotUpload = await this.uploadBatches(
|
|
449
459
|
batchesToUpload,
|
|
450
460
|
useBeacon
|
|
451
461
|
);
|
|
452
462
|
|
|
463
|
+
// Only a batch that will be retried needs its place kept.
|
|
464
|
+
const retriedIds = (batchesThatDidNotUpload || []).map(
|
|
465
|
+
batch => batch.source_request_id
|
|
466
|
+
);
|
|
467
|
+
batchesToUpload.forEach(batch => {
|
|
468
|
+
if (retriedIds.indexOf(batch.source_request_id) === -1) {
|
|
469
|
+
this.batchOrder.delete(batch.source_request_id);
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
453
473
|
// Batches that do not successfully upload are added back to the process queue
|
|
454
474
|
// in the order they were created so that we can attempt re-transmission in
|
|
455
475
|
// the same sequence. This is to prevent any potential data corruption.
|
|
@@ -475,7 +495,19 @@ export class BatchUploader {
|
|
|
475
495
|
}
|
|
476
496
|
|
|
477
497
|
private storeBatchesQueuedForProcessing(): void {
|
|
478
|
-
|
|
498
|
+
// Another upload may have stored its failed batches while this one was
|
|
499
|
+
// in flight. Merge them in creation order, whichever upload finished
|
|
500
|
+
// first, so they are not overwritten and quota trimming drops the oldest.
|
|
501
|
+
const orderOf = (batch: Batch): number =>
|
|
502
|
+
this.batchOrder.get(batch.source_request_id) ?? -1;
|
|
503
|
+
const batches = [
|
|
504
|
+
...this.batchesQueuedForProcessing,
|
|
505
|
+
...(this.batchVault.retrieve() ?? []),
|
|
506
|
+
].sort(
|
|
507
|
+
(a, b) =>
|
|
508
|
+
orderOf(a) - orderOf(b) ||
|
|
509
|
+
(a.timestamp_unixtime_ms ?? 0) - (b.timestamp_unixtime_ms ?? 0)
|
|
510
|
+
);
|
|
479
511
|
|
|
480
512
|
// Nothing to persist: reset offline storage to an empty state.
|
|
481
513
|
if (isEmpty(batches)) {
|
|
@@ -33,12 +33,18 @@ export const buildButtonClickEvent = (
|
|
|
33
33
|
test_id: click.testId,
|
|
34
34
|
controls: click.controls,
|
|
35
35
|
form: click.form,
|
|
36
|
+
link_type: click.linkType,
|
|
37
|
+
link_domain: click.linkDomain,
|
|
38
|
+
file_extension: click.fileExtension,
|
|
36
39
|
};
|
|
37
40
|
for (const key of Object.keys(optional)) {
|
|
38
41
|
if (optional[key]) {
|
|
39
42
|
data[key] = optional[key];
|
|
40
43
|
}
|
|
41
44
|
}
|
|
45
|
+
if (click.checked !== undefined) {
|
|
46
|
+
data.checked = click.checked;
|
|
47
|
+
}
|
|
42
48
|
return {
|
|
43
49
|
messageType: MessageType.PageEvent,
|
|
44
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';
|
|
@@ -8,12 +9,18 @@ import { maskLabel } from './labelMasking';
|
|
|
8
9
|
|
|
9
10
|
// Describes a clicked button for auto button tracking: its masked name, icon,
|
|
10
11
|
// hand-written identifiers and kind. Never reads form field values.
|
|
11
|
-
export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet';
|
|
12
|
+
export type ButtonElementKind = 'button' | 'input' | 'role' | 'wallet' | 'link';
|
|
13
|
+
|
|
14
|
+
// The link clicks page views can't see: ones that stay on the page, leave the site or download a file.
|
|
15
|
+
export type LinkType = 'same_page' | 'outbound' | 'download';
|
|
16
|
+
|
|
17
|
+
const DOWNLOAD_EXTENSIONS = /\.(pdf|zip|csv|txt|ics|docx?|xlsx?|pptx?|dmg|exe|apk|mp3|mp4)$/i;
|
|
12
18
|
|
|
13
19
|
export type ButtonLabelSource =
|
|
14
20
|
| 'labelledby'
|
|
15
21
|
| 'aria'
|
|
16
22
|
| 'text'
|
|
23
|
+
| 'label'
|
|
17
24
|
| 'alt'
|
|
18
25
|
| 'title'
|
|
19
26
|
| 'value'
|
|
@@ -31,6 +38,10 @@ export interface IButtonClick {
|
|
|
31
38
|
form?: string;
|
|
32
39
|
element: ButtonElementKind;
|
|
33
40
|
type: string;
|
|
41
|
+
linkType?: LinkType;
|
|
42
|
+
linkDomain?: string;
|
|
43
|
+
fileExtension?: string;
|
|
44
|
+
checked?: boolean;
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
function elementKind(control: Element): ButtonElementKind {
|
|
@@ -38,7 +49,10 @@ function elementKind(control: Element): ButtonElementKind {
|
|
|
38
49
|
return 'wallet';
|
|
39
50
|
}
|
|
40
51
|
const tag = control.tagName.toLowerCase();
|
|
41
|
-
|
|
52
|
+
if (tag === 'button' || tag === 'input') {
|
|
53
|
+
return tag;
|
|
54
|
+
}
|
|
55
|
+
return control.matches('a[href]:not([role="button"])') ? 'link' : 'role';
|
|
42
56
|
}
|
|
43
57
|
|
|
44
58
|
function textOf(element: Element | null): string {
|
|
@@ -52,9 +66,10 @@ function nonBlank(value: string | null): string | undefined {
|
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
// The button's name as a screen reader would read it, simplified: the first of
|
|
55
|
-
// aria-labelledby, aria-label, own text (or an inner image's alt), title,
|
|
56
|
-
// inputs their value
|
|
57
|
-
//
|
|
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.
|
|
58
73
|
function accessibleName(
|
|
59
74
|
control: Element
|
|
60
75
|
): { raw?: string; source: ButtonLabelSource } {
|
|
@@ -97,6 +112,16 @@ function accessibleName(
|
|
|
97
112
|
// What the browser shows on an input without a value.
|
|
98
113
|
return { raw: type, source: 'value' };
|
|
99
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
|
+
}
|
|
100
125
|
}
|
|
101
126
|
} else if (readable) {
|
|
102
127
|
const text = textOf(control);
|
|
@@ -230,13 +255,62 @@ function controlType(control: Element, element: ButtonElementKind): string {
|
|
|
230
255
|
: 'button';
|
|
231
256
|
}
|
|
232
257
|
|
|
233
|
-
|
|
234
|
-
|
|
258
|
+
interface ILinkDetails {
|
|
259
|
+
type: LinkType;
|
|
260
|
+
domain?: string;
|
|
261
|
+
fileExtension?: string;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Reads the link's own resolved URL parts, never its full href: only the hostname of
|
|
265
|
+
// a link that leaves the site and the extension of a file it downloads travel. Navigation
|
|
266
|
+
// within the site is left to page views, and mailto:, tel: and other schemes are skipped.
|
|
267
|
+
function describeLink(link: Element): ILinkDetails | undefined {
|
|
268
|
+
const href = (link.getAttribute('href') || '').trim();
|
|
269
|
+
if (!href || href.charAt(0) === '#') {
|
|
270
|
+
return { type: 'same_page' };
|
|
271
|
+
}
|
|
272
|
+
if (!(link instanceof HTMLAnchorElement)) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
const protocol = link.protocol.toLowerCase();
|
|
276
|
+
if (protocol === 'javascript:') {
|
|
277
|
+
return { type: 'same_page' };
|
|
278
|
+
}
|
|
279
|
+
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
const domain =
|
|
283
|
+
link.hostname !== window.location.hostname
|
|
284
|
+
? link.hostname.toLowerCase()
|
|
285
|
+
: undefined;
|
|
286
|
+
const extension = DOWNLOAD_EXTENSIONS.exec(link.pathname);
|
|
287
|
+
if (link.hasAttribute('download') || extension) {
|
|
288
|
+
return {
|
|
289
|
+
type: 'download',
|
|
290
|
+
domain,
|
|
291
|
+
fileExtension: extension ? extension[1].toLowerCase() : undefined,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (domain) {
|
|
295
|
+
return { type: 'outbound', domain };
|
|
296
|
+
}
|
|
297
|
+
return link.pathname === window.location.pathname &&
|
|
298
|
+
link.search === window.location.search
|
|
299
|
+
? { type: 'same_page' }
|
|
300
|
+
: undefined;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Describes a clicked control for auto button tracking. A link counts only when page
|
|
304
|
+
// views can't see the click, and carries its link type.
|
|
235
305
|
export function describeButtonClick(
|
|
236
306
|
control: Element
|
|
237
307
|
): IButtonClick | undefined {
|
|
308
|
+
let link: ILinkDetails | undefined;
|
|
238
309
|
if (control.matches('a[href]:not([role="button"])')) {
|
|
239
|
-
|
|
310
|
+
link = describeLink(control);
|
|
311
|
+
if (!link) {
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
240
314
|
}
|
|
241
315
|
const element = elementKind(control);
|
|
242
316
|
const name = accessibleName(control);
|
|
@@ -255,5 +329,15 @@ export function describeButtonClick(
|
|
|
255
329
|
form: formIdentifier(control),
|
|
256
330
|
element,
|
|
257
331
|
type: controlType(control, element),
|
|
332
|
+
linkType: link ? link.type : undefined,
|
|
333
|
+
linkDomain: link ? link.domain : undefined,
|
|
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,
|
|
258
342
|
};
|
|
259
343
|
}
|
|
@@ -4,7 +4,8 @@ import { buildButtonClickEvent } from './buttonClickEvent';
|
|
|
4
4
|
import { describeButtonClick, IButtonClick } from './buttonDescription';
|
|
5
5
|
import {
|
|
6
6
|
CheckoutIntentKind,
|
|
7
|
-
|
|
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.
|
|
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
|
-
|
|
260
|
-
?
|
|
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
|
-
|
|
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
|
}
|