@thg-agentic/hyper-personalisation-web 3.17.1 → 3.19.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.
@@ -9,8 +9,8 @@
9
9
  * (3.6). Under strict consent it is written only once analytics is granted.
10
10
  */
11
11
 
12
- import { getCookie } from './context.js';
13
- import { uuid } from './shape.js';
12
+ import { getCookie, perRequest } from './context.js';
13
+ import { realId, uuid } from './shape.js';
14
14
  import { storageKey, storageName } from './storage-scope.js';
15
15
 
16
16
  /** How long held events may wait for an answer across pages. A tab left for longer starts over. */
@@ -153,9 +153,14 @@ export function createDeviceCookie({
153
153
  peek: () => id,
154
154
  /** Consent was granted: write what was minted before it. */
155
155
  persist,
156
- /** Consent was refused or withdrawn: the id must not outlive the permission it was kept under. */
156
+ /**
157
+ * Consent was refused or withdrawn, or the user signed out: the id must not outlive the
158
+ * permission or the person it was kept under, so the next event starts a new device, as
159
+ * Segment's `analytics.reset()` does.
160
+ */
157
161
  forget() {
158
162
  writeCookie(name, '', 0, domain);
163
+ id = null;
159
164
  written = false;
160
165
  try {
161
166
  local()?.removeItem(legacyKey);
@@ -168,34 +173,42 @@ export function createDeviceCookie({
168
173
  }
169
174
 
170
175
  /**
171
- * Where device ids come from, from the source's settings (`hp.anonymousId`, ADR-0017): one place,
172
- * never a fallback between two.
176
+ * Where device ids come from, from the source's settings (`hp.anonymousId`, ADR-0017).
173
177
  *
174
- * site_cookie the cookie the settings name, or (no name) `getAnonymousId`, the integration's
175
- * own. Read only: a visitor without it has no device id.
178
+ * site_cookie the cookie the settings name, or (no name) `getAnonymousId`, the integration's own.
176
179
  * hp_cookie Hyper-Personalisation's cookie, minted and kept here. `getAnonymousId` is ignored.
177
180
  * absent settings from before 3.6: `getAnonymousId`, as before.
178
181
  *
179
- * Replaces `cfg.getAnonymousId` to match, and returns the device cookie under `hp_cookie`.
182
+ * With `orOwn`, a visitor the site has no id for (the cookie missing, empty or a placeholder) gets
183
+ * Hyper-Personalisation's cookie rather than none, as analytics.js does: an event with no device id
184
+ * belongs to nobody (3.18).
185
+ *
186
+ * Replaces `cfg.getAnonymousId` to match, and returns the device cookie it may fall back to.
180
187
  */
181
- export function deviceIdFrom(cfg, { carried, mayStore }) {
188
+ export function deviceIdFrom(cfg, { carried = null, mayStore, orOwn = false }) {
182
189
  const setting = cfg.anonymousIdSource;
190
+ const own = () =>
191
+ createDeviceCookie({
192
+ name: storageName('anonymousId'),
193
+ domain: cfg.consentCookieDomain ?? null,
194
+ legacyKey: storageKey(storageName('anonymousId'), cfg.propertyId),
195
+ read: getCookie,
196
+ mint: uuid,
197
+ mayStore,
198
+ carried,
199
+ });
200
+ if (setting?.source === 'hp_cookie') {
201
+ const device = own();
202
+ cfg.getAnonymousId = () => device.get();
203
+ return device;
204
+ }
183
205
  if (setting?.source === 'site_cookie' && setting.cookie) {
184
206
  const name = setting.cookie;
185
207
  cfg.getAnonymousId = () => getCookie(name);
186
- return null;
187
208
  }
188
- if (setting?.source !== 'hp_cookie') return null;
189
- const name = storageName('anonymousId');
190
- const device = createDeviceCookie({
191
- name,
192
- domain: cfg.consentCookieDomain ?? null,
193
- legacyKey: storageKey(name, cfg.propertyId),
194
- read: getCookie,
195
- mint: uuid,
196
- mayStore,
197
- carried,
198
- });
199
- cfg.getAnonymousId = () => device.get();
209
+ if (!orOwn) return null;
210
+ const site = cfg.getAnonymousId;
211
+ const device = own();
212
+ cfg.getAnonymousId = () => realId(perRequest(site, null)) ?? device.get();
200
213
  return device;
201
214
  }
package/src/guard.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Tracking never throws into the host. Every public method runs behind `guard`: a failure is logged
3
+ * and the method answers what a client that could not decide answers. That fails closed, as the CDN
4
+ * stub does before the client starts: no personalisation, no consent headers, nothing sent.
5
+ */
6
+
7
+ /** `throwOnInvalid`: the one failure a caller asked to have thrown at them. */
8
+ export class InvalidEventError extends Error {}
9
+
10
+ const FALLBACKS = {
11
+ inHoldout: () => true,
12
+ personalisationGroup: () => 'excluded',
13
+ servingHeaders: () => ({}),
14
+ recommendationContext: () => ({}),
15
+ flush: () => Promise.resolve(),
16
+ reset: () => undefined,
17
+ };
18
+
19
+ /** `method` behind the guard, answering `name`'s fallback when it throws. */
20
+ export function guard(name, method) {
21
+ const fallback = FALLBACKS[name] ?? (() => null);
22
+ const failed = (err) => {
23
+ console.warn(`[hp] ${String(name)} failed; nothing was sent`, err);
24
+ return fallback();
25
+ };
26
+ return function guarded(...args) {
27
+ try {
28
+ const result = method.apply(this, args);
29
+ return typeof result?.then === 'function'
30
+ ? Promise.resolve(result).catch(failed)
31
+ : result;
32
+ } catch (err) {
33
+ if (err instanceof InvalidEventError) throw err;
34
+ return failed(err);
35
+ }
36
+ };
37
+ }
38
+
39
+ /** Every method of `client` behind the guard, in place. Getters are left as they are. */
40
+ export function guardMethods(client) {
41
+ for (const key of Reflect.ownKeys(client)) {
42
+ const { value } = Object.getOwnPropertyDescriptor(client, key) ?? {};
43
+ if (typeof value === 'function') client[key] = guard(key, value);
44
+ }
45
+ return client;
46
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Marks bootstrap's own identify, from its `identity` option, so it never counts as the earlier
3
+ * user a later host `identify` would be switching away from: the host may name the same person
4
+ * in another form.
5
+ */
6
+ export const IDENTITY_OPTION = Symbol('hp.identityOption');
package/src/index.js CHANGED
@@ -14,7 +14,8 @@
14
14
  * propertyId: 'acme-gb',
15
15
  * consent: oneTrustConsent(),
16
16
  * // Where device ids come from, as the source's settings say (`hp.anonymousId`). Without it,
17
- * // getAnonymousId is the device id; with `hp_cookie` the SDK mints and keeps its own.
17
+ * // getAnonymousId is the device id; with `hp_cookie`, or when the site has none, the SDK mints
18
+ * // and keeps its own.
18
19
  * anonymousIdSource: settings.anonymousId,
19
20
  * getAnonymousId: () => getCookie('device_id'),
20
21
  * getSessionId: () => getCookie('session_id'),
@@ -52,13 +53,16 @@ import {
52
53
  holdoutAssignment,
53
54
  holdoutShares,
54
55
  } from './experiments.js';
56
+ import { guardMethods, InvalidEventError } from './guard.js';
55
57
  import { emailSha256, sha256Hex } from './hash.js';
58
+ import { IDENTITY_OPTION } from './identity-option.js';
56
59
  import { applyPreview } from './preview.js';
57
60
  import {
58
61
  forgetVisit,
59
62
  keepVisit,
60
63
  recommendationContextFrom,
61
64
  } from './recommendation-context.js';
65
+ import { sampledIn } from './sampling.js';
62
66
  import { recordSessionEvent } from './session/record.js';
63
67
  import {
64
68
  clearSessionSignals,
@@ -80,7 +84,11 @@ import {
80
84
  storageKey,
81
85
  storageName,
82
86
  } from './storage-scope.js';
83
- import { Transport } from './transport.js';
87
+ import {
88
+ MAX_EVENT_BYTES,
89
+ Transport,
90
+ byteLength as utf8Bytes,
91
+ } from './transport.js';
84
92
  import { LIB, VERSION } from './version.js';
85
93
 
86
94
  /** The surfaces a `Personalisation Exposed` names, as the public API's exposures do. */
@@ -190,6 +198,19 @@ export { createSocialProof } from './social-proof.js';
190
198
  export { VERSION };
191
199
 
192
200
  let warnedMintAnonymousId = false;
201
+ let warnedHostBasis = false;
202
+
203
+ /**
204
+ * Without a consent adapter the SDK sends under the host's lawful basis: fail-open, so say it once.
205
+ * Requiring `consent` is a breaking change, planned for 4.0.
206
+ */
207
+ function warnHostBasis() {
208
+ if (warnedHostBasis) return;
209
+ warnedHostBasis = true;
210
+ console.warn(
211
+ "[hp] no consent adapter: events are sent under the host's lawful basis (basis 'host'). Pass `consent` to send only once the visitor allows analytics.",
212
+ );
213
+ }
193
214
 
194
215
  /** 3.5's `mintAnonymousId` does nothing since 3.6: where device ids come from is the source's. */
195
216
  function warnMintAnonymousId(debug) {
@@ -200,6 +221,15 @@ function warnMintAnonymousId(debug) {
200
221
  );
201
222
  }
202
223
 
224
+ /** UTF-8 bytes of the event as sent; 0 for one JSON cannot serialise, which delivery drops itself. */
225
+ function serialisedBytes(event) {
226
+ try {
227
+ return utf8Bytes(JSON.stringify(event));
228
+ } catch {
229
+ return 0;
230
+ }
231
+ }
232
+
203
233
  /** Per-call context overrides, given directly or analytics-next style as `{ context }`. */
204
234
  function contextOverrides(options) {
205
235
  if (
@@ -238,6 +268,7 @@ export function createAnalytics(options) {
238
268
  // A consent adapter makes the SDK strict: nothing is sent or stored until analytics is granted.
239
269
  // Without one the host has decided the lawful basis itself, and every event says so (spec 1.5).
240
270
  const strict = Boolean(cfg.consent);
271
+ if (!strict) warnHostBasis();
241
272
  let gate = null;
242
273
  cfg.getConsent = () => (gate ? gate.stamp() : hostConsent());
243
274
  const granted = () => !strict || gate?.status === 'granted';
@@ -253,24 +284,28 @@ export function createAnalytics(options) {
253
284
  const device = deviceIdFrom(cfg, {
254
285
  carried: carried.anonymousId,
255
286
  mayStore: granted,
287
+ orOwn: true,
256
288
  });
257
289
 
258
- // No session from the host: the SDK keeps one (3.15). Never a second beside the host's.
259
- const session =
260
- cfg.getSessionId == null
261
- ? createSessionId({
262
- name: storageName('session'),
263
- domain: cfg.consentCookieDomain ?? null,
264
- mayStore: granted,
265
- })
266
- : null;
267
- if (session) cfg.getSessionId = () => session.get();
290
+ // The host's session when it has one, otherwise the SDK's (3.15; 3.18 when the host's is empty).
291
+ const session = createSessionId({
292
+ name: storageName('session'),
293
+ domain: cfg.consentCookieDomain ?? null,
294
+ mayStore: granted,
295
+ });
296
+ const hostSession = cfg.getSessionId;
297
+ cfg.getSessionId = () =>
298
+ realId(perRequest(hostSession, null)) ?? session.get();
268
299
 
269
300
  const previewing = applyPreview(
270
301
  storageName('preview'),
271
302
  cfg.consentCookieDomain ?? null,
272
303
  );
273
304
 
305
+ /** Delivery problems, logged with `debug`. */
306
+ const reportError = (message, err) =>
307
+ cfg.debug && console.warn('[hp]', message, err ?? '');
308
+
274
309
  const transport =
275
310
  cfg.transport ??
276
311
  new Transport({
@@ -283,7 +318,7 @@ export function createAnalytics(options) {
283
318
  // grant (see onStatus below, which replays on a grant and clears on a refusal).
284
319
  mayPersist: strict ? () => gate?.status === 'granted' : undefined,
285
320
  deferReplay: strict,
286
- onError: (m, e) => cfg.debug && console.warn('[hp]', m, e ?? ''),
321
+ onError: reportError,
287
322
  });
288
323
 
289
324
  // The previous page view's messageId, carried across navigations in sessionStorage, per tenant.
@@ -318,11 +353,26 @@ export function createAnalytics(options) {
318
353
  cfg.getDecisions ??= decisions.current;
319
354
 
320
355
  let userId = realId(perRequest(cfg.getUserId, null));
356
+ /** The user the host's last `identify` named: a different one later on this page is a switch. */
357
+ let identifiedUserId = null;
358
+ /** The user bootstrap's `identity` option named, only to warn when the host names another form. */
359
+ let optionsUserId = null;
321
360
 
322
361
  /** The call being replayed: its first event keeps the messageId and time it was made with. */
323
362
  let replaying = null;
324
363
 
364
+ /** Tracking never throws into the host: an event that fails to build or deliver is dropped. */
325
365
  function emit(partial, ctxOverrides, force = false) {
366
+ try {
367
+ return compose(partial, ctxOverrides, force);
368
+ } catch (err) {
369
+ if (err instanceof InvalidEventError) throw err;
370
+ console.warn('[hp] the event was not sent', err);
371
+ return null;
372
+ }
373
+ }
374
+
375
+ function compose(partial, ctxOverrides, force) {
326
376
  const made = replaying;
327
377
  replaying = null;
328
378
  let event = {
@@ -336,6 +386,12 @@ export function createAnalytics(options) {
336
386
 
337
387
  if (event.properties) event.properties = pruneProperties(event.properties);
338
388
 
389
+ // Outside the source's sample (3.18): nothing about this visitor is sent.
390
+ if (!force && !sampledIn(event.anonymousId, cfg.sampleRate)) {
391
+ if (cfg.debug) console.debug('[hp] not sent: outside the sample', event);
392
+ return null;
393
+ }
394
+
339
395
  // A redaction or sampling hook may change what an event says or drop it, never what it is.
340
396
  // One that fails drops the event rather than sending what it was meant to redact.
341
397
  if (cfg.beforeSend) {
@@ -362,13 +418,23 @@ export function createAnalytics(options) {
362
418
  const res = cfg.validate(event, { producer: 'web' });
363
419
  if (!res.valid) {
364
420
  const msg = `[hp] invalid ${event.event ?? event.type}:\n ${res.errors.join('\n ')}`;
365
- if (cfg.throwOnInvalid) throw new Error(msg);
421
+ if (cfg.throwOnInvalid) throw new InvalidEventError(msg);
366
422
  console.error(msg);
367
423
  }
368
424
  }
369
425
 
370
426
  if (cfg.debug) console.debug('[hp]', event.event ?? event.type, event);
371
427
 
428
+ // Too big to travel in a keepalive request on its own: it could never go at unload, and once
429
+ // persisted it would push every other batch out of the retry buffer.
430
+ const bytes = serialisedBytes(event);
431
+ if (bytes > MAX_EVENT_BYTES) {
432
+ reportError(
433
+ `${event.event ?? event.type} not sent: ${bytes} bytes, over the ${MAX_EVENT_BYTES}-byte limit for one event`,
434
+ );
435
+ return null;
436
+ }
437
+
372
438
  deliver(event, force);
373
439
  return event;
374
440
  }
@@ -407,15 +473,36 @@ export function createAnalytics(options) {
407
473
  visitKept = true;
408
474
  } else {
409
475
  // Not allowed, or withdrawn: what was kept under the permission goes with it.
410
- forgetVisit();
411
- if (visitKept) clearSessionSignals();
412
- visitKept = false;
476
+ forgetKeptVisit();
413
477
  }
414
478
  } catch (err) {
415
479
  if (cfg.debug) console.warn('[hp] the visit was not recorded', err);
416
480
  }
417
481
  }
418
482
 
483
+ /**
484
+ * What the visit built up for recommendations, kept only under personalisation consent. The
485
+ * session signals are cleared only when this SDK built them, unless `all`: a refusal or a sign-out.
486
+ */
487
+ function forgetKeptVisit(all = false) {
488
+ forgetVisit();
489
+ if (all || visitKept) clearSessionSignals();
490
+ visitKept = false;
491
+ }
492
+
493
+ /**
494
+ * A sign-out, or another user signing in: the SDK's session, the decisions in force, the visit
495
+ * and the SDK's own device id go with the user, as Segment's `reset()` starts a new anonymousId.
496
+ * A device id read from the site's own cookie stays: the SDK does not own it.
497
+ */
498
+ function forgetVisitor() {
499
+ session.forget();
500
+ decisions.forget();
501
+ exposedTokens.clear();
502
+ device?.forget();
503
+ forgetKeptVisit(true);
504
+ }
505
+
419
506
  function deliver(event, force) {
420
507
  // The observer only sees what will be sent, never an event the visitor has not allowed.
421
508
  if (!strict || force || gate.admits()) {
@@ -471,7 +558,7 @@ export function createAnalytics(options) {
471
558
  onStatus(status) {
472
559
  if (status === 'granted') {
473
560
  device?.persist();
474
- session?.persist();
561
+ session.persist();
475
562
  if (lastPageMessageId) writeLastPageMessageId(lastPageMessageId);
476
563
  decisions.persist();
477
564
  transport.replayPersisted?.();
@@ -480,13 +567,14 @@ export function createAnalytics(options) {
480
567
  // Nothing kept on the device may outlive the permission it was kept under.
481
568
  transport.clearPersisted?.();
482
569
  device?.forget();
483
- session?.forget();
570
+ session.forget();
484
571
  try {
485
572
  sessionStorage.removeItem(PREV_PAGE_KEY);
486
573
  } catch {
487
574
  /* nothing was stored */
488
575
  }
489
576
  decisions.forget();
577
+ forgetKeptVisit(true);
490
578
  }
491
579
  try {
492
580
  cfg.onConsentStatus?.(status);
@@ -494,7 +582,12 @@ export function createAnalytics(options) {
494
582
  console.warn('[hp] onConsentStatus observer failed', err);
495
583
  }
496
584
  },
497
- announce({ action, state, withdrawal }) {
585
+ announce({ action, state, previous, withdrawal }) {
586
+ // Withdrawn here, not only when the next event reaches recordVisit: a beforeSend that drops
587
+ // it must not keep the visit. A withdrawal on a later page, before it recorded anything,
588
+ // forgets the signals an earlier page kept under the permission.
589
+ if (state?.personalisation !== true)
590
+ forgetKeptVisit(previous?.personalisation === true);
498
591
  emit(
499
592
  {
500
593
  type: 'track',
@@ -589,11 +682,12 @@ export function createAnalytics(options) {
589
682
  );
590
683
  }
591
684
 
592
- return {
685
+ return guardMethods({
593
686
  /** Escape hatch for events not yet in the core spec. Prefer a named method. */
594
687
  track,
595
688
 
596
- page(props = {}, ctx) {
689
+ page(props, ctx) {
690
+ props ??= {};
597
691
  // page.type is inherited by every later event on the page, so an unknown value would get
598
692
  // them all rejected. Keep the last good type instead: stale beats invalid.
599
693
  let pageType = cfg.pageType ?? null;
@@ -625,10 +719,31 @@ export function createAnalytics(options) {
625
719
  return ev;
626
720
  },
627
721
 
628
- identify(traits, ctx) {
722
+ identify(traits, ctx, source) {
629
723
  const { userId: id, ...rest } =
630
724
  traits && typeof traits === 'object' ? traits : {};
631
- if (id !== undefined) userId = realId(id);
725
+ if (id !== undefined) {
726
+ const next = realId(id);
727
+ if (source === IDENTITY_OPTION) {
728
+ optionsUserId = next;
729
+ } else if (identifiedUserId && next && next !== identifiedUserId) {
730
+ // The host named another user after an earlier one on this page: nothing the last one
731
+ // built up carries over. A host cookie or bootstrap's identity option never counts as
732
+ // the earlier user: either may name the same person in another form.
733
+ forgetVisitor();
734
+ } else if (
735
+ cfg.debug &&
736
+ optionsUserId &&
737
+ next &&
738
+ next !== optionsUserId
739
+ ) {
740
+ console.warn(
741
+ '[hp] identify names another userId than the identity option; using the new one',
742
+ );
743
+ }
744
+ userId = next;
745
+ if (next && source !== IDENTITY_OPTION) identifiedUserId = next;
746
+ }
632
747
  // A digest in hex or base64 is sent as hex; anything that is not one is not sent.
633
748
  if (typeof rest.email_sha256 === 'string') {
634
749
  const email = normaliseSha256(rest.email_sha256);
@@ -638,9 +753,16 @@ export function createAnalytics(options) {
638
753
  return emit({ type: 'identify', traits: rest }, ctx);
639
754
  },
640
755
 
641
- /** Clear identity on sign-out so the next events are not attributed to the old user. */
756
+ /**
757
+ * Clear the signed-in user. At a real sign-out (a user was signed in), also forget what the visit
758
+ * built up and the SDK's own device id, so the next person on a shared device inherits none of
759
+ * it. A device id from the site's own cookie stays: the site owns it.
760
+ */
642
761
  reset() {
762
+ if (userId) forgetVisitor();
643
763
  userId = null;
764
+ identifiedUserId = null;
765
+ optionsUserId = null;
644
766
  writeLastPageMessageId('');
645
767
  },
646
768
 
@@ -706,7 +828,14 @@ export function createAnalytics(options) {
706
828
  personalisationGroup,
707
829
  productClicked: (p, c) => track('Product Clicked', p, c),
708
830
  productListFiltered: (p, c) => track('Product List Filtered', p, c),
831
+ /** A submitted search and the results page it produced: the query, its results and `pagination`. */
709
832
  productsSearched: (p, c) => track('Products Searched', p, c),
833
+ /**
834
+ * The search box's suggested products for a partly typed query, once seen: never
835
+ * productsSearched, which is a submitted search's results page. A click on one carries this
836
+ * set's `search_request_id`.
837
+ */
838
+ searchSuggestionsViewed: (p, c) => track('Search Suggestions Viewed', p, c),
710
839
  productAdded: (p, c) => track('Product Added', p, c),
711
840
  productRemoved: (p, c) => track('Product Removed', p, c),
712
841
  /**
@@ -758,7 +887,10 @@ export function createAnalytics(options) {
758
887
  /** Sent under the signed-in identity, which is then cleared for whoever uses the device next. */
759
888
  signedOut(c) {
760
889
  const event = track('Signed Out', {}, c);
890
+ if (userId) forgetVisitor();
761
891
  userId = null;
892
+ identifiedUserId = null;
893
+ optionsUserId = null;
762
894
  return event;
763
895
  },
764
896
  newsletterSubscribed: (p, c) => track('Newsletter Subscribed', p, c),
@@ -796,7 +928,7 @@ export function createAnalytics(options) {
796
928
  get lastPageMessageId() {
797
929
  return readLastPageMessageId();
798
930
  },
799
- /** The device id events carry: the site's cookie's, or under `hp_cookie` the SDK's own. */
931
+ /** The device id events carry: the site's, or the SDK's own under `hp_cookie` or without one. */
800
932
  get anonymousId() {
801
933
  return realId(perRequest(cfg.getAnonymousId, null));
802
934
  },
@@ -814,5 +946,5 @@ export function createAnalytics(options) {
814
946
  replaying = null;
815
947
  }
816
948
  },
817
- };
949
+ });
818
950
  }
@@ -7,6 +7,8 @@
7
7
  * `new URLSearchParams(context)` is the query string to append, and an empty visit appends nothing.
8
8
  */
9
9
 
10
+ import { parseConsentCookie } from './consent.js';
11
+ import { getCookie } from './context.js';
10
12
  import { storageName } from './storage-scope.js';
11
13
 
12
14
  /** packages/cdp/src/recommendations/request.ts: MAX_IDS, MAX_ID_LENGTH, five queries, 60 pairs. */
@@ -86,9 +88,16 @@ export function forgetVisit() {
86
88
  }
87
89
  }
88
90
 
89
- /** The visit an earlier page kept, or `{}` once it has lapsed. */
91
+ /**
92
+ * The visit an earlier page kept, or `{}` once it has lapsed, or when the consent cookie does not
93
+ * grant personalisation: a withdrawal on a page this SDK never ran on still stops it being read.
94
+ */
90
95
  export function keptVisit(now = Date.now()) {
91
96
  try {
97
+ const { personalisation } = parseConsentCookie(
98
+ getCookie(storageName('consent')),
99
+ );
100
+ if (personalisation !== true) return {};
92
101
  const kept = JSON.parse(
93
102
  localStorage.getItem(storageName('recommendationContext')) ?? 'null',
94
103
  );
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Sampling (3.18): the source's settings may send for only a share of visitors. Decided per
3
+ * anonymous id, so a visitor is in or out for every event, on every page and in every tab, as the
4
+ * holdout is. A visitor with no anonymous id yet is kept.
5
+ */
6
+
7
+ import { hashBucket } from './experiments.js';
8
+
9
+ /** Salts the hash, so who is sampled is independent of who is held out or in an experiment. */
10
+ export const SAMPLE_SALT = 'hp_sample';
11
+
12
+ const BUCKETS = 10000;
13
+
14
+ /** Whether the visitor `unit` is sent for at `rate`, a share in [0, 1]. */
15
+ export function sampledIn(unit, rate) {
16
+ if (!(typeof rate === 'number' && rate < 1)) return true;
17
+ if (unit == null || unit === '') return true;
18
+ return hashBucket(unit, SAMPLE_SALT, BUCKETS) < Math.round(rate * BUCKETS);
19
+ }
package/src/segment.js CHANGED
@@ -52,6 +52,11 @@ import { VERSION } from './version.js';
52
52
 
53
53
  /** Option key that lets the withdrawal record through a gate that is about to close. */
54
54
  const WITHDRAWAL_FLAG = 'thg_hp_consent_withdrawal';
55
+ /**
56
+ * The device id a withdrawal withdraws, taken when it is announced: analytics-next runs the plugin
57
+ * later, after the refusal has forgotten that id.
58
+ */
59
+ const WITHDRAWN_AS = 'thg_hp_withdrawn_as';
55
60
 
56
61
  /** Context keys analytics-next fills itself; the spec's versions (scrubbed, site locale) replace them. */
57
62
  const LIBRARY_OWNED = new Set([
@@ -234,14 +239,17 @@ export function cdpPlugins(options = {}) {
234
239
  function admit(ctx) {
235
240
  const context = ctx.event.context ?? {};
236
241
  const forced = context[WITHDRAWAL_FLAG] === true;
242
+ const withdrawnAs = context[WITHDRAWN_AS];
237
243
  delete context[WITHDRAWAL_FLAG];
244
+ delete context[WITHDRAWN_AS];
238
245
 
239
246
  if (off) throw cancellation(ctx, 'not enabled for this write key');
240
247
  if (ctx.event.type === 'track' && !planAllows(cfg.plan, ctx.event.event)) {
241
248
  throw cancellation(ctx, 'disabled by the tracking plan');
242
249
  }
243
250
 
244
- const anonymousId = cfg.getAnonymousId?.();
251
+ const anonymousId =
252
+ forced && withdrawnAs ? withdrawnAs : cfg.getAnonymousId?.();
245
253
  if (anonymousId) ctx.updateEvent('anonymousId', String(anonymousId));
246
254
 
247
255
  if (!gate || forced || gate.admits()) return ctx;
@@ -346,7 +354,12 @@ export function cdpPlugins(options = {}) {
346
354
  analytics.track(
347
355
  'Consent Updated',
348
356
  { action, state },
349
- withdrawal ? { [WITHDRAWAL_FLAG]: true } : undefined,
357
+ withdrawal
358
+ ? {
359
+ [WITHDRAWAL_FLAG]: true,
360
+ [WITHDRAWN_AS]: cfg.getAnonymousId?.() ?? null,
361
+ }
362
+ : undefined,
350
363
  );
351
364
  },
352
365
  });
@@ -13,11 +13,12 @@ const ENGAGEMENT_EVENTS = new Set([
13
13
  'Product Added to Wishlist',
14
14
  ]);
15
15
 
16
- /** Engagement the warehouse's category affinity does not count, so a session measure must not. */
17
- const UNCOUNTED_ENGAGEMENT = new Set([
18
- 'Product Clicked',
19
- 'Product Added to Wishlist',
20
- ]);
16
+ /**
17
+ * The browsing events the warehouse's category affinity counts (identifier_affinity_daily), so a
18
+ * session measure counts the same: a product shown in a listing, removed or found in search is not
19
+ * interest. The warehouse also counts orders, which a visit's own measure leaves out.
20
+ */
21
+ const CATEGORY_EVENTS = new Set(['Product Viewed', 'Product Added']);
21
22
 
22
23
  /**
23
24
  * Fold one CDP event into the session signals.
@@ -39,9 +40,8 @@ export function recordSessionEvent(eventName, properties) {
39
40
  bump(s.recentBrowse, s.browsingCategory);
40
41
  break;
41
42
  case 'Product List Viewed':
42
- s.listViews += 1;
43
- // Only a category list is browsing. A rail or carousel names its widget (no slash, or a
44
- // `cdp-` id), and counting it would let personalisation measure itself.
43
+ // Only a category list is browsing, or a category page viewed. A rail or carousel names its
44
+ // widget (no slash, or a `cdp-` id), and counting it would let personalisation measure itself.
45
45
  {
46
46
  const list = properties?.list_id;
47
47
  const isCategoryPath =
@@ -49,6 +49,7 @@ export function recordSessionEvent(eventName, properties) {
49
49
  list.includes('/') &&
50
50
  !list.startsWith('cdp-');
51
51
  if (isCategoryPath) {
52
+ s.listViews += 1;
52
53
  s.browsingCategory = bump(s.recentBrowse, list) ?? s.browsingCategory;
53
54
  }
54
55
  }
@@ -103,6 +104,19 @@ export function recordSessionEvent(eventName, properties) {
103
104
  case 'Product Clicked':
104
105
  case 'Product Added to Wishlist':
105
106
  break;
107
+ case 'Cart Updated': {
108
+ // The basket as it now stands, priced by the platform, so a merge, a repricing or a code is
109
+ // already in it. Summing the lines of Product Added would drift from that, and its line
110
+ // prices include tax where `value` does not. Its lines are the basket, not attention.
111
+ // The latest basket is the only one that counts: unpriced, its value is unknown again.
112
+ const value = properties?.value;
113
+ s.basketValue =
114
+ typeof value === 'number' && Number.isFinite(value) && value >= 0
115
+ ? value
116
+ : null;
117
+ writeSignals(s);
118
+ return s;
119
+ }
106
120
  default:
107
121
  return s;
108
122
  }
@@ -146,7 +160,7 @@ export function recordSessionEvent(eventName, properties) {
146
160
  ].slice(0, RECENT_LIMIT);
147
161
  }
148
162
  if (
149
- !UNCOUNTED_ENGAGEMENT.has(eventName) &&
163
+ CATEGORY_EVENTS.has(eventName) &&
150
164
  typeof p.category === 'string' &&
151
165
  p.category
152
166
  ) {