@thg-agentic/hyper-personalisation-web 0.0.0-stage → 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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +860 -2
  3. package/dist/hp.min.js +3 -0
  4. package/dist/snippet.min.js +1 -0
  5. package/package.json +83 -3
  6. package/src/assignment.js +75 -0
  7. package/src/auto/attributes.js +16 -0
  8. package/src/auto/exposures.js +45 -0
  9. package/src/auto/lists.js +136 -0
  10. package/src/auto/promotions.js +104 -0
  11. package/src/auto/rails.js +217 -0
  12. package/src/auto/viewability.js +150 -0
  13. package/src/auto.js +13 -0
  14. package/src/bootstrap.js +504 -0
  15. package/src/carry.js +74 -0
  16. package/src/conformance.js +530 -0
  17. package/src/consent-gate.js +324 -0
  18. package/src/consent.js +604 -0
  19. package/src/context.js +439 -0
  20. package/src/datalayer.js +411 -0
  21. package/src/debug.js +45 -0
  22. package/src/decisions.js +90 -0
  23. package/src/device-storage.js +213 -0
  24. package/src/experiments.js +220 -0
  25. package/src/ga4.js +124 -0
  26. package/src/hash.js +138 -0
  27. package/src/hp.js +135 -0
  28. package/src/index.js +840 -0
  29. package/src/preview.js +44 -0
  30. package/src/recommendation-context.js +101 -0
  31. package/src/sampling.js +19 -0
  32. package/src/segment.js +388 -0
  33. package/src/session/category.js +40 -0
  34. package/src/session/record.js +176 -0
  35. package/src/session/rules.js +195 -0
  36. package/src/session/signals.js +150 -0
  37. package/src/session-id.js +62 -0
  38. package/src/session.js +28 -0
  39. package/src/settings.js +230 -0
  40. package/src/shape.js +120 -0
  41. package/src/social-proof.js +139 -0
  42. package/src/spec-version.js +4 -0
  43. package/src/storage-scope.js +164 -0
  44. package/src/transport.js +451 -0
  45. package/src/version.js +2 -0
  46. package/types/auto.d.ts +51 -0
  47. package/types/bootstrap.d.ts +165 -0
  48. package/types/consent.d.ts +126 -0
  49. package/types/datalayer.d.ts +76 -0
  50. package/types/events.d.ts +713 -0
  51. package/types/hp.d.ts +64 -0
  52. package/types/index.d.ts +617 -0
  53. package/types/segment.d.ts +68 -0
package/src/index.js ADDED
@@ -0,0 +1,840 @@
1
+ /**
2
+ * @thg-agentic/hyper-personalisation-web — browser SDK for the THG Hyper-Personalisation event specification.
3
+ *
4
+ * It emits the wire format defined in @thg-agentic/hyper-personalisation-spec, as do the
5
+ * @segment/analytics-next plugins in ./segment.js. Most sites start it with
6
+ * `bootstrap({ writeKey, apiHost }, options)` from ./bootstrap.js, which fetches the tenant's
7
+ * settings and calls this. createAnalytics is the lower-level API:
8
+ *
9
+ * import { createAnalytics } from '@thg-agentic/hyper-personalisation-web';
10
+ *
11
+ * const analytics = createAnalytics({
12
+ * apiHost: 'https://api.example.com',
13
+ * writeKey: 'hp_pk_live_…',
14
+ * propertyId: 'acme-gb',
15
+ * consent: oneTrustConsent(),
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`, or when the site has none, the SDK mints
18
+ * // and keeps its own.
19
+ * anonymousIdSource: settings.anonymousId,
20
+ * getAnonymousId: () => getCookie('device_id'),
21
+ * getSessionId: () => getCookie('session_id'),
22
+ * // The rails on the page, stamped onto every event as context.recommendations.
23
+ * getRecommendations: () => railsOnThisPage,
24
+ * });
25
+ *
26
+ * analytics.page({ type: 'product' });
27
+ * analytics.productViewed({ products: [item] });
28
+ *
29
+ * Recommendation rails are instrumented from markup, against a viewability standard:
30
+ *
31
+ * import { trackRecommendationRails } from '@thg-agentic/hyper-personalisation-web/auto';
32
+ * import { assignRecommendationVariant } from '@thg-agentic/hyper-personalisation-web';
33
+ *
34
+ * // Before requesting the rail: a control arm must not call the recommender.
35
+ * const arm = assignRecommendationVariant({
36
+ * unit: getCookie('device_id'),
37
+ * experiment: 'reco_ranker_v2',
38
+ * variants: [{ name: 'blend', share: 0.5 }, { name: 'ranker', share: 0.5 }],
39
+ * holdoutShare: 0.05,
40
+ * });
41
+ *
42
+ * trackRecommendationRails(analytics);
43
+ */
44
+
45
+ import { assignmentSession, surfaceExposureToken } from './assignment.js';
46
+ import { hostConsent, servingHeaders } from './consent.js';
47
+ import { createConsentGate } from './consent-gate.js';
48
+ import { buildContext, getCookie, perRequest } from './context.js';
49
+ import { createDecisionMemory, servedDecisions } from './decisions.js';
50
+ import { createHeldStore, deviceIdFrom } from './device-storage.js';
51
+ import {
52
+ banditSplit,
53
+ holdoutAssignment,
54
+ holdoutShares,
55
+ } from './experiments.js';
56
+ import { emailSha256, sha256Hex } from './hash.js';
57
+ import { applyPreview } from './preview.js';
58
+ import {
59
+ forgetVisit,
60
+ keepVisit,
61
+ recommendationContextFrom,
62
+ } from './recommendation-context.js';
63
+ import { sampledIn } from './sampling.js';
64
+ import { recordSessionEvent } from './session/record.js';
65
+ import {
66
+ clearSessionSignals,
67
+ getSessionSignals,
68
+ setSessionScope,
69
+ } from './session/signals.js';
70
+ import { createSessionId } from './session-id.js';
71
+ import { planAllows, resolveApiHost } from './settings.js';
72
+ import {
73
+ detectNavigationTrigger,
74
+ PAGE_TYPES,
75
+ pruneProperties,
76
+ realId,
77
+ uuid,
78
+ } from './shape.js';
79
+ import {
80
+ configureStorageNames,
81
+ migrateLegacyStorage,
82
+ storageKey,
83
+ storageName,
84
+ } from './storage-scope.js';
85
+ import { Transport } from './transport.js';
86
+ import { LIB, VERSION } from './version.js';
87
+
88
+ /** The surfaces a `Personalisation Exposed` names, as the public API's exposures do. */
89
+ const EXPOSURE_SURFACES = new Set([
90
+ 'audiences',
91
+ 'decisions',
92
+ 'recommendations',
93
+ ]);
94
+
95
+ export {
96
+ ASSIGNMENT_DECISION_NAMESPACE,
97
+ assignmentDecisionId,
98
+ } from './assignment.js';
99
+ export { defaultProductId, fromGa4Item, toProducts } from './ga4.js';
100
+ export { DEFAULT_API_HOST, resolveApiHost } from './settings.js';
101
+ export { PAGE_TYPES, pruneProperties } from './shape.js';
102
+ export { emailSha256, getCookie };
103
+
104
+ /** How a call made before the SDK started is replayed with the messageId and time it was made at. */
105
+ const REPLAY = Symbol.for('@thg-agentic/hyper-personalisation-web.replay');
106
+
107
+ /** `throwOnInvalid`: the one failure a caller asked to have thrown at them. */
108
+ class InvalidEventError extends Error {}
109
+
110
+ /** SHA-256 of a string as lowercase hex. Synchronous since 3.9 (sha256Hex); kept async for callers. */
111
+ export async function sha256(value) {
112
+ return sha256Hex(String(value));
113
+ }
114
+
115
+ /**
116
+ * Cart Updated's properties from the platform's confirmed basket: the lines as given, and
117
+ * `item_count` summed from their quantities when the basket does not carry one. Prices are left
118
+ * alone: a line price includes tax where the market displays it so, and `value` does not, so the
119
+ * SDK never derives `value` from the lines.
120
+ */
121
+ export function cartSnapshot(basket) {
122
+ const props = { ...(basket ?? {}) };
123
+ const lines = Array.isArray(props.products) ? props.products : null;
124
+ if (props.item_count == null && lines) {
125
+ const quantities = lines.map((line) => line?.quantity);
126
+ if (quantities.every(Number.isInteger))
127
+ props.item_count = quantities.reduce((sum, q) => sum + q, 0);
128
+ }
129
+ return props;
130
+ }
131
+
132
+ /** As emailSha256, kept async for the callers that await it. */
133
+ export async function hashEmail(email) {
134
+ return emailSha256(email);
135
+ }
136
+
137
+ /**
138
+ * `phone_sha256` for identify: the E.164 number (`+` then 8 to 15 digits, spaces, dots, dashes and
139
+ * brackets removed), hashed. Null for a national number such as `07700 900123`: its country cannot
140
+ * be known here, and hashing it as typed would never match the same number sent in E.164.
141
+ */
142
+ export async function hashPhone(phone) {
143
+ if (typeof phone !== 'string') return null;
144
+ const normalised = phone.replace(/[\s.()-]/g, '');
145
+ if (!/^\+[1-9]\d{7,14}$/.test(normalised)) return null;
146
+ return sha256Hex(normalised);
147
+ }
148
+
149
+ /**
150
+ * The digest of the empty string: what a platform that hashes a blank email field sends for "no
151
+ * email". Every customer without an address would share it.
152
+ */
153
+ const EMPTY_SHA256 =
154
+ 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
155
+
156
+ function hexDigest(text) {
157
+ if (/^[0-9a-fA-F]{64}$/.test(text)) return text.toLowerCase();
158
+ if (!/^[A-Za-z0-9+/_-]{43}=?$/.test(text)) return null;
159
+ try {
160
+ const binary = atob(
161
+ text.replace(/-/g, '+').replace(/_/g, '/').padEnd(44, '='),
162
+ );
163
+ if (binary.length !== 32) return null;
164
+ return Array.from(binary, (c) =>
165
+ c.charCodeAt(0).toString(16).padStart(2, '0'),
166
+ ).join('');
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * A SHA-256 digest as the lowercase hex `email_sha256` wants, from hex or base64 (standard or
174
+ * URL-safe). Re-encodes, never re-hashes. Null for anything that is not a 32-byte digest, and for
175
+ * the digest of the empty string.
176
+ */
177
+ export function normaliseSha256(value) {
178
+ if (typeof value !== 'string') return null;
179
+ const hex = hexDigest(value.trim());
180
+ return hex === EMPTY_SHA256 ? null : hex;
181
+ }
182
+
183
+ // On the main entry so integrators find them: without an assigned variant a rail is unmeasurable.
184
+ export {
185
+ assignRecommendationVariant,
186
+ banditSplit,
187
+ candidateSetFingerprint,
188
+ hash32,
189
+ hashBucket,
190
+ holdoutAssignment,
191
+ } from './experiments.js';
192
+ /** Social proof: the one part of this package that reads rather than writes. */
193
+ export { createSocialProof } from './social-proof.js';
194
+
195
+ export { VERSION };
196
+
197
+ let warnedMintAnonymousId = false;
198
+
199
+ /** 3.5's `mintAnonymousId` does nothing since 3.6: where device ids come from is the source's. */
200
+ function warnMintAnonymousId(debug) {
201
+ if (!debug || warnedMintAnonymousId) return;
202
+ warnedMintAnonymousId = true;
203
+ console.warn(
204
+ '[hp] mintAnonymousId is ignored since 3.6: where device ids come from is set per source in the console (the site’s own cookie, or Hyper-Personalisation’s).',
205
+ );
206
+ }
207
+
208
+ /** Per-call context overrides, given directly or analytics-next style as `{ context }`. */
209
+ function contextOverrides(options) {
210
+ if (
211
+ options &&
212
+ typeof options.context === 'object' &&
213
+ options.context !== null
214
+ )
215
+ return options.context;
216
+ return options;
217
+ }
218
+
219
+ export function createAnalytics(options) {
220
+ const apiHost = resolveApiHost(options?.apiHost);
221
+ if (!apiHost)
222
+ throw new Error(
223
+ 'createAnalytics: apiHost, when given, must be a host or an http(s) origin',
224
+ );
225
+ if (!options?.propertyId)
226
+ throw new Error('createAnalytics: propertyId is required');
227
+
228
+ const cfg = {
229
+ libraryName: LIB,
230
+ libraryVersion: VERSION,
231
+ writeKey: 'web',
232
+ queryParamBlocklist: [],
233
+ debug: false,
234
+ validate: null, // inject @thg-agentic/hyper-personalisation-spec's validateEvent in dev
235
+ consentTimeoutMs: 5000,
236
+ ...options,
237
+ apiHost,
238
+ };
239
+
240
+ configureStorageNames(cfg.storageNames);
241
+ migrateLegacyStorage();
242
+
243
+ // A consent adapter makes the SDK strict: nothing is sent or stored until analytics is granted.
244
+ // Without one the host has decided the lawful basis itself, and every event says so (spec 1.5).
245
+ const strict = Boolean(cfg.consent);
246
+ let gate = null;
247
+ cfg.getConsent = () => (gate ? gate.stamp() : hostConsent());
248
+ const granted = () => !strict || gate?.status === 'granted';
249
+
250
+ // Opt-in (3.5): events held for an unanswered CMP survive a navigation, in sessionStorage.
251
+ const heldStore =
252
+ strict && cfg.holdAcrossPages
253
+ ? createHeldStore(storageKey(storageName('held'), cfg.propertyId))
254
+ : null;
255
+ const carried = heldStore?.read() ?? { items: [], anonymousId: null };
256
+
257
+ if (cfg.mintAnonymousId !== undefined) warnMintAnonymousId(cfg.debug);
258
+ const device = deviceIdFrom(cfg, {
259
+ carried: carried.anonymousId,
260
+ mayStore: granted,
261
+ orOwn: true,
262
+ });
263
+
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();
273
+
274
+ const previewing = applyPreview(
275
+ storageName('preview'),
276
+ cfg.consentCookieDomain ?? null,
277
+ );
278
+
279
+ const transport =
280
+ cfg.transport ??
281
+ new Transport({
282
+ apiHost: cfg.apiHost,
283
+ writeKey: cfg.writeKey,
284
+ // Per tenant: a persisted batch is replayed through whichever instance starts next, with its
285
+ // writeKey, and one origin can serve several tenants.
286
+ storageKey: storageKey(storageName('retry'), cfg.propertyId),
287
+ // Under strict consent, persisting is a device write and replaying is a send: both wait for a
288
+ // grant (see onStatus below, which replays on a grant and clears on a refusal).
289
+ mayPersist: strict ? () => gate?.status === 'granted' : undefined,
290
+ deferReplay: strict,
291
+ onError: (m, e) => cfg.debug && console.warn('[hp]', m, e ?? ''),
292
+ });
293
+
294
+ // The previous page view's messageId, carried across navigations in sessionStorage, per tenant.
295
+ const PREV_PAGE_KEY = storageKey(storageName('previousPage'), cfg.propertyId);
296
+
297
+ // Memory is authoritative within a document; storage only carries the link across a navigation.
298
+ let lastPageMessageId = null;
299
+
300
+ const readLastPageMessageId = () => {
301
+ if (lastPageMessageId) return lastPageMessageId;
302
+ try {
303
+ return sessionStorage.getItem(PREV_PAGE_KEY) || null;
304
+ } catch {
305
+ return null;
306
+ }
307
+ };
308
+
309
+ const writeLastPageMessageId = (id) => {
310
+ lastPageMessageId = id || null;
311
+ if (strict && gate.status !== 'granted') return;
312
+ try {
313
+ sessionStorage.setItem(PREV_PAGE_KEY, id);
314
+ } catch {
315
+ /* private browsing: the chain still holds within this document */
316
+ }
317
+ };
318
+ // What decisioned slots served, stamped on every later event unless the host supplies its own.
319
+ const decisions = createDecisionMemory({
320
+ key: storageKey(storageName('decisions'), cfg.propertyId),
321
+ mayStore: granted,
322
+ });
323
+ cfg.getDecisions ??= decisions.current;
324
+
325
+ let userId = realId(perRequest(cfg.getUserId, null));
326
+
327
+ /** The call being replayed: its first event keeps the messageId and time it was made with. */
328
+ let replaying = null;
329
+
330
+ /** Tracking never throws into the host: an event that fails to build or deliver is dropped. */
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) {
342
+ const made = replaying;
343
+ replaying = null;
344
+ let event = {
345
+ messageId: made?.messageId ?? uuid(),
346
+ timestamp: made?.timestamp ?? new Date().toISOString(),
347
+ anonymousId: realId(perRequest(cfg.getAnonymousId, null)),
348
+ userId: userId ?? null,
349
+ ...partial,
350
+ context: buildContext(cfg, contextOverrides(ctxOverrides)),
351
+ };
352
+
353
+ if (event.properties) event.properties = pruneProperties(event.properties);
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
+
361
+ // A redaction or sampling hook may change what an event says or drop it, never what it is.
362
+ // One that fails drops the event rather than sending what it was meant to redact.
363
+ if (cfg.beforeSend) {
364
+ let shaped;
365
+ try {
366
+ shaped = cfg.beforeSend(event);
367
+ } catch (err) {
368
+ console.warn('[hp] beforeSend failed; the event was not sent', err);
369
+ return null;
370
+ }
371
+ if (!shaped) return null;
372
+ const { type, event: name, messageId, timestamp } = event;
373
+ event = {
374
+ ...shaped,
375
+ type,
376
+ messageId,
377
+ timestamp,
378
+ ...(name ? { event: name } : {}),
379
+ };
380
+ }
381
+
382
+ // Injected rather than imported, so the validator and ajv never reach a production bundle.
383
+ if (cfg.validate) {
384
+ const res = cfg.validate(event, { producer: 'web' });
385
+ if (!res.valid) {
386
+ const msg = `[hp] invalid ${event.event ?? event.type}:\n ${res.errors.join('\n ')}`;
387
+ if (cfg.throwOnInvalid) throw new InvalidEventError(msg);
388
+ console.error(msg);
389
+ }
390
+ }
391
+
392
+ if (cfg.debug) console.debug('[hp]', event.event ?? event.type, event);
393
+
394
+ deliver(event, force);
395
+ return event;
396
+ }
397
+
398
+ function observe(event) {
399
+ if (event.type === 'track') recordVisit(event);
400
+ // Lets a caller fold the event the warehouse will receive into session state. An observer
401
+ // that throws must not cost the event.
402
+ if (cfg.onEvent) {
403
+ try {
404
+ cfg.onEvent(event);
405
+ } catch (err) {
406
+ console.warn('[hp] onEvent observer failed', err);
407
+ }
408
+ }
409
+ }
410
+
411
+ /** The visit is kept on the device only for a visitor who allowed personalisation. */
412
+ const personalisationGranted = () =>
413
+ strict && gate?.state?.personalisation === true;
414
+ const scopeVisit = () =>
415
+ setSessionScope(perRequest(cfg.getSessionId, null), cfg.propertyId);
416
+
417
+ let visitKept = false;
418
+
419
+ /** What `recommendationContext()` reads: the products and searches of the events sent. */
420
+ function recordVisit(event) {
421
+ try {
422
+ if (personalisationGranted()) {
423
+ scopeVisit();
424
+ keepVisit(
425
+ recommendationContextFrom(
426
+ recordSessionEvent(event.event, event.properties),
427
+ ),
428
+ );
429
+ visitKept = true;
430
+ } else {
431
+ // Not allowed, or withdrawn: what was kept under the permission goes with it.
432
+ forgetVisit();
433
+ if (visitKept) clearSessionSignals();
434
+ visitKept = false;
435
+ }
436
+ } catch (err) {
437
+ if (cfg.debug) console.warn('[hp] the visit was not recorded', err);
438
+ }
439
+ }
440
+
441
+ function deliver(event, force) {
442
+ // The observer only sees what will be sent, never an event the visitor has not allowed.
443
+ if (!strict || force || gate.admits()) {
444
+ observe(event);
445
+ transport.enqueue(event);
446
+ } else {
447
+ gate.hold(event);
448
+ }
449
+ }
450
+
451
+ const track = (name, properties, ctx) =>
452
+ planAllows(cfg.plan, name)
453
+ ? emit({ type: 'track', event: name, properties: properties ?? {} }, ctx)
454
+ : null;
455
+
456
+ // One exposure event per serving response, however often the page reuses it.
457
+ const exposedTokens = new Set();
458
+
459
+ /** What the response personalised, by surface. Nothing in the holdout: nothing was applied. */
460
+ const exposed = (surface, holdout, body) => {
461
+ if (surface === 'audiences')
462
+ return { audiences: holdout ? null : (body.audiences ?? []) };
463
+ if (surface === 'decisions')
464
+ return {
465
+ decisions: (body.decisions ?? []).map((d) => ({
466
+ slot: d.slot,
467
+ decision_id: d.decision_id ?? null,
468
+ arm: d.arm ?? null,
469
+ ...(d.variant && { variant: d.variant }),
470
+ })),
471
+ };
472
+ return {
473
+ recommendation_request_id: body.recommendation_request_id ?? null,
474
+ };
475
+ };
476
+
477
+ if (strict) {
478
+ gate = createConsentGate({
479
+ adapter: cfg.consent,
480
+ timeoutMs: cfg.consentTimeoutMs,
481
+ cookieDomain: cfg.consentCookieDomain,
482
+ debug: cfg.debug,
483
+ store: heldStore && {
484
+ read: () => carried.items,
485
+ write: (items) => heldStore.write(items, device?.peek() ?? null),
486
+ },
487
+ release(event) {
488
+ // Only the consent block is rewritten; everything else was true when it was collected.
489
+ if (event.context) event.context.consent = gate.stamp();
490
+ observe(event);
491
+ transport.enqueue(event);
492
+ },
493
+ onStatus(status) {
494
+ if (status === 'granted') {
495
+ device?.persist();
496
+ session.persist();
497
+ if (lastPageMessageId) writeLastPageMessageId(lastPageMessageId);
498
+ decisions.persist();
499
+ transport.replayPersisted?.();
500
+ }
501
+ if (status === 'denied') {
502
+ // Nothing kept on the device may outlive the permission it was kept under.
503
+ transport.clearPersisted?.();
504
+ device?.forget();
505
+ session.forget();
506
+ try {
507
+ sessionStorage.removeItem(PREV_PAGE_KEY);
508
+ } catch {
509
+ /* nothing was stored */
510
+ }
511
+ decisions.forget();
512
+ }
513
+ try {
514
+ cfg.onConsentStatus?.(status);
515
+ } catch (err) {
516
+ console.warn('[hp] onConsentStatus observer failed', err);
517
+ }
518
+ },
519
+ announce({ action, state, withdrawal }) {
520
+ emit(
521
+ {
522
+ type: 'track',
523
+ event: 'Consent Updated',
524
+ properties: { action, state },
525
+ },
526
+ undefined,
527
+ withdrawal,
528
+ );
529
+ if (withdrawal) transport.flush();
530
+ },
531
+ });
532
+ gate.start();
533
+ }
534
+
535
+ /**
536
+ * This shopper's group, as the edge and the public API decide it: `holdout` and `excluded` (outside
537
+ * the property's ramp) both get the default everywhere, so personalise in the page only for
538
+ * `personalised`.
539
+ */
540
+ /** The property's holdout split from the settings, as a serving response would state it. */
541
+ const configuredShares = () =>
542
+ cfg.holdout ? holdoutShares(cfg.holdout) : null;
543
+
544
+ const personalisationGroup = () =>
545
+ holdoutAssignment(cfg.holdout, realId(perRequest(cfg.getAnonymousId, null)))
546
+ .group;
547
+
548
+ /**
549
+ * The response with each readout slot's side of its bandit-vs-default 50/50 (`variant`): the
550
+ * response's own, else this visitor's from the settings, the hash serving used. A page cached per
551
+ * arm has no response, so its side is computed here. The default side is no decision: its id is
552
+ * dropped, so it is never in force and never names a decision. A held-out visitor is in no split.
553
+ */
554
+ function withSplit(body, holdout) {
555
+ if (!Array.isArray(body?.decisions)) return body;
556
+ const readout = cfg.holdout?.readoutSlots ?? [];
557
+ const visitor = realId(perRequest(cfg.getAnonymousId, null));
558
+ return {
559
+ ...body,
560
+ decisions: body.decisions.map((d) => {
561
+ const variant =
562
+ d?.variant === 'bandit' || d?.variant === 'default'
563
+ ? d.variant
564
+ : !holdout && d?.holdout !== true && readout.includes(d?.slot)
565
+ ? banditSplit(d.slot, visitor)
566
+ : null;
567
+ if (!variant) return d;
568
+ return variant === 'default'
569
+ ? { ...d, variant, decision_id: null }
570
+ : { ...d, variant };
571
+ }),
572
+ };
573
+ }
574
+
575
+ /**
576
+ * Log the exposure a serving response carries (membership, decisions or recommendations):
577
+ * pass the parsed body after rendering it. Null when the response personalised nothing, or its
578
+ * token was already recorded. A decisions response's decisions are then in force: every later
579
+ * event this session carries them as context.decisions, unless the host passes getDecisions.
580
+ *
581
+ * A page rendered server-side from `/v1/assignments` and cached has no serving response, so it
582
+ * passes the exposure itself (`token` the assignment's derived id, `holdout` from the page's
583
+ * hash) and omits the shares: they are then the property's, from these settings, so the
584
+ * sample-ratio check can read the split as it does for any response.
585
+ */
586
+ function recordExposure(response, c) {
587
+ // A trader previewing an arm is not a shopper: nothing they see is exposure or in force.
588
+ if (previewing) return null;
589
+ const exposure = response?.exposure;
590
+ const holdout = exposure?.holdout === true;
591
+ const body = withSplit(response, holdout);
592
+ // In force from the render, so a basket later in the session is credited to the slot.
593
+ decisions.remember(servedDecisions(body));
594
+ if (!exposure?.token || exposedTokens.has(exposure.token)) return null;
595
+ exposedTokens.add(exposure.token);
596
+ return track(
597
+ 'Personalisation Exposed',
598
+ {
599
+ exposure_token: exposure.token,
600
+ surface: exposure.surface,
601
+ holdout,
602
+ holdout_share:
603
+ exposure.holdout_share ?? configuredShares()?.holdout_share ?? null,
604
+ personalised_share:
605
+ exposure.personalised_share ??
606
+ configuredShares()?.personalised_share ??
607
+ null,
608
+ ...exposed(exposure.surface, holdout, body),
609
+ },
610
+ c,
611
+ );
612
+ }
613
+
614
+ return {
615
+ /** Escape hatch for events not yet in the core spec. Prefer a named method. */
616
+ track,
617
+
618
+ page(props = {}, ctx) {
619
+ // page.type is inherited by every later event on the page, so an unknown value would get
620
+ // them all rejected. Keep the last good type instead: stale beats invalid.
621
+ let pageType = cfg.pageType ?? null;
622
+ if (props.type) {
623
+ if (PAGE_TYPES.has(props.type)) {
624
+ pageType = props.type;
625
+ cfg.pageType = props.type;
626
+ } else if (cfg.debug) {
627
+ console.warn(
628
+ `[hp] ignoring unknown page.type "${props.type}". Allowed: ${[...PAGE_TYPES].join(', ')}`,
629
+ );
630
+ }
631
+ }
632
+
633
+ const ev = emit(
634
+ {
635
+ type: 'page',
636
+ properties: {
637
+ name: props.name ?? null,
638
+ navigation_trigger:
639
+ props.navigation_trigger ?? detectNavigationTrigger(),
640
+ previous_message_id: readLastPageMessageId(),
641
+ },
642
+ },
643
+ // page.type is a context field, inherited by every later event on this page.
644
+ { page: { type: pageType }, ...(contextOverrides(ctx) ?? {}) },
645
+ );
646
+ if (ev) writeLastPageMessageId(ev.messageId);
647
+ return ev;
648
+ },
649
+
650
+ identify(traits, ctx) {
651
+ const { userId: id, ...rest } =
652
+ 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;
659
+ }
660
+ return emit({ type: 'identify', traits: rest }, ctx);
661
+ },
662
+
663
+ /** Clear identity on sign-out so the next events are not attributed to the old user. */
664
+ reset() {
665
+ userId = null;
666
+ writeLastPageMessageId('');
667
+ },
668
+
669
+ productViewed: (p, c) => track('Product Viewed', p, c),
670
+ productListViewed: (p, c) => track('Product List Viewed', p, c),
671
+
672
+ /**
673
+ * A recommendation rail was on screen.
674
+ *
675
+ * Prefer `trackRecommendationRails` from ./auto, which fires on the MRC viewability standard
676
+ * and fills in `viewable`. Call this directly only if the host measures viewability to the same
677
+ * standard itself; omit `viewable` rather than guess it.
678
+ */
679
+ recommendationsViewed: (p, c) => track('Recommendations Viewed', p, c),
680
+
681
+ /**
682
+ * A serving response personalised the page, or would have outside the holdout. Prefer
683
+ * `recordExposure`, which reads the response's `exposure` block.
684
+ */
685
+ personalisationExposed: (p, c) => track('Personalisation Exposed', p, c),
686
+
687
+ recordExposure,
688
+
689
+ /**
690
+ * A surface rendered server-side from a serving answer the page never sees (a product rail
691
+ * the site's server fetched from HP) came into view: record the property holdout's exposure for it, for
692
+ * both groups on the same trigger, so the readout counts like with like. The group is this
693
+ * visitor's as serving decides it, from the same holdout settings, salt and visitor id
694
+ * (spec/assignment/holdout.json), so it is the one serving answered with. Nothing is recorded
695
+ * for a visitor outside the experiment: without the property's holdout in the settings, outside
696
+ * the ramp, with no visitor or session id, or where serving personalises the surface only with
697
+ * consent this visitor has not given (rails and audiences need personalisation; decisions run
698
+ * on analytics, which every event already needs). One exposure per session per surface.
699
+ */
700
+ surfaceViewed({ surface } = {}, c) {
701
+ if (!EXPOSURE_SURFACES.has(surface) || !cfg.holdout) return null;
702
+ if (surface !== 'decisions' && !personalisationGranted()) return null;
703
+ const visitor = realId(perRequest(cfg.getAnonymousId, null));
704
+ const group = holdoutAssignment(cfg.holdout, visitor).group;
705
+ if (group === 'excluded') return null;
706
+ const token = surfaceExposureToken({
707
+ sessionId: assignmentSession(
708
+ perRequest(cfg.getSessionId, null),
709
+ visitor,
710
+ ),
711
+ surface,
712
+ });
713
+ if (!token) return null;
714
+ return recordExposure(
715
+ { exposure: { token, surface, holdout: group === 'holdout' } },
716
+ c,
717
+ );
718
+ },
719
+
720
+ /**
721
+ * Whether this shopper must see the default: held out, or outside the property's ramp
722
+ * (excluded), as the edge and the public API decide it. Skip anything personalised in the page
723
+ * itself (session-evaluated audiences, client re-ranking) when true. `personalisationGroup()`
724
+ * gives the exact group. An exposure's `holdout` flag comes from the serving response, never
725
+ * from this, except in `surfaceViewed`, for a surface whose response the page never sees.
726
+ */
727
+ inHoldout: () => personalisationGroup() !== 'personalised',
728
+ personalisationGroup,
729
+ productClicked: (p, c) => track('Product Clicked', p, c),
730
+ productListFiltered: (p, c) => track('Product List Filtered', p, c),
731
+ productsSearched: (p, c) => track('Products Searched', p, c),
732
+ productAdded: (p, c) => track('Product Added', p, c),
733
+ productRemoved: (p, c) => track('Product Removed', p, c),
734
+ /**
735
+ * The basket as the platform confirmed it after a change: pass the basket your cart API
736
+ * returned, every line with its total quantity. Send it alongside productAdded and
737
+ * productRemoved, never instead of them. `item_count` is summed from the lines when omitted.
738
+ */
739
+ cartUpdated: (basket, c) => track('Cart Updated', cartSnapshot(basket), c),
740
+ /**
741
+ * An identity edge an identify cannot imply: a guest checkout, a newsletter sign-up, an email
742
+ * click. Send the event-named trigger (`signed_in`, not `user_login`).
743
+ */
744
+ identityLinked: (p, c) => track('Identity Linked', p, c),
745
+ /** A visitor saw, clicked or dismissed a decisioned slot: the bandit's reward signal. */
746
+ slotEngaged: (p, c) => (previewing ? null : track('Slot Engaged', p, c)),
747
+ /** A size, colour or flavour the shopper picked: product_id the master, sku the new variant. */
748
+ productVariantSelected: (p, c) => track('Product Variant Selected', p, c),
749
+ cartViewed: (p, c) => track('Cart Viewed', p, c),
750
+ productAddedToWishlist: (p, c) => track('Product Added to Wishlist', p, c),
751
+ productRemovedFromWishlist: (p, c) =>
752
+ track('Product Removed from Wishlist', p, c),
753
+ /** A wishlist conversion. Send productAdded for the cart add as well: they are two facts. */
754
+ wishlistProductAddedToCart: (p, c) =>
755
+ track('Wishlist Product Added to Cart', p, c),
756
+ productShared: (p, c) => track('Product Shared', p, c),
757
+ cartShared: (p, c) => track('Cart Shared', p, c),
758
+
759
+ // Segment fires the coupon events from wherever the code is handled. Report each coupon fact
760
+ // from ONE producer per property, browser or server, or it is counted twice.
761
+ couponEntered: (p, c) => track('Coupon Entered', p, c),
762
+ couponApplied: (p, c) => track('Coupon Applied', p, c),
763
+ couponDenied: (p, c) => track('Coupon Denied', p, c),
764
+ couponRemoved: (p, c) => track('Coupon Removed', p, c),
765
+
766
+ // Order outcomes are backend-only: the server SDK sends them, and the ingest refuses them from
767
+ // a web key.
768
+ checkoutStarted: (p, c) => track('Checkout Started', p, c),
769
+ promotionViewed: (p, c) => track('Promotion Viewed', p, c),
770
+ promotionClicked: (p, c) => track('Promotion Clicked', p, c),
771
+ checkoutStepViewed: (p, c) => track('Checkout Step Viewed', p, c),
772
+ checkoutStepCompleted: (p, c) => track('Checkout Step Completed', p, c),
773
+ /** Send INSTEAD of checkoutStepCompleted for the payment step, never as well as it. */
774
+ paymentInfoEntered: (p, c) => track('Payment Info Entered', p, c),
775
+ /** Where checkout failed and why. The kind of payment instrument only, never card data. */
776
+ checkoutFailed: (p, c) => track('Checkout Failed', p, c),
777
+
778
+ signedIn: (p, c) => track('Signed In', p, c),
779
+ signedUp: (p, c) => track('Signed Up', p, c),
780
+ /** 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
+ },
786
+ newsletterSubscribed: (p, c) => track('Newsletter Subscribed', p, c),
787
+ /** An on-site opt-out. The ESP reports its own as Email Unsubscribed. */
788
+ newsletterUnsubscribed: (p, c) => track('Newsletter Unsubscribed', p, c),
789
+ /** An alert request for one product: back in stock, a price drop or a launch. */
790
+ backInStockSubscribed: (p, c) => track('Back In Stock Subscribed', p, c),
791
+ productReviewed: (p, c) => track('Product Reviewed', p, c),
792
+ consentUpdated: (p, c) => track('Consent Updated', p, c),
793
+
794
+ flush: (o) => transport.flush(o),
795
+ /**
796
+ * This visit as the recommendations API's session context: `viewed`, `basket`, `q` and
797
+ * `impressed`, newest first, in its wire form. `{}` unless the visitor allowed personalisation.
798
+ * Synchronous and never throws: `new URLSearchParams(context)` is what to append. Since 3.11.
799
+ */
800
+ recommendationContext() {
801
+ if (!personalisationGranted()) return {};
802
+ try {
803
+ scopeVisit();
804
+ return recommendationContextFrom(getSessionSignals());
805
+ } catch {
806
+ return {};
807
+ }
808
+ },
809
+ /** The consent state in force (strict mode), or null while the CMP has not answered. */
810
+ get consent() {
811
+ return gate ? gate.state : null;
812
+ },
813
+ /**
814
+ * Headers every request to the serving API must carry: `hp-consent`, so serving personalises
815
+ * only what the visitor allowed. Spread them into the recommendations or decisions fetch.
816
+ */
817
+ servingHeaders: () => servingHeaders(gate?.state ?? null),
818
+ get lastPageMessageId() {
819
+ return readLastPageMessageId();
820
+ },
821
+ /** The device id events carry: the site's, or the SDK's own under `hp_cookie` or without one. */
822
+ get anonymousId() {
823
+ return realId(perRequest(cfg.getAnonymousId, null));
824
+ },
825
+ /** `hp` and bootstrap replay a call made before the start through this. Not public API. */
826
+ [REPLAY](method, args, made) {
827
+ if (typeof this[method] !== 'function') return;
828
+ replaying =
829
+ typeof made?.messageId === 'string' &&
830
+ typeof made?.timestamp === 'string'
831
+ ? { messageId: made.messageId, timestamp: made.timestamp }
832
+ : null;
833
+ try {
834
+ this[method](...(Array.isArray(args) ? args : []));
835
+ } finally {
836
+ replaying = null;
837
+ }
838
+ },
839
+ };
840
+ }