@thg-agentic/hyper-personalisation-web 3.18.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/src/bootstrap.js CHANGED
@@ -41,11 +41,14 @@ import {
41
41
  import { getCookie } from './context.js';
42
42
  import { connectDataLayer } from './datalayer.js';
43
43
  import { sessionDebug } from './debug.js';
44
+ import { guard } from './guard.js';
45
+ import { IDENTITY_OPTION } from './identity-option.js';
44
46
  import { createAnalytics, normaliseSha256, PAGE_TYPES } from './index.js';
45
47
  import {
46
48
  cachedSettings,
47
49
  cacheSettings,
48
50
  DISABLED,
51
+ eventsHost,
49
52
  fetchSettings,
50
53
  forgetSettings,
51
54
  readSettings,
@@ -229,7 +232,8 @@ async function resolveSettings({ writeKey, apiHost }, options) {
229
232
  * @param {import('../types/bootstrap').BootstrapOptions} options See types/bootstrap.d.ts.
230
233
  * @returns {Promise<object|null>} the client, or null when it is off.
231
234
  */
232
- export function bootstrap(config, options = {}) {
235
+ export function bootstrap(config, maybeOptions) {
236
+ const options = maybeOptions ?? {};
233
237
  const host = typeof window !== 'undefined' ? window : null;
234
238
  if (host?.[INSTANCE]) return host[INSTANCE];
235
239
  // What `hp` queued on an earlier page that navigated before its client started. Carried only as
@@ -355,8 +359,8 @@ async function start(config, options, carry) {
355
359
  );
356
360
  return off(carry);
357
361
  }
358
- // A source scoped to one property names it; a source scoped to several leaves it to the page.
359
- const propertyId = settings.propertyId ?? options.propertyId;
362
+ // The page's own property, else the source's default; a source with several and no default needs the page's.
363
+ const propertyId = options.propertyId ?? settings.propertyId;
360
364
  if (!propertyId) {
361
365
  console.warn(
362
366
  '[hp] not started: this write key covers several properties, so propertyId is required',
@@ -374,7 +378,7 @@ async function start(config, options, carry) {
374
378
  const userId = realId(identity?.userId);
375
379
 
376
380
  const client = createAnalytics({
377
- apiHost: settings.apiHost ?? resolveApiHost(config.apiHost),
381
+ apiHost: eventsHost(config.apiHost, settings.apiHost),
378
382
  writeKey,
379
383
  propertyId,
380
384
  // Known before the first page view, so an event the client sends while starting (the consent
@@ -472,12 +476,12 @@ async function start(config, options, carry) {
472
476
  let stopAutocapture = startAutocapture(client, options.autocapture);
473
477
  // Impressions count once per page view, so a single-page app's next route reports afresh.
474
478
  const page = client.page;
475
- client.page = (...args) => {
479
+ client.page = guard('page', (...args) => {
476
480
  const event = page(...args);
477
481
  stopAutocapture();
478
482
  stopAutocapture = startAutocapture(client, options.autocapture);
479
483
  return event;
480
- };
484
+ });
481
485
 
482
486
  const host = typeof window !== 'undefined' ? window : null;
483
487
  if (options.dataLayer && host) {
@@ -495,7 +499,7 @@ async function start(config, options, carry) {
495
499
  });
496
500
  }
497
501
 
498
- if (identified) client.identify(identified);
502
+ if (identified) client.identify(identified, undefined, IDENTITY_OPTION);
499
503
  return client;
500
504
  } catch (err) {
501
505
  console.warn('[hp] not started:', err?.message ?? err);
@@ -92,7 +92,7 @@ function previousFromCookie(name) {
92
92
  * @param {string} [options.cookieName] Defaults to storageName('consent'), `thg_hp_consent`.
93
93
  * @param {(item: any) => void} options.release A held item may now be sent.
94
94
  * @param {(item: any) => void} [options.discard] A held item will never be sent.
95
- * @param {(update: { action: string, state: object, withdrawal: boolean }) => void} options.announce
95
+ * @param {(update: { action: string, state: object, previous: object|null, withdrawal: boolean }) => void} options.announce
96
96
  * Record a Consent Updated. Called only when analytics is granted, or for the withdrawal itself.
97
97
  * @param {(status: string) => void} [options.onStatus]
98
98
  * @param {boolean} [options.debug]
@@ -201,6 +201,7 @@ export function createConsentGate(options) {
201
201
  announce({
202
202
  action: consentAction(next, previous),
203
203
  state: next,
204
+ previous,
204
205
  withdrawal: false,
205
206
  });
206
207
  return;
@@ -210,7 +211,12 @@ export function createConsentGate(options) {
210
211
  // A withdrawal is recorded, then nothing more: the raw table is where a refusal is proven to
211
212
  // have been received, and the warehouse's consent filter keeps it out of every model.
212
213
  if (previous?.analytics === true)
213
- announce({ action: 'withdrawn', state: next, withdrawal: true });
214
+ announce({
215
+ action: 'withdrawn',
216
+ state: next,
217
+ previous,
218
+ withdrawal: true,
219
+ });
214
220
  setStatus('denied');
215
221
  }
216
222
 
package/src/context.js CHANGED
@@ -304,7 +304,6 @@ export const CONTEXT_KEYS = [
304
304
  'library',
305
305
  'source',
306
306
  'session_id',
307
- 'render_id',
308
307
  'identity_trust',
309
308
  'page',
310
309
  'campaign',
@@ -366,7 +365,6 @@ export function buildContext(cfg, overrides = {}) {
366
365
  spec: { version: SPEC_VERSION },
367
366
  source: sourceContext(cfg),
368
367
  session_id: perRequest(cfg.getSessionId, null),
369
- render_id: perRequest(cfg.getRenderId, null),
370
368
  // The browser is always a real device. Only a cacheable server render is not.
371
369
  identity_trust: realId(perRequest(cfg.getAnonymousId, null))
372
370
  ? 'device'
@@ -154,8 +154,9 @@ export function createDeviceCookie({
154
154
  /** Consent was granted: write what was minted before it. */
155
155
  persist,
156
156
  /**
157
- * Consent was refused or withdrawn: the id must not outlive the permission it was kept under,
158
- * so a later grant starts a new device, as `analytics.reset()` does.
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.
159
160
  */
160
161
  forget() {
161
162
  writeCookie(name, '', 0, domain);
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
@@ -53,7 +53,9 @@ import {
53
53
  holdoutAssignment,
54
54
  holdoutShares,
55
55
  } from './experiments.js';
56
+ import { guardMethods, InvalidEventError } from './guard.js';
56
57
  import { emailSha256, sha256Hex } from './hash.js';
58
+ import { IDENTITY_OPTION } from './identity-option.js';
57
59
  import { applyPreview } from './preview.js';
58
60
  import {
59
61
  forgetVisit,
@@ -82,7 +84,11 @@ import {
82
84
  storageKey,
83
85
  storageName,
84
86
  } from './storage-scope.js';
85
- import { Transport } from './transport.js';
87
+ import {
88
+ MAX_EVENT_BYTES,
89
+ Transport,
90
+ byteLength as utf8Bytes,
91
+ } from './transport.js';
86
92
  import { LIB, VERSION } from './version.js';
87
93
 
88
94
  /** The surfaces a `Personalisation Exposed` names, as the public API's exposures do. */
@@ -104,9 +110,6 @@ export { emailSha256, getCookie };
104
110
  /** How a call made before the SDK started is replayed with the messageId and time it was made at. */
105
111
  const REPLAY = Symbol.for('@thg-agentic/hyper-personalisation-web.replay');
106
112
 
107
- /** `throwOnInvalid`: the one failure a caller asked to have thrown at them. */
108
- class InvalidEventError extends Error {}
109
-
110
113
  /** SHA-256 of a string as lowercase hex. Synchronous since 3.9 (sha256Hex); kept async for callers. */
111
114
  export async function sha256(value) {
112
115
  return sha256Hex(String(value));
@@ -180,6 +183,64 @@ export function normaliseSha256(value) {
180
183
  return hex === EMPTY_SHA256 ? null : hex;
181
184
  }
182
185
 
186
+ /** The identify traits the spec defines (core/identify, `additionalProperties: false`), by type. */
187
+ export const IDENTIFY_TRAITS = Object.freeze({
188
+ email_sha256: 'sha256',
189
+ phone_sha256: 'sha256',
190
+ accepts_marketing: 'boolean',
191
+ gender: 'string',
192
+ created_at: 'date-time',
193
+ });
194
+
195
+ const RFC3339 =
196
+ /^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)$/i;
197
+
198
+ /** An RFC 3339 date-time naming a real day: 2026-02-30 is not one, though Date.parse rolls it on. */
199
+ function isDateTime(value) {
200
+ if (typeof value !== 'string' || !RFC3339.test(value)) return false;
201
+ const [year, month, day] = value.slice(0, 10).split('-').map(Number);
202
+ const date = new Date(Date.UTC(year, month - 1, day));
203
+ return (
204
+ date.getUTCMonth() === month - 1 &&
205
+ date.getUTCDate() === day &&
206
+ !Number.isNaN(Date.parse(value))
207
+ );
208
+ }
209
+
210
+ /** One trait as the spec takes it, or undefined when it is not one: then it is not sent. */
211
+ function identifyTrait(kind, value) {
212
+ if (value === null) return null;
213
+ if (kind === 'sha256') return normaliseSha256(value) ?? undefined;
214
+ if (kind === 'date-time') {
215
+ if (value instanceof Date)
216
+ return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
217
+ return isDateTime(value) ? value : undefined;
218
+ }
219
+ return typeof value === kind ? value : undefined;
220
+ }
221
+
222
+ /**
223
+ * Only the traits the spec defines, each of its type: a raw email or phone never leaves the page, and
224
+ * an identify is never quarantined for an extra key. Digests in hex or base64 are sent as hex.
225
+ */
226
+ function identifyTraits(traits, debug) {
227
+ const kept = {};
228
+ const dropped = [];
229
+ for (const [key, value] of Object.entries(traits)) {
230
+ const kind = Object.hasOwn(IDENTIFY_TRAITS, key)
231
+ ? IDENTIFY_TRAITS[key]
232
+ : null;
233
+ const trait = kind ? identifyTrait(kind, value) : undefined;
234
+ if (trait === undefined) dropped.push(key);
235
+ else kept[key] = trait;
236
+ }
237
+ if (debug && dropped.length)
238
+ console.warn(
239
+ `[hp] identify: not sent, not spec traits: ${dropped.join(', ')}`,
240
+ );
241
+ return kept;
242
+ }
243
+
183
244
  // On the main entry so integrators find them: without an assigned variant a rail is unmeasurable.
184
245
  export {
185
246
  assignRecommendationVariant,
@@ -195,6 +256,19 @@ export { createSocialProof } from './social-proof.js';
195
256
  export { VERSION };
196
257
 
197
258
  let warnedMintAnonymousId = false;
259
+ let warnedHostBasis = false;
260
+
261
+ /**
262
+ * Without a consent adapter the SDK sends under the host's lawful basis: fail-open, so say it once.
263
+ * Requiring `consent` is a breaking change, planned for 4.0.
264
+ */
265
+ function warnHostBasis() {
266
+ if (warnedHostBasis) return;
267
+ warnedHostBasis = true;
268
+ console.warn(
269
+ "[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.",
270
+ );
271
+ }
198
272
 
199
273
  /** 3.5's `mintAnonymousId` does nothing since 3.6: where device ids come from is the source's. */
200
274
  function warnMintAnonymousId(debug) {
@@ -205,6 +279,15 @@ function warnMintAnonymousId(debug) {
205
279
  );
206
280
  }
207
281
 
282
+ /** UTF-8 bytes of the event as sent; 0 for one JSON cannot serialise, which delivery drops itself. */
283
+ function serialisedBytes(event) {
284
+ try {
285
+ return utf8Bytes(JSON.stringify(event));
286
+ } catch {
287
+ return 0;
288
+ }
289
+ }
290
+
208
291
  /** Per-call context overrides, given directly or analytics-next style as `{ context }`. */
209
292
  function contextOverrides(options) {
210
293
  if (
@@ -243,6 +326,7 @@ export function createAnalytics(options) {
243
326
  // A consent adapter makes the SDK strict: nothing is sent or stored until analytics is granted.
244
327
  // Without one the host has decided the lawful basis itself, and every event says so (spec 1.5).
245
328
  const strict = Boolean(cfg.consent);
329
+ if (!strict) warnHostBasis();
246
330
  let gate = null;
247
331
  cfg.getConsent = () => (gate ? gate.stamp() : hostConsent());
248
332
  const granted = () => !strict || gate?.status === 'granted';
@@ -276,6 +360,10 @@ export function createAnalytics(options) {
276
360
  cfg.consentCookieDomain ?? null,
277
361
  );
278
362
 
363
+ /** Delivery problems, logged with `debug`. */
364
+ const reportError = (message, err) =>
365
+ cfg.debug && console.warn('[hp]', message, err ?? '');
366
+
279
367
  const transport =
280
368
  cfg.transport ??
281
369
  new Transport({
@@ -288,7 +376,7 @@ export function createAnalytics(options) {
288
376
  // grant (see onStatus below, which replays on a grant and clears on a refusal).
289
377
  mayPersist: strict ? () => gate?.status === 'granted' : undefined,
290
378
  deferReplay: strict,
291
- onError: (m, e) => cfg.debug && console.warn('[hp]', m, e ?? ''),
379
+ onError: reportError,
292
380
  });
293
381
 
294
382
  // The previous page view's messageId, carried across navigations in sessionStorage, per tenant.
@@ -323,6 +411,10 @@ export function createAnalytics(options) {
323
411
  cfg.getDecisions ??= decisions.current;
324
412
 
325
413
  let userId = realId(perRequest(cfg.getUserId, null));
414
+ /** The user the host's last `identify` named: a different one later on this page is a switch. */
415
+ let identifiedUserId = null;
416
+ /** The user bootstrap's `identity` option named, only to warn when the host names another form. */
417
+ let optionsUserId = null;
326
418
 
327
419
  /** The call being replayed: its first event keeps the messageId and time it was made with. */
328
420
  let replaying = null;
@@ -341,6 +433,10 @@ export function createAnalytics(options) {
341
433
  function compose(partial, ctxOverrides, force) {
342
434
  const made = replaying;
343
435
  replaying = null;
436
+ // The plan is keyed as the gateway keys it: a track's name, otherwise the call's type. A
437
+ // withdrawal is the record of the refusal, so no plan drops it.
438
+ const key = partial.type === 'track' ? partial.event : partial.type;
439
+ if (!force && !planAllows(cfg.plan, key)) return null;
344
440
  let event = {
345
441
  messageId: made?.messageId ?? uuid(),
346
442
  timestamp: made?.timestamp ?? new Date().toISOString(),
@@ -391,6 +487,16 @@ export function createAnalytics(options) {
391
487
 
392
488
  if (cfg.debug) console.debug('[hp]', event.event ?? event.type, event);
393
489
 
490
+ // Too big to travel in a keepalive request on its own: it could never go at unload, and once
491
+ // persisted it would push every other batch out of the retry buffer.
492
+ const bytes = serialisedBytes(event);
493
+ if (bytes > MAX_EVENT_BYTES) {
494
+ reportError(
495
+ `${event.event ?? event.type} not sent: ${bytes} bytes, over the ${MAX_EVENT_BYTES}-byte limit for one event`,
496
+ );
497
+ return null;
498
+ }
499
+
394
500
  deliver(event, force);
395
501
  return event;
396
502
  }
@@ -429,15 +535,36 @@ export function createAnalytics(options) {
429
535
  visitKept = true;
430
536
  } else {
431
537
  // Not allowed, or withdrawn: what was kept under the permission goes with it.
432
- forgetVisit();
433
- if (visitKept) clearSessionSignals();
434
- visitKept = false;
538
+ forgetKeptVisit();
435
539
  }
436
540
  } catch (err) {
437
541
  if (cfg.debug) console.warn('[hp] the visit was not recorded', err);
438
542
  }
439
543
  }
440
544
 
545
+ /**
546
+ * What the visit built up for recommendations, kept only under personalisation consent. The
547
+ * session signals are cleared only when this SDK built them, unless `all`: a refusal or a sign-out.
548
+ */
549
+ function forgetKeptVisit(all = false) {
550
+ forgetVisit();
551
+ if (all || visitKept) clearSessionSignals();
552
+ visitKept = false;
553
+ }
554
+
555
+ /**
556
+ * A sign-out, or another user signing in: the SDK's session, the decisions in force, the visit
557
+ * and the SDK's own device id go with the user, as Segment's `reset()` starts a new anonymousId.
558
+ * A device id read from the site's own cookie stays: the SDK does not own it.
559
+ */
560
+ function forgetVisitor() {
561
+ session.forget();
562
+ decisions.forget();
563
+ exposedTokens.clear();
564
+ device?.forget();
565
+ forgetKeptVisit(true);
566
+ }
567
+
441
568
  function deliver(event, force) {
442
569
  // The observer only sees what will be sent, never an event the visitor has not allowed.
443
570
  if (!strict || force || gate.admits()) {
@@ -449,9 +576,7 @@ export function createAnalytics(options) {
449
576
  }
450
577
 
451
578
  const track = (name, properties, ctx) =>
452
- planAllows(cfg.plan, name)
453
- ? emit({ type: 'track', event: name, properties: properties ?? {} }, ctx)
454
- : null;
579
+ emit({ type: 'track', event: name, properties: properties ?? {} }, ctx);
455
580
 
456
581
  // One exposure event per serving response, however often the page reuses it.
457
582
  const exposedTokens = new Set();
@@ -509,6 +634,7 @@ export function createAnalytics(options) {
509
634
  /* nothing was stored */
510
635
  }
511
636
  decisions.forget();
637
+ forgetKeptVisit(true);
512
638
  }
513
639
  try {
514
640
  cfg.onConsentStatus?.(status);
@@ -516,7 +642,14 @@ export function createAnalytics(options) {
516
642
  console.warn('[hp] onConsentStatus observer failed', err);
517
643
  }
518
644
  },
519
- announce({ action, state, withdrawal }) {
645
+ announce({ action, state, previous, withdrawal }) {
646
+ // Withdrawn here, not only when the next event reaches recordVisit: a beforeSend that drops
647
+ // it must not keep the visit. A withdrawal on a later page, before it recorded anything,
648
+ // forgets the signals an earlier page kept under the permission.
649
+ if (state?.personalisation !== true)
650
+ forgetKeptVisit(previous?.personalisation === true);
651
+ // Events collected under the grant are not sent after it is withdrawn: only the withdrawal.
652
+ if (withdrawal) transport.discardQueued?.();
520
653
  emit(
521
654
  {
522
655
  type: 'track',
@@ -611,11 +744,22 @@ export function createAnalytics(options) {
611
744
  );
612
745
  }
613
746
 
614
- return {
747
+ /** After a sign-out is sent under the signed-in identity: clear it for whoever uses the device next. */
748
+ function signOut(event) {
749
+ if (userId) forgetVisitor();
750
+ userId = null;
751
+ identifiedUserId = null;
752
+ optionsUserId = null;
753
+ return event;
754
+ }
755
+
756
+ return guardMethods({
615
757
  /** Escape hatch for events not yet in the core spec. Prefer a named method. */
616
- track,
758
+ track: (event, p, c) =>
759
+ event === 'Signed Out' ? signOut(track(event, p, c)) : track(event, p, c),
617
760
 
618
- page(props = {}, ctx) {
761
+ page(props, ctx) {
762
+ props ??= {};
619
763
  // page.type is inherited by every later event on the page, so an unknown value would get
620
764
  // them all rejected. Keep the last good type instead: stale beats invalid.
621
765
  let pageType = cfg.pageType ?? null;
@@ -647,22 +791,47 @@ export function createAnalytics(options) {
647
791
  return ev;
648
792
  },
649
793
 
650
- identify(traits, ctx) {
794
+ identify(traits, ctx, source) {
651
795
  const { userId: id, ...rest } =
652
796
  traits && typeof traits === 'object' ? traits : {};
653
- if (id !== undefined) userId = realId(id);
654
- // A digest in hex or base64 is sent as hex; anything that is not one is not sent.
655
- if (typeof rest.email_sha256 === 'string') {
656
- const email = normaliseSha256(rest.email_sha256);
657
- if (email) rest.email_sha256 = email;
658
- else delete rest.email_sha256;
797
+ if (id !== undefined) {
798
+ const next = realId(id);
799
+ if (source === IDENTITY_OPTION) {
800
+ optionsUserId = next;
801
+ } else if (identifiedUserId && next && next !== identifiedUserId) {
802
+ // The host named another user after an earlier one on this page: nothing the last one
803
+ // built up carries over. A host cookie or bootstrap's identity option never counts as
804
+ // the earlier user: either may name the same person in another form.
805
+ forgetVisitor();
806
+ } else if (
807
+ cfg.debug &&
808
+ optionsUserId &&
809
+ next &&
810
+ next !== optionsUserId
811
+ ) {
812
+ console.warn(
813
+ '[hp] identify names another userId than the identity option; using the new one',
814
+ );
815
+ }
816
+ userId = next;
817
+ if (next && source !== IDENTITY_OPTION) identifiedUserId = next;
659
818
  }
660
- return emit({ type: 'identify', traits: rest }, ctx);
819
+ return emit(
820
+ { type: 'identify', traits: identifyTraits(rest, cfg.debug) },
821
+ ctx,
822
+ );
661
823
  },
662
824
 
663
- /** Clear identity on sign-out so the next events are not attributed to the old user. */
825
+ /**
826
+ * Clear the signed-in user. At a real sign-out (a user was signed in), also forget what the visit
827
+ * built up and the SDK's own device id, so the next person on a shared device inherits none of
828
+ * it. A device id from the site's own cookie stays: the site owns it.
829
+ */
664
830
  reset() {
831
+ if (userId) forgetVisitor();
665
832
  userId = null;
833
+ identifiedUserId = null;
834
+ optionsUserId = null;
666
835
  writeLastPageMessageId('');
667
836
  },
668
837
 
@@ -728,7 +897,14 @@ export function createAnalytics(options) {
728
897
  personalisationGroup,
729
898
  productClicked: (p, c) => track('Product Clicked', p, c),
730
899
  productListFiltered: (p, c) => track('Product List Filtered', p, c),
900
+ /** A submitted search and the results page it produced: the query, its results and `pagination`. */
731
901
  productsSearched: (p, c) => track('Products Searched', p, c),
902
+ /**
903
+ * The search box's suggested products for a partly typed query, once seen: never
904
+ * productsSearched, which is a submitted search's results page. A click on one carries this
905
+ * set's `search_request_id`.
906
+ */
907
+ searchSuggestionsViewed: (p, c) => track('Search Suggestions Viewed', p, c),
732
908
  productAdded: (p, c) => track('Product Added', p, c),
733
909
  productRemoved: (p, c) => track('Product Removed', p, c),
734
910
  /**
@@ -778,11 +954,7 @@ export function createAnalytics(options) {
778
954
  signedIn: (p, c) => track('Signed In', p, c),
779
955
  signedUp: (p, c) => track('Signed Up', p, c),
780
956
  /** Sent under the signed-in identity, which is then cleared for whoever uses the device next. */
781
- signedOut(c) {
782
- const event = track('Signed Out', {}, c);
783
- userId = null;
784
- return event;
785
- },
957
+ signedOut: (c) => signOut(track('Signed Out', {}, c)),
786
958
  newsletterSubscribed: (p, c) => track('Newsletter Subscribed', p, c),
787
959
  /** An on-site opt-out. The ESP reports its own as Email Unsubscribed. */
788
960
  newsletterUnsubscribed: (p, c) => track('Newsletter Unsubscribed', p, c),
@@ -836,5 +1008,5 @@ export function createAnalytics(options) {
836
1008
  replaying = null;
837
1009
  }
838
1010
  },
839
- };
1011
+ });
840
1012
  }
@@ -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
  );
package/src/segment.js CHANGED
@@ -33,7 +33,7 @@ import { hostConsent, reportingPurposes } from './consent.js';
33
33
  import { createConsentGate } from './consent-gate.js';
34
34
  import { buildContext, CONTEXT_KEYS } from './context.js';
35
35
  import { deviceIdFrom } from './device-storage.js';
36
- import { planAllows, readSettings, resolveApiHost } from './settings.js';
36
+ import { eventsHost, planAllows, readSettings } from './settings.js';
37
37
 
38
38
  export { DEFAULT_API_HOST } from './settings.js';
39
39
 
@@ -134,12 +134,20 @@ export function hpLoadOptions({ writeKey, apiHost }) {
134
134
  disableClientPersistence: true,
135
135
  updateCDNSettings(cdnSettings) {
136
136
  const integrations = { ...cdnSettings.integrations };
137
- if (!integrations['Segment.io']?.apiHost) {
138
- const origin =
139
- readSettings(cdnSettings).apiHost ?? resolveApiHost(apiHost);
137
+ const block = integrations['Segment.io'];
138
+ // The settings' block is kept only where the settings' host would be: no apiHost set, and https.
139
+ const configured =
140
+ apiHost !== undefined && apiHost !== null && apiHost !== '';
141
+ if (
142
+ configured ||
143
+ !block?.apiHost ||
144
+ (block.protocol ?? 'https') !== 'https'
145
+ ) {
146
+ const origin = eventsHost(apiHost, readSettings(cdnSettings).apiHost);
140
147
  if (!origin) return cdnSettings;
141
148
  const { protocol, host } = new URL(origin);
142
149
  integrations['Segment.io'] = {
150
+ ...block,
143
151
  apiKey: writeKey,
144
152
  apiHost: `${host}/v1`,
145
153
  protocol: protocol.replace(':', ''),
@@ -156,8 +164,8 @@ export function hpLoadOptions({ writeKey, apiHost }) {
156
164
 
157
165
  /**
158
166
  * @param {object} options Same names and meanings as createAnalytics().
159
- * @param {string} [options.propertyId] For a key scoped to several properties, or without settings:
160
- * the `hp` block's propertyId wins when it has one.
167
+ * @param {string} [options.propertyId] The page's property. It wins over the `hp` block's, which is
168
+ * the source's default property, as on Android, iOS and React Native.
161
169
  * @param {string} [options.channel]
162
170
  * @param {string} [options.market]
163
171
  * @param {string} [options.environment]
@@ -181,7 +189,6 @@ export function hpLoadOptions({ writeKey, apiHost }) {
181
189
  * @param {object} [options.anonymousIdSource] Only without settings: the `hp` block's wins.
182
190
  * @param {() => string|null} [options.getPageType]
183
191
  * @param {() => string|null} [options.getSessionId]
184
- * @param {() => string|null} [options.getRenderId]
185
192
  * @param {() => object|null} [options.getExtensions]
186
193
  * @param {() => object[]|null} [options.getExperiments]
187
194
  * @param {() => object[]|null} [options.getAudiences]
@@ -211,7 +218,7 @@ export function cdpPlugins(options = {}) {
211
218
  if (cdnSettings?.hp !== undefined) {
212
219
  const settings = readSettings(cdnSettings);
213
220
  if (settings.enabled) {
214
- cfg.propertyId = settings.propertyId ?? cfg.propertyId;
221
+ cfg.propertyId = cfg.propertyId ?? settings.propertyId;
215
222
  cfg.environment = settings.environment;
216
223
  if (settings.queryParamAllowlist)
217
224
  cfg.queryParamAllowlist = settings.queryParamAllowlist;
@@ -244,7 +251,8 @@ export function cdpPlugins(options = {}) {
244
251
  delete context[WITHDRAWN_AS];
245
252
 
246
253
  if (off) throw cancellation(ctx, 'not enabled for this write key');
247
- if (ctx.event.type === 'track' && !planAllows(cfg.plan, ctx.event.event)) {
254
+ const key = ctx.event.type === 'track' ? ctx.event.event : ctx.event.type;
255
+ if (!forced && !planAllows(cfg.plan, key)) {
248
256
  throw cancellation(ctx, 'disabled by the tracking plan');
249
257
  }
250
258