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