@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.
@@ -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
- const PATH_LIMIT = 12;
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
- this.leaving = true;
351
- try {
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
- if (control) {
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 || control.matches('a[href]'))
419
- ? describeButtonClick(control, path)
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
- control.matches('a[href]:not([role="button"])')
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 = control as HTMLInputElement;
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
- const queued = this.queuedForUpload();
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
- const checkoutPath = /(?:^|\/)(?:checkouts?|payment|order|kasse|paiement|commander?|pago)(?:\/|$)/i;
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) ? 'checkout_path' : 'other';
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
- // "Buy now" and "Purchase" count only on a checkout route, and never on a plain
266
- // link, which is navigation.
267
- function isCommitLabel(text: string, isLink: boolean, pathname: string): boolean {
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 (steps.test(text)) {
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, 12)) {
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. `full` adds the
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
- /^#[/!]/.test(hash) || (queryStart !== -1 && route.indexOf('=') === -1);
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 = (queryStart === -1
176
- ? beforeHash
177
- : beforeHash.slice(0, queryStart)
178
- ).replace(USERINFO, '$1');
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;
@@ -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
- return names.length ? `${page.path} (params: ${names.join()})` : page.path;
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
- )}, title: ${title})`
669
+ )})`
664
670
  );
665
671
 
666
672
  this.mpInstance._Events.logEvent(event);