@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/README.md +68 -33
- package/dist/hp.min.js +3 -3
- package/package.json +2 -2
- package/src/bootstrap.js +33 -5
- package/src/debug.js +45 -0
- package/src/device-storage.js +34 -22
- package/src/index.js +37 -15
- package/src/sampling.js +19 -0
- package/src/segment.js +15 -2
- package/src/session/record.js +13 -0
- package/src/session/signals.js +4 -0
- package/src/session-id.js +2 -2
- package/src/settings.js +25 -6
- package/src/shape.js +16 -7
- package/src/spec-version.js +1 -1
- package/src/storage-scope.js +6 -1
- package/src/transport.js +16 -1
- package/src/version.js +1 -1
- package/types/bootstrap.d.ts +11 -0
- package/types/events.d.ts +35 -2
- package/types/index.d.ts +11 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thg-agentic/hyper-personalisation-web",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/device-storage.js
CHANGED
|
@@ -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
|
-
/**
|
|
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)
|
|
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
|
-
*
|
|
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 (
|
|
189
|
-
const
|
|
190
|
-
const device =
|
|
191
|
-
|
|
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
|
|
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
|
-
//
|
|
259
|
-
const session =
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
},
|
package/src/sampling.js
ADDED
|
@@ -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 =
|
|
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
|
|
357
|
+
withdrawal
|
|
358
|
+
? {
|
|
359
|
+
[WITHDRAWAL_FLAG]: true,
|
|
360
|
+
[WITHDRAWN_AS]: cfg.getAnonymousId?.() ?? null,
|
|
361
|
+
}
|
|
362
|
+
: undefined,
|
|
350
363
|
);
|
|
351
364
|
},
|
|
352
365
|
});
|
package/src/session/record.js
CHANGED
|
@@ -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
|
}
|
package/src/session/signals.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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=
|
|
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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.
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
package/src/spec-version.js
CHANGED
package/src/storage-scope.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
1
|
+
export const VERSION = '3.18.0';
|
|
2
2
|
export const LIB = '@thg-agentic/hyper-personalisation-web';
|