@mparticle/web-sdk 3.26.1 → 3.28.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 +1 -1
- package/dist/mparticle.esm.js +1 -1
- package/dist/mparticle.js +622 -110
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +3 -1
- package/dist/types/src/buttonTracking/buttonTracker.d.ts +0 -6
- package/dist/types/src/buttonTracking/controls.d.ts +2 -1
- package/dist/types/src/buttonTracking/labelMasking.d.ts +1 -0
- package/dist/types/src/pageUrl.d.ts +1 -0
- package/dist/types/src/pathMasking.d.ts +9 -0
- package/package.json +1 -1
- package/src/batchUploader.ts +25 -9
- package/src/buttonTracking/buttonClickEvent.ts +9 -3
- package/src/buttonTracking/buttonDescription.ts +325 -17
- package/src/buttonTracking/buttonTracker.ts +21 -68
- package/src/buttonTracking/checkoutIntent.ts +63 -9
- package/src/buttonTracking/controls.ts +54 -2
- package/src/buttonTracking/labelMasking.ts +6 -0
- package/src/pageUrl.ts +58 -9
- package/src/pageViewTracker.ts +9 -3
- package/src/pathMasking.ts +306 -0
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
classifyPaymentFrame,
|
|
17
17
|
PaymentFrameFamily,
|
|
18
18
|
} from './checkoutIntent';
|
|
19
|
-
import { findControl, isExcludedPath } from './controls';
|
|
19
|
+
import { findControl, findRoleControl, isExcludedPath } from './controls';
|
|
20
20
|
|
|
21
21
|
// State lives on `window`, like APV, so a re-executed bundle never doubles the
|
|
22
22
|
// listeners. Inspect `window.__mpBtn__` in a console to see if one is live.
|
|
@@ -26,13 +26,12 @@ const THROTTLE_MS = 2000;
|
|
|
26
26
|
const SUBMIT_AFTER_COMMIT_CLICK_MS = 1000;
|
|
27
27
|
// Apple Pay must begin inside the tap's own handler, so the tap is this recent.
|
|
28
28
|
const APPLE_PAY_AFTER_CLICK_MS = 1000;
|
|
29
|
-
|
|
29
|
+
// How far up from the clicked element a control is looked for. Checkout intent
|
|
30
|
+
// keeps findControl's own 12; logging looks further, past deep product cards.
|
|
31
|
+
const PATH_LIMIT = 20;
|
|
30
32
|
// A ceiling, not a dedupe: a held Enter key or browser automation fires a
|
|
31
33
|
// trusted click per repeat, and each one is forwarded to every kit.
|
|
32
34
|
const PAGE_EVENT_LIMIT = 100;
|
|
33
|
-
// A click uploads at once. Taps that follow it this quickly are held and share
|
|
34
|
-
// one upload once they stop.
|
|
35
|
-
const UPLOAD_DEBOUNCE_MS = 100;
|
|
36
35
|
|
|
37
36
|
interface IBtnState {
|
|
38
37
|
tracker?: ButtonTracker;
|
|
@@ -174,10 +173,6 @@ export class ButtonTracker {
|
|
|
174
173
|
private pageEvents = 0;
|
|
175
174
|
// Clicks read at dispatch and waiting for the next paint.
|
|
176
175
|
private pending: Array<() => void> = [];
|
|
177
|
-
private uploadTimerId: ReturnType<typeof setTimeout> | null = null;
|
|
178
|
-
private uploadHeld = false;
|
|
179
|
-
// Set while the page is left, when the SDK's exit upload sends the queue.
|
|
180
|
-
private leaving = false;
|
|
181
176
|
|
|
182
177
|
constructor(mpInstance: IMParticleWebSDKInstance) {
|
|
183
178
|
this.mpInstance = mpInstance;
|
|
@@ -321,7 +316,6 @@ export class ButtonTracker {
|
|
|
321
316
|
this.lastSent = {};
|
|
322
317
|
this.pagePath = null;
|
|
323
318
|
this.pageEvents = 0;
|
|
324
|
-
this.cancelUpload();
|
|
325
319
|
}
|
|
326
320
|
|
|
327
321
|
private enqueue(delivery: () => void): void {
|
|
@@ -347,14 +341,8 @@ export class ButtonTracker {
|
|
|
347
341
|
event.type === 'pagehide' ||
|
|
348
342
|
document.visibilityState === 'hidden'
|
|
349
343
|
) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
this.drain();
|
|
353
|
-
} finally {
|
|
354
|
-
this.leaving = false;
|
|
355
|
-
}
|
|
356
|
-
// The SDK's exit upload sends the queue, held taps included.
|
|
357
|
-
this.cancelUpload();
|
|
344
|
+
// The SDK's exit upload then sends these clicks with the queue.
|
|
345
|
+
this.drain();
|
|
358
346
|
}
|
|
359
347
|
};
|
|
360
348
|
|
|
@@ -411,12 +399,18 @@ export class ButtonTracker {
|
|
|
411
399
|
: [];
|
|
412
400
|
const excluded = isExcludedPath(path);
|
|
413
401
|
const control = excluded ? undefined : findControl(path, true);
|
|
414
|
-
|
|
402
|
+
const logged =
|
|
403
|
+
control ||
|
|
404
|
+
(excluded
|
|
405
|
+
? undefined
|
|
406
|
+
: findControl(path, true, PATH_LIMIT) ||
|
|
407
|
+
findRoleControl(path, PATH_LIMIT));
|
|
408
|
+
if (logged) {
|
|
415
409
|
// With links alone, only an anchor can be logged.
|
|
416
410
|
click =
|
|
417
411
|
logsEvents &&
|
|
418
|
-
(this.logButtons ||
|
|
419
|
-
? describeButtonClick(
|
|
412
|
+
(this.logButtons || logged.matches('a[href]'))
|
|
413
|
+
? describeButtonClick(logged, path)
|
|
420
414
|
: undefined;
|
|
421
415
|
if (click) {
|
|
422
416
|
asLink = this.logLinks && isLinkClick(click);
|
|
@@ -428,15 +422,17 @@ export class ButtonTracker {
|
|
|
428
422
|
!asLink &&
|
|
429
423
|
!(
|
|
430
424
|
click.linkType === 'internal' &&
|
|
431
|
-
|
|
425
|
+
logged.matches('a[href]:not([role="button"])')
|
|
432
426
|
);
|
|
433
427
|
if (!asLink && !asButton) {
|
|
434
428
|
click = undefined;
|
|
435
429
|
}
|
|
436
430
|
}
|
|
437
431
|
if (click && click.checked !== undefined) {
|
|
438
|
-
box =
|
|
432
|
+
box = logged as HTMLInputElement;
|
|
439
433
|
}
|
|
434
|
+
}
|
|
435
|
+
if (control) {
|
|
440
436
|
// Checkout intent is only worked out when the kit can use it.
|
|
441
437
|
const intent = signalsKit
|
|
442
438
|
? classifyIntent(control, pathname)
|
|
@@ -538,56 +534,13 @@ export class ButtonTracker {
|
|
|
538
534
|
this.pageEvents++;
|
|
539
535
|
// Restarts an expired session, as public logEvent() and page views do.
|
|
540
536
|
this.mpInstance._SessionManager.resetSessionTimer();
|
|
541
|
-
|
|
537
|
+
// No upload of its own, which a click that opens another page would
|
|
538
|
+
// cancel: with batching on, the next batch or the exit upload sends it.
|
|
542
539
|
this.mpInstance._Events.logEvent(
|
|
543
540
|
asLink
|
|
544
541
|
? buildLinkClickEvent(click, pathname)
|
|
545
542
|
: buildButtonClickEvent(click, pathname)
|
|
546
543
|
);
|
|
547
|
-
// Only a click waiting for the batch interval is sent early: a held
|
|
548
|
-
// one goes out when released, and with batching off it already has.
|
|
549
|
-
// A checkbox or radio waits for the batch, since choosing an option
|
|
550
|
-
// doesn't leave the page the way a confirm click can.
|
|
551
|
-
if (
|
|
552
|
-
!this.leaving &&
|
|
553
|
-
click.checked === undefined &&
|
|
554
|
-
this.queuedForUpload() > queued
|
|
555
|
-
) {
|
|
556
|
-
this.uploadNow();
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// Events waiting in the upload queue for the next batch interval.
|
|
561
|
-
private queuedForUpload(): number {
|
|
562
|
-
const uploader = this.mpInstance._APIClient.uploader;
|
|
563
|
-
return uploader ? uploader.eventsQueuedForProcessing.length : 0;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
// Uploads a click without waiting for the batch interval. A tap inside the
|
|
567
|
-
// wait after the last one is held, and each restarts the wait, so a burst
|
|
568
|
-
// after the first click goes out in one request once the taps stop.
|
|
569
|
-
private uploadNow(): void {
|
|
570
|
-
if (this.uploadTimerId === null) {
|
|
571
|
-
this.mpInstance.upload();
|
|
572
|
-
} else {
|
|
573
|
-
clearTimeout(this.uploadTimerId);
|
|
574
|
-
this.uploadHeld = true;
|
|
575
|
-
}
|
|
576
|
-
this.uploadTimerId = setTimeout(() => {
|
|
577
|
-
this.uploadTimerId = null;
|
|
578
|
-
if (this.uploadHeld) {
|
|
579
|
-
this.uploadHeld = false;
|
|
580
|
-
this.mpInstance.upload();
|
|
581
|
-
}
|
|
582
|
-
}, UPLOAD_DEBOUNCE_MS);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
private cancelUpload(): void {
|
|
586
|
-
if (this.uploadTimerId !== null) {
|
|
587
|
-
clearTimeout(this.uploadTimerId);
|
|
588
|
-
this.uploadTimerId = null;
|
|
589
|
-
}
|
|
590
|
-
this.uploadHeld = false;
|
|
591
544
|
}
|
|
592
545
|
|
|
593
546
|
private emit(
|
|
@@ -17,7 +17,25 @@ export type CheckoutIntentKind =
|
|
|
17
17
|
| 'frame'
|
|
18
18
|
| 'cart';
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// A checkout segment may name its kind or its version, as /express-checkout, /checkout-v2 and /checkout-external do.
|
|
21
|
+
// Other hyphenated words make it a content slug, such as /help/checkout-faq or /products/self-checkout-kiosk.
|
|
22
|
+
const CHECKOUT_SEGMENT =
|
|
23
|
+
'(?:(?:express|guest|quick|secure|complete|one-?page|single-?page|mobile)-)?checkouts?' +
|
|
24
|
+
'(?:-(?:v\\d+|\\d+|flow|external|process|step(?:-\\d+)?))?';
|
|
25
|
+
const checkoutPath = new RegExp(
|
|
26
|
+
'(?:^|\\/)(?:' + CHECKOUT_SEGMENT + '|payment|order|kasse|paiement|commander?|pago)(?:\\/|$)',
|
|
27
|
+
'i'
|
|
28
|
+
);
|
|
29
|
+
// A route with a checkout segment itself, not just a payment or order one, which account pages also use.
|
|
30
|
+
const checkoutSegmentPath = new RegExp('(?:^|\\/)' + CHECKOUT_SEGMENT + '(?:\\/|$)', 'i');
|
|
31
|
+
// A confirmation page under a checkout route, such as /checkout/thankyou/<order> or /checkouts/<token>/thank_you, comes
|
|
32
|
+
// after the confirm click. "Complete" and "success" mark one only as the last word of a segment, so /complete-checkout
|
|
33
|
+
// and /checkout/complete-registration stay checkout routes.
|
|
34
|
+
const confirmationPath = /(?:^|[\/_-])(?:thank[-_]?you|thanks|confirmation|confirmed|receipt|received)(?:[\/_-]|$)|(?:^|[\/_-])(?:complete|completed|success)(?:\/|$)/i;
|
|
35
|
+
// A bare "Register" creates an account, rather than placing the order, on a checkout's sign-in or account step or in a
|
|
36
|
+
// form that asks for a password.
|
|
37
|
+
const bareRegister = /^register(?: now)?$/;
|
|
38
|
+
const accountStepPath = /(?:^|\/)(?:register|registration|login|log-in|signin|sign-in|signup|sign-up|account|create-account)(?:\/|$)/i;
|
|
21
39
|
// English look-alikes are whole words; the other languages stay substring
|
|
22
40
|
// matches, since several of them do not separate words with spaces.
|
|
23
41
|
const englishExclusions = new RegExp(
|
|
@@ -63,7 +81,7 @@ const linkCommits = new RegExp(
|
|
|
63
81
|
')$'
|
|
64
82
|
);
|
|
65
83
|
const routeGatedCommits = new RegExp(
|
|
66
|
-
'^(?:buy now|purchase|jetzt kaufen|kaufen|commander|(?:passer|valider) (?:la |ma )?commande|finalizar (?:la |mi )?compra|' +
|
|
84
|
+
'^(?:buy now|purchase|register(?: now)?|complete (?:my |your )?registration|jetzt kaufen|kaufen|commander|(?:passer|valider) (?:la |ma )?commande|finalizar (?:la |mi )?compra|' +
|
|
67
85
|
'comprar ahora|acquista ora|nu kopen|今すぐ購入|購入する|この内容で申し込む)$'
|
|
68
86
|
);
|
|
69
87
|
const steps = new RegExp(
|
|
@@ -81,6 +99,9 @@ const steps = new RegExp(
|
|
|
81
99
|
].join('|') +
|
|
82
100
|
')$'
|
|
83
101
|
);
|
|
102
|
+
// "Save and continue" also saves profile, address and card forms, so it is a step only on a checkout segment's route
|
|
103
|
+
// that isn't an account or confirmation page.
|
|
104
|
+
const routeGatedSteps = /^save and continue$/;
|
|
84
105
|
const CART_NOUN = '(?:(?:my|your|the) )?(?:shopping )?(?:cart|bag|basket)';
|
|
85
106
|
// "Add to bag" and "Add 2 to cart": a click that puts something in the cart.
|
|
86
107
|
const addToCartLabels = new RegExp('^add(?: [^ ]+){0,5} to ' + CART_NOUN + '$');
|
|
@@ -156,7 +177,9 @@ const EDGE =
|
|
|
156
177
|
const EDGE_SYMBOLS = new RegExp('^' + EDGE + '|' + EDGE + '$', 'g');
|
|
157
178
|
|
|
158
179
|
function getIntentRouteClass(pathname: string): 'checkout_path' | 'other' {
|
|
159
|
-
return checkoutPath.test(pathname)
|
|
180
|
+
return checkoutPath.test(pathname) && !confirmationPath.test(pathname)
|
|
181
|
+
? 'checkout_path'
|
|
182
|
+
: 'other';
|
|
160
183
|
}
|
|
161
184
|
|
|
162
185
|
const SEPARATORS = /[-_|·•]/g;
|
|
@@ -262,16 +285,40 @@ export interface ICheckoutIntent {
|
|
|
262
285
|
adds?: true;
|
|
263
286
|
}
|
|
264
287
|
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
function
|
|
288
|
+
// Every field the browser counts as the form's own, form-attribute fields included. Read through the native getter
|
|
289
|
+
// where the browser has one, since a field named "elements" shadows the form's own property.
|
|
290
|
+
function formFields(form: HTMLFormElement): ArrayLike<Element> {
|
|
291
|
+
const descriptor = Object.getOwnPropertyDescriptor(HTMLFormElement.prototype, 'elements');
|
|
292
|
+
return descriptor && descriptor.get ? descriptor.get.call(form) : form.elements;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// The form a button or input submits, which its form attribute can name from outside it.
|
|
296
|
+
function createsAccount(control: Element, pathname: string): boolean {
|
|
297
|
+
const form =
|
|
298
|
+
control instanceof HTMLButtonElement || control instanceof HTMLInputElement
|
|
299
|
+
? control.form
|
|
300
|
+
: control.closest('form');
|
|
301
|
+
return (
|
|
302
|
+
accountStepPath.test(pathname) ||
|
|
303
|
+
(!!form &&
|
|
304
|
+
Array.prototype.some.call(
|
|
305
|
+
formFields(form),
|
|
306
|
+
(field: Element) => field instanceof HTMLInputElement && field.type === 'password'
|
|
307
|
+
))
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// "Buy now", "Purchase" and "Register" count only on a checkout route that isn't a
|
|
312
|
+
// confirmation page, and never on a plain link, which is navigation.
|
|
313
|
+
function isCommitLabel(text: string, isLink: boolean, pathname: string, control: Element): boolean {
|
|
268
314
|
if (isLink) {
|
|
269
315
|
return linkCommits.test(text);
|
|
270
316
|
}
|
|
271
317
|
return (
|
|
272
318
|
commits.test(text) ||
|
|
273
319
|
(routeGatedCommits.test(text) &&
|
|
274
|
-
getIntentRouteClass(pathname) === 'checkout_path'
|
|
320
|
+
getIntentRouteClass(pathname) === 'checkout_path' &&
|
|
321
|
+
!(bareRegister.test(text) && createsAccount(control, pathname)))
|
|
275
322
|
);
|
|
276
323
|
}
|
|
277
324
|
|
|
@@ -311,7 +358,7 @@ export function classifyIntent(
|
|
|
311
358
|
return cart;
|
|
312
359
|
}
|
|
313
360
|
const isLink = control.matches('a[href]:not([role="button"])');
|
|
314
|
-
if (cores.some(text => isCommitLabel(text, isLink, pathname))) {
|
|
361
|
+
if (cores.some(text => isCommitLabel(text, isLink, pathname, control))) {
|
|
315
362
|
return { kind: 'commit', source: 'label' };
|
|
316
363
|
}
|
|
317
364
|
if (
|
|
@@ -332,7 +379,14 @@ export function classifyIntent(
|
|
|
332
379
|
if (isButton && walletBrands.test(text)) {
|
|
333
380
|
return { kind: 'wallet', source: 'brand' };
|
|
334
381
|
}
|
|
335
|
-
if (
|
|
382
|
+
if (
|
|
383
|
+
steps.test(text) ||
|
|
384
|
+
(isButton &&
|
|
385
|
+
routeGatedSteps.test(text) &&
|
|
386
|
+
checkoutSegmentPath.test(pathname) &&
|
|
387
|
+
!confirmationPath.test(pathname) &&
|
|
388
|
+
!accountStepPath.test(pathname))
|
|
389
|
+
) {
|
|
336
390
|
return { kind: 'checkout_step', source: 'label' };
|
|
337
391
|
}
|
|
338
392
|
}
|
|
@@ -283,7 +283,8 @@ export function isExcludedPath(path: Array<EventTarget | null>): boolean {
|
|
|
283
283
|
// found the path not excluded passes `included` so the walk runs once.
|
|
284
284
|
export function findControl(
|
|
285
285
|
path: Array<EventTarget | null>,
|
|
286
|
-
included = false
|
|
286
|
+
included = false,
|
|
287
|
+
limit = 12
|
|
287
288
|
): Element | undefined {
|
|
288
289
|
if (!included && isExcludedPath(path)) {
|
|
289
290
|
return undefined;
|
|
@@ -291,7 +292,7 @@ export function findControl(
|
|
|
291
292
|
let control: Element | undefined;
|
|
292
293
|
let option: Element | undefined;
|
|
293
294
|
let picked = false;
|
|
294
|
-
for (const target of path.slice(0,
|
|
295
|
+
for (const target of path.slice(0, limit)) {
|
|
295
296
|
if (control) {
|
|
296
297
|
break;
|
|
297
298
|
}
|
|
@@ -321,6 +322,57 @@ export function findControl(
|
|
|
321
322
|
return control;
|
|
322
323
|
}
|
|
323
324
|
|
|
325
|
+
// Roles that act as controls without being buttons or links: pickers, tabs,
|
|
326
|
+
// options and links run by script. A role can list fallbacks, so any of its
|
|
327
|
+
// tokens counts.
|
|
328
|
+
const ROLE_CONTROL =
|
|
329
|
+
'[role~="radio"], [role~="menuitemradio"], [role~="checkbox"], [role~="menuitemcheckbox"], ' +
|
|
330
|
+
'[role~="switch"], [role~="option"], [role~="tab"], [role~="link"]';
|
|
331
|
+
|
|
332
|
+
const TYPING_FIELD =
|
|
333
|
+
'input:not([type="submit"]):not([type="button"]):not([type="image"]):not([type="reset"]):not([type="radio"]):not([type="checkbox"]), ' +
|
|
334
|
+
'textarea, select, ' +
|
|
335
|
+
EDITABLE;
|
|
336
|
+
|
|
337
|
+
// The nearest role control on the click path, for logging the click alone:
|
|
338
|
+
// checkout intent and payment choices only ever read what findControl finds.
|
|
339
|
+
// None when anything disabled sits on the path, so a role control around or
|
|
340
|
+
// inside a disabled control never logs the click it suppressed.
|
|
341
|
+
export function findRoleControl(
|
|
342
|
+
path: Array<EventTarget | null>,
|
|
343
|
+
limit: number
|
|
344
|
+
): Element | undefined {
|
|
345
|
+
const entries = path
|
|
346
|
+
.slice(0, limit)
|
|
347
|
+
.filter((target): target is Element => target instanceof Element);
|
|
348
|
+
if (
|
|
349
|
+
entries.some(
|
|
350
|
+
target =>
|
|
351
|
+
target.matches(':disabled, [aria-disabled="true"]') ||
|
|
352
|
+
(target.classList.contains('disabled') &&
|
|
353
|
+
isWalletNamed(target))
|
|
354
|
+
)
|
|
355
|
+
) {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
for (const target of entries) {
|
|
359
|
+
if (target.matches(ROLE_CONTROL)) {
|
|
360
|
+
// A typing field, or anything in an editable region, takes typing
|
|
361
|
+
// whatever role it carries.
|
|
362
|
+
return target.matches(TYPING_FIELD) ||
|
|
363
|
+
hasComposedAncestor(target, EDITABLE)
|
|
364
|
+
? undefined
|
|
365
|
+
: target;
|
|
366
|
+
}
|
|
367
|
+
// A click in a typing field belongs to the field, not to the role
|
|
368
|
+
// control around it.
|
|
369
|
+
if (target.matches(TYPING_FIELD)) {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
|
|
324
376
|
// Text that may hold what a shopper typed or what the page opted out: form
|
|
325
377
|
// fields, editable regions, and anything under data-mp-no-track. `own` is a
|
|
326
378
|
// box the element labels, which holds no typing of its own.
|
|
@@ -42,3 +42,9 @@ export function maskLabel(raw: string | null | undefined): string | undefined {
|
|
|
42
42
|
.trim();
|
|
43
43
|
return masked || undefined;
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
// Masks numbers and amounts in any script as '#', or undefined where the browser
|
|
47
|
+
// can't see every script's digits.
|
|
48
|
+
export function maskNumbers(text: string): string | undefined {
|
|
49
|
+
return digitPatterns ? text.replace(digitPatterns.number, '#') : undefined;
|
|
50
|
+
}
|
package/src/pageUrl.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { isAllowedQueryParam } from './pageViewTracker';
|
|
2
|
+
import { pagePath, pageUrlPath } from './pathMasking';
|
|
2
3
|
import { getHref, valueof } from './utils';
|
|
3
4
|
|
|
4
5
|
// The one rule for a URL that leaves the SDK, shared with kits through
|
|
5
|
-
// `mParticle._cleanUrl`. `path` keeps scheme, host and path
|
|
6
|
+
// `mParticle._cleanUrl`. `path` keeps scheme, host and path, with any path
|
|
7
|
+
// segment that can single out one shopper as `*` (see pageUrlPath). `full` adds the
|
|
6
8
|
// query params an auto page view captures by default and the campaign
|
|
7
|
-
// attribution params, then the fragment.
|
|
9
|
+
// attribution params, then the fragment, a hash route's path masked the same way.
|
|
8
10
|
export const UrlMode = {
|
|
9
11
|
Path: 'path',
|
|
10
12
|
Full: 'full',
|
|
13
|
+
// A path for a log or an error report, masked with the click path's fuller
|
|
14
|
+
// rules, since no output reads it as a page.
|
|
15
|
+
Report: 'report',
|
|
11
16
|
} as const;
|
|
12
17
|
|
|
13
18
|
export type UrlMode = valueof<typeof UrlMode>;
|
|
@@ -79,6 +84,37 @@ export const EXCLUDED_URL_FRAGMENT_PARAMS: string[] = EXCLUDED_URL_QUERY_PARAMS.
|
|
|
79
84
|
const URL_VALUED_QUERY_PARAMS: string[] = ['ref', 'referrer'];
|
|
80
85
|
|
|
81
86
|
const USERINFO = /^((?:[a-z][a-z0-9+.-]*:)?\/\/)[^/]*@/i;
|
|
87
|
+
const ORIGIN = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/]*/i;
|
|
88
|
+
|
|
89
|
+
// Only a URL or a path is masked. A bare value, such as `ref=spring2024`, is a
|
|
90
|
+
// code rather than a page, and stays as written.
|
|
91
|
+
const maskPath = (
|
|
92
|
+
url: string,
|
|
93
|
+
mask: (pathname: string) => string = pageUrlPath
|
|
94
|
+
): string => {
|
|
95
|
+
const origin = ORIGIN.exec(url);
|
|
96
|
+
if (origin) {
|
|
97
|
+
return origin[0] + mask(url.slice(origin[0].length));
|
|
98
|
+
}
|
|
99
|
+
return url.charAt(0) === '/' ? mask(url) : url;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// A hash route keeps its `#` or `#!` and has its path masked as the URL's own.
|
|
103
|
+
// The `&` pairs or flags after the path lose the OAuth/OIDC ones, as a bare
|
|
104
|
+
// fragment's pairs do.
|
|
105
|
+
const maskRoute = (route: string): string => {
|
|
106
|
+
const prefix = /^#!?/.exec(route)[0];
|
|
107
|
+
const pairsStart = route.indexOf('&');
|
|
108
|
+
const pathEnd = pairsStart === -1 ? route.length : pairsStart;
|
|
109
|
+
const pairs = route
|
|
110
|
+
.slice(pathEnd)
|
|
111
|
+
.split('&')
|
|
112
|
+
.slice(1)
|
|
113
|
+
.filter(keepsFragmentPair)
|
|
114
|
+
.map(pair => `&${pair}`)
|
|
115
|
+
.join('');
|
|
116
|
+
return prefix + pageUrlPath(route.slice(prefix.length, pathEnd)) + pairs;
|
|
117
|
+
};
|
|
82
118
|
|
|
83
119
|
const decodeQueryComponent = (component: string): string =>
|
|
84
120
|
decodeURIComponent(component.replace(/\+/g, ' '));
|
|
@@ -140,21 +176,31 @@ const keepsFragmentPair = (pair: string): boolean => {
|
|
|
140
176
|
const cleanFragment = (hash: string): string => {
|
|
141
177
|
const queryStart = hash.indexOf('?');
|
|
142
178
|
const route = queryStart === -1 ? hash : hash.slice(0, queryStart);
|
|
179
|
+
// `#/…`, `#!…`, or a fragment with a `/` before any `&` or `?`, as
|
|
180
|
+
// `#orders/12345678` is in routers that drop the leading slash.
|
|
181
|
+
// A `/` counts only before any `=`, so a bare pair such as
|
|
182
|
+
// `#access_token=abc/def` is never read as a route.
|
|
183
|
+
const isHashRoute = /^#!|^#[^?&=]*\//.test(hash);
|
|
143
184
|
const isRoute =
|
|
144
|
-
|
|
185
|
+
isHashRoute || (queryStart !== -1 && route.indexOf('=') === -1);
|
|
145
186
|
|
|
146
187
|
if (isRoute && queryStart !== -1) {
|
|
147
188
|
const query = cleanQuery(hash.slice(queryStart + 1));
|
|
148
|
-
return route + (query ? `?${query}` : '');
|
|
189
|
+
return maskRoute(route) + (query ? `?${query}` : '');
|
|
149
190
|
}
|
|
150
191
|
|
|
192
|
+
// A plain anchor such as `#reviews` names a part of the page, not a route.
|
|
151
193
|
if (hash.indexOf('=') === -1) {
|
|
152
|
-
return hash;
|
|
194
|
+
return isHashRoute ? maskRoute(hash) : hash;
|
|
153
195
|
}
|
|
154
196
|
|
|
197
|
+
// A hash route's path leads its pairs, as in `#/cb&tab=2`.
|
|
155
198
|
const pairs = hash
|
|
156
199
|
.slice(1)
|
|
157
200
|
.split('&')
|
|
201
|
+
.map((pair, i) =>
|
|
202
|
+
i === 0 && isHashRoute ? maskRoute(`#${pair}`).slice(1) : pair
|
|
203
|
+
)
|
|
158
204
|
.filter(keepsFragmentPair)
|
|
159
205
|
.join('&');
|
|
160
206
|
return pairs ? `#${pairs}` : '';
|
|
@@ -172,10 +218,13 @@ export const cleanUrl = (href: string, mode: UrlMode): string => {
|
|
|
172
218
|
const hash = hashStart === -1 ? '' : href.slice(hashStart);
|
|
173
219
|
const beforeHash = hashStart === -1 ? href : href.slice(0, hashStart);
|
|
174
220
|
const queryStart = beforeHash.indexOf('?');
|
|
175
|
-
const path = (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
221
|
+
const path = maskPath(
|
|
222
|
+
(queryStart === -1
|
|
223
|
+
? beforeHash
|
|
224
|
+
: beforeHash.slice(0, queryStart)
|
|
225
|
+
).replace(USERINFO, '$1'),
|
|
226
|
+
mode === UrlMode.Report ? pagePath : pageUrlPath
|
|
227
|
+
);
|
|
179
228
|
|
|
180
229
|
if (mode !== UrlMode.Full) {
|
|
181
230
|
return path;
|
package/src/pageViewTracker.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { IMParticleWebSDKInstance } from './mp-instance';
|
|
|
2
2
|
import { BaseEvent } from './sdkRuntimeModels';
|
|
3
3
|
import { EventType, MessageType } from './types';
|
|
4
4
|
import { Dictionary, getHref, hasOwnProp, queryStringParser } from './utils';
|
|
5
|
+
import { pagePath, pageUrlPath } from './pathMasking';
|
|
5
6
|
import {
|
|
6
7
|
subscribeToRouteChange,
|
|
7
8
|
supportsHistoryTracking,
|
|
@@ -318,7 +319,8 @@ export const buildPageViewEvent = ({
|
|
|
318
319
|
...paramsToAttributes(params),
|
|
319
320
|
hostname,
|
|
320
321
|
title,
|
|
321
|
-
path
|
|
322
|
+
// Masked here, not in the page snapshot, whose raw path tells one page from the next.
|
|
323
|
+
path: pageUrlPath(path),
|
|
322
324
|
...autoPageViewAttribute(isAutoPageView),
|
|
323
325
|
},
|
|
324
326
|
eventType: EventType.Unknown,
|
|
@@ -422,7 +424,9 @@ const describePage = (page: IPageSnapshot | null): string => {
|
|
|
422
424
|
}
|
|
423
425
|
|
|
424
426
|
const names = page.params.map(({ name }) => name);
|
|
425
|
-
|
|
427
|
+
// A log line is read by no output, so it takes the click path's fuller rules.
|
|
428
|
+
const path = pagePath(page.path);
|
|
429
|
+
return names.length ? `${path} (params: ${names.join()})` : path;
|
|
426
430
|
};
|
|
427
431
|
|
|
428
432
|
// ---------------------------------------------------------------------------
|
|
@@ -657,10 +661,12 @@ export class PageViewTracker {
|
|
|
657
661
|
isAutoPageView: this.isAutoPageView,
|
|
658
662
|
});
|
|
659
663
|
|
|
664
|
+
// A title can name the shopper or an order, so it stays out of the log
|
|
665
|
+
// line, for the same reason describePage omits param values.
|
|
660
666
|
this.log(
|
|
661
667
|
`[fire] deferred flush -> _Events.logEvent(PageView) (page: ${describePage(
|
|
662
668
|
page
|
|
663
|
-
)}
|
|
669
|
+
)})`
|
|
664
670
|
);
|
|
665
671
|
|
|
666
672
|
this.mpInstance._Events.logEvent(event);
|