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