@thg-agentic/hyper-personalisation-web 3.17.1 → 3.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thg-agentic/hyper-personalisation-web",
3
- "version": "3.17.1",
3
+ "version": "3.18.0",
4
4
  "description": "Browser SDK for the THG Hyper-Personalisation event specification: event tracking from a write key and remote settings, strict CMP-agnostic consent, a GA4 dataLayer bridge and in-session audience evaluation. Dependency-free ESM on the Segment wire protocol, a CDN script (dist/hp.min.js), and an optional @segment/analytics-next plugin path.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -74,7 +74,7 @@
74
74
  "esbuild": "^0.28.2",
75
75
  "jsdom": "^30.1.1",
76
76
  "typescript": "^7.0.2",
77
- "@thg-agentic/hyper-personalisation-spec": "1.8.1"
77
+ "@thg-agentic/hyper-personalisation-spec": "1.10.0"
78
78
  },
79
79
  "scripts": {
80
80
  "test": "node --test tests/*.test.mjs",
package/src/bootstrap.js CHANGED
@@ -10,7 +10,8 @@
10
10
  * The key is the whole config: `apiHost` is optional, for a first-party proxy (DEFAULT_API_HOST
11
11
  * otherwise). Everything tenant-level comes from `GET {apiHost}/v1/projects/{writeKey}/settings`: whether the
12
12
  * SDK is on, the property, the environment, the reported consent purposes, the query-param
13
- * allowlist, where device ids come from, and the tracking plan. What only the page can know stays
13
+ * allowlist, where device ids come from, the tracking plan (which events are blocked), the sampling
14
+ * rate and debug logging. What only the page can know stays
14
15
  * in code: the CMP, the session cookie's name (and, for a source whose settings name no device
15
16
  * cookie, the device cookie's), the per-request currency / ship-to / locale getters, the preset,
16
17
  * the page type, debug, and which property this page is for a key scoped to several.
@@ -39,6 +40,7 @@ import {
39
40
  } from './consent.js';
40
41
  import { getCookie } from './context.js';
41
42
  import { connectDataLayer } from './datalayer.js';
43
+ import { sessionDebug } from './debug.js';
42
44
  import { createAnalytics, normaliseSha256, PAGE_TYPES } from './index.js';
43
45
  import {
44
46
  cachedSettings,
@@ -49,7 +51,7 @@ import {
49
51
  readSettings,
50
52
  resolveApiHost,
51
53
  } from './settings.js';
52
- import { realId } from './shape.js';
54
+ import { realId, uuid } from './shape.js';
53
55
  import {
54
56
  configureStorageNames,
55
57
  migrateLegacyStorage,
@@ -261,6 +263,17 @@ export function bootstrap(config, options = {}) {
261
263
  return started;
262
264
  }
263
265
 
266
+ /**
267
+ * When this document was navigated to: the wall clock less the time since, not `timeOrigin`, which
268
+ * browsers derive from a monotonic clock that drifts from the wall clock after a sleep.
269
+ */
270
+ function navigatedAt() {
271
+ const since = globalThis.performance?.now?.();
272
+ return new Date(
273
+ Date.now() - (Number.isFinite(since) ? since : 0),
274
+ ).toISOString();
275
+ }
276
+
264
277
  /**
265
278
  * The declarative trackers: they report only what the page tags (`data-hp-promotion`,
266
279
  * `data-hp-list` / `data-hp-product`, `data-hp-reco-request`, `data-hp-exposure`), so they are on
@@ -353,6 +366,11 @@ async function start(config, options, carry) {
353
366
 
354
367
  const { preset } = options;
355
368
  const identified = pendingIdentify(propertyId, identity);
369
+ // Settings from before 3.18, cached or served, have none of these: nothing sampled, no logging.
370
+ const debug =
371
+ options.debug === true ||
372
+ settings.debug?.enabled === true ||
373
+ sessionDebug(settings.debug?.tokenSha256 ?? null, storageName('debug'));
356
374
  const userId = realId(identity?.userId);
357
375
 
358
376
  const client = createAnalytics({
@@ -365,6 +383,7 @@ async function start(config, options, carry) {
365
383
  environment: settings.environment,
366
384
  queryParamAllowlist: settings.queryParamAllowlist ?? undefined,
367
385
  plan: settings.plan,
386
+ sampleRate: settings.sampleRate ?? 1,
368
387
  holdout: settings.holdouts?.[propertyId],
369
388
  // Per request, never per tenant: a site can trade in several currencies and ship anywhere.
370
389
  getCurrency: options.getCurrency,
@@ -376,7 +395,7 @@ async function start(config, options, carry) {
376
395
  holdAcrossPages: options.holdAcrossPages === true,
377
396
  // Deprecated in 3.6 and ignored; passed on only so debug can say so.
378
397
  mintAnonymousId: options.mintAnonymousId,
379
- debug: options.debug === true,
398
+ debug,
380
399
  // Where device ids come from is the source's: with no setting (settings from before 3.6),
381
400
  // the cookie named here, as before.
382
401
  anonymousIdSource: settings.anonymousId ?? undefined,
@@ -440,7 +459,16 @@ async function start(config, options, carry) {
440
459
  }
441
460
  }
442
461
 
443
- client.page(options.pageType ? { type: options.pageType } : {});
462
+ // Stamped when the document was navigated to, not when the settings came back: the page view
463
+ // is the page's first event, ahead of what the page queued through `hp` meanwhile (#527).
464
+ client[REPLAY](
465
+ 'page',
466
+ [options.pageType ? { type: options.pageType } : {}],
467
+ {
468
+ messageId: uuid(),
469
+ timestamp: navigatedAt(),
470
+ },
471
+ );
444
472
  let stopAutocapture = startAutocapture(client, options.autocapture);
445
473
  // Impressions count once per page view, so a single-page app's next route reports afresh.
446
474
  const page = client.page;
@@ -463,7 +491,7 @@ async function start(config, options, carry) {
463
491
  ...(options.dataLayer.ignore ?? []),
464
492
  ],
465
493
  onError: (err) =>
466
- options.debug && console.warn('[hp] dataLayer mapping failed', err),
494
+ debug && console.warn('[hp] dataLayer mapping failed', err),
467
495
  });
468
496
  }
469
497
 
package/src/debug.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Session debug (3.18): `?hp_debug=<token>` turns console logging on for the rest of the browser
3
+ * session, but only when the token hashes to the one the source's settings name, so a shopper who
4
+ * guesses the parameter turns nothing on. `?hp_debug=` with no value ends it. The token is kept in
5
+ * sessionStorage and checked on every page against the settings that page starts from, so a token
6
+ * cleared in the console stops working once the browser has read the new settings.
7
+ */
8
+
9
+ import { sha256Hex } from './hash.js';
10
+
11
+ export const DEBUG_PARAM = 'hp_debug';
12
+
13
+ const MAX_TOKEN_LENGTH = 256;
14
+
15
+ function readParam() {
16
+ try {
17
+ return new URLSearchParams(location.search).get(DEBUG_PARAM);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Whether this session has debug on: apply the page URL's `hp_debug` to sessionStorage under
25
+ * `name`, then check what is kept against `tokenSha256`.
26
+ *
27
+ * @param {string|null} tokenSha256 The settings' `hp.debug.tokenSha256`.
28
+ * @param {string} name The sessionStorage key, storageName('debug').
29
+ */
30
+ export function sessionDebug(tokenSha256, name) {
31
+ if (typeof location === 'undefined') return false;
32
+ try {
33
+ const given = readParam();
34
+ if (given !== null) {
35
+ const token = given.trim();
36
+ if (token === '' || token.length > MAX_TOKEN_LENGTH)
37
+ sessionStorage.removeItem(name);
38
+ else sessionStorage.setItem(name, token);
39
+ }
40
+ const kept = sessionStorage.getItem(name);
41
+ return Boolean(tokenSha256 && kept && sha256Hex(kept) === tokenSha256);
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
@@ -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,13 @@ 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: the id must not outlive the permission it was kept under,
158
+ * so a later grant starts a new device, as `analytics.reset()` does.
159
+ */
157
160
  forget() {
158
161
  writeCookie(name, '', 0, domain);
162
+ id = null;
159
163
  written = false;
160
164
  try {
161
165
  local()?.removeItem(legacyKey);
@@ -168,34 +172,42 @@ export function createDeviceCookie({
168
172
  }
169
173
 
170
174
  /**
171
- * Where device ids come from, from the source's settings (`hp.anonymousId`, ADR-0017): one place,
172
- * never a fallback between two.
175
+ * Where device ids come from, from the source's settings (`hp.anonymousId`, ADR-0017).
173
176
  *
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.
177
+ * site_cookie the cookie the settings name, or (no name) `getAnonymousId`, the integration's own.
176
178
  * hp_cookie Hyper-Personalisation's cookie, minted and kept here. `getAnonymousId` is ignored.
177
179
  * absent settings from before 3.6: `getAnonymousId`, as before.
178
180
  *
179
- * Replaces `cfg.getAnonymousId` to match, and returns the device cookie under `hp_cookie`.
181
+ * With `orOwn`, a visitor the site has no id for (the cookie missing, empty or a placeholder) gets
182
+ * Hyper-Personalisation's cookie rather than none, as analytics.js does: an event with no device id
183
+ * belongs to nobody (3.18).
184
+ *
185
+ * Replaces `cfg.getAnonymousId` to match, and returns the device cookie it may fall back to.
180
186
  */
181
- export function deviceIdFrom(cfg, { carried, mayStore }) {
187
+ export function deviceIdFrom(cfg, { carried = null, mayStore, orOwn = false }) {
182
188
  const setting = cfg.anonymousIdSource;
189
+ const own = () =>
190
+ createDeviceCookie({
191
+ name: storageName('anonymousId'),
192
+ domain: cfg.consentCookieDomain ?? null,
193
+ legacyKey: storageKey(storageName('anonymousId'), cfg.propertyId),
194
+ read: getCookie,
195
+ mint: uuid,
196
+ mayStore,
197
+ carried,
198
+ });
199
+ if (setting?.source === 'hp_cookie') {
200
+ const device = own();
201
+ cfg.getAnonymousId = () => device.get();
202
+ return device;
203
+ }
183
204
  if (setting?.source === 'site_cookie' && setting.cookie) {
184
205
  const name = setting.cookie;
185
206
  cfg.getAnonymousId = () => getCookie(name);
186
- return null;
187
207
  }
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();
208
+ if (!orOwn) return null;
209
+ const site = cfg.getAnonymousId;
210
+ const device = own();
211
+ cfg.getAnonymousId = () => realId(perRequest(site, null)) ?? device.get();
200
212
  return device;
201
213
  }
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'),
@@ -59,6 +60,7 @@ import {
59
60
  keepVisit,
60
61
  recommendationContextFrom,
61
62
  } from './recommendation-context.js';
63
+ import { sampledIn } from './sampling.js';
62
64
  import { recordSessionEvent } from './session/record.js';
63
65
  import {
64
66
  clearSessionSignals,
@@ -102,6 +104,9 @@ export { emailSha256, getCookie };
102
104
  /** How a call made before the SDK started is replayed with the messageId and time it was made at. */
103
105
  const REPLAY = Symbol.for('@thg-agentic/hyper-personalisation-web.replay');
104
106
 
107
+ /** `throwOnInvalid`: the one failure a caller asked to have thrown at them. */
108
+ class InvalidEventError extends Error {}
109
+
105
110
  /** SHA-256 of a string as lowercase hex. Synchronous since 3.9 (sha256Hex); kept async for callers. */
106
111
  export async function sha256(value) {
107
112
  return sha256Hex(String(value));
@@ -253,18 +258,18 @@ export function createAnalytics(options) {
253
258
  const device = deviceIdFrom(cfg, {
254
259
  carried: carried.anonymousId,
255
260
  mayStore: granted,
261
+ orOwn: true,
256
262
  });
257
263
 
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();
264
+ // The host's session when it has one, otherwise the SDK's (3.15; 3.18 when the host's is empty).
265
+ const session = createSessionId({
266
+ name: storageName('session'),
267
+ domain: cfg.consentCookieDomain ?? null,
268
+ mayStore: granted,
269
+ });
270
+ const hostSession = cfg.getSessionId;
271
+ cfg.getSessionId = () =>
272
+ realId(perRequest(hostSession, null)) ?? session.get();
268
273
 
269
274
  const previewing = applyPreview(
270
275
  storageName('preview'),
@@ -322,7 +327,18 @@ export function createAnalytics(options) {
322
327
  /** The call being replayed: its first event keeps the messageId and time it was made with. */
323
328
  let replaying = null;
324
329
 
330
+ /** Tracking never throws into the host: an event that fails to build or deliver is dropped. */
325
331
  function emit(partial, ctxOverrides, force = false) {
332
+ try {
333
+ return compose(partial, ctxOverrides, force);
334
+ } catch (err) {
335
+ if (err instanceof InvalidEventError) throw err;
336
+ console.warn('[hp] the event was not sent', err);
337
+ return null;
338
+ }
339
+ }
340
+
341
+ function compose(partial, ctxOverrides, force) {
326
342
  const made = replaying;
327
343
  replaying = null;
328
344
  let event = {
@@ -336,6 +352,12 @@ export function createAnalytics(options) {
336
352
 
337
353
  if (event.properties) event.properties = pruneProperties(event.properties);
338
354
 
355
+ // Outside the source's sample (3.18): nothing about this visitor is sent.
356
+ if (!force && !sampledIn(event.anonymousId, cfg.sampleRate)) {
357
+ if (cfg.debug) console.debug('[hp] not sent: outside the sample', event);
358
+ return null;
359
+ }
360
+
339
361
  // A redaction or sampling hook may change what an event says or drop it, never what it is.
340
362
  // One that fails drops the event rather than sending what it was meant to redact.
341
363
  if (cfg.beforeSend) {
@@ -362,7 +384,7 @@ export function createAnalytics(options) {
362
384
  const res = cfg.validate(event, { producer: 'web' });
363
385
  if (!res.valid) {
364
386
  const msg = `[hp] invalid ${event.event ?? event.type}:\n ${res.errors.join('\n ')}`;
365
- if (cfg.throwOnInvalid) throw new Error(msg);
387
+ if (cfg.throwOnInvalid) throw new InvalidEventError(msg);
366
388
  console.error(msg);
367
389
  }
368
390
  }
@@ -471,7 +493,7 @@ export function createAnalytics(options) {
471
493
  onStatus(status) {
472
494
  if (status === 'granted') {
473
495
  device?.persist();
474
- session?.persist();
496
+ session.persist();
475
497
  if (lastPageMessageId) writeLastPageMessageId(lastPageMessageId);
476
498
  decisions.persist();
477
499
  transport.replayPersisted?.();
@@ -480,7 +502,7 @@ export function createAnalytics(options) {
480
502
  // Nothing kept on the device may outlive the permission it was kept under.
481
503
  transport.clearPersisted?.();
482
504
  device?.forget();
483
- session?.forget();
505
+ session.forget();
484
506
  try {
485
507
  sessionStorage.removeItem(PREV_PAGE_KEY);
486
508
  } catch {
@@ -796,7 +818,7 @@ export function createAnalytics(options) {
796
818
  get lastPageMessageId() {
797
819
  return readLastPageMessageId();
798
820
  },
799
- /** The device id events carry: the site's cookie's, or under `hp_cookie` the SDK's own. */
821
+ /** The device id events carry: the site's, or the SDK's own under `hp_cookie` or without one. */
800
822
  get anonymousId() {
801
823
  return realId(perRequest(cfg.getAnonymousId, null));
802
824
  },
@@ -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
  });
@@ -103,6 +103,19 @@ export function recordSessionEvent(eventName, properties) {
103
103
  case 'Product Clicked':
104
104
  case 'Product Added to Wishlist':
105
105
  break;
106
+ case 'Cart Updated': {
107
+ // The basket as it now stands, priced by the platform, so a merge, a repricing or a code is
108
+ // already in it. Summing the lines of Product Added would drift from that, and its line
109
+ // prices include tax where `value` does not. Its lines are the basket, not attention.
110
+ // The latest basket is the only one that counts: unpriced, its value is unknown again.
111
+ const value = properties?.value;
112
+ s.basketValue =
113
+ typeof value === 'number' && Number.isFinite(value) && value >= 0
114
+ ? value
115
+ : null;
116
+ writeSignals(s);
117
+ return s;
118
+ }
106
119
  default:
107
120
  return s;
108
121
  }
@@ -67,6 +67,7 @@ function readUnscoped() {
67
67
  parsed &&
68
68
  (parsed.productViews > 0 ||
69
69
  parsed.listViews > 0 ||
70
+ parsed.basketValue != null ||
70
71
  parsed.recentProducts?.length ||
71
72
  parsed.recentBrowse?.length);
72
73
  return busy ? parsed : null;
@@ -86,6 +87,9 @@ function emptySignals() {
86
87
  recentCategories: [],
87
88
  recentBrowse: [],
88
89
  basketProducts: [],
90
+ // The last Cart Updated's `value`, in its currency. Unknown (so every rule on it fails closed)
91
+ // until the visit sends one: a basket carried over from an earlier visit is not zero.
92
+ basketValue: null,
89
93
  recentSearches: [],
90
94
  // Shown-and-ignored counts, kept apart from `recentProducts` so a rail's own impressions never
91
95
  // read as interest. The threshold that makes them meaningful lives server-side in `fatigue.ts`.
package/src/session-id.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The session id the SDK keeps when the host names none (3.15): a first-party cookie holding a
3
3
  * random id, ended by 30 minutes without an event, as Amplitude and GA4 sessionise. A host that
4
- * has its own session (`getSessionId`, `identityCookies.sessionId`) keeps it: the SDK never mints a
5
- * second one beside it.
4
+ * has its own session (`getSessionId`, `identityCookies.sessionId`) keeps it; this one stands in
5
+ * only while the host's is missing or empty (3.18), so no event is sent without a session.
6
6
  *
7
7
  * The cookie is shared by the site's tabs and readable by its servers. Under strict consent it is
8
8
  * written only once analytics is granted; until then the id lives in memory, and it is deleted on
package/src/settings.js CHANGED
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * The last good answer is cached, only under an analytics grant (it is a device write), and a page
11
11
  * with a cache starts from it and revalidates in the background. Until the grant the HTTP cache
12
- * (`max-age=60, stale-while-revalidate=600`) saves the round trip.
12
+ * (`max-age=10` and an `ETag`) saves the round trip or answers a `304`.
13
13
  */
14
14
 
15
15
  import { storageKey, storageName } from './storage-scope.js';
@@ -38,12 +38,11 @@ export function settingsUrl(apiHost, writeKey) {
38
38
  }
39
39
 
40
40
  /**
41
- * Where the SDK talks to when the integrator names no host. The key alone selects the tenant, the
42
- * property and test or live, as with Stripe and LaunchDarkly; a host is only for a first-party proxy.
43
- * Until production is live this is the nonprod host, reachable only from allowlisted networks, so
44
- * for test keys, UAT and previews; the release that moves it to production is the one to go live on.
41
+ * Where the SDK talks to when the integrator names no host: the one host every client uses, for
42
+ * test and live keys alike. The key alone selects the tenant, the property and test or live, as
43
+ * with Stripe and LaunchDarkly; a host is only for a first-party proxy or our own nonprod testing.
45
44
  */
46
- export const DEFAULT_API_HOST = 'https://hp.uat.thgingenuity.com';
45
+ export const DEFAULT_API_HOST = 'https://hp.thgingenuity.com';
47
46
 
48
47
  /** The integrator's `apiHost` as an origin, DEFAULT_API_HOST when none is given, null when invalid. */
49
48
  export function resolveApiHost(host) {
@@ -76,6 +75,23 @@ function readPurposes(raw) {
76
75
  const isFraction = (value) =>
77
76
  typeof value === 'number' && value >= 0 && value <= 1;
78
77
 
78
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
79
+
80
+ /**
81
+ * Console logging (3.18): on for everyone, or for a session opened with `?hp_debug=<token>`, checked
82
+ * against the token's hash. Off when the settings do not say.
83
+ */
84
+ function readDebug(raw) {
85
+ if (!isObject(raw)) return { enabled: false, tokenSha256: null };
86
+ return {
87
+ enabled: raw.enabled === true,
88
+ tokenSha256:
89
+ typeof raw.tokenSha256 === 'string' && SHA256_HEX.test(raw.tokenSha256)
90
+ ? raw.tokenSha256
91
+ : null,
92
+ };
93
+ }
94
+
79
95
  /**
80
96
  * The personalisation holdout per property, `{ share, ramp, salt, readoutSlots }`, or null when the settings carry
81
97
  * none. An entry that is not a share in [0, 1] with a non-empty salt, or whose ramp is neither null
@@ -150,6 +166,9 @@ export function readSettings(body) {
150
166
  anonymousId: readAnonymousId(hp.anonymousId),
151
167
  apiHost: apiOrigin(hp.apiHost),
152
168
  holdouts: readHoldouts(hp.holdouts),
169
+ // The share of visitors sent for (3.18). Anything that is not a share sends for everyone.
170
+ sampleRate: isFraction(hp.sampleRate) ? hp.sampleRate : 1,
171
+ debug: readDebug(hp.debug),
153
172
  plan:
154
173
  isObject(body.plan) && isObject(body.plan.track)
155
174
  ? { track: body.plan.track }
package/src/shape.js CHANGED
@@ -37,14 +37,23 @@ const NEVER_PRUNE = new Set(['products']);
37
37
  /** Restore an exempt key only when it was null; an array stays pruned. */
38
38
  const isNullish = (v) => v === null || v === undefined;
39
39
 
40
- function pruneNulls(value) {
41
- if (Array.isArray(value)) return value.map(pruneNulls);
40
+ /**
41
+ * A Date becomes the ISO string JSON would make of it. A cycle is left in place, not followed: the
42
+ * transport drops an event it cannot serialise, where following it would overflow the stack.
43
+ */
44
+ function pruneNulls(value, ancestors = new WeakSet()) {
45
+ if (value instanceof Date) return value.toJSON();
42
46
  if (value === null || typeof value !== 'object') return value;
43
- const out = {};
44
- for (const [k, v] of Object.entries(value)) {
45
- if (isNullish(v)) continue;
46
- out[k] = pruneNulls(v);
47
- }
47
+ if (ancestors.has(value)) return value;
48
+ ancestors.add(value);
49
+ const out = Array.isArray(value)
50
+ ? value.map((v) => pruneNulls(v, ancestors))
51
+ : Object.fromEntries(
52
+ Object.entries(value)
53
+ .filter(([, v]) => !isNullish(v))
54
+ .map(([k, v]) => [k, pruneNulls(v, ancestors)]),
55
+ );
56
+ ancestors.delete(value);
48
57
  return out;
49
58
  }
50
59
 
@@ -1,4 +1,4 @@
1
1
  // Generated from the spec by scripts/build-types.mjs. DO NOT EDIT: run `pnpm run build:types`.
2
2
 
3
3
  /** The event specification this SDK emits, stamped on every event as context.spec.version. */
4
- export const SPEC_VERSION = '1.8';
4
+ export const SPEC_VERSION = '1.10';
@@ -45,7 +45,7 @@ export const DEFAULT_STORAGE_NAMES = Object.freeze({
45
45
  */
46
46
  anonymousId: 'thg_hp_anonymous_id',
47
47
  /**
48
- * Cookie, one per browser: the session id the SDK keeps when the host names none, expiring 30
48
+ * Cookie, one per browser: the session id the SDK keeps when the host has none, expiring 30
49
49
  * minutes after the last event (3.15). Only under a grant.
50
50
  */
51
51
  session: 'thg_hp_session',
@@ -54,6 +54,11 @@ export const DEFAULT_STORAGE_NAMES = Object.freeze({
54
54
  * (`?hp_preview=`), for the site's server to forward to `/v1/assignments` (3.15).
55
55
  */
56
56
  preview: 'thg_hp_preview',
57
+ /**
58
+ * sessionStorage, for the browser session: the debug token a developer opened the site with
59
+ * (`?hp_debug=`), checked against the settings on every page (3.18).
60
+ */
61
+ debug: 'thg_hp_debug',
57
62
  });
58
63
 
59
64
  /**
package/src/transport.js CHANGED
@@ -111,8 +111,23 @@ export class Transport {
111
111
  }
112
112
  }
113
113
 
114
+ /**
115
+ * Queued as its JSON form, made once here: an event JSON cannot serialise (a BigInt, a cycle, a
116
+ * throwing toJSON) is dropped on its own, rather than throwing from every later flush and holding
117
+ * every event behind it.
118
+ */
114
119
  enqueue(event) {
115
- this.queue.push(event);
120
+ let wire;
121
+ try {
122
+ wire = JSON.parse(JSON.stringify(event));
123
+ } catch (err) {
124
+ this.o.onError?.(
125
+ `event ${event?.messageId ?? ''} cannot be serialised; dropped`,
126
+ err,
127
+ );
128
+ return;
129
+ }
130
+ this.queue.push(wire);
116
131
  if (this.queue.length > this.o.maxQueue) {
117
132
  // Drop oldest: recent behaviour is more useful than a complete-but-stale tail.
118
133
  this.queue.splice(0, this.queue.length - this.o.maxQueue);
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '3.17.1';
1
+ export const VERSION = '3.18.0';
2
2
  export const LIB = '@thg-agentic/hyper-personalisation-web';